1<template>
2 <div class="flex flex-col h-full">
3 <LayoutHeader>
4 <template #left-header>
5 <div class="text-lg-medium text-ink-gray-9">{{ __("Home") }}</div>
6 </template>
7 <template #right-header>
8 <div class="flex items-center gap-2">
9 <Button
10 v-if="layout.length > 0 && !editing"
11 :label="__('Refresh')"
12 variant="subtle"
13 :icon-left="'lucide-refresh-ccw'"
14 @click="agentDashboard.reload({ reset_layout: false })"
15 :disabled="isLoading"
16 />
17 <Button
18 v-if="editing && isDashboardModified"
19 :label="__('Reset')"
20 variant="subtle"
21 :icon-left="'lucide-rotate-cw'"
22 @click="onReset"
23 :disabled="isLoading"
24 />
25 <Button
26 v-if="editing"
27 :label="__('Save')"
28 variant="subtle"
29 :icon-left="'lucide-check'"
30 @click="onSave"
31 :disabled="!isDirty"
32 :loading="isLoading"
33 />
34 <Button
35 v-if="layout.length > 0 && !editing"
36 :label="__('Edit')"
37 variant="subtle"
38 :icon-left="'lucide-edit'"
39 @click="onEdit"
40 :disabled="isLoading"
41 />
42 <Button
43 v-if="editing"
44 :label="__('Cancel')"
45 variant="subtle"
46 @click="onCancel"
47 :disabled="isLoading"
48 />
49 <Dropdown
50 v-if="chartsDropdown.length > 0"
51 :options="chartsDropdown"
52 align="end"
53 >
54 <Button
55 :label="__('New')"
56 variant="solid"
57 icon-left="lucide-plus"
58 :disabled="isLoading"
59 />
60 </Dropdown>
61 </div>
62 </template>
63 </LayoutHeader>
64 <div class="flex-1 overflow-auto">
65 <div
66 v-if="agentDashboard.loading"
67 class="flex items-center justify-center absolute inset-0 z-10"
68 >
69 <LoadingIndicator class="size-8" />
70 </div>
71 <div
72 class="flex flex-col p-1 pt-4 md:p-4 md:ps-3 mx-auto max-w-screen-xl w-full grow relative h-full"
73 >
74 <div class="grow pb-12">
75 <div
76 v-if="!agentDashboard.loading && layout.length > 0"
77 class="text-xl-semibold text-ink-gray-8 ps-2"
78 >
79 {{ __("Hey") }}, {{ userName }}
80 </div>
81 <div
82 v-if="!agentDashboard.loading && layout.length === 0"
83 class="absolute top-1/2 start-1/2 transform -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2"
84 >
85 <div class="flex flex-col items-center justify-center gap-1">
86 <LucideLayout class="size-12 text-ink-gray-4" />
87 <div class="text-md-medium text-ink-gray-8">
88 {{ __("No charts added") }}
89 </div>
90 <div class="text-p-base text-ink-gray-6">
91 {{ __("Add charts to get started") }}
92 </div>
93 </div>
94 </div>
95 <div class="mt-3">
96 <GridLayout
97 v-if="!agentDashboard.loading && layout.length > 0"
98 class="h-fit w-full"
99 :class="[editing ? 'mb-[20rem] !select-none' : '']"
100 :cols="50"
101 :rowHeight="14"
102 :disabled="!editing"
103 :modelValue="layout.map((item) => item.layout)"
104 @update:modelValue="onLayoutUpdate"
105 >
106 <template #item="{ index }">
107 <div
108 class="group relative flex h-full w-full p-2 text-ink-gray-8"
109 >
110 <div
111 class="flex h-full w-full items-center justify-center"
112 :class="
113 editing
114 ? 'pointer-events-none [&>div:first-child]:rounded-4 [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
115 : ''
116 "
117 >
118 <ChartItem :item="layout[index]" />
119 </div>
120 <div
121 v-if="editing"
122 class="flex absolute end-0 top-0 bg-surface-gray-9 rounded-4 cursor-pointer opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
123 >
124 <div
125 class="rounded-4 p-1 hover:bg-surface-gray-8"
126 @click="layout.splice(index, 1)"
127 >
128 <LucideTrash2 class="size-3 text-ink-base" />
129 </div>
130 </div>
131 </div>
132 </template>
133 </GridLayout>
134 </div>
135 </div>
136 </div>
137 </div>
138 </div>
139</template>
140
141<script setup lang="ts">
142import LucideLayout from "~icons/lucide/layout";
143import LucideTrash2 from "~icons/lucide/trash-2";
144import { LayoutHeader } from "@/components";
145import { useAuthStore } from "@/stores/auth";
146import { capture } from "@/telemetry";
147import { __ } from "@/translation";
148import {
149 Button,
150 createResource,
151 Dropdown,
152 LoadingIndicator,
153 toast,
154} from "frappe-ui";
155import { GridLayout } from "@/components/frappe-ui/GridLayout";
156import { storeToRefs } from "pinia";
157import { computed, ref } from "vue";
158import ChartItem from "./components/ChartItem.vue";
159
160type Layout = {
161 x: number;
162 y: number;
163 w: number;
164 h: number;
165 i: string;
166 minW?: number;
167 minH?: number;
168 maxW?: number;
169 maxH?: number;
170};
171
172type LayoutItem = {
173 chart: string;
174 data: Record<string, unknown>;
175 layout: Layout;
176};
177
178type DashboardResponse = {
179 layout: LayoutItem[];
180 default_layout: string;
181 dashboard_id?: string;
182};
183
184const { userName, userId } = storeToRefs(useAuthStore());
185const editing = ref(false);
186const layout = ref<LayoutItem[]>([]);
187const oldLayout = ref<LayoutItem[]>([]);
188
189const isDirty = computed(() => {
190 return JSON.stringify(layout.value) !== JSON.stringify(oldLayout.value);
191});
192const isLoading = computed(() => {
193 return (
194 agentDashboard.loading || createDashboard.loading || saveDashboard.loading
195 );
196});
197
198const agentDashboard = createResource({
199 url: "opendesk.api.agent_home.agent_home.get_dashboard",
200 auto: true,
201 transform(data: DashboardResponse) {
202 data.layout = data.layout.map((item) => {
203 return {
204 ...item,
205 layout: {
206 ...item.layout,
207 i: Math.random().toString(36).substring(2, 9),
208 },
209 };
210 });
211 return data;
212 },
213 onSuccess(data: DashboardResponse) {
214 layout.value = data.layout;
215 },
216});
217
218const cleanUpLayoutData = (layout: LayoutItem[]) => {
219 return layout.map((item) => {
220 const l = item.layout;
221 return {
222 chart: item.chart,
223 layout: {
224 x: l.x,
225 y: l.y,
226 w: l.w,
227 h: l.h,
228 minW: l.minW,
229 minH: l.minH,
230 maxW: l.maxW,
231 maxH: l.maxH,
232 },
233 };
234 });
235};
236
237const isDashboardModified = computed(() => {
238 const _layout = cleanUpLayoutData(layout.value);
239 const defaultLayout = cleanUpLayoutData(
240 JSON.parse(agentDashboard.data.default_layout)
241 );
242 return JSON.stringify(_layout) !== JSON.stringify(defaultLayout);
243});
244
245const createDashboard = createResource({
246 url: "frappe.client.insert",
247 makeParams() {
248 const layoutData = layout.value.map((item) => {
249 return {
250 chart: item.chart,
251 layout: item.layout,
252 };
253 }) as LayoutItem[];
254 return {
255 doc: {
256 doctype: "OD Field Layout",
257 user: userId.value,
258 layout: JSON.stringify(cleanUpLayoutData(layoutData)),
259 },
260 };
261 },
262 onSuccess() {
263 toast.success(__("Dashboard saved"));
264 agentDashboard.reload();
265 },
266});
267
268const saveDashboard = createResource({
269 url: "frappe.client.set_value",
270 makeParams() {
271 const layoutData = layout.value.map((item) => {
272 return {
273 chart: item.chart,
274 layout: item.layout,
275 };
276 }) as LayoutItem[];
277 return {
278 doctype: "OD Field Layout",
279 name: agentDashboard.data.dashboard_id,
280 fieldname: "layout",
281 value: JSON.stringify(cleanUpLayoutData(layoutData)),
282 };
283 },
284 onSuccess() {
285 toast.success(__("Dashboard saved"));
286 },
287});
288
289const chartsDropdown = computed(() => {
290 const _charts = [
291 {
292 label: __("My Tickets"),
293 chart: "agent_tickets",
294 onClick: () =>
295 addChart("agent_tickets", {
296 w: 16,
297 h: 10,
298 minW: 16,
299 minH: 10,
300 maxH: 11,
301 }),
302 },
303 {
304 label: __("Avg. Time Metrics"),
305 chart: "avg_time_metrics",
306 onClick: () =>
307 addChart("avg_time_metrics", {
308 w: 50,
309 h: 24,
310 minW: 18,
311 minH: 24,
312 maxH: 44,
313 }),
314 },
315 {
316 label: __("Avg. First Response Time"),
317 chart: "avg_first_response_time",
318 onClick: () =>
319 addChart("avg_first_response_time", {
320 w: 16,
321 h: 10,
322 minW: 16,
323 minH: 10,
324 maxH: 11,
325 }),
326 },
327 {
328 label: __("Avg. Resolution Time"),
329 chart: "avg_resolution_time",
330 onClick: () =>
331 addChart("avg_resolution_time", {
332 w: 16,
333 h: 10,
334 minW: 16,
335 minH: 10,
336 maxH: 11,
337 }),
338 },
339 {
340 label: __("Reviews"),
341 chart: "recent_feedback",
342 onClick: () =>
343 addChart("recent_feedback", {
344 w: 50,
345 h: 21,
346 minW: 50,
347 maxH: 21,
348 minH: 21,
349 }),
350 },
351 {
352 label: __("Pending Tickets"),
353 chart: "pending_tickets",
354 onClick: () =>
355 addChart("pending_tickets", {
356 w: 50,
357 h: 32,
358 minW: 25,
359 minH: 32,
360 maxH: 32,
361 }),
362 },
363 {
364 label: __("My Recent Activity"),
365 chart: "recent_activity",
366 onClick: () =>
367 addChart("recent_activity", {
368 w: 16,
369 h: 32,
370 minW: 12,
371 minH: 20,
372 maxH: 44,
373 }),
374 },
375 ].filter((chart) => {
376 return !layout.value.some((item) => item.chart === chart.chart);
377 });
378 return _charts;
379});
380
381type ChartSize = {
382 w: number;
383 h: number;
384 minW?: number;
385 minH?: number;
386 maxW?: number;
387 maxH?: number;
388};
389
390const addChart = (chart: string, config: ChartSize) => {
391 if (!editing.value) {
392 onEdit();
393 }
394 layout.value.unshift({
395 chart: chart,
396 data: {},
397 layout: {
398 x: 0,
399 y: 0,
400 w: config.w,
401 h: config.h,
402 i: Math.random().toString(),
403 minW: config.minW,
404 minH: config.minH,
405 maxW: config.maxW,
406 maxH: config.maxH,
407 },
408 });
409};
410
411const onEdit = () => {
412 oldLayout.value = JSON.parse(JSON.stringify(layout.value));
413 editing.value = true;
414};
415
416const onSave = () => {
417 if (agentDashboard.data?.dashboard_id) {
418 saveDashboard.submit().then(() => {
419 editing.value = false;
420 });
421 } else {
422 createDashboard.submit().then(() => {
423 editing.value = false;
424 });
425 }
426 capture("home_page_updated");
427};
428
429const onCancel = () => {
430 layout.value = oldLayout.value;
431 editing.value = false;
432};
433
434const onReset = () => {
435 agentDashboard.submit({
436 reset_layout: true,
437 });
438 // the flag sticks to the resource, so every later reload would reset again
439 agentDashboard.update({ params: {} });
440};
441
442const onLayoutUpdate = (newLayout: Layout[]) => {
443 layout.value.forEach((item, idx) => {
444 item.layout = newLayout[idx];
445 });
446};
447</script>
448