1<template>
2 <div class="flex flex-col">
3 <LayoutHeader>
4 <template #left-header>
5 <div class="text-lg-medium text-ink-gray-9">
6 {{ __(dashboardTitle) }}
7 </div>
8 </template>
9 <template #right-header>
10 <!-- Segmented pill toggle: only visible to managers -->
11 <div v-if="isManager">
12 <TabButtons v-model="activeTab" :options="tabButtons" />
13 </div>
14 </template>
15 </LayoutHeader>
16
17 <div class="px-4 py-4 w-full overflow-y-scroll">
18 <!-- Filters -->
19 <div class="mb-4 flex items-center gap-4 overflow-x-auto px-1 py-1">
20 <Dropdown v-if="!showDatePicker" :options="options">
21 <template #default>
22 <div
23 class="flex justify-between !min-w-48 items-center border border-outline-gray-2 rounded-4 text-ink-gray-8 px-2 py-1.5 hover:border-outline-gray-3 hover:shadow-sm focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-0 transition-colors h-7 cursor-pointer"
24 >
25 <div class="flex items-center">
26 <LucideCalendar class="size-4 text-ink-gray-5 me-2" />
27 <span class="text-base whitespace-nowrap">{{ preset }}</span>
28 </div>
29 <LucideChevronDown class="size-4 text-ink-gray-5" />
30 </div>
31 </template>
32 </Dropdown>
33 <DateRangePicker
34 v-else
35 class="!w-48"
36 ref="datePickerRef"
37 v-model="periodRange"
38 variant="outline"
39 :placeholder="__('Period')"
40 :format="'MMM D'"
41 @update:open="onPickerToggle"
42 >
43 <template #prefix>
44 <!-- ms-px: TextInput anchors the prefix to the wrapper, which sits
45 outside the input's 1px border, so it lands 1px left of the
46 preset trigger's icon. -->
47 <LucideCalendar class="size-4 text-ink-gray-5 ms-px me-2" />
48 </template>
49 </DateRangePicker>
50 <Link
51 v-if="isManager && !viewMyStats"
52 class="w-48"
53 doctype="OD Team"
54 variant="outline"
55 :placeholder="__('Team')"
56 v-model="filters.team"
57 >
58 <template #prefix>
59 <LucideUsers class="size-4 text-ink-gray-5 me-1" />
60 </template>
61 <!-- One line per row: the filter only needs the name. -->
62 <template #item-label="{ item }">
63 <div class="truncate">{{ item.label }}</div>
64 </template>
65 </Link>
66 <Link
67 v-if="isManager && !viewMyStats"
68 class="w-48"
69 doctype="OD Agent"
70 variant="outline"
71 :placeholder="__('Agent')"
72 v-model="filters.agent"
73 :filters="agentFilter"
74 >
75 <template #prefix>
76 <LucideUser class="size-4 text-ink-gray-5 me-1" />
77 </template>
78 <!-- One line per row: the filter only needs the name. -->
79 <template #item-label="{ item }">
80 <div class="truncate">{{ item.label }}</div>
81 </template>
82 <!-- An empty list means an empty team, not a failed search. -->
83 <template #empty>
84 {{
85 filters.team && !teamMembers.data?.length
86 ? __("No agents in this team")
87 : __("No results")
88 }}
89 </template>
90 </Link>
91 </div>
92 <!-- Charts -->
93
94 <!-- Number Cards -->
95 <div
96 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4 px-1"
97 v-if="!numberCards.loading"
98 >
99 <Tooltip
100 v-for="({ tooltip, ...card }, index) in numberCards.data"
101 :key="index"
102 :text="tooltip"
103 >
104 <NumberCard v-bind="card" />
105 </Tooltip>
106 </div>
107 <div
108 v-if="!loading && !isEmpty"
109 class="transition-all animate-fade-in duration-300"
110 >
111 <!-- Trend Charts -->
112 <div
113 class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 px-1"
114 v-if="!trendData.loading"
115 >
116 <template v-for="(chart, index) in trendData.data" :key="index">
117 <!-- has data -->
118 <ChartCard v-if="!isChartEmpty(chart)" class="h-80">
119 <component :is="getChartType(chart)" />
120 </ChartCard>
121
122 <!-- chart with no data -->
123 <SkeletonLoader
124 v-else
125 :variants="['bar-chart', 'empty-state']"
126 :bar-chart-count="1"
127 :has-applied-filter="hasAppliedFilter"
128 :empty-states="chartEmptyState(chart)"
129 />
130 </template>
131 </div>
132 <!-- Master Data Charts -->
133 <div
134 class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-4 px-1"
135 v-if="!masterData.loading"
136 >
137 <template v-for="(chart, index) in masterData.data" :key="index">
138 <!-- has data -->
139 <ChartCard v-if="!isChartEmpty(chart)" class="h-80">
140 <component :is="getChartType(chart)" />
141 </ChartCard>
142
143 <!-- chart with no data -->
144 <SkeletonLoader
145 v-else
146 :variants="['bar-chart', 'empty-state']"
147 :bar-chart-count="1"
148 :has-applied-filter="hasAppliedFilter"
149 :empty-states="chartEmptyState(chart)"
150 />
151 </template>
152 </div>
153
154 <!-- Tag Charts: org level insight, an agent cannot act on their own tag mix -->
155 <div
156 class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-4 px-1"
157 v-if="isManager && !tagData.loading"
158 >
159 <template v-for="(chart, index) in tagData.data" :key="index">
160 <!-- has data -->
161 <ChartCard v-if="!isChartEmpty(chart)" class="h-80">
162 <component :is="getChartType(chart)" />
163 </ChartCard>
164
165 <!-- chart with no data -->
166 <SkeletonLoader
167 v-else
168 :variants="['bar-chart', 'empty-state']"
169 :bar-chart-count="1"
170 :has-applied-filter="hasAppliedFilter"
171 :empty-states="chartEmptyState(chart)"
172 />
173 </template>
174 </div>
175 </div>
176
177 <!-- Skeleton Loading State -->
178 <div class="flex flex-col gap-4">
179 <SkeletonLoader
180 v-if="numberCards.loading"
181 :variants="['number-cards']"
182 :number-cards-count="5"
183 :loading="true"
184 />
185 <SkeletonLoader
186 v-if="trendData.loading"
187 :variants="['bar-chart']"
188 :bar-chart-count="4"
189 :loading="true"
190 />
191 </div>
192
193 <!-- complete empty state -->
194 <div
195 v-if="isEmpty"
196 class="transition-all animate-fade-in duration-300 relative"
197 >
198 <div>
199 <SkeletonLoader
200 :variants="['bar-chart', 'empty-state']"
201 :bar-chart-count="emptyStates.length"
202 :empty-states="emptyStates"
203 :has-applied-filter="hasAppliedFilter"
204 />
205 </div>
206 </div>
207 </div>
208 </div>
209</template>
210
211<script setup lang="ts">
212import { useScreenSize } from "@/composables/screen";
213import { useAuthStore } from "@/stores/auth";
214import { __ } from "@/translation";
215import { Link } from "@framework/ui";
216import { useStorage } from "@vueuse/core";
217import {
218 DateRangePicker,
219 Dropdown,
220 TabButtons,
221 Tooltip,
222 createResource,
223 dayjs,
224 usePageMeta,
225} from "frappe-ui";
226import { BarChart, ChartCard, DonutChart, NumberCard } from "frappe-ui/charts";
227import { computed, h, onMounted, reactive, ref, watch } from "vue";
228import LucideBuilding2 from "~icons/lucide/building-2";
229import LucideUser from "~icons/lucide/user";
230const { isMobileView } = useScreenSize();
231
232interface NumberCardData {
233 title: string;
234 value: number | null;
235 delta: number | null;
236 deltaSuffix: string;
237 suffix?: string;
238 negativeIsBetter?: boolean;
239 tooltip: string;
240}
241
242type Filters = {
243 period: string;
244 agent: null | string;
245 team: null | string;
246};
247
248const filters = reactive<Filters>({
249 period: getLastXDays(),
250 agent: null,
251 team: null,
252});
253
254interface ChartData {
255 data: ChartValues[];
256 title: string;
257 type: "bar" | "donut";
258}
259
260interface ChartValues {
261 date: Date;
262 Open: number;
263 Closed: number;
264 "SLA Fulfilled": number;
265}
266
267const dashboardTitle = computed(() => {
268 if (!isManager) return __("Agent Dashboard");
269 return viewMyStats.value ? __("My Dashboard") : __("Organization Dashboard");
270});
271
272interface ChartEmptyState {
273 // header of the card, shown untranslated only when the chart itself is missing
274 chartTitle: string;
275 chartSubtitle?: string;
276 title: string;
277 message: string;
278}
279
280// Chart key (stable, untranslated identifier from the dashboard APIs) → empty state copy.
281const emptyStateByChart: Record<string, ChartEmptyState> = {
282 ticket_trend: {
283 chartTitle: "Ticket Trend",
284 title: "No ticket activity",
285 message: "Ticket trends will appear here once tickets are created.",
286 },
287 feedback_trend: {
288 chartTitle: "Feedback Trend",
289 title: "No feedback data",
290 message: "Feedback insights will appear once responses are collected.",
291 },
292 tickets_by_team: {
293 chartTitle: "Tickets by Team",
294 title: "No team data",
295 message: "Tickets will be grouped by team once available.",
296 },
297 tickets_by_type: {
298 chartTitle: "Tickets by Type",
299 title: "No ticket type data",
300 message: "Tickets will be categorized by type once created.",
301 },
302 tickets_by_priority: {
303 chartTitle: "Tickets by Priority",
304 title: "No priority data",
305 message: "Ticket priorities will be reflected here once assigned.",
306 },
307 tickets_by_channel: {
308 chartTitle: "Tickets by Channel",
309 title: "No channel data",
310 message: "Tickets will be grouped by channel once received.",
311 },
312 top_tags: {
313 chartTitle: "Top Tags",
314 title: "No tags used yet",
315 message: "The most used tags will be ranked here once tickets are tagged.",
316 },
317 tag_trend: {
318 chartTitle: "Tag Trend",
319 title: "No tag activity",
320 message: "Daily tag volume will appear here once tickets are tagged.",
321 },
322};
323
324const managerOnlyCharts = ["top_tags", "tag_trend"];
325
326// whole dashboard empty: no chart payload to read titles from, so fall back to ours
327const emptyStates = computed(() =>
328 Object.entries(emptyStateByChart)
329 .filter(([key]) => isManager || !managerOnlyCharts.includes(key))
330 .map(([, state]) => state)
331);
332
333function chartEmptyState(chart: any) {
334 const state = emptyStateByChart[chart?.key] ?? {
335 title: `No ${String(chart?.title).toLowerCase()} available`,
336 };
337 return [
338 { ...state, chartTitle: chart?.title, chartSubtitle: chart?.subtitle },
339 ];
340}
341
342const tabButtons = computed(() => {
343 if (isMobileView.value) {
344 return [
345 {
346 value: "organization",
347 label: __("My Organization"),
348 icon: h(LucideBuilding2, { class: "size-4" }),
349 },
350 {
351 value: "my_stats",
352 label: __("My Stats"),
353 icon: h(LucideUser, { class: "size-4" }),
354 },
355 ];
356 }
357 return [
358 {
359 value: "organization",
360 iconLeft: h(LucideBuilding2, { class: "size-4" }),
361 label: __("My Organization"),
362 },
363 {
364 value: "my_stats",
365 iconLeft: h(LucideUser, { class: "size-4" }),
366 label: __("My Stats"),
367 },
368 ];
369});
370
371const hasAppliedFilter = computed(() =>
372 Boolean(
373 filters.agent ||
374 filters.team ||
375 (filters.period && filters.period !== getLastXDays(30))
376 )
377);
378
379const isEmpty = computed(() => {
380 if (!numberCards.data || !trendData.data || !masterData.data) return false;
381 return (
382 (numberCards.data as NumberCardData[]).every((d) => !d.value) &&
383 (trendData.data as ChartData[]).every((d) => !d.data?.length) &&
384 (masterData.data as ChartData[]).every((d) => !d.data?.length)
385 );
386});
387
388const parseFilters = (filters: Filters) => {
389 return {
390 from_date: filters.period?.split(",")[0] ?? null,
391 to_date: filters.period?.split(",")[1] ?? null,
392 team: filters.team,
393 agent: filters.agent,
394 };
395};
396
397// No `cache` key here: a static cache key makes frappe-ui return the same
398// resource instance across navigations, still bound to the previous mount's
399// `filters` closure, so reloads after navigating back send stale params.
400const numberCards = createResource({
401 url: "opendesk.api.dashboard.get_dashboard_data",
402 makeParams: () => ({
403 dashboard_type: "number_card",
404 filters: parseFilters(filters),
405 }),
406});
407
408const masterData = createResource({
409 url: "opendesk.api.dashboard.get_dashboard_data",
410 makeParams: () => ({
411 dashboard_type: "master",
412 filters: parseFilters(filters),
413 }),
414});
415
416const trendData = createResource({
417 url: "opendesk.api.dashboard.get_dashboard_data",
418 makeParams: () => ({
419 dashboard_type: "trend",
420 filters: parseFilters(filters),
421 }),
422});
423
424const tagData = createResource({
425 url: "opendesk.api.dashboard.get_dashboard_data",
426 makeParams: () => ({
427 dashboard_type: "tags",
428 filters: parseFilters(filters),
429 }),
430});
431
432const agentFilter = ref(null);
433const teamMembers = createResource({
434 url: "opendesk.opendesk.doctype.od_team.od_team.get_team_members",
435 cache: ["Analytics", "TeamMembers"],
436 params: {
437 team: filters.team,
438 },
439 onSuccess: (data) => {
440 agentFilter.value = { name: ["in", data] };
441 },
442});
443
444const { isManager, userId } = useAuthStore();
445
446const viewMyStats = ref(false);
447const activeTab = useStorage("dashboard_active_tab", "organization");
448function validateView(myStats: boolean) {
449 viewMyStats.value = myStats;
450 if (myStats) {
451 filters.team = null;
452 filters.agent = userId;
453 } else {
454 filters.agent = null;
455 }
456}
457
458watch(activeTab, (val) => {
459 validateView(val === "my_stats");
460});
461
462watch(
463 () => filters.team,
464 (newVal) => {
465 filters.agent = null; // Reset agent when team is selected
466 if (newVal) {
467 teamMembers.update({
468 params: {
469 team: newVal,
470 },
471 });
472 teamMembers.reload();
473 }
474 if (!newVal) {
475 agentFilter.value = null; // Reset agent filter if no team is selected
476 }
477 }
478);
479
480//check empty for individual charts
481function isChartEmpty(chart: any) {
482 if (!chart.data?.length) return true;
483 return chart.data.every((row: any) =>
484 Object.entries(row)
485 .filter(([key]) => key !== "date")
486 .every(([, val]) => val === null || val === 0)
487 );
488}
489
490const loading = computed(() => {
491 return numberCards.loading || masterData.loading || trendData.loading;
492});
493
494// api already sends the frappe-ui props for each chart
495function getChartType({ type, key, ...props }: any) {
496 return h(type === "donut" ? DonutChart : BarChart, props);
497}
498
499// include today, so last 7 days is today and the 6 days before. The site's
500// today, not the browser's: tickets are dated in the site's timezone.
501function getLastXDays(range: number = 30): string {
502 const today = dayjs().tz(window.timezone?.system);
503 const from = today.subtract(range - 1, "day");
504 return `${from.format("YYYY-MM-DD")},${today.format("YYYY-MM-DD")}`;
505}
506
507const showDatePicker = ref(false);
508const datePickerRef = ref(null);
509const preset = ref(__("Last 30 Days"));
510
511// frappe-ui v1 DateRangePicker models a [from, to] tuple; filters.period and
512// the API keep the legacy "from,to" string, so convert at the picker boundary.
513const periodRange = computed({
514 get: (): string[] => (filters.period ? filters.period.split(",") : []),
515 set: (range: string[]) => {
516 showDatePicker.value = false;
517 filters.period = range?.length ? range.join(",") : "";
518 preset.value = formatter(filters.period);
519 },
520});
521
522// dismissing the calendar without a range would otherwise leave an empty
523// field where the preset menu used to be, with no way back to it
524function onPickerToggle(open: boolean) {
525 if (!open && !filters.period) {
526 showDatePicker.value = false;
527 preset.value = formatter("");
528 }
529}
530
531const options = computed(() => [
532 {
533 group: __("Presets"),
534 hideLabel: true,
535 options: [
536 {
537 label: __("Today"),
538 onClick: () => {
539 preset.value = __("Today");
540 filters.period = getLastXDays(1);
541 },
542 },
543 {
544 label: __("Last 7 Days"),
545 onClick: () => {
546 preset.value = __("Last 7 Days");
547 filters.period = getLastXDays(7);
548 },
549 },
550 {
551 label: __("Last 30 Days"),
552 onClick: () => {
553 preset.value = __("Last 30 Days");
554 filters.period = getLastXDays(30);
555 },
556 },
557 {
558 label: __("Last 60 Days"),
559 onClick: () => {
560 preset.value = __("Last 60 Days");
561 filters.period = getLastXDays(60);
562 },
563 },
564 {
565 label: __("Last 90 Days"),
566 onClick: () => {
567 preset.value = __("Last 90 Days");
568 filters.period = getLastXDays(90);
569 },
570 },
571 ],
572 },
573 {
574 label: __("Custom Range"),
575 onClick: () => {
576 showDatePicker.value = true;
577 setTimeout(() => {
578 datePickerRef.value?.open();
579 }, 0);
580 preset.value = __("Custom Range");
581 filters.period = null; // Reset period to allow custom date selection
582 },
583 },
584]);
585
586function formatter(range: string) {
587 if (!range) {
588 filters.period = getLastXDays();
589 preset.value = __("Last 30 Days");
590 return preset.value;
591 }
592 let [from, to] = range.split(",");
593 return `${formatRange(from)} to ${formatRange(to)}`;
594}
595
596function formatRange(date: string) {
597 const dateObj = new Date(date);
598 return dateObj.toLocaleDateString("en-US", {
599 month: "short",
600 day: "numeric",
601 year:
602 dateObj.getFullYear() === new Date().getFullYear()
603 ? undefined
604 : "numeric",
605 });
606}
607
608watch(
609 () => filters,
610 () => {
611 if (showDatePicker.value && !filters.period) return;
612 numberCards.reload();
613 masterData.reload();
614 trendData.reload();
615 if (isManager) tagData.reload();
616 },
617 { deep: true }
618);
619
620onMounted(() => {
621 if (!isManager) {
622 filters.agent = userId;
623 } else {
624 validateView(activeTab.value === "my_stats");
625 }
626 numberCards.reload();
627 masterData.reload();
628 trendData.reload();
629 if (isManager) tagData.reload();
630});
631
632usePageMeta(() => {
633 return {
634 title: __("Dashboard"),
635 };
636});
637</script>
638
639<style scoped>
640:deep(.form-control button) {
641 @apply text-base rounded-4 h-7 py-1.5 border border-outline-gray-2 bg-surface-base placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-0 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark];
642}
643:deep(.form-control button > div) {
644 overflow: hidden;
645 text-overflow: ellipsis;
646 white-space: nowrap;
647}
648
649:deep(.form-control div) {
650 width: 100%;
651 display: flex;
652}
653</style>
654