@7365admin1/layer-common 1.11.44 → 1.11.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,77 +1,415 @@
1
1
  <template>
2
- <v-row no-gutters class="mb-6">
3
- <v-col cols="12">
4
- <h1 class="text-h4 text-md-h3 font-weight-bold">
5
- Dashboard{{ currentSiteName ? ` - ${currentSiteName}` : "" }}
6
- </h1>
7
- </v-col>
8
- </v-row>
9
-
10
- <v-row>
11
- <v-col cols="12">
12
- <v-progress-linear
13
- v-if="loading"
14
- indeterminate
15
- color="primary"
16
- class="mb-4"
17
- />
18
- </v-col>
19
- </v-row>
20
-
21
- <v-row>
22
- <v-col
23
- v-for="card in countCardList"
24
- :key="card.id"
25
- cols="12"
26
- sm="6"
27
- md="6"
28
- lg="4"
29
- xl="2"
30
- >
31
- <v-card flat border class="fill-height" elevation="0" hover>
32
- <v-card-text class="pa-4 pa-md-6">
33
- <v-row no-gutters align="start">
34
- <v-col cols="8">
35
- <v-card-subtitle class="text-caption text-uppercase pa-0 mb-2">
36
- {{ card.label }}
37
- </v-card-subtitle>
38
- <v-card-title class="text-h4 text-md-h3 pa-0 mb-3">
39
- {{ card.value }}
40
- </v-card-title>
41
- <v-chip :color="card.chipColor" size="small" variant="flat">
42
- <v-icon size="14" start>mdi-trending-up</v-icon>
43
- {{ card.percentage }}
2
+ <section class="service-dashboard">
3
+ <div class="dashboard-header">
4
+ <div>
5
+ <h1 class="dashboard-title">{{ dashboardTitle }}</h1>
6
+ <div class="site-label">
7
+ <v-icon icon="mdi-map-marker" size="16" />
8
+ <span>{{ currentSiteName || "Selected site" }}</span>
9
+ </div>
10
+ </div>
11
+
12
+ <div class="header-actions">
13
+ <v-btn
14
+ variant="outlined"
15
+ color="default"
16
+ density="comfortable"
17
+ class="text-none action-button"
18
+ prepend-icon="mdi-tune-variant"
19
+ @click="openCustomizeDialog"
20
+ >
21
+ Customize Dashboard
22
+ </v-btn>
23
+
24
+ <v-menu>
25
+ <template #activator="{ props: menuProps }">
26
+ <v-btn
27
+ v-bind="menuProps"
28
+ variant="outlined"
29
+ color="default"
30
+ density="comfortable"
31
+ class="text-none action-button"
32
+ append-icon="mdi-chevron-down"
33
+ >
34
+ Export
35
+ </v-btn>
36
+ </template>
37
+
38
+ <v-list class="export-menu" density="compact" border>
39
+ <v-list-item title="PDF" @click="notifyExport('PDF')" />
40
+ <v-list-item title="Word" @click="notifyExport('Word')" />
41
+ </v-list>
42
+ </v-menu>
43
+
44
+ <v-btn-toggle
45
+ v-model="selectedRange"
46
+ mandatory
47
+ density="comfortable"
48
+ class="range-toggle"
49
+ divided
50
+ >
51
+ <v-btn
52
+ v-for="range in rangeOptions"
53
+ :key="range.value"
54
+ :value="range.value"
55
+ class="text-none"
56
+ size="small"
57
+ >
58
+ {{ range.label }}
59
+ </v-btn>
60
+ </v-btn-toggle>
61
+ </div>
62
+ </div>
63
+
64
+ <v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
65
+
66
+ <v-row class="kpi-grid" dense>
67
+ <v-col
68
+ v-for="card in visibleKpiCards"
69
+ :key="card.key"
70
+ cols="12"
71
+ md="6"
72
+ >
73
+ <v-card :class="['dashboard-card', 'kpi-card', `kpi-card-${card.key}`]" flat border>
74
+ <div class="kpi-topline">
75
+ <div :class="['kpi-icon', `kpi-icon-${card.key}`]" :style="{ backgroundColor: card.tint }">
76
+ <v-icon :icon="card.icon" :color="card.color" size="15" />
77
+ </div>
78
+
79
+ <v-menu v-if="card.filter" v-model="filterMenus[card.key]">
80
+ <template #activator="{ props: menuProps }">
81
+ <v-btn
82
+ v-bind="menuProps"
83
+ variant="outlined"
84
+ density="compact"
85
+ class="text-none mini-filter"
86
+ append-icon="mdi-chevron-down"
87
+ >
88
+ {{ card.filter.model }}
89
+ </v-btn>
90
+ </template>
91
+
92
+ <v-list class="filter-menu" density="compact" border>
93
+ <v-list-item
94
+ v-for="option in card.filter.options"
95
+ :key="option"
96
+ :active="card.filter.model === option"
97
+ :title="option"
98
+ @click="card.filter.set(option)"
99
+ >
100
+ <template #prepend>
101
+ <v-icon
102
+ :icon="card.filter.model === option ? 'mdi-check' : 'mdi-blank'"
103
+ size="16"
104
+ />
105
+ </template>
106
+ </v-list-item>
107
+ </v-list>
108
+ </v-menu>
109
+ </div>
110
+
111
+ <div class="kpi-content">
112
+ <p class="kpi-label">{{ card.title }}</p>
113
+ <div class="kpi-value-row">
114
+ <strong>{{ card.value }}</strong>
115
+ </div>
116
+ <div class="kpi-meta-row">
117
+ <p v-if="card.meta" class="kpi-meta">{{ card.meta }}</p>
118
+ <v-chip
119
+ :color="card.percentage >= 0 ? 'success' : 'error'"
120
+ size="x-small"
121
+ variant="flat"
122
+ class="font-weight-bold"
123
+ >
124
+ <v-icon
125
+ :icon="card.percentage >= 0 ? 'mdi-arrow-up-right' : 'mdi-arrow-down-right'"
126
+ size="13"
127
+ start
128
+ />
129
+ {{ Math.abs(card.percentage) }}%
44
130
  </v-chip>
45
- </v-col>
46
- <v-col cols="4" class="d-flex justify-end">
47
- <v-avatar :color="card.color" size="56" rounded="lg">
48
- <v-icon :icon="card.icon" color="white" size="28"></v-icon>
49
- </v-avatar>
50
- </v-col>
51
- </v-row>
52
- <v-row no-gutters class="mt-4">
53
- <v-col cols="12">
54
- <v-select
55
- :model-value="card.period"
56
- :items="periodOptions"
57
- density="compact"
131
+ </div>
132
+ </div>
133
+ </v-card>
134
+ </v-col>
135
+ </v-row>
136
+
137
+ <v-card v-if="isWidgetVisible('activity')" class="dashboard-card activity-card" flat border>
138
+ <div class="section-header">
139
+ <h2>{{ activityTitle }}</h2>
140
+ </div>
141
+
142
+ <div v-if="hasChartData" class="chart-wrap">
143
+ <svg viewBox="0 0 920 260" preserveAspectRatio="none" class="activity-chart">
144
+ <line
145
+ v-for="tick in chartTicks"
146
+ :key="tick"
147
+ x1="48"
148
+ :y1="tick"
149
+ x2="900"
150
+ :y2="tick"
151
+ class="chart-grid"
152
+ />
153
+
154
+ <polyline :points="primaryPoints" class="primary-line" />
155
+ <polyline :points="secondaryPoints" class="secondary-line" />
156
+
157
+ <circle
158
+ v-for="point in primaryDots"
159
+ :key="`primary-${point.x}-${point.y}`"
160
+ :cx="point.x"
161
+ :cy="point.y"
162
+ r="4"
163
+ class="primary-dot"
164
+ />
165
+ <circle
166
+ v-for="point in secondaryDots"
167
+ :key="`secondary-${point.x}-${point.y}`"
168
+ :cx="point.x"
169
+ :cy="point.y"
170
+ r="4"
171
+ class="secondary-dot"
172
+ />
173
+
174
+ <text
175
+ v-for="label in chartAxisLabels"
176
+ :key="`${label.text}-${label.x}`"
177
+ :x="label.x"
178
+ y="246"
179
+ class="chart-label"
180
+ text-anchor="middle"
181
+ >
182
+ {{ label.text }}
183
+ </text>
184
+ </svg>
185
+
186
+ <div class="chart-legend">
187
+ <span><i class="legend-dot primary" />{{ primaryLegendLabel }}</span>
188
+ <span><i class="legend-dot secondary" />{{ secondaryLegendLabel }}</span>
189
+ </div>
190
+ </div>
191
+
192
+ <EmptyDashboardState v-else />
193
+ </v-card>
194
+
195
+ <v-row class="dashboard-section-grid" dense>
196
+ <v-col v-if="isWidgetVisible('taskSchedule')" cols="12" lg="7">
197
+ <DashboardPanel :title="primaryListConfig.title" action-label="View All" @action="goToDashboardRoute(primaryListConfig.route)">
198
+ <div v-if="taskScheduleItems.length" class="task-list">
199
+ <div v-for="item in taskScheduleItems" :key="item.id" class="task-row">
200
+ <div class="task-summary">
201
+ <strong>{{ item.title }}</strong>
202
+ <p>{{ item.subtitle }}</p>
203
+ </div>
204
+
205
+ <div class="person-cell">
206
+ <span class="avatar">{{ getInitials(item.person) }}</span>
207
+ <span>{{ item.person }}</span>
208
+ </div>
209
+
210
+ <span :class="['status-pill', `status-pill-${getStatusTone(item.status)}`]">
211
+ {{ item.status }}
212
+ </span>
213
+
214
+ <button class="row-action row-action-right" type="button" @click="goToListItem(item, primaryListConfig.route)">View</button>
215
+ </div>
216
+ </div>
217
+ <EmptyDashboardState v-else />
218
+ </DashboardPanel>
219
+ </v-col>
220
+
221
+ <v-col v-if="isWidgetVisible('staffStatus')" cols="12" lg="5">
222
+ <DashboardPanel title="Staff Status" action-label="View All" @action="goToDashboardRoute(staffStatusRoute)">
223
+ <div v-if="staffStatusItems.length" class="staff-list">
224
+ <div v-for="item in staffStatusItems" :key="item.id" class="staff-row">
225
+ <span class="avatar">{{ getInitials(item.name) }}</span>
226
+ <div class="staff-main">
227
+ <strong>{{ item.name }}</strong>
228
+ <p>{{ item.role }}</p>
229
+ </div>
230
+ <span :class="['status-pill', `status-pill-${getStaffTone(item.status)}`]">
231
+ {{ item.status }}
232
+ </span>
233
+ </div>
234
+ </div>
235
+ <EmptyDashboardState v-else />
236
+ </DashboardPanel>
237
+ </v-col>
238
+
239
+ <v-col v-if="isWidgetVisible('feedbacks')" cols="12">
240
+ <DashboardPanel title="Feedbacks" action-label="View All" @action="goToDashboardRoute('feedbacks')">
241
+ <div v-if="feedbackItems.length" class="feedback-list">
242
+ <div v-for="item in feedbackItems" :key="item.id" class="feedback-row">
243
+ <div class="feedback-summary">
244
+ <strong>{{ item.title }}</strong>
245
+ <p>{{ item.subtitle }}</p>
246
+ </div>
247
+
248
+ <div class="person-cell">
249
+ <span class="avatar">{{ getInitials(item.person) }}</span>
250
+ <span>{{ item.person }}</span>
251
+ </div>
252
+
253
+ <span :class="['status-pill', `status-pill-${getStatusTone(item.status)}`]">
254
+ {{ item.status }}
255
+ </span>
256
+
257
+ <button class="row-action row-action-right" type="button" @click="goToListItem(item, 'feedbacks')">View</button>
258
+ </div>
259
+ </div>
260
+ <EmptyDashboardState v-else />
261
+ </DashboardPanel>
262
+ </v-col>
263
+
264
+ <v-col v-if="isWidgetVisible('recentActivity')" cols="12">
265
+ <DashboardPanel title="Recent Activity" action-label="View All" variant="recent" @action="goToDashboardRoute('workOrders')">
266
+ <DashboardList :items="recentActivities" empty-icon="mdi-history" align-time />
267
+ </DashboardPanel>
268
+ </v-col>
269
+ </v-row>
270
+
271
+ <v-dialog v-model="customizeDialog" max-width="720" persistent scrollable>
272
+ <v-card class="customize-dialog" rounded="lg">
273
+ <div class="customize-dialog-header">
274
+ <div>
275
+ <h2>Customize Dashboard</h2>
276
+ <p>Select which widgets to display on your dashboard</p>
277
+ </div>
278
+ <v-btn
279
+ icon="mdi-close"
280
+ variant="text"
281
+ density="comfortable"
282
+ aria-label="Close customize dashboard"
283
+ @click="cancelCustomize"
284
+ />
285
+ </div>
286
+
287
+ <v-card-text class="customize-dialog-body">
288
+ <v-text-field
289
+ v-model="widgetSearch"
290
+ placeholder="Search widgets..."
291
+ prepend-inner-icon="mdi-magnify"
292
+ variant="outlined"
293
+ density="compact"
294
+ hide-details
295
+ class="widget-search"
296
+ />
297
+
298
+ <div class="reset-row">
299
+ <v-btn
300
+ variant="text"
301
+ color="#0b2f47"
302
+ size="small"
303
+ class="text-none font-weight-bold"
304
+ prepend-icon="mdi-refresh"
305
+ @click="resetCustomizeDraft"
306
+ >
307
+ Reset to default
308
+ </v-btn>
309
+ </div>
310
+
311
+ <div class="widget-list">
312
+ <div
313
+ v-for="widget in filteredCustomizeWidgets"
314
+ :key="widget.key"
315
+ class="widget-row"
316
+ >
317
+ <div>
318
+ <strong>{{ widget.title }}</strong>
319
+ <p>{{ widget.description }}</p>
320
+ </div>
321
+ <v-switch
322
+ v-model="draftVisibleWidgetKeys"
323
+ :value="widget.key"
324
+ color="#0b2f47"
325
+ inset
58
326
  hide-details
59
- variant="outlined"
60
- rounded="lg"
61
- :disabled="loading"
62
- @update:model-value="onUpdateCardPeriod(card.key, $event)"
63
- ></v-select>
64
- </v-col>
65
- </v-row>
327
+ density="compact"
328
+ aria-label="Toggle dashboard widget"
329
+ />
330
+ </div>
331
+
332
+ <EmptyDashboardState v-if="!filteredCustomizeWidgets.length" compact />
333
+ </div>
66
334
  </v-card-text>
335
+
336
+ <v-card-actions class="customize-dialog-actions">
337
+ <v-btn
338
+ variant="text"
339
+ class="text-none cancel-button"
340
+ @click="cancelCustomize"
341
+ >
342
+ Cancel
343
+ </v-btn>
344
+ <v-btn
345
+ variant="flat"
346
+ class="text-none submit-button"
347
+ @click="submitCustomize"
348
+ >
349
+ Submit
350
+ </v-btn>
351
+ </v-card-actions>
67
352
  </v-card>
68
- </v-col>
69
- </v-row>
353
+ </v-dialog>
70
354
 
71
- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
355
+ <v-snackbar v-model="snackbar.visible" :timeout="2200" color="primary">
356
+ {{ snackbar.text }}
357
+ </v-snackbar>
358
+ </section>
72
359
  </template>
73
360
 
74
361
  <script setup lang="ts">
362
+ import { defineComponent, h } from "vue";
363
+
364
+ type DashboardMetric = {
365
+ count?: number;
366
+ percentage?: number;
367
+ overdue?: number;
368
+ inProgress?: number;
369
+ pending?: number;
370
+ highSeverity?: number;
371
+ currentlyOnSite?: number;
372
+ lowStock?: number;
373
+ outOfStock?: number;
374
+ completed?: number;
375
+ total?: number;
376
+ };
377
+
378
+ type DashboardListItem = {
379
+ id: string;
380
+ title: string;
381
+ subtitle?: string;
382
+ time?: string;
383
+ icon?: string;
384
+ tone?: string;
385
+ raw?: Record<string, any>;
386
+ };
387
+
388
+ type TaskItem = {
389
+ id: string;
390
+ title: string;
391
+ subtitle: string;
392
+ person: string;
393
+ status: string;
394
+ raw?: Record<string, any>;
395
+ };
396
+
397
+ type StaffItem = {
398
+ id: string;
399
+ name: string;
400
+ role: string;
401
+ status: string;
402
+ raw?: Record<string, any>;
403
+ };
404
+
405
+ type FeedbackItem = {
406
+ id: string;
407
+ title: string;
408
+ subtitle: string;
409
+ person: string;
410
+ status: string;
411
+ raw?: Record<string, any>;
412
+ };
75
413
 
76
414
  const props = defineProps({
77
415
  orgId: {
@@ -97,80 +435,1666 @@ const props = defineProps({
97
435
  currentSiteName: {
98
436
  type: String,
99
437
  default: "",
100
- }, cardValues: {
438
+ },
439
+ cardValues: {
101
440
  type: Array as () => TDashboardCardValue[],
102
441
  required: true,
103
442
  },
104
- })
443
+ title: {
444
+ type: String,
445
+ default: "",
446
+ },
447
+ });
105
448
 
106
449
  const emit = defineEmits<{
107
450
  updatePeriod: [payload: { key: string; period: string }];
108
451
  }>();
109
452
 
110
- const periodOptions = ["Today", "This Week", "This Month"];
453
+ const router = useRouter();
454
+ const runtimeConfig = useRuntimeConfig();
455
+ const customizeDialog = ref(false);
456
+ const widgetSearch = ref("");
457
+ const selectedRange = ref("Today");
458
+ const visitorType = ref("All");
459
+ const visibleWidgetKeys = ref<string[]>([]);
460
+ const draftVisibleWidgetKeys = ref<string[]>([]);
461
+ const filterMenus = reactive<Record<string, boolean>>({});
462
+ const snackbar = reactive({
463
+ visible: false,
464
+ text: "",
465
+ });
111
466
 
112
- const periodValue: Record<string, TDashboardValues> = {
113
- Today: "today",
114
- "This Week": "thisWeek",
115
- "This Month": "thisMonth",
116
- };
467
+ const rangeOptions = [
468
+ { label: "Today", value: "Today" },
469
+ { label: "7d", value: "This Week" },
470
+ { label: "30d", value: "This Month" },
471
+ ];
472
+ const defaultVisitorOptions = ["All", "Drive-In", "Walk-In", "Pick-up", "Drop-Off", "Delivery", "Contractor"];
117
473
 
118
- const datePeriod: Record<TDashboardValues, string> = {
119
- today: "Today",
120
- thisWeek: "This Week",
121
- thisMonth: "This Month",
122
- };
474
+ const dashboardTitle = computed(() => {
475
+ if (props.title) return props.title;
476
+ const appName = String(runtimeConfig.public.APP_NAME || runtimeConfig.public.APP || "");
477
+ return `${getServiceDisplayName(appName)} - Dashboard`;
478
+ });
479
+ const appKey = computed(() => String(runtimeConfig.public.APP || runtimeConfig.public.APP_NAME || "").toLowerCase());
480
+ const isSecurityDashboard = computed(() => appKey.value.includes("security"));
481
+ const primaryListConfig = computed(() => isSecurityDashboard.value
482
+ ? {
483
+ title: "Active Patrol",
484
+ route: "virtualPatrol",
485
+ keys: ["activePatrolItems", "patrolItems"],
486
+ }
487
+ : {
488
+ title: "Today's Task Schedule",
489
+ route: "scheduleTask",
490
+ keys: ["taskScheduleItems", "scheduleTaskItems", "scheduledTaskItems", "cleaningScheduleItems", "tasks"],
491
+ });
492
+ const staffStatusRoute = computed(() => isSecurityDashboard.value ? "manpower" : "attendance");
123
493
 
124
- const message = ref("");
125
- const messageSnackbar = ref(false);
126
- const messageColor = ref("");
494
+ const defaultVisibleWidgetKeys = computed(() => [
495
+ ...props.cardValues.map((card) => card.key),
496
+ "activity",
497
+ "taskSchedule",
498
+ "staffStatus",
499
+ "feedbacks",
500
+ "recentActivity",
501
+ ]);
127
502
 
128
- const countCardList = computed(() => {
129
- const fetchedData = props.dashboardData || {};
130
- const hasFetchedData = fetchedData && Object.keys(fetchedData).length > 0;
503
+ watch(
504
+ defaultVisibleWidgetKeys,
505
+ (keys) => {
506
+ const nextKeys = [...keys];
507
+ if (!visibleWidgetKeys.value.length) {
508
+ visibleWidgetKeys.value = nextKeys;
509
+ draftVisibleWidgetKeys.value = nextKeys;
510
+ return;
511
+ }
131
512
 
132
- const sourceCards = hasFetchedData
133
- ? buildCardsFromFetchedData(fetchedData)
134
- : [];
513
+ visibleWidgetKeys.value = visibleWidgetKeys.value.filter((key) => nextKeys.includes(key));
514
+ draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((key) => nextKeys.includes(key));
515
+ },
516
+ { immediate: true },
517
+ );
135
518
 
136
- return sourceCards.map((card: any, idx: number) => {
137
- const periodKey = card.periodKey as keyof Record<string, TDashboardValues>;
138
- const apiPeriod = props.cardPeriods[periodKey] || "today";
139
- const displayPeriod = datePeriod[apiPeriod] || "Today";
519
+ watch(selectedRange, (period) => {
520
+ emit("updatePeriod", { key: "__all__", period });
521
+ });
140
522
 
141
- return {
142
- id: idx + 1,
143
- key: card.key,
144
- label: card.title || `Card ${idx + 1}`,
145
- value:
146
- card.value >= 1000
147
- ? card.value.toLocaleString()
148
- : card.value.toString(),
149
- icon: card.icon,
150
- color: card.color,
151
- percentage: `${Number(card.percentage) > 0 ? "+" : ""}${
152
- card.percentage
153
- }%`,
154
- chipColor: card.isPositive ? "success" : "error",
155
- period: displayPeriod,
156
- };
157
- });
523
+ const customizeWidgets = computed(() => [
524
+ ...props.cardValues.map((card) => ({
525
+ key: card.key,
526
+ title: card.title,
527
+ description: `Show ${card.title} metrics for the selected period.`,
528
+ })),
529
+ {
530
+ key: "activity",
531
+ title: "Activity Chart",
532
+ description: "Compare activity trends over time.",
533
+ },
534
+ {
535
+ key: "taskSchedule",
536
+ title: primaryListConfig.value.title,
537
+ description: "Show current tasks and their latest status.",
538
+ },
539
+ {
540
+ key: "staffStatus",
541
+ title: "Staff Status",
542
+ description: "See who is on duty, on break, or absent.",
543
+ },
544
+ {
545
+ key: "feedbacks",
546
+ title: "Feedbacks",
547
+ description: "Show recent feedback and resolution state.",
548
+ },
549
+ {
550
+ key: "recentActivity",
551
+ title: "Recent Activity",
552
+ description: "Latest updates across the service.",
553
+ },
554
+ ]);
555
+
556
+ const filteredCustomizeWidgets = computed(() => {
557
+ const search = widgetSearch.value.trim().toLowerCase();
558
+ if (!search) return customizeWidgets.value;
559
+
560
+ return customizeWidgets.value.filter((widget) => (
561
+ widget.title.toLowerCase().includes(search) ||
562
+ widget.description.toLowerCase().includes(search)
563
+ ));
158
564
  });
565
+ const visitorItems = computed(() => getDashboardArray(["visitorItems", "visitorsList", "visitorTransactionsList"]));
566
+ const visitorOptions = computed(() => buildFilterOptions(defaultVisitorOptions, visitorItems.value, getVisitorFilterValue));
567
+
568
+ const kpiCards = computed(() => props.cardValues.map((mapping, index) => {
569
+ const metric = mapping.key === "visitors" && isSecurityDashboard.value
570
+ ? getFilteredVisitorMetric()
571
+ : getMetric([mapping.key]);
572
+ const value = getMetricValue(metric, mapping.key);
159
573
 
160
- function buildCardsFromFetchedData(data: Record<string, any>) {
161
- return props.cardValues.map((mapping) => ({
574
+ return {
162
575
  key: mapping.key,
163
- periodKey: mapping.periodKey,
164
576
  title: mapping.title,
165
- value: data[mapping.key]?.count ?? 0,
166
- percentage: data[mapping.key]?.percentage ?? 0,
167
577
  icon: mapping.icon,
168
- color: mapping.color,
169
- isPositive: (data[mapping.key]?.percentage ?? 0) >= 0,
578
+ tint: getCardTint(mapping.color, index),
579
+ color: mapping.color || getCardColor(index),
580
+ value: typeof value === "string" ? value : formatNumber(value),
581
+ percentage: toNumber(metric.percentage),
582
+ meta: getMetricMeta(metric),
583
+ filter: mapping.key === "visitors" && isSecurityDashboard.value
584
+ ? {
585
+ model: visitorType.value,
586
+ options: visitorOptions.value,
587
+ set: (option: string) => {
588
+ visitorType.value = option;
589
+ filterMenus.visitors = false;
590
+ },
591
+ }
592
+ : undefined,
593
+ };
594
+ }));
595
+
596
+ const visibleKpiCards = computed(() => kpiCards.value.filter((card) => isWidgetVisible(card.key)));
597
+
598
+ const activityTitle = computed(() => {
599
+ if (selectedRange.value === "Today") return "Today's Activity";
600
+ if (selectedRange.value === "This Month") return "This Month Activity";
601
+ return "This Week Activity";
602
+ });
603
+
604
+ const activityChart = computed(() => normalizeActivityChart(getActivityChartSource()));
605
+ const chartLabels = computed<string[]>(() => {
606
+ const labels = activityChart.value.labels;
607
+ if (labels.length) return labels;
608
+
609
+ if (selectedRange.value === "Today") return ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"];
610
+ if (selectedRange.value === "This Month") return ["Week 1", "Week 2", "Week 3", "Week 4"];
611
+ return ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
612
+ });
613
+ const primarySeries = computed(() => normalizeSeries(activityChart.value.primary, chartLabels.value.length));
614
+ const secondarySeries = computed(() => normalizeSeries(activityChart.value.secondary, chartLabels.value.length));
615
+ const primaryLegendLabel = computed(() => activityChart.value.primaryLabel || "Work Order");
616
+ const secondaryLegendLabel = computed(() => activityChart.value.secondaryLabel || "Feedback");
617
+ const chartMax = computed(() => Math.max(1, ...primarySeries.value, ...secondarySeries.value));
618
+ const hasChartData = computed(() => primarySeries.value.some(Boolean) || secondarySeries.value.some(Boolean));
619
+ const chartTicks = [28, 76, 124, 172, 220];
620
+ const primaryDots = computed(() => buildChartDots(primarySeries.value));
621
+ const secondaryDots = computed(() => buildChartDots(secondarySeries.value));
622
+ const primaryPoints = computed(() => primaryDots.value.map((point) => `${point.x},${point.y}`).join(" "));
623
+ const secondaryPoints = computed(() => secondaryDots.value.map((point) => `${point.x},${point.y}`).join(" "));
624
+ const chartAxisLabels = computed(() => {
625
+ const labels = chartLabels.value;
626
+ const count = Math.max(1, labels.length - 1);
627
+
628
+ return labels.map((label, index) => ({
629
+ text: label,
630
+ x: 54 + ((846 / count) * index),
631
+ }));
632
+ });
633
+
634
+ const taskScheduleItems = computed<TaskItem[]>(() => getDashboardArray([
635
+ ...primaryListConfig.value.keys,
636
+ ]).map(normalizeTaskItem).slice(0, 4));
637
+ const staffStatusItems = computed<StaffItem[]>(() => getDashboardArray([
638
+ "staffStatusItems",
639
+ "attendanceItems",
640
+ "staffItems",
641
+ ]).map(normalizeStaffItem).slice(0, 4));
642
+ const feedbackItems = computed<FeedbackItem[]>(() => getDashboardArray([
643
+ "feedbackItems",
644
+ "feedbacksList",
645
+ "feedbacks",
646
+ ]).map(normalizeFeedbackItem).slice(0, 4));
647
+ const recentActivities = computed<DashboardListItem[]>(() => getDashboardArray([
648
+ "recentActivities",
649
+ "activityItems",
650
+ ]).map(normalizeActivityItem).slice(0, 3));
651
+
652
+ const EmptyDashboardState = defineComponent({
653
+ name: "EmptyDashboardState",
654
+ props: {
655
+ compact: Boolean,
656
+ },
657
+ setup(componentProps) {
658
+ return () => h("div", { class: ["empty-state", componentProps.compact ? "empty-state-compact" : ""] }, [
659
+ h("i", { class: "mdi mdi-database-search-outline" }),
660
+ h("span", "No data to display"),
661
+ ]);
662
+ },
663
+ });
664
+
665
+ const DashboardPanel = defineComponent({
666
+ name: "DashboardPanel",
667
+ props: {
668
+ title: {
669
+ type: String,
670
+ required: true,
671
+ },
672
+ actionLabel: {
673
+ type: String,
674
+ default: "",
675
+ },
676
+ variant: {
677
+ type: String,
678
+ default: "",
679
+ },
680
+ },
681
+ emits: ["action"],
682
+ setup(componentProps, { slots, emit: componentEmit }) {
683
+ return () => h("div", {
684
+ class: [
685
+ "dashboard-card",
686
+ "dashboard-panel",
687
+ componentProps.variant ? `dashboard-panel-${componentProps.variant}` : "",
688
+ ],
689
+ }, [
690
+ h("div", {
691
+ class: "panel-header",
692
+ style: {
693
+ alignItems: "center",
694
+ display: "flex",
695
+ flexDirection: "row",
696
+ justifyContent: "space-between",
697
+ width: "100%",
698
+ },
699
+ }, [
700
+ h("h2", {
701
+ style: {
702
+ color: "#132b43",
703
+ fontSize: componentProps.variant === "recent" ? "15px" : "15px",
704
+ fontWeight: "800",
705
+ letterSpacing: "0",
706
+ lineHeight: "1.2",
707
+ margin: "0",
708
+ textTransform: componentProps.variant === "recent" ? "uppercase" : "none",
709
+ },
710
+ }, componentProps.title),
711
+ componentProps.actionLabel
712
+ ? h("button", {
713
+ class: "panel-action",
714
+ style: {
715
+ appearance: "none",
716
+ background: "transparent",
717
+ border: "0",
718
+ color: "#2196f3",
719
+ cursor: "pointer",
720
+ fontSize: "12px",
721
+ fontWeight: "800",
722
+ lineHeight: "1.2",
723
+ marginLeft: "auto",
724
+ padding: "0",
725
+ },
726
+ type: "button",
727
+ onClick: () => componentEmit("action"),
728
+ }, componentProps.actionLabel)
729
+ : null,
730
+ ]),
731
+ h("div", { class: "panel-body" }, slots.default?.()),
732
+ ]);
733
+ },
734
+ });
735
+
736
+ const DashboardList = defineComponent({
737
+ name: "DashboardList",
738
+ props: {
739
+ items: {
740
+ type: Array as () => DashboardListItem[],
741
+ default: () => [],
742
+ },
743
+ emptyIcon: {
744
+ type: String,
745
+ default: "mdi-database-search-outline",
746
+ },
747
+ alignTime: Boolean,
748
+ },
749
+ setup(componentProps) {
750
+ return () => componentProps.items.length
751
+ ? h("div", {
752
+ class: "simple-list",
753
+ style: {
754
+ display: "grid",
755
+ width: "100%",
756
+ },
757
+ }, componentProps.items.map((item) => {
758
+ const tone = getActivityToneStyle(item.tone || "neutral");
759
+
760
+ return h("div", {
761
+ class: "simple-list-row",
762
+ key: item.id,
763
+ style: {
764
+ alignItems: "center",
765
+ background: "#fff",
766
+ borderBottom: "1px solid #e0e0e0",
767
+ display: "grid",
768
+ gap: "14px",
769
+ gridTemplateColumns: componentProps.alignTime ? "34px minmax(0, 1fr) 96px" : "34px minmax(0, 1fr)",
770
+ minHeight: "58px",
771
+ padding: "10px 0",
772
+ },
773
+ }, [
774
+ h("span", {
775
+ class: ["list-icon", `list-icon-${item.tone || "neutral"}`],
776
+ style: {
777
+ alignItems: "center",
778
+ background: tone.background,
779
+ borderRadius: "6px",
780
+ color: tone.color,
781
+ display: "inline-flex",
782
+ fontSize: "13px",
783
+ height: "25px",
784
+ justifyContent: "center",
785
+ width: "25px",
786
+ },
787
+ }, [
788
+ h("i", { class: `mdi ${item.icon || componentProps.emptyIcon}` }),
789
+ ]),
790
+ h("div", {
791
+ class: "list-main",
792
+ style: {
793
+ minWidth: "0",
794
+ },
795
+ }, [
796
+ h("strong", {
797
+ style: {
798
+ color: "#132b43",
799
+ display: "block",
800
+ fontSize: "13px",
801
+ fontWeight: "700",
802
+ lineHeight: "1.3",
803
+ overflow: "hidden",
804
+ textOverflow: "ellipsis",
805
+ whiteSpace: "nowrap",
806
+ },
807
+ }, item.title),
808
+ item.subtitle ? h("p", {
809
+ style: {
810
+ color: "#888",
811
+ fontSize: "12px",
812
+ fontWeight: "500",
813
+ lineHeight: "1.35",
814
+ margin: "5px 0 0",
815
+ overflow: "hidden",
816
+ textOverflow: "ellipsis",
817
+ whiteSpace: "nowrap",
818
+ },
819
+ }, item.subtitle) : null,
820
+ ]),
821
+ componentProps.alignTime
822
+ ? h("span", {
823
+ class: "list-action",
824
+ style: {
825
+ color: "#888",
826
+ fontSize: "12px",
827
+ fontWeight: "500",
828
+ justifySelf: "end",
829
+ textAlign: "right",
830
+ whiteSpace: "nowrap",
831
+ },
832
+ }, item.time || "")
833
+ : null,
834
+ ]);
835
+ }))
836
+ : h(EmptyDashboardState);
837
+ },
838
+ });
839
+
840
+ function getServiceDisplayName(value: string) {
841
+ const normalized = value.toLowerCase();
842
+ const titleMap: Record<string, string> = {
843
+ security_agency: "Security",
844
+ cleaning_services: "Hygiene",
845
+ hygiene: "Hygiene",
846
+ mechanical_electrical: "Mechanical & Electrical",
847
+ mechanical_electrical_services: "Mechanical & Electrical",
848
+ pest_control_services: "Pest Control",
849
+ pool_maintenance_services: "Pool Maintenance",
850
+ landscaping_services: "Landscaping",
851
+ property_management_agency: "Property Management",
852
+ };
853
+
854
+ if (titleMap[normalized]) return titleMap[normalized];
855
+
856
+ const cleaned = normalized
857
+ .replace(/_services|_agency/g, "")
858
+ .replace(/[_-]+/g, " ")
859
+ .trim();
860
+
861
+ return cleaned
862
+ ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase())
863
+ : "Service";
864
+ }
865
+
866
+ function getMetric(keys: string[]): DashboardMetric {
867
+ const data = props.dashboardData || {};
868
+ for (const key of keys) {
869
+ const value = data[key];
870
+ if (value && typeof value === "object" && !Array.isArray(value)) {
871
+ return value as DashboardMetric;
872
+ }
873
+ }
874
+ return {};
875
+ }
876
+
877
+ function getMetricValue(metric: DashboardMetric, key?: string) {
878
+ if (key === "patrolCompliance") {
879
+ return `${formatNumber(toNumber(metric.count))}%`;
880
+ }
881
+
882
+ const completed = toNumber(metric.completed);
883
+ const total = toNumber(metric.total);
884
+
885
+ if (total > 0 && typeof metric.completed !== "undefined") {
886
+ return `${formatNumber(completed)}/${formatNumber(total)}`;
887
+ }
888
+
889
+ return toNumber(metric.count);
890
+ }
891
+
892
+ function getMetricMeta(metric: DashboardMetric) {
893
+ const parts: string[] = [];
894
+
895
+ if (typeof metric.overdue !== "undefined") parts.push(`${formatNumber(toNumber(metric.overdue))} overdue`);
896
+ if (typeof metric.inProgress !== "undefined") parts.push(`${formatNumber(toNumber(metric.inProgress))} in progress`);
897
+ if (typeof metric.pending !== "undefined") parts.push(`${formatNumber(toNumber(metric.pending))} pending`);
898
+ if (typeof metric.highSeverity !== "undefined") parts.push(`${formatNumber(toNumber(metric.highSeverity))} high severity`);
899
+ if (typeof metric.currentlyOnSite !== "undefined") parts.push(`${formatNumber(toNumber(metric.currentlyOnSite))} currently on site`);
900
+ if (typeof (metric as Record<string, any>).missedCheckpoints !== "undefined" || typeof (metric as Record<string, any>).missed !== "undefined") {
901
+ parts.push(`${formatNumber(toNumber((metric as Record<string, any>).missedCheckpoints ?? (metric as Record<string, any>).missed))} checkpoint missed`);
902
+ }
903
+ if (typeof metric.lowStock !== "undefined") parts.push(`${formatNumber(toNumber(metric.lowStock))} low stock`);
904
+ if (typeof metric.outOfStock !== "undefined") parts.push(`${formatNumber(toNumber(metric.outOfStock))} out of stock`);
905
+ if (typeof metric.completed !== "undefined" && typeof metric.total !== "undefined") {
906
+ parts.push(`${formatNumber(toNumber(metric.completed))} completed`);
907
+ }
908
+
909
+ return parts.join(" - ");
910
+ }
911
+
912
+ function getCardColor(index: number) {
913
+ return ["#155178", "#9f2f29", "#1f8a56", "#b96d17"][index % 4];
914
+ }
915
+
916
+ function getCardTint(color: string, index: number) {
917
+ const tints = ["#dbeeff", "#f8c6c3", "#d8f5e5", "#ffe7c2"];
918
+ if (!color) return tints[index % tints.length];
919
+
920
+ const normalized = color.toLowerCase();
921
+ if (normalized.includes("f44336") || normalized.includes("d32f2f")) return "#f8c6c3";
922
+ if (normalized.includes("4caf50") || normalized.includes("43a047")) return "#d8f5e5";
923
+ if (normalized.includes("ff9800") || normalized.includes("ffa726")) return "#ffe7c2";
924
+ return "#dbeeff";
925
+ }
926
+
927
+ function getActivityChartSource() {
928
+ const data = props.dashboardData || {};
929
+ return data.activityChart ||
930
+ data.statistic?.activityChart ||
931
+ data.statistics?.activityChart ||
932
+ data.statisticChart ||
933
+ data.statisticsChart ||
934
+ data.activityStatistics ||
935
+ data.activityStatistic ||
936
+ {};
937
+ }
938
+
939
+ function normalizeActivityChart(source: any) {
940
+ const labels = Array.isArray(source?.labels)
941
+ ? source.labels.map((label: any) => String(label))
942
+ : [];
943
+ const primary = extractChartSeries(source, ["workOrders", "workOrder", "work_orders"]);
944
+ const secondaryCandidate = getSecondaryChartSeries(source);
945
+
946
+ return {
947
+ labels,
948
+ primary,
949
+ secondary: secondaryCandidate.series,
950
+ primaryLabel: "Work Order",
951
+ secondaryLabel: secondaryCandidate.label,
952
+ };
953
+ }
954
+
955
+ function getSecondaryChartSeries(source: any) {
956
+ const candidates = [
957
+ { label: "Task Completed", keys: ["taskCompleted", "completedTasks", "completed"] },
958
+ { label: "Incident", keys: ["incidents", "incident", "incidentReports"] },
959
+ { label: "Feedback", keys: ["feedbacks", "feedback", "feedbackTickets"] },
960
+ ];
961
+
962
+ for (const candidate of candidates) {
963
+ const series = extractChartSeries(source, candidate.keys);
964
+ if (series.length) return { label: candidate.label, series };
965
+ }
966
+
967
+ return { label: "Feedback", series: [] as number[] };
968
+ }
969
+
970
+ function extractChartSeries(source: any, keys: string[]) {
971
+ for (const key of keys) {
972
+ const value = source?.[key];
973
+ if (Array.isArray(value)) return value.map(toNumber);
974
+ }
975
+
976
+ if (Array.isArray(source?.datasets)) {
977
+ const dataset = source.datasets.find((item: any) => {
978
+ const keyText = String(item?.key || item?.label || item?.name || "").toLowerCase();
979
+ return keys.some((key) => keyText.includes(key.toLowerCase()));
980
+ });
981
+ if (Array.isArray(dataset?.data)) return dataset.data.map(toNumber);
982
+ }
983
+
984
+ return [];
985
+ }
986
+
987
+ function normalizeSeries(value: any, targetLength: number) {
988
+ const source = Array.isArray(value) ? value : [];
989
+ return Array.from({ length: targetLength }, (_, index) => toNumber(source[index]));
990
+ }
991
+
992
+ function buildChartDots(series: number[]) {
993
+ const count = Math.max(1, series.length - 1);
994
+
995
+ return series.map((value, index) => ({
996
+ x: 54 + ((846 / count) * index),
997
+ y: 220 - ((toNumber(value) / chartMax.value) * 184),
170
998
  }));
171
999
  }
172
1000
 
173
- function onUpdateCardPeriod(cardKey: string, period: string) {
174
- emit("updatePeriod", { key: cardKey, period });
1001
+ function getDashboardArray(keys: string[]) {
1002
+ const data = props.dashboardData || {};
1003
+
1004
+ for (const key of keys) {
1005
+ const value = data[key];
1006
+ if (Array.isArray(value)) return value;
1007
+ if (Array.isArray(value?.items)) return value.items;
1008
+ if (Array.isArray(value?.docs)) return value.docs;
1009
+ if (Array.isArray(value?.data)) return value.data;
1010
+ }
1011
+
1012
+ return [];
1013
+ }
1014
+
1015
+ function getFilteredVisitorMetric(): DashboardMetric {
1016
+ const metric = getMetric(["visitors", "visitor"]);
1017
+ const selected = visitorType.value;
1018
+ const items = visitorItems.value;
1019
+
1020
+ if (selected === "All") {
1021
+ return {
1022
+ ...metric,
1023
+ count: toNumber(metric.count ?? items.length),
1024
+ currentlyOnSite: toNumber(metric.currentlyOnSite ?? items.filter((item) => !isClosedStatus(item.status) && !item.checkOut).length),
1025
+ };
1026
+ }
1027
+
1028
+ const filtered = items.filter((item) => getVisitorFilterValue(item) === selected);
1029
+
1030
+ return {
1031
+ ...metric,
1032
+ count: filtered.length,
1033
+ currentlyOnSite: filtered.filter((item) => !isClosedStatus(item.status) && !item.checkOut).length,
1034
+ };
1035
+ }
1036
+
1037
+ function buildFilterOptions(
1038
+ defaults: string[],
1039
+ items: Record<string, any>[],
1040
+ getValue: (item: Record<string, any>) => string,
1041
+ ) {
1042
+ const options = new Set(defaults);
1043
+ items.forEach((item) => {
1044
+ const value = getValue(item);
1045
+ if (value) options.add(value);
1046
+ });
1047
+
1048
+ return Array.from(options);
1049
+ }
1050
+
1051
+ function getVisitorFilterValue(item: Record<string, any>) {
1052
+ const raw = String(item.type || item.visitorType || item.category || "").toLowerCase();
1053
+ if (raw.includes("contractor")) return "Contractor";
1054
+ if (raw.includes("walk")) return "Walk-In";
1055
+ if (raw.includes("delivery")) return "Delivery";
1056
+ if (raw.includes("pick")) return "Pick-up";
1057
+ if (raw.includes("drop")) return "Drop-Off";
1058
+ if (raw.includes("guest") || raw.includes("drive")) return "Drive-In";
1059
+ return "All";
1060
+ }
1061
+
1062
+ function normalizeTaskItem(item: Record<string, any>, index: number): TaskItem {
1063
+ const title = item.title || item.subject || item.name || item.taskName || item.description || "Task";
1064
+ const subtitle = item.subtitle || item.area || item.location || item.asset || item.category || "";
1065
+ const person = item.person || item.assigneeName || item.assignedTo?.name || item.staff?.name || item.createdBy?.name || "Unassigned";
1066
+
1067
+ return {
1068
+ id: String(item.id || item._id || `${title}-${index}`),
1069
+ title,
1070
+ subtitle,
1071
+ person,
1072
+ status: normalizeStatus(item.status || item.state || item.taskStatus || "To-Do"),
1073
+ raw: item,
1074
+ };
1075
+ }
1076
+
1077
+ function normalizeStaffItem(item: Record<string, any>, index: number): StaffItem {
1078
+ const name = item.name || item.employeeName || item.user?.name || item.staff?.name || item.createdByName || "Unassigned";
1079
+ const role = item.role || item.jobTitle || item.designation || item.position || "Officer";
1080
+
1081
+ return {
1082
+ id: String(item.id || item._id || `${name}-${index}`),
1083
+ name,
1084
+ role,
1085
+ status: normalizeStatus(item.statusLabel || item.status || item.currentStatus || item.attendanceStatus || "Off duty"),
1086
+ raw: item,
1087
+ };
1088
+ }
1089
+
1090
+ function normalizeFeedbackItem(item: Record<string, any>, index: number): FeedbackItem {
1091
+ const title = item.title || item.subject || item.description || "Feedback";
1092
+ const subject = item.category || item.type || item.service || "Subject";
1093
+ const app = item.app || item.source || item.channel || "";
1094
+ const person = item.person || item.createdByName || item.submittedBy || item.createdBy?.name || item.user?.name || "Unassigned";
1095
+
1096
+ return {
1097
+ id: String(item.id || item._id || `${title}-${index}`),
1098
+ title,
1099
+ subtitle: [subject, app].filter(Boolean).join(" - "),
1100
+ person,
1101
+ status: normalizeStatus(item.status || item.state || "To-Do"),
1102
+ raw: item,
1103
+ };
1104
+ }
1105
+
1106
+ function normalizeActivityItem(item: Record<string, any>, index: number): DashboardListItem {
1107
+ const title = item.title || item.subject || item.description || item.message || "Activity";
1108
+ const subtitle = item.subtitle || item.type || item.category || item.status || "";
1109
+
1110
+ return {
1111
+ id: String(item.id || item._id || `${title}-${index}`),
1112
+ title,
1113
+ subtitle,
1114
+ time: formatActivityTime(item),
1115
+ icon: item.icon || "mdi-history",
1116
+ tone: item.tone || getStatusTone(item.status || item.type),
1117
+ raw: item,
1118
+ };
1119
+ }
1120
+
1121
+ function formatActivityTime(item: Record<string, any>) {
1122
+ if (item.displayTime) return String(item.displayTime);
1123
+
1124
+ const rawTime = item.time || getItemTime(item);
1125
+ const rawText = String(rawTime || "");
1126
+
1127
+ if (!rawText) return "";
1128
+ if (/\b(ago|now|today|yesterday|min|mins|hr|hrs|hour|hours|day|days)\b/i.test(rawText)) {
1129
+ return rawText;
1130
+ }
1131
+
1132
+ return formatRelativeTime(rawTime);
1133
+ }
1134
+
1135
+ function getActivityToneStyle(tone: string) {
1136
+ const toneMap: Record<string, { background: string; color: string }> = {
1137
+ info: {
1138
+ background: "#d8ecff",
1139
+ color: "#17496f",
1140
+ },
1141
+ danger: {
1142
+ background: "#ffd9d6",
1143
+ color: "#bd413c",
1144
+ },
1145
+ success: {
1146
+ background: "#d8f4e3",
1147
+ color: "#2b9160",
1148
+ },
1149
+ warning: {
1150
+ background: "#ffe0ad",
1151
+ color: "#d88717",
1152
+ },
1153
+ neutral: {
1154
+ background: "#edf2f6",
1155
+ color: "#607386",
1156
+ },
1157
+ };
1158
+
1159
+ return toneMap[tone] || toneMap.neutral;
1160
+ }
1161
+
1162
+ function getItemTime(item: Record<string, any>) {
1163
+ return item.createdAt || item.updatedAt || item.date || item.time || item.created_on || item.updated_on;
1164
+ }
1165
+
1166
+ function normalizeStatus(value: any) {
1167
+ const text = String(value || "").trim();
1168
+ if (!text) return "To-Do";
1169
+
1170
+ return text
1171
+ .replace(/[_-]+/g, " ")
1172
+ .replace(/\b\w/g, (char) => char.toUpperCase());
1173
+ }
1174
+
1175
+ function getStatusTone(status: any) {
1176
+ const normalized = String(status || "").toLowerCase();
1177
+ if (normalized.includes("complete") || normalized.includes("done") || normalized.includes("resolved") || normalized.includes("closed")) return "success";
1178
+ if (normalized.includes("progress") || normalized.includes("active") || normalized.includes("ongoing")) return "info";
1179
+ if (normalized.includes("overdue") || normalized.includes("urgent") || normalized.includes("high")) return "danger";
1180
+ return "neutral";
1181
+ }
1182
+
1183
+ function getStaffTone(status: any) {
1184
+ const normalized = String(status || "").toLowerCase();
1185
+ if (normalized.includes("duty") || normalized.includes("present") || normalized.includes("active")) return "success";
1186
+ if (normalized.includes("break")) return "warning";
1187
+ if (normalized.includes("absent") || normalized.includes("off")) return "danger";
1188
+ return "neutral";
1189
+ }
1190
+
1191
+ function isClosedStatus(status: any) {
1192
+ const normalized = String(status || "").toLowerCase();
1193
+ return normalized.includes("complete") ||
1194
+ normalized.includes("cancel") ||
1195
+ normalized.includes("reject") ||
1196
+ normalized.includes("closed") ||
1197
+ normalized.includes("deleted") ||
1198
+ normalized.includes("check-out") ||
1199
+ normalized.includes("checkout");
1200
+ }
1201
+
1202
+ function isWidgetVisible(key: string) {
1203
+ return visibleWidgetKeys.value.includes(key);
1204
+ }
1205
+
1206
+ function openCustomizeDialog() {
1207
+ draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
1208
+ customizeDialog.value = true;
1209
+ }
1210
+
1211
+ function cancelCustomize() {
1212
+ draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
1213
+ widgetSearch.value = "";
1214
+ customizeDialog.value = false;
1215
+ }
1216
+
1217
+ function resetCustomizeDraft() {
1218
+ draftVisibleWidgetKeys.value = [...defaultVisibleWidgetKeys.value];
1219
+ }
1220
+
1221
+ function submitCustomize() {
1222
+ visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
1223
+ widgetSearch.value = "";
1224
+ customizeDialog.value = false;
1225
+ }
1226
+
1227
+ function notifyExport(format: string) {
1228
+ snackbar.text = `${format} export is being prepared.`;
1229
+ snackbar.visible = true;
1230
+ }
1231
+
1232
+ function goToDashboardRoute(routeKey: string) {
1233
+ if (!props.orgId || !props.site) return;
1234
+
1235
+ const pathMap: Record<string, string> = {
1236
+ attendance: "attendance",
1237
+ feedbacks: "feedbacks",
1238
+ scheduleTask: "schedule-task",
1239
+ virtualPatrol: "virtual-patrol/logs",
1240
+ manpower: "manpower-monitoring/attendance",
1241
+ incidentReports: "incident-reports",
1242
+ visitorManagement: "visitor-management",
1243
+ workOrders: "work-orders",
1244
+ };
1245
+ const path = pathMap[routeKey] || routeKey;
1246
+ router.push(`/${props.orgId}/${props.site}/${path}`);
1247
+ }
1248
+
1249
+ function goToListItem(item: TaskItem | FeedbackItem, routeKey: string) {
1250
+ if (routeKey === "virtualPatrol") {
1251
+ goToDashboardRoute(routeKey);
1252
+ return;
1253
+ }
1254
+
1255
+ const id = item.raw?._id || item.raw?.id || item.id;
1256
+ if (!id || String(id).includes("-")) {
1257
+ goToDashboardRoute(routeKey);
1258
+ return;
1259
+ }
1260
+
1261
+ const pathMap: Record<string, string> = {
1262
+ feedbacks: "feedbacks",
1263
+ scheduleTask: "schedule-task",
1264
+ };
1265
+ const path = pathMap[routeKey] || routeKey;
1266
+ router.push(`/${props.orgId}/${props.site}/${path}/${id}`);
1267
+ }
1268
+
1269
+ function getInitials(name: string) {
1270
+ const parts = String(name || "")
1271
+ .trim()
1272
+ .split(/\s+/)
1273
+ .filter(Boolean);
1274
+ if (!parts.length) return "?";
1275
+ return parts.slice(0, 2).map((part) => part[0]).join("").toUpperCase();
1276
+ }
1277
+
1278
+ function formatNumber(value: number) {
1279
+ return new Intl.NumberFormat("en-US").format(toNumber(value));
1280
+ }
1281
+
1282
+ function toNumber(value: any) {
1283
+ const numberValue = Number(value ?? 0);
1284
+ return Number.isFinite(numberValue) ? numberValue : 0;
1285
+ }
1286
+
1287
+ function formatRelativeTime(value: any) {
1288
+ if (!value) return "";
1289
+
1290
+ const date = new Date(value);
1291
+ if (Number.isNaN(date.getTime())) return "";
1292
+
1293
+ const diffMs = Date.now() - date.getTime();
1294
+ const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
1295
+ if (diffMinutes < 60) {
1296
+ const minutes = diffMinutes || 1;
1297
+ return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
1298
+ }
1299
+
1300
+ const diffHours = Math.round(diffMinutes / 60);
1301
+ if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? "" : "s"} ago`;
1302
+
1303
+ const diffDays = Math.round(diffHours / 24);
1304
+ return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
175
1305
  }
176
1306
  </script>
1307
+
1308
+ <style scoped>
1309
+ .service-dashboard {
1310
+ color: #132b43;
1311
+ padding-bottom: 32px;
1312
+ }
1313
+
1314
+ .dashboard-header {
1315
+ align-items: flex-start;
1316
+ display: flex;
1317
+ gap: 20px;
1318
+ justify-content: space-between;
1319
+ margin-bottom: 24px;
1320
+ }
1321
+
1322
+ .dashboard-title {
1323
+ color: #0b2f47;
1324
+ font-size: 28px;
1325
+ font-weight: 800;
1326
+ letter-spacing: 0;
1327
+ line-height: 1.2;
1328
+ margin: 0;
1329
+ }
1330
+
1331
+ .site-label {
1332
+ align-items: center;
1333
+ color: #6d7d8b;
1334
+ display: flex;
1335
+ font-size: 13px;
1336
+ gap: 6px;
1337
+ margin-top: 8px;
1338
+ }
1339
+
1340
+ .header-actions {
1341
+ align-items: center;
1342
+ display: flex;
1343
+ flex-wrap: wrap;
1344
+ gap: 12px;
1345
+ justify-content: flex-end;
1346
+ }
1347
+
1348
+ .action-button {
1349
+ border-color: #d9e1e8;
1350
+ border-radius: 6px;
1351
+ color: #102b40;
1352
+ height: 36px;
1353
+ font-weight: 700;
1354
+ letter-spacing: 0.04em;
1355
+ min-height: 36px;
1356
+ padding: 0 16px;
1357
+ }
1358
+
1359
+ .action-button :deep(.v-btn__content) {
1360
+ font-size: 15px;
1361
+ line-height: 1;
1362
+ }
1363
+
1364
+ .action-button :deep(.v-btn__prepend) {
1365
+ margin-inline: 0 8px;
1366
+ }
1367
+
1368
+ .action-button :deep(.v-btn__append) {
1369
+ margin-inline: 8px 0;
1370
+ }
1371
+
1372
+ .range-toggle {
1373
+ border: 1px solid #d9e1e8;
1374
+ border-radius: 6px;
1375
+ height: 36px;
1376
+ min-height: 36px;
1377
+ overflow: hidden;
1378
+ }
1379
+
1380
+ .range-toggle :deep(.v-btn) {
1381
+ border-radius: 0;
1382
+ color: #102b40;
1383
+ font-size: 14px;
1384
+ font-weight: 500;
1385
+ height: 34px;
1386
+ letter-spacing: 0.04em;
1387
+ min-height: 34px;
1388
+ min-width: 62px;
1389
+ padding: 0 20px;
1390
+ }
1391
+
1392
+ .range-toggle :deep(.v-btn--active) {
1393
+ background: #e0e0e0;
1394
+ }
1395
+
1396
+ .range-toggle :deep(.v-btn__overlay) {
1397
+ opacity: 0;
1398
+ }
1399
+
1400
+ .export-menu,
1401
+ .filter-menu {
1402
+ border-color: #d9e1e8;
1403
+ border-radius: 7px;
1404
+ box-shadow: 0 12px 30px rgba(16, 35, 54, 0.12);
1405
+ }
1406
+
1407
+ .kpi-grid {
1408
+ margin-bottom: 14px;
1409
+ }
1410
+
1411
+ .dashboard-card {
1412
+ background: #fff;
1413
+ border: 1px solid #d9e0e7;
1414
+ border-radius: 8px;
1415
+ box-shadow: 0 1px 2px rgba(15, 35, 54, 0.04);
1416
+ }
1417
+
1418
+ .kpi-card {
1419
+ display: flex;
1420
+ flex-direction: column;
1421
+ min-height: 158px;
1422
+ padding: 16px 18px 18px;
1423
+ }
1424
+
1425
+ .kpi-topline {
1426
+ align-items: flex-start;
1427
+ display: flex;
1428
+ justify-content: space-between;
1429
+ }
1430
+
1431
+ .kpi-icon {
1432
+ align-items: center;
1433
+ border-radius: 8px;
1434
+ display: inline-flex;
1435
+ height: 28px;
1436
+ justify-content: center;
1437
+ width: 28px;
1438
+ }
1439
+
1440
+ .kpi-icon :deep(.v-icon) {
1441
+ font-size: 15px;
1442
+ height: 15px;
1443
+ width: 15px;
1444
+ }
1445
+
1446
+ .mini-filter {
1447
+ border-color: #d9e1e8;
1448
+ font-size: 12px;
1449
+ height: 26px;
1450
+ min-width: 50px;
1451
+ padding-inline: 10px 6px;
1452
+ }
1453
+
1454
+ .mini-filter :deep(.v-btn__append) {
1455
+ margin-inline-start: 4px;
1456
+ }
1457
+
1458
+ .kpi-content {
1459
+ display: flex;
1460
+ flex-direction: column;
1461
+ margin-top: 26px;
1462
+ min-width: 0;
1463
+ }
1464
+
1465
+ .kpi-label {
1466
+ color: #2a465a;
1467
+ font-size: 12px;
1468
+ font-weight: 800;
1469
+ letter-spacing: 0;
1470
+ line-height: 1.2;
1471
+ margin: 0 0 13px;
1472
+ text-transform: uppercase;
1473
+ }
1474
+
1475
+ .kpi-value-row {
1476
+ align-items: center;
1477
+ display: flex;
1478
+ min-height: 31px;
1479
+ }
1480
+
1481
+ .kpi-value-row strong {
1482
+ color: #092f49;
1483
+ font-size: 30px;
1484
+ font-weight: 800;
1485
+ letter-spacing: 0;
1486
+ line-height: 1;
1487
+ }
1488
+
1489
+ .kpi-meta-row {
1490
+ align-items: center;
1491
+ display: flex;
1492
+ flex-wrap: wrap;
1493
+ gap: 8px;
1494
+ margin-top: 12px;
1495
+ min-height: 22px;
1496
+ }
1497
+
1498
+ .kpi-meta-row :deep(.v-chip) {
1499
+ border-radius: 999px;
1500
+ font-size: 11px;
1501
+ font-weight: 800;
1502
+ height: 20px;
1503
+ padding-inline: 8px;
1504
+ }
1505
+
1506
+ .kpi-meta-row :deep(.v-chip__prepend) {
1507
+ margin-inline-end: 3px;
1508
+ }
1509
+
1510
+ .kpi-meta {
1511
+ color: #4f6576;
1512
+ font-size: 12px;
1513
+ font-weight: 600;
1514
+ line-height: 1.35;
1515
+ margin: 0;
1516
+ }
1517
+
1518
+ .activity-card {
1519
+ padding: 18px 20px 14px;
1520
+ }
1521
+
1522
+ .section-header,
1523
+ .panel-header {
1524
+ align-items: center;
1525
+ display: flex;
1526
+ justify-content: space-between;
1527
+ }
1528
+
1529
+ .section-header h2,
1530
+ .panel-header h2 {
1531
+ color: #132b43;
1532
+ font-size: 15px;
1533
+ font-weight: 800;
1534
+ letter-spacing: 0;
1535
+ line-height: 1.2;
1536
+ margin: 0;
1537
+ }
1538
+
1539
+ .chart-wrap {
1540
+ margin-top: 18px;
1541
+ }
1542
+
1543
+ .activity-chart {
1544
+ display: block;
1545
+ height: 260px;
1546
+ width: 100%;
1547
+ }
1548
+
1549
+ .chart-grid {
1550
+ stroke: #e9eef2;
1551
+ stroke-width: 1;
1552
+ }
1553
+
1554
+ .primary-line,
1555
+ .secondary-line {
1556
+ fill: none;
1557
+ stroke-linecap: round;
1558
+ stroke-linejoin: round;
1559
+ stroke-width: 3;
1560
+ }
1561
+
1562
+ .primary-line {
1563
+ stroke: #2196f3;
1564
+ }
1565
+
1566
+ .secondary-line {
1567
+ stroke: #1eca6b;
1568
+ }
1569
+
1570
+ .primary-dot,
1571
+ .secondary-dot {
1572
+ stroke: #fff;
1573
+ stroke-width: 2;
1574
+ }
1575
+
1576
+ .primary-dot {
1577
+ fill: #2196f3;
1578
+ }
1579
+
1580
+ .secondary-dot {
1581
+ fill: #1eca6b;
1582
+ }
1583
+
1584
+ .chart-label {
1585
+ fill: #7e8b96;
1586
+ font-size: 12px;
1587
+ font-weight: 600;
1588
+ }
1589
+
1590
+ .chart-legend {
1591
+ align-items: center;
1592
+ display: flex;
1593
+ gap: 18px;
1594
+ justify-content: center;
1595
+ margin-top: 10px;
1596
+ }
1597
+
1598
+ .chart-legend span {
1599
+ align-items: center;
1600
+ color: #6d7c88;
1601
+ display: inline-flex;
1602
+ font-size: 12px;
1603
+ font-weight: 700;
1604
+ gap: 7px;
1605
+ }
1606
+
1607
+ .legend-dot {
1608
+ border-radius: 50%;
1609
+ display: inline-flex;
1610
+ height: 8px;
1611
+ width: 8px;
1612
+ }
1613
+
1614
+ .legend-dot.primary {
1615
+ background: #2196f3;
1616
+ }
1617
+
1618
+ .legend-dot.secondary {
1619
+ background: #1eca6b;
1620
+ }
1621
+
1622
+ .dashboard-section-grid {
1623
+ align-items: stretch;
1624
+ margin-top: 14px;
1625
+ }
1626
+
1627
+ .dashboard-section-grid > .v-col {
1628
+ display: flex;
1629
+ }
1630
+
1631
+ .dashboard-panel {
1632
+ display: flex;
1633
+ flex-direction: column;
1634
+ height: 100%;
1635
+ padding: 24px;
1636
+ width: 100%;
1637
+ }
1638
+
1639
+ .dashboard-panel-recent {
1640
+ min-height: 224px;
1641
+ padding: 26px 30px 16px;
1642
+ }
1643
+
1644
+ .panel-body {
1645
+ flex: 1;
1646
+ margin-top: 20px;
1647
+ max-height: 380px;
1648
+ overflow-y: auto;
1649
+ }
1650
+
1651
+ .panel-action,
1652
+ .row-action {
1653
+ appearance: none;
1654
+ background: transparent;
1655
+ border: 0;
1656
+ color: #2196f3;
1657
+ cursor: pointer;
1658
+ font-size: 12px;
1659
+ font-weight: 800;
1660
+ letter-spacing: 0;
1661
+ line-height: 1.2;
1662
+ padding: 0;
1663
+ }
1664
+
1665
+ .panel-action {
1666
+ margin-right: -10px;
1667
+ padding: 0 10px;
1668
+ }
1669
+
1670
+ .task-list,
1671
+ .feedback-list,
1672
+ .staff-list {
1673
+ display: grid;
1674
+ gap: 10px;
1675
+ }
1676
+
1677
+ .task-row,
1678
+ .feedback-row {
1679
+ align-items: center;
1680
+ background: #fff;
1681
+ border: 1px solid #e0e5ea;
1682
+ border-radius: 8px;
1683
+ display: grid;
1684
+ gap: 16px;
1685
+ min-height: 76px;
1686
+ padding: 15px 15px 15px 10px;
1687
+ }
1688
+
1689
+ .task-row {
1690
+ grid-template-columns: minmax(220px, 280px) minmax(130px, 170px) minmax(82px, 120px) minmax(44px, 1fr);
1691
+ }
1692
+
1693
+ .feedback-row {
1694
+ grid-template-columns: minmax(220px, 280px) minmax(130px, 170px) minmax(82px, 120px) minmax(44px, 1fr);
1695
+ }
1696
+
1697
+ .task-row:hover,
1698
+ .feedback-row:hover,
1699
+ .staff-row:hover,
1700
+ .simple-list-row:hover {
1701
+ border-color: #d8e3ec;
1702
+ box-shadow: 0 6px 14px rgba(15, 44, 69, 0.04);
1703
+ }
1704
+
1705
+ .task-summary,
1706
+ .feedback-summary,
1707
+ .staff-main,
1708
+ .list-main {
1709
+ min-width: 0;
1710
+ }
1711
+
1712
+ .task-summary,
1713
+ .feedback-summary {
1714
+ padding: 0 10px;
1715
+ }
1716
+
1717
+ .task-summary strong,
1718
+ .feedback-summary strong,
1719
+ .staff-main strong,
1720
+ .list-main strong {
1721
+ color: #132b43;
1722
+ display: block;
1723
+ font-size: 13px;
1724
+ font-weight: 800;
1725
+ line-height: 1.3;
1726
+ white-space: normal;
1727
+ }
1728
+
1729
+ .task-summary p,
1730
+ .feedback-summary p,
1731
+ .staff-main p,
1732
+ .list-main p {
1733
+ color: #888;
1734
+ font-size: 12px;
1735
+ font-weight: 500;
1736
+ line-height: 1.35;
1737
+ margin: 5px 0 0;
1738
+ overflow: hidden;
1739
+ text-overflow: ellipsis;
1740
+ white-space: nowrap;
1741
+ }
1742
+
1743
+ .person-cell {
1744
+ align-items: center;
1745
+ color: #888;
1746
+ display: inline-flex;
1747
+ font-size: 12px;
1748
+ font-weight: 600;
1749
+ gap: 7px;
1750
+ min-width: 0;
1751
+ }
1752
+
1753
+ .person-cell span:last-child {
1754
+ overflow: hidden;
1755
+ text-overflow: ellipsis;
1756
+ white-space: nowrap;
1757
+ }
1758
+
1759
+ .avatar {
1760
+ align-items: center;
1761
+ background: #e0e0e0;
1762
+ border-radius: 999px;
1763
+ color: #fff;
1764
+ display: inline-flex;
1765
+ flex: 0 0 34px;
1766
+ font-size: 11px;
1767
+ font-weight: 800;
1768
+ height: 34px;
1769
+ justify-content: center;
1770
+ letter-spacing: 0;
1771
+ width: 34px;
1772
+ }
1773
+
1774
+ .status-pill {
1775
+ align-items: center;
1776
+ border-radius: 999px;
1777
+ display: inline-flex;
1778
+ font-size: 12px;
1779
+ font-weight: 600;
1780
+ justify-content: center;
1781
+ line-height: 1;
1782
+ min-height: 26px;
1783
+ padding: 6px 10px;
1784
+ white-space: nowrap;
1785
+ width: fit-content;
1786
+ }
1787
+
1788
+ .status-pill-success {
1789
+ background: rgba(30, 202, 107, 0.5);
1790
+ color: #015b29;
1791
+ }
1792
+
1793
+ .status-pill-info {
1794
+ background: #d8ecff;
1795
+ color: #14557d;
1796
+ }
1797
+
1798
+ .status-pill-warning {
1799
+ background: #ffe9c7;
1800
+ color: #9c5e0e;
1801
+ }
1802
+
1803
+ .status-pill-danger {
1804
+ background: #ffd9d6;
1805
+ color: #9f2f29;
1806
+ }
1807
+
1808
+ .status-pill-neutral {
1809
+ background: #e0e0e0;
1810
+ color: #616065;
1811
+ }
1812
+
1813
+ .row-action-right {
1814
+ justify-self: end;
1815
+ }
1816
+
1817
+ .task-row .row-action-right {
1818
+ align-items: center;
1819
+ background: #fff;
1820
+ border: 1px solid #b9ddfb;
1821
+ border-radius: 8px;
1822
+ box-shadow: 0 1px 3px rgba(33, 150, 243, 0.1);
1823
+ color: #0b8ff1;
1824
+ display: inline-flex;
1825
+ height: 32px;
1826
+ justify-content: center;
1827
+ min-width: 54px;
1828
+ padding: 0 12px;
1829
+ }
1830
+
1831
+ .task-row .row-action-right:hover {
1832
+ background: #f2f9ff;
1833
+ border-color: #80c3f7;
1834
+ color: #0878d3;
1835
+ }
1836
+
1837
+ .feedback-row .row-action-right {
1838
+ padding-right: 8px;
1839
+ }
1840
+
1841
+ .staff-row {
1842
+ align-items: center;
1843
+ background: #fff;
1844
+ border: 1px solid #e0e5ea;
1845
+ border-radius: 8px;
1846
+ display: grid;
1847
+ gap: 10px;
1848
+ grid-template-columns: 34px minmax(0, 1fr) auto;
1849
+ min-height: 68px;
1850
+ padding: 12px 14px;
1851
+ }
1852
+
1853
+ .simple-list {
1854
+ display: grid;
1855
+ }
1856
+
1857
+ .simple-list-row {
1858
+ align-items: center;
1859
+ background: #fff;
1860
+ border-bottom: 1px solid #e8edf1;
1861
+ display: grid;
1862
+ gap: 14px;
1863
+ grid-template-columns: 30px minmax(0, 1fr) 82px;
1864
+ min-height: 58px;
1865
+ padding: 9px 0;
1866
+ }
1867
+
1868
+ .simple-list-row:last-child {
1869
+ border-bottom: 0;
1870
+ }
1871
+
1872
+ .simple-list-row:hover {
1873
+ box-shadow: none;
1874
+ }
1875
+
1876
+ .list-icon {
1877
+ align-items: center;
1878
+ border-radius: 6px;
1879
+ display: inline-flex;
1880
+ flex: 0 0 auto;
1881
+ font-size: 13px;
1882
+ height: 25px;
1883
+ justify-content: center;
1884
+ width: 25px;
1885
+ }
1886
+
1887
+ .list-action {
1888
+ color: #768493;
1889
+ font-size: 12px;
1890
+ font-weight: 700;
1891
+ justify-self: end;
1892
+ text-align: right;
1893
+ white-space: nowrap;
1894
+ }
1895
+
1896
+ .list-icon-info {
1897
+ background: #d8ecff;
1898
+ color: #17496f;
1899
+ }
1900
+
1901
+ .list-icon-danger {
1902
+ background: #ffd9d6;
1903
+ color: #bd413c;
1904
+ }
1905
+
1906
+ .list-icon-success {
1907
+ background: #d8f4e3;
1908
+ color: #2b9160;
1909
+ }
1910
+
1911
+ .list-icon-warning {
1912
+ background: #ffe9c7;
1913
+ color: #be7418;
1914
+ }
1915
+
1916
+ .list-icon-neutral {
1917
+ background: #edf2f6;
1918
+ color: #607386;
1919
+ }
1920
+
1921
+ .empty-state {
1922
+ align-items: center;
1923
+ color: #8a98a5;
1924
+ display: flex;
1925
+ flex-direction: column;
1926
+ font-size: 13px;
1927
+ font-weight: 700;
1928
+ gap: 8px;
1929
+ justify-content: center;
1930
+ min-height: 140px;
1931
+ text-align: center;
1932
+ }
1933
+
1934
+ .empty-state i {
1935
+ font-size: 28px;
1936
+ }
1937
+
1938
+ .empty-state-compact {
1939
+ min-height: 72px;
1940
+ }
1941
+
1942
+ .customize-dialog {
1943
+ background: #fff;
1944
+ }
1945
+
1946
+ .customize-dialog-header {
1947
+ align-items: flex-start;
1948
+ border-bottom: 1px solid #eef2f5;
1949
+ display: flex;
1950
+ justify-content: space-between;
1951
+ padding: 24px 26px 18px;
1952
+ }
1953
+
1954
+ .customize-dialog-header h2 {
1955
+ color: #0b2f47;
1956
+ font-size: 20px;
1957
+ font-weight: 800;
1958
+ line-height: 1.2;
1959
+ margin: 0;
1960
+ }
1961
+
1962
+ .customize-dialog-header p {
1963
+ color: #71808d;
1964
+ font-size: 13px;
1965
+ margin: 6px 0 0;
1966
+ }
1967
+
1968
+ .customize-dialog-body {
1969
+ padding: 20px 26px 8px;
1970
+ }
1971
+
1972
+ .reset-row {
1973
+ display: flex;
1974
+ justify-content: flex-end;
1975
+ margin: 12px 0;
1976
+ }
1977
+
1978
+ .widget-list {
1979
+ display: grid;
1980
+ gap: 10px;
1981
+ }
1982
+
1983
+ .widget-row {
1984
+ align-items: center;
1985
+ border: 1px solid #e2e8ee;
1986
+ border-radius: 8px;
1987
+ display: flex;
1988
+ gap: 16px;
1989
+ justify-content: space-between;
1990
+ padding: 14px 16px;
1991
+ }
1992
+
1993
+ .widget-row strong {
1994
+ color: #132b43;
1995
+ display: block;
1996
+ font-size: 14px;
1997
+ font-weight: 800;
1998
+ }
1999
+
2000
+ .widget-row p {
2001
+ color: #71808d;
2002
+ font-size: 12px;
2003
+ margin: 4px 0 0;
2004
+ }
2005
+
2006
+ .customize-dialog-actions {
2007
+ border-top: 1px solid #eef2f5;
2008
+ gap: 10px;
2009
+ justify-content: flex-end;
2010
+ padding: 16px 26px 22px;
2011
+ }
2012
+
2013
+ .cancel-button {
2014
+ color: #607386;
2015
+ font-weight: 800;
2016
+ }
2017
+
2018
+ .submit-button {
2019
+ background: #0b2f47;
2020
+ color: #fff;
2021
+ font-weight: 800;
2022
+ min-width: 110px;
2023
+ }
2024
+
2025
+ @media (max-width: 960px) {
2026
+ .dashboard-header {
2027
+ align-items: stretch;
2028
+ flex-direction: column;
2029
+ }
2030
+
2031
+ .header-actions {
2032
+ justify-content: flex-start;
2033
+ }
2034
+
2035
+ .task-row,
2036
+ .feedback-row {
2037
+ grid-template-columns: 1fr;
2038
+ }
2039
+
2040
+ .row-action-right {
2041
+ justify-self: start;
2042
+ }
2043
+
2044
+ .status-pill {
2045
+ margin-left: 0;
2046
+ }
2047
+ }
2048
+
2049
+ @media (max-width: 640px) {
2050
+ .dashboard-title {
2051
+ font-size: 24px;
2052
+ }
2053
+
2054
+ .header-actions,
2055
+ .action-button,
2056
+ .range-toggle {
2057
+ width: 100%;
2058
+ }
2059
+
2060
+ .range-toggle :deep(.v-btn) {
2061
+ flex: 1 1 0;
2062
+ }
2063
+
2064
+ .kpi-card,
2065
+ .activity-card,
2066
+ .dashboard-panel {
2067
+ padding: 16px;
2068
+ }
2069
+
2070
+ .kpi-card {
2071
+ min-height: 154px;
2072
+ }
2073
+
2074
+ .kpi-content {
2075
+ margin-left: 0;
2076
+ margin-top: 22px;
2077
+ }
2078
+
2079
+ .kpi-value-row strong {
2080
+ font-size: 26px;
2081
+ }
2082
+
2083
+ .staff-row {
2084
+ grid-template-columns: 30px minmax(0, 1fr);
2085
+ }
2086
+
2087
+ .staff-row .status-pill {
2088
+ grid-column: 2;
2089
+ }
2090
+
2091
+ .simple-list-row {
2092
+ grid-template-columns: 30px minmax(0, 1fr);
2093
+ }
2094
+
2095
+ .list-action {
2096
+ grid-column: 2;
2097
+ justify-self: start;
2098
+ }
2099
+ }
2100
+ </style>