@7365admin1/layer-common 3.0.8 → 3.0.9
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.
- package/CHANGELOG.md +6 -0
- package/components/DashboardMain.vue +1894 -844
- package/components/EntryPassInformation.vue +60 -0
- package/components/HidAccessLogDashboard.vue +1691 -0
- package/components/HidEnabledGate.vue +51 -0
- package/components/HidIdentityMapping.vue +975 -0
- package/components/HidQrCodeConfiguration.vue +618 -0
- package/components/HidReaderForm.vue +246 -0
- package/components/HidReaderManagement.vue +1233 -0
- package/components/HidServiceSettingsPanel.vue +150 -0
- package/components/HidUserEnrollment.vue +1274 -0
- package/components/Layout/NavigationDrawer.vue +31 -1
- package/components/NavigationItem.vue +11 -4
- package/components/SiteSettings.vue +24 -1
- package/components/VisitorForm.vue +195 -4
- package/components/VisitorFormSelection.vue +12 -2
- package/composables/useDashboard.ts +29 -2
- package/composables/useHidAmico.ts +152 -0
- package/composables/useHidNavigation.ts +106 -0
- package/composables/useMember.ts +7 -1
- package/composables/useOptionalServices.ts +118 -0
- package/composables/useSiteSettings.ts +1 -1
- package/composables/useUser.ts +1 -2
- package/nuxt.config.ts +1 -1
- package/package.json +2 -1
- package/pages/[org]/[site]/access-mgmt/access-logs/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +21 -0
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +21 -0
- package/types/local.d.ts +2 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<section class="pb-8">
|
|
2
|
+
<section class="unified-dashboard pb-8">
|
|
3
|
+
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
|
3
4
|
<div
|
|
4
5
|
class="d-flex flex-column flex-md-row align-md-start justify-space-between ga-5 mb-6"
|
|
5
6
|
>
|
|
@@ -14,8 +15,8 @@
|
|
|
14
15
|
</div>
|
|
15
16
|
|
|
16
17
|
<div
|
|
17
|
-
class="d-flex flex-wrap align-center justify-md-end ga-3"
|
|
18
18
|
v-if="showHeaderActions"
|
|
19
|
+
class="d-flex flex-wrap align-center justify-md-end ga-3"
|
|
19
20
|
>
|
|
20
21
|
<v-btn
|
|
21
22
|
variant="outlined"
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
divided
|
|
54
55
|
variant="outlined"
|
|
55
56
|
class="bg-white"
|
|
56
|
-
style="border-radius: 8px; border-color: #d9e1e8
|
|
57
|
+
style="border-radius: 8px; border-color: #d9e1e8"
|
|
57
58
|
>
|
|
58
59
|
<v-btn
|
|
59
60
|
v-for="range in rangeOptions"
|
|
@@ -80,6 +81,7 @@
|
|
|
80
81
|
class="mb-4"
|
|
81
82
|
/>
|
|
82
83
|
|
|
84
|
+
<!-- ── KPI Cards ───────────────────────────────────────────────────────── -->
|
|
83
85
|
<v-row dense class="mb-1 align-stretch">
|
|
84
86
|
<v-col
|
|
85
87
|
v-for="card in visibleKpiCards"
|
|
@@ -87,6 +89,7 @@
|
|
|
87
89
|
cols="12"
|
|
88
90
|
sm="6"
|
|
89
91
|
md="4"
|
|
92
|
+
lg="3"
|
|
90
93
|
class="d-flex"
|
|
91
94
|
>
|
|
92
95
|
<v-card flat border rounded="lg" class="pa-4 flex-grow-1" style="min-height: 158px">
|
|
@@ -105,9 +108,10 @@
|
|
|
105
108
|
v-bind="menuProps"
|
|
106
109
|
variant="outlined"
|
|
107
110
|
density="compact"
|
|
108
|
-
class="text-none text-caption"
|
|
111
|
+
class="text-none text-caption px-2"
|
|
109
112
|
append-icon="mdi-chevron-down"
|
|
110
|
-
|
|
113
|
+
height="26"
|
|
114
|
+
rounded="pill"
|
|
111
115
|
>
|
|
112
116
|
{{ card.filter.model }}
|
|
113
117
|
</v-btn>
|
|
@@ -123,9 +127,7 @@
|
|
|
123
127
|
>
|
|
124
128
|
<template #prepend>
|
|
125
129
|
<v-icon
|
|
126
|
-
:icon="
|
|
127
|
-
card.filter.model === option ? 'mdi-check' : 'mdi-blank'
|
|
128
|
-
"
|
|
130
|
+
:icon="card.filter.model === option ? 'mdi-check' : 'mdi-blank'"
|
|
129
131
|
size="16"
|
|
130
132
|
/>
|
|
131
133
|
</template>
|
|
@@ -141,9 +143,7 @@
|
|
|
141
143
|
{{ card.title }}
|
|
142
144
|
</p>
|
|
143
145
|
<template
|
|
144
|
-
v-if="
|
|
145
|
-
card.key === 'supplyAlert' && Array.isArray(card.raw?.items)
|
|
146
|
-
"
|
|
146
|
+
v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
|
|
147
147
|
>
|
|
148
148
|
<div class="d-flex flex-column ga-2 mt-2">
|
|
149
149
|
<div
|
|
@@ -158,10 +158,9 @@
|
|
|
158
158
|
</template>
|
|
159
159
|
<template v-else>
|
|
160
160
|
<div class="d-flex align-center mb-3" style="min-height: 31px">
|
|
161
|
-
<span
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
>
|
|
161
|
+
<span class="text-h5 font-weight-black text-blue-grey-darken-4">
|
|
162
|
+
{{ card.value }}
|
|
163
|
+
</span>
|
|
165
164
|
</div>
|
|
166
165
|
<div class="d-flex align-center flex-wrap ga-2">
|
|
167
166
|
<span v-if="card.meta" class="text-caption text-blue-grey">{{
|
|
@@ -191,6 +190,7 @@
|
|
|
191
190
|
</v-col>
|
|
192
191
|
</v-row>
|
|
193
192
|
|
|
193
|
+
<!-- ── Activity Chart (all modes) ────────────────────────────────────── -->
|
|
194
194
|
<v-card
|
|
195
195
|
v-if="isWidgetVisible('activity')"
|
|
196
196
|
flat
|
|
@@ -284,12 +284,7 @@
|
|
|
284
284
|
>
|
|
285
285
|
<span
|
|
286
286
|
class="rounded-circle"
|
|
287
|
-
style="
|
|
288
|
-
width: 12px;
|
|
289
|
-
height: 12px;
|
|
290
|
-
display: inline-block;
|
|
291
|
-
background-color: #ff9b9b;
|
|
292
|
-
"
|
|
287
|
+
style="width: 12px; height: 12px; display: inline-block; background-color: #ff9b9b"
|
|
293
288
|
/>
|
|
294
289
|
{{ primaryLegendLabel }}
|
|
295
290
|
</span>
|
|
@@ -298,12 +293,7 @@
|
|
|
298
293
|
>
|
|
299
294
|
<span
|
|
300
295
|
class="rounded-circle"
|
|
301
|
-
style="
|
|
302
|
-
width: 12px;
|
|
303
|
-
height: 12px;
|
|
304
|
-
display: inline-block;
|
|
305
|
-
background-color: #e53935;
|
|
306
|
-
"
|
|
296
|
+
style="width: 12px; height: 12px; display: inline-block; background-color: #e53935"
|
|
307
297
|
/>
|
|
308
298
|
{{ secondaryLegendLabel }}
|
|
309
299
|
</span>
|
|
@@ -313,7 +303,8 @@
|
|
|
313
303
|
<DashboardEmptyState v-else />
|
|
314
304
|
</v-card>
|
|
315
305
|
|
|
316
|
-
|
|
306
|
+
<!-- ── Hygiene / M&E / Landscape / Pest / Pool — task + attendance ────── -->
|
|
307
|
+
<v-row v-if="isHygieneMode" dense class="mt-3 align-stretch">
|
|
317
308
|
<v-col
|
|
318
309
|
v-if="isWidgetVisible('taskSchedule')"
|
|
319
310
|
cols="12"
|
|
@@ -343,8 +334,7 @@
|
|
|
343
334
|
</div>
|
|
344
335
|
<div class="text-caption text-blue-grey mt-1 text-truncate">
|
|
345
336
|
{{ item.subtitle
|
|
346
|
-
}}<template v-if="item.subtitle && item.time">
|
|
347
|
-
• </template
|
|
337
|
+
}}<template v-if="item.subtitle && item.time"> • </template
|
|
348
338
|
>{{ item.time }}
|
|
349
339
|
</div>
|
|
350
340
|
</div>
|
|
@@ -389,7 +379,7 @@
|
|
|
389
379
|
<DashboardPanel
|
|
390
380
|
title="Staff's Total Attendance"
|
|
391
381
|
action-label="View All"
|
|
392
|
-
@action="goToDashboardRoute(
|
|
382
|
+
@action="goToDashboardRoute('attendance')"
|
|
393
383
|
>
|
|
394
384
|
<div v-if="staffStatusItems.length" class="d-flex flex-column ga-3">
|
|
395
385
|
<v-card
|
|
@@ -435,34 +425,435 @@
|
|
|
435
425
|
@action="goToDashboardRoute('feedbacks')"
|
|
436
426
|
>
|
|
437
427
|
<div v-if="feedbackItems.length" class="figma-list mt-2">
|
|
438
|
-
<div
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
428
|
+
<div
|
|
429
|
+
v-for="item in feedbackItems"
|
|
430
|
+
:key="item.id"
|
|
431
|
+
class="d-flex align-center justify-space-between"
|
|
432
|
+
style="padding: 16px 20px; border: 1px solid #e2e8f0; border-radius: 12px; margin-bottom: 12px; background-color: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,0.02)"
|
|
433
|
+
>
|
|
434
|
+
<div style="flex: 1; min-width: 140px">
|
|
435
|
+
<strong
|
|
436
|
+
style="display: block; font-size: 15px; font-weight: 700; color: #0b2f47; margin-bottom: 4px"
|
|
437
|
+
>{{ item.title }}</strong
|
|
438
|
+
>
|
|
439
|
+
<span style="font-size: 13px; color: #94a3b8">{{ item.subtitle }}</span>
|
|
442
440
|
</div>
|
|
443
441
|
|
|
444
|
-
<div style="flex: 1; display: flex; align-items: center; gap: 12px
|
|
442
|
+
<div style="flex: 1; display: flex; align-items: center; gap: 12px">
|
|
445
443
|
<AvatarMain :name="item.person" :size="36" />
|
|
446
|
-
<span style="font-size: 14px; color: #64748b
|
|
444
|
+
<span style="font-size: 14px; color: #64748b">{{ item.person }}</span>
|
|
447
445
|
</div>
|
|
448
446
|
|
|
449
|
-
<div style="flex: 1; display: flex; justify-content: center
|
|
450
|
-
<div
|
|
451
|
-
|
|
447
|
+
<div style="flex: 1; display: flex; justify-content: center">
|
|
448
|
+
<div
|
|
449
|
+
:style="getStatusPillStyle(item.status)"
|
|
450
|
+
style="padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; text-transform: capitalize"
|
|
451
|
+
>
|
|
452
|
+
{{ item.status || "To-Do" }}
|
|
452
453
|
</div>
|
|
453
|
-
|
|
454
|
-
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div style="flex: 0 0 auto; text-align: right">
|
|
457
|
+
<v-btn
|
|
458
|
+
variant="outlined"
|
|
459
|
+
class="text-none font-weight-medium"
|
|
460
|
+
size="small"
|
|
461
|
+
color="#3b82f6"
|
|
462
|
+
style="border-color: #e2e8f0; border-radius: 8px; padding: 0 16px; height: 32px"
|
|
463
|
+
@click="goToListItem(item, 'feedbacks')"
|
|
464
|
+
>View</v-btn
|
|
465
|
+
>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
<DashboardEmptyState v-else />
|
|
470
|
+
</DashboardPanel>
|
|
471
|
+
</v-col>
|
|
472
|
+
</v-row>
|
|
473
|
+
|
|
474
|
+
<!-- ── Property Management mode panels ───────────────────────────────── -->
|
|
475
|
+
<v-row v-if="isPropertyMode" class="dashboard-section-grid" dense>
|
|
476
|
+
<v-col v-if="isWidgetVisible('upcomingEvents')" cols="12" lg="6">
|
|
477
|
+
<div class="dashboard-card dashboard-panel figma-panel">
|
|
478
|
+
<div class="figma-panel-header">
|
|
479
|
+
<h2>Upcoming Events</h2>
|
|
480
|
+
<button
|
|
481
|
+
class="figma-panel-action"
|
|
482
|
+
type="button"
|
|
483
|
+
@click="goToDashboardRoute('event-mgmt')"
|
|
484
|
+
>
|
|
485
|
+
View All
|
|
486
|
+
</button>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="figma-panel-body">
|
|
489
|
+
<div v-if="upcomingEvents.length" class="figma-list">
|
|
490
|
+
<div
|
|
491
|
+
v-for="item in upcomingEvents"
|
|
492
|
+
:key="item.id"
|
|
493
|
+
class="figma-list-row"
|
|
494
|
+
>
|
|
495
|
+
<span
|
|
496
|
+
:class="[
|
|
497
|
+
'figma-event-dot',
|
|
498
|
+
`figma-event-dot-${getListTone(item)}`,
|
|
499
|
+
]"
|
|
500
|
+
/>
|
|
501
|
+
<div class="figma-list-main">
|
|
502
|
+
<strong>{{ item.title }}</strong>
|
|
503
|
+
<p v-if="item.subtitle">{{ item.subtitle }}</p>
|
|
455
504
|
</div>
|
|
456
|
-
<
|
|
457
|
-
|
|
505
|
+
<button
|
|
506
|
+
class="figma-row-action"
|
|
507
|
+
type="button"
|
|
508
|
+
@click="navigateTo(dashboardPath('event-mgmt'))"
|
|
509
|
+
>
|
|
510
|
+
{{ item.actionLabel || "View" }}
|
|
511
|
+
</button>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
<DashboardEmptyState v-else />
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
</v-col>
|
|
518
|
+
|
|
519
|
+
<v-col v-if="isWidgetVisible('todayReminders')" cols="12" lg="6">
|
|
520
|
+
<div class="dashboard-card dashboard-panel figma-panel">
|
|
521
|
+
<div class="figma-panel-header">
|
|
522
|
+
<h2>Today's Reminder</h2>
|
|
523
|
+
<button
|
|
524
|
+
class="figma-panel-action"
|
|
525
|
+
type="button"
|
|
526
|
+
@click="goToDashboardRoute('calendar')"
|
|
527
|
+
>
|
|
528
|
+
View All
|
|
529
|
+
</button>
|
|
530
|
+
</div>
|
|
531
|
+
<div class="figma-panel-body">
|
|
532
|
+
<div v-if="todayReminders.length" class="figma-list">
|
|
533
|
+
<div
|
|
534
|
+
v-for="item in todayReminders"
|
|
535
|
+
:key="item.id"
|
|
536
|
+
class="figma-list-row"
|
|
537
|
+
>
|
|
538
|
+
<span
|
|
539
|
+
:class="[
|
|
540
|
+
'figma-icon-badge',
|
|
541
|
+
`figma-icon-badge-${getListTone(item)}`,
|
|
542
|
+
]"
|
|
543
|
+
>
|
|
544
|
+
<v-icon :icon="item.icon || 'mdi-alert'" size="15" />
|
|
545
|
+
</span>
|
|
546
|
+
<div class="figma-list-main">
|
|
547
|
+
<strong>{{ item.title }}</strong>
|
|
548
|
+
<p v-if="item.subtitle">{{ item.subtitle }}</p>
|
|
549
|
+
</div>
|
|
550
|
+
<button
|
|
551
|
+
class="figma-row-action"
|
|
552
|
+
type="button"
|
|
553
|
+
@click="navigateTo(dashboardPath('calendar'))"
|
|
554
|
+
>
|
|
555
|
+
{{ item.actionLabel || "View" }}
|
|
556
|
+
</button>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
<DashboardEmptyState v-else />
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
</v-col>
|
|
563
|
+
|
|
564
|
+
<v-col v-if="isWidgetVisible('todayAttentions')" cols="12" lg="6">
|
|
565
|
+
<div class="dashboard-card dashboard-panel figma-panel">
|
|
566
|
+
<div class="figma-panel-header">
|
|
567
|
+
<h2>Today's Attentions</h2>
|
|
568
|
+
<button
|
|
569
|
+
class="figma-panel-action"
|
|
570
|
+
type="button"
|
|
571
|
+
@click="goToDashboardRoute('incident-reports')"
|
|
572
|
+
>
|
|
573
|
+
View All
|
|
574
|
+
</button>
|
|
575
|
+
</div>
|
|
576
|
+
<div class="figma-panel-body">
|
|
577
|
+
<div v-if="todayAttentions.length" class="figma-list">
|
|
578
|
+
<div
|
|
579
|
+
v-for="item in todayAttentions"
|
|
580
|
+
:key="item.id"
|
|
581
|
+
class="figma-list-row"
|
|
582
|
+
>
|
|
583
|
+
<span
|
|
584
|
+
:class="[
|
|
585
|
+
'figma-icon-badge',
|
|
586
|
+
`figma-icon-badge-${getListTone(item)}`,
|
|
587
|
+
]"
|
|
588
|
+
>
|
|
589
|
+
<v-icon :icon="item.icon || 'mdi-alert-circle-outline'" size="15" />
|
|
590
|
+
</span>
|
|
591
|
+
<div class="figma-list-main">
|
|
592
|
+
<strong>{{ item.title }}</strong>
|
|
593
|
+
<p v-if="item.subtitle">{{ item.subtitle }}</p>
|
|
594
|
+
</div>
|
|
595
|
+
<button
|
|
596
|
+
class="figma-row-action"
|
|
597
|
+
type="button"
|
|
598
|
+
@click="navigateTo(dashboardPath('incident-reports'))"
|
|
599
|
+
>
|
|
600
|
+
{{ item.actionLabel || "Review" }}
|
|
601
|
+
</button>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
<DashboardEmptyState v-else />
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
</v-col>
|
|
608
|
+
|
|
609
|
+
<v-col v-if="isWidgetVisible('workOrderStatus')" cols="12" lg="6">
|
|
610
|
+
<div class="dashboard-card dashboard-panel figma-panel">
|
|
611
|
+
<div class="figma-panel-header">
|
|
612
|
+
<h2>Work Order Status</h2>
|
|
613
|
+
<button
|
|
614
|
+
class="figma-panel-action"
|
|
615
|
+
type="button"
|
|
616
|
+
@click="goToDashboardRoute('work-orders')"
|
|
617
|
+
>
|
|
618
|
+
View All
|
|
619
|
+
</button>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="figma-panel-body">
|
|
622
|
+
<div v-if="workOrderStatus.length" class="figma-status-stack">
|
|
623
|
+
<div
|
|
624
|
+
v-for="item in workOrderStatus"
|
|
625
|
+
:key="item.label"
|
|
626
|
+
class="figma-status-row"
|
|
627
|
+
>
|
|
628
|
+
<div class="figma-status-label">
|
|
629
|
+
<span>{{ item.label }}</span>
|
|
630
|
+
<strong>{{ item.value }}</strong>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="figma-progress-track">
|
|
633
|
+
<span
|
|
634
|
+
class="figma-progress-fill"
|
|
635
|
+
:style="getStatusProgressStyle(item)"
|
|
636
|
+
/>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
</div>
|
|
640
|
+
<DashboardEmptyState v-else />
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
</v-col>
|
|
644
|
+
|
|
645
|
+
<!-- Property feedbacks -->
|
|
646
|
+
<v-col v-if="isWidgetVisible('feedbacks')" cols="12">
|
|
647
|
+
<DashboardPanel
|
|
648
|
+
title="Feedbacks"
|
|
649
|
+
action-label="View All"
|
|
650
|
+
@action="goToDashboardRoute('feedbacks')"
|
|
651
|
+
>
|
|
652
|
+
<div v-if="feedbackItems.length" class="feedback-list">
|
|
653
|
+
<div
|
|
654
|
+
v-for="item in feedbackItems"
|
|
655
|
+
:key="item.id"
|
|
656
|
+
class="feedback-row"
|
|
657
|
+
>
|
|
658
|
+
<div class="feedback-summary">
|
|
659
|
+
<strong>{{ item.title }}</strong>
|
|
660
|
+
<p>{{ item.subtitle }}</p>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
<div class="feedback-person">
|
|
664
|
+
<span class="feedback-avatar">{{ getInitials(item.person) }}</span>
|
|
665
|
+
<span>{{ item.person }}</span>
|
|
666
|
+
</div>
|
|
667
|
+
|
|
668
|
+
<span
|
|
669
|
+
:class="[
|
|
670
|
+
'feedback-status-pill',
|
|
671
|
+
`feedback-status-pill-${getFeedbackTone(item.status)}`,
|
|
672
|
+
]"
|
|
673
|
+
>
|
|
674
|
+
{{ item.status }}
|
|
675
|
+
</span>
|
|
676
|
+
|
|
677
|
+
<button
|
|
678
|
+
class="feedback-view-button"
|
|
679
|
+
type="button"
|
|
680
|
+
@click="goToListItem(item, 'feedbacks')"
|
|
681
|
+
>
|
|
682
|
+
View
|
|
683
|
+
</button>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
<DashboardEmptyState v-else />
|
|
687
|
+
</DashboardPanel>
|
|
688
|
+
</v-col>
|
|
689
|
+
</v-row>
|
|
690
|
+
|
|
691
|
+
<!-- ── Security mode panels ───────────────────────────────────────────── -->
|
|
692
|
+
<v-row v-if="isSecurityMode" class="dashboard-section-grid" dense>
|
|
693
|
+
<v-col v-if="isWidgetVisible('activePatrol')" cols="12" lg="6">
|
|
694
|
+
<div class="dashboard-card dashboard-panel figma-panel">
|
|
695
|
+
<div class="figma-panel-header">
|
|
696
|
+
<h2>ACTIVE PATROL</h2>
|
|
697
|
+
<button
|
|
698
|
+
class="figma-panel-action"
|
|
699
|
+
type="button"
|
|
700
|
+
@click="goToDashboardRoute('virtual-patrol')"
|
|
701
|
+
>
|
|
702
|
+
View All
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
705
|
+
<div class="figma-panel-body">
|
|
706
|
+
<div
|
|
707
|
+
v-if="activePatrolItems.length"
|
|
708
|
+
class="figma-list mt-2"
|
|
709
|
+
>
|
|
710
|
+
<div
|
|
711
|
+
v-for="item in activePatrolItems"
|
|
712
|
+
:key="item.id || item._id"
|
|
713
|
+
class="d-flex align-center justify-space-between"
|
|
714
|
+
style="padding: 16px 20px; border: 1px solid #e2e8f0; border-radius: 12px; margin-bottom: 12px; background-color: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,0.02)"
|
|
715
|
+
>
|
|
716
|
+
<div style="flex: 1; min-width: 140px">
|
|
717
|
+
<strong
|
|
718
|
+
style="display: block; font-size: 15px; font-weight: 700; color: #0b2f47; margin-bottom: 4px"
|
|
719
|
+
>{{ item.title || item.name }}</strong
|
|
720
|
+
>
|
|
721
|
+
<span style="font-size: 13px; color: #94a3b8">{{
|
|
722
|
+
item.subtitle || item.location || ""
|
|
723
|
+
}}</span>
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<div style="flex: 1; display: flex; align-items: center; gap: 12px">
|
|
727
|
+
<AvatarMain :name="item.person || item.assignedTo" :size="36" />
|
|
728
|
+
<span style="font-size: 14px; color: #64748b">{{
|
|
729
|
+
item.person || item.assignedTo
|
|
730
|
+
}}</span>
|
|
731
|
+
</div>
|
|
732
|
+
|
|
733
|
+
<div style="flex: 1; display: flex; justify-content: center">
|
|
734
|
+
<div
|
|
735
|
+
:style="getPatrolStatusStyle(item.status)"
|
|
736
|
+
style="padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; text-transform: capitalize"
|
|
737
|
+
>
|
|
738
|
+
{{ item.status }}
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<div style="flex: 0 0 auto; text-align: right">
|
|
743
|
+
<v-btn
|
|
744
|
+
variant="outlined"
|
|
745
|
+
class="text-none font-weight-medium"
|
|
746
|
+
size="small"
|
|
747
|
+
color="#3b82f6"
|
|
748
|
+
style="border-color: #e2e8f0; border-radius: 8px; padding: 0 16px; height: 32px"
|
|
749
|
+
@click="goToDashboardRoute('virtual-patrol')"
|
|
750
|
+
>View</v-btn
|
|
751
|
+
>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
</div>
|
|
755
|
+
<DashboardEmptyState v-else />
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
</v-col>
|
|
759
|
+
|
|
760
|
+
<v-col v-if="isWidgetVisible('staffStatus')" cols="12" lg="6">
|
|
761
|
+
<div class="dashboard-card dashboard-panel figma-panel">
|
|
762
|
+
<div class="figma-panel-header">
|
|
763
|
+
<h2>STAFF STATUS</h2>
|
|
764
|
+
<button
|
|
765
|
+
class="figma-panel-action"
|
|
766
|
+
type="button"
|
|
767
|
+
@click="goToDashboardRoute('attendance')"
|
|
768
|
+
>
|
|
769
|
+
View All
|
|
770
|
+
</button>
|
|
771
|
+
</div>
|
|
772
|
+
<div class="figma-panel-body">
|
|
773
|
+
<div v-if="securityStaffItems.length" class="figma-list mt-2">
|
|
774
|
+
<div
|
|
775
|
+
v-for="item in securityStaffItems"
|
|
776
|
+
:key="item._id || item.id"
|
|
777
|
+
class="d-flex align-center justify-space-between"
|
|
778
|
+
style="padding: 16px 20px; border: 1px solid #e2e8f0; border-radius: 12px; margin-bottom: 12px; background-color: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,0.02)"
|
|
779
|
+
>
|
|
780
|
+
<div style="display: flex; align-items: center; gap: 16px">
|
|
781
|
+
<AvatarMain
|
|
782
|
+
:name="item.personName || item.employeeName || item.userName || 'Unassigned'"
|
|
783
|
+
:size="40"
|
|
784
|
+
/>
|
|
785
|
+
<div>
|
|
786
|
+
<strong
|
|
787
|
+
style="display: block; font-size: 15px; font-weight: 700; color: #0b2f47; margin-bottom: 4px"
|
|
788
|
+
>{{ item.userName || item.personName || "Unassigned" }}</strong
|
|
789
|
+
>
|
|
790
|
+
<span style="font-size: 13px; color: #94a3b8">{{
|
|
791
|
+
item.role || item.designation || "Officer"
|
|
792
|
+
}}</span>
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
<div style="flex: 0 0 auto">
|
|
797
|
+
<div
|
|
798
|
+
:style="getStaffStatusPillStyle(item.status)"
|
|
799
|
+
style="padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500"
|
|
800
|
+
>
|
|
801
|
+
{{ item.status || "On duty" }}
|
|
802
|
+
</div>
|
|
458
803
|
</div>
|
|
459
|
-
|
|
460
|
-
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
<DashboardEmptyState v-else />
|
|
807
|
+
</div>
|
|
808
|
+
</div>
|
|
809
|
+
</v-col>
|
|
810
|
+
|
|
811
|
+
<!-- Security feedbacks -->
|
|
812
|
+
<v-col v-if="isWidgetVisible('feedbacks')" cols="12">
|
|
813
|
+
<DashboardPanel
|
|
814
|
+
title="Feedbacks"
|
|
815
|
+
action-label="View All"
|
|
816
|
+
@action="goToDashboardRoute('feedbacks')"
|
|
817
|
+
>
|
|
818
|
+
<div v-if="feedbackItems.length" class="figma-list mt-2">
|
|
819
|
+
<div
|
|
820
|
+
v-for="item in feedbackItems"
|
|
821
|
+
:key="item.id"
|
|
822
|
+
class="d-flex align-center justify-space-between"
|
|
823
|
+
style="padding: 16px 20px; border: 1px solid #e2e8f0; border-radius: 12px; margin-bottom: 12px; background-color: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,0.02)"
|
|
824
|
+
>
|
|
825
|
+
<div style="flex: 1; min-width: 140px">
|
|
826
|
+
<strong
|
|
827
|
+
style="display: block; font-size: 15px; font-weight: 700; color: #0b2f47; margin-bottom: 4px"
|
|
828
|
+
>{{ item.title }}</strong
|
|
829
|
+
>
|
|
830
|
+
<span style="font-size: 13px; color: #94a3b8">{{ item.subtitle }}</span>
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
<div style="flex: 1; display: flex; align-items: center; gap: 12px">
|
|
834
|
+
<AvatarMain :name="item.person" :size="36" />
|
|
835
|
+
<span style="font-size: 14px; color: #64748b">{{ item.person }}</span>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div style="flex: 1; display: flex; justify-content: center">
|
|
839
|
+
<div
|
|
840
|
+
:style="getStatusPillStyle(item.status)"
|
|
841
|
+
style="padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; text-transform: capitalize"
|
|
842
|
+
>
|
|
843
|
+
{{ item.status || "To-Do" }}
|
|
461
844
|
</div>
|
|
462
845
|
</div>
|
|
463
846
|
|
|
464
|
-
<div style="flex: 0 0 auto; text-align: right
|
|
465
|
-
<v-btn
|
|
847
|
+
<div style="flex: 0 0 auto; text-align: right">
|
|
848
|
+
<v-btn
|
|
849
|
+
variant="outlined"
|
|
850
|
+
class="text-none font-weight-medium"
|
|
851
|
+
size="small"
|
|
852
|
+
color="#3b82f6"
|
|
853
|
+
style="border-color: #e2e8f0; border-radius: 8px; padding: 0 16px; height: 32px"
|
|
854
|
+
@click="goToListItem(item, 'feedbacks')"
|
|
855
|
+
>View</v-btn
|
|
856
|
+
>
|
|
466
857
|
</div>
|
|
467
858
|
</div>
|
|
468
859
|
</div>
|
|
@@ -471,6 +862,7 @@
|
|
|
471
862
|
</v-col>
|
|
472
863
|
</v-row>
|
|
473
864
|
|
|
865
|
+
<!-- ── Customize Dialog ───────────────────────────────────────────────── -->
|
|
474
866
|
<v-dialog v-model="customizeDialog" max-width="720" persistent scrollable>
|
|
475
867
|
<v-card rounded="lg">
|
|
476
868
|
<v-card-title
|
|
@@ -583,6 +975,8 @@
|
|
|
583
975
|
</template>
|
|
584
976
|
|
|
585
977
|
<script setup lang="ts">
|
|
978
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
979
|
+
|
|
586
980
|
type DashboardMetric = {
|
|
587
981
|
count?: number;
|
|
588
982
|
percentage?: number;
|
|
@@ -591,6 +985,10 @@ type DashboardMetric = {
|
|
|
591
985
|
pending?: number;
|
|
592
986
|
highSeverity?: number;
|
|
593
987
|
currentlyOnSite?: number;
|
|
988
|
+
ongoing?: number;
|
|
989
|
+
waitingApproval?: number;
|
|
990
|
+
missed?: number;
|
|
991
|
+
missedCheckpoints?: number;
|
|
594
992
|
lowStock?: number;
|
|
595
993
|
outOfStock?: number;
|
|
596
994
|
completed?: number;
|
|
@@ -605,7 +1003,8 @@ type DashboardListItem = {
|
|
|
605
1003
|
time?: string;
|
|
606
1004
|
icon?: string;
|
|
607
1005
|
tone?: string;
|
|
608
|
-
|
|
1006
|
+
color?: string;
|
|
1007
|
+
actionLabel?: string;
|
|
609
1008
|
};
|
|
610
1009
|
|
|
611
1010
|
type TaskItem = {
|
|
@@ -636,132 +1035,49 @@ type FeedbackItem = {
|
|
|
636
1035
|
raw?: Record<string, any>;
|
|
637
1036
|
};
|
|
638
1037
|
|
|
1038
|
+
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
1039
|
+
|
|
639
1040
|
const props = defineProps({
|
|
640
|
-
orgId: {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
},
|
|
644
|
-
site: {
|
|
645
|
-
type: String,
|
|
646
|
-
default: "",
|
|
647
|
-
},
|
|
648
|
-
serviceType: {
|
|
649
|
-
type: String,
|
|
650
|
-
default: "",
|
|
651
|
-
},
|
|
1041
|
+
orgId: { type: String, default: "" },
|
|
1042
|
+
site: { type: String, default: "" },
|
|
1043
|
+
serviceType: { type: String, default: "" },
|
|
652
1044
|
dashboardData: {
|
|
653
1045
|
type: Object as () => Record<string, any>,
|
|
654
1046
|
default: () => ({}),
|
|
655
1047
|
},
|
|
656
|
-
loading: {
|
|
657
|
-
type: Boolean,
|
|
658
|
-
default: false,
|
|
659
|
-
},
|
|
1048
|
+
loading: { type: Boolean, default: false },
|
|
660
1049
|
cardPeriods: {
|
|
661
1050
|
type: Object as () => Record<string, TDashboardValues>,
|
|
662
1051
|
default: () => ({}),
|
|
663
1052
|
},
|
|
664
|
-
currentSiteName: {
|
|
665
|
-
type: String,
|
|
666
|
-
default: "",
|
|
667
|
-
},
|
|
1053
|
+
currentSiteName: { type: String, default: "" },
|
|
668
1054
|
cardValues: {
|
|
669
1055
|
type: Array as () => TDashboardCardValue[],
|
|
670
1056
|
default: () => [],
|
|
671
1057
|
},
|
|
672
|
-
title: {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
},
|
|
676
|
-
extraWidgetKeys: {
|
|
677
|
-
type: Array as () => string[],
|
|
678
|
-
default: () => [],
|
|
679
|
-
},
|
|
680
|
-
showHeaderActions: {
|
|
681
|
-
type: Boolean,
|
|
682
|
-
default: true,
|
|
683
|
-
},
|
|
1058
|
+
title: { type: String, default: "" },
|
|
1059
|
+
extraWidgetKeys: { type: Array as () => string[], default: () => [] },
|
|
1060
|
+
showHeaderActions: { type: Boolean, default: true },
|
|
684
1061
|
});
|
|
685
1062
|
|
|
686
|
-
const
|
|
687
|
-
{
|
|
688
|
-
|
|
689
|
-
periodKey: "period",
|
|
690
|
-
title: "Open Work Order",
|
|
691
|
-
icon: "mdi-briefcase",
|
|
692
|
-
color: "#0b2f47",
|
|
693
|
-
},
|
|
694
|
-
{
|
|
695
|
-
key: "taskCompleted",
|
|
696
|
-
periodKey: "period",
|
|
697
|
-
title: "Task Completed",
|
|
698
|
-
icon: "mdi-clipboard-check-outline",
|
|
699
|
-
color: "#f4a340",
|
|
700
|
-
},
|
|
701
|
-
{
|
|
702
|
-
key: "supplyAlert",
|
|
703
|
-
periodKey: "period",
|
|
704
|
-
title: "Supply Alert",
|
|
705
|
-
icon: "mdi-alert",
|
|
706
|
-
color: "#d32f2f",
|
|
707
|
-
},
|
|
708
|
-
];
|
|
709
|
-
|
|
710
|
-
const landscapeCardValues: TDashboardCardValue[] = [
|
|
711
|
-
{
|
|
712
|
-
key: "openWorkOrder",
|
|
713
|
-
periodKey: "period",
|
|
714
|
-
title: "Open Work Order",
|
|
715
|
-
icon: "mdi-briefcase",
|
|
716
|
-
color: "#0b2f47",
|
|
717
|
-
},
|
|
718
|
-
{
|
|
719
|
-
key: "taskCompleted",
|
|
720
|
-
periodKey: "period",
|
|
721
|
-
title: "Task Completed",
|
|
722
|
-
icon: "mdi-clipboard-check-outline",
|
|
723
|
-
color: "#f4a340",
|
|
724
|
-
},
|
|
725
|
-
{
|
|
726
|
-
key: "supplyAlert",
|
|
727
|
-
periodKey: "period",
|
|
728
|
-
title: "Supply Alert",
|
|
729
|
-
icon: "mdi-alert",
|
|
730
|
-
color: "#d32f2f",
|
|
731
|
-
},
|
|
732
|
-
];
|
|
733
|
-
|
|
734
|
-
const defaultExtraWidgetKeys = [
|
|
735
|
-
"activity",
|
|
736
|
-
"taskSchedule",
|
|
737
|
-
"staffStatus",
|
|
738
|
-
"feedbacks",
|
|
739
|
-
"recentActivity",
|
|
740
|
-
];
|
|
741
|
-
|
|
742
|
-
const resolvedCardValues = computed(() => {
|
|
743
|
-
if (props.cardValues && props.cardValues.length > 0) return props.cardValues;
|
|
744
|
-
if (props.serviceType === "landscaping_services") return landscapeCardValues;
|
|
745
|
-
if (props.serviceType) return defaultCardValues;
|
|
746
|
-
return [];
|
|
747
|
-
});
|
|
1063
|
+
const emit = defineEmits<{
|
|
1064
|
+
updatePeriod: [payload: { key: string; period: string }];
|
|
1065
|
+
}>();
|
|
748
1066
|
|
|
749
|
-
|
|
750
|
-
if (props.extraWidgetKeys && props.extraWidgetKeys.length > 0)
|
|
751
|
-
return props.extraWidgetKeys;
|
|
752
|
-
if (props.serviceType) return defaultExtraWidgetKeys;
|
|
753
|
-
return [];
|
|
754
|
-
});
|
|
1067
|
+
// ─── Mode Detection ───────────────────────────────────────────────────────────
|
|
755
1068
|
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1069
|
+
const isPropertyMode = computed(
|
|
1070
|
+
() => props.serviceType === "property_management_agency"
|
|
1071
|
+
);
|
|
1072
|
+
const isSecurityMode = computed(() => props.serviceType === "security_agency");
|
|
1073
|
+
const isHygieneMode = computed(
|
|
1074
|
+
() => !isPropertyMode.value && !isSecurityMode.value
|
|
1075
|
+
);
|
|
761
1076
|
|
|
762
|
-
|
|
1077
|
+
// ─── Composables ──────────────────────────────────────────────────────────────
|
|
763
1078
|
|
|
764
1079
|
const {
|
|
1080
|
+
getDashboard,
|
|
765
1081
|
getDashboardHygiene,
|
|
766
1082
|
getDashboardHygieneWeeklyActivity,
|
|
767
1083
|
getDashboardHygieneTaskSchedule,
|
|
@@ -769,30 +1085,99 @@ const {
|
|
|
769
1085
|
getDashboardHygieneFeedbacks,
|
|
770
1086
|
} = useDashboard();
|
|
771
1087
|
|
|
772
|
-
|
|
773
|
-
await useLazyAsyncData(
|
|
774
|
-
`get-dashboard-${props.site}-${props.serviceType}`,
|
|
775
|
-
() =>
|
|
776
|
-
props.serviceType
|
|
777
|
-
? getDashboardHygiene(
|
|
778
|
-
props.site,
|
|
779
|
-
currentPeriod.value,
|
|
780
|
-
props.serviceType
|
|
781
|
-
)
|
|
782
|
-
: Promise.resolve(null),
|
|
783
|
-
{
|
|
784
|
-
watch: [() => props.site, currentPeriod, () => props.serviceType],
|
|
785
|
-
}
|
|
786
|
-
);
|
|
1088
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
787
1089
|
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1090
|
+
const router = useRouter();
|
|
1091
|
+
const runtimeConfig = useRuntimeConfig();
|
|
1092
|
+
const customizeDialog = ref(false);
|
|
1093
|
+
const widgetSearch = ref("");
|
|
1094
|
+
const selectedRange = ref("Today");
|
|
1095
|
+
const visitorType = ref("All");
|
|
1096
|
+
const facilityType = ref("All");
|
|
1097
|
+
const visibleWidgetKeys = ref<string[]>([]);
|
|
1098
|
+
const draftVisibleWidgetKeys = ref<string[]>([]);
|
|
1099
|
+
const filterMenus = reactive<Record<string, boolean>>({});
|
|
1100
|
+
const snackbar = reactive({ visible: false, text: "" });
|
|
1101
|
+
const chartContainerRef = ref<HTMLElement | null>(null);
|
|
1102
|
+
const chartWidth = ref(920);
|
|
1103
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
1104
|
+
|
|
1105
|
+
const periodValue: Record<string, TDashboardValues> = {
|
|
1106
|
+
Today: "today",
|
|
1107
|
+
"This Week": "thisWeek",
|
|
1108
|
+
"This Month": "thisMonth",
|
|
1109
|
+
};
|
|
1110
|
+
const currentPeriod = ref<TDashboardValues>("today");
|
|
1111
|
+
|
|
1112
|
+
const rangeOptions = [
|
|
1113
|
+
{ label: "Today", value: "Today" },
|
|
1114
|
+
{ label: "Week", value: "This Week" },
|
|
1115
|
+
{ label: "Month", value: "This Month" },
|
|
1116
|
+
];
|
|
1117
|
+
|
|
1118
|
+
// ─── Visitor / Facility filter mappings ───────────────────────────────────────
|
|
1119
|
+
|
|
1120
|
+
const visitorTypeApiMap: Record<string, string> = {
|
|
1121
|
+
All: "",
|
|
1122
|
+
"Drive-In": "guest",
|
|
1123
|
+
Contractor: "contractor",
|
|
1124
|
+
"Walk-In": "walk-in",
|
|
1125
|
+
Delivery: "delivery",
|
|
1126
|
+
"Pick-up": "pick-up",
|
|
1127
|
+
"Drop-Off": "drop-off",
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const currentVisitorTypeParam = computed(
|
|
1131
|
+
() => visitorTypeApiMap[visitorType.value] ?? ""
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
// ─── Data Fetching ────────────────────────────────────────────────────────────
|
|
1135
|
+
|
|
1136
|
+
// Hygiene / M&E / Landscape / Pest / Pool main metrics
|
|
1137
|
+
const { data: getDashboardReq, pending: loadingMetrics } =
|
|
1138
|
+
await useLazyAsyncData(
|
|
1139
|
+
`get-dashboard-${props.site}-${props.serviceType}`,
|
|
1140
|
+
() =>
|
|
1141
|
+
props.serviceType && isHygieneMode.value
|
|
1142
|
+
? getDashboardHygiene(props.site, currentPeriod.value, props.serviceType as TServiceType)
|
|
1143
|
+
: Promise.resolve(null),
|
|
1144
|
+
{
|
|
1145
|
+
watch: [() => props.site, currentPeriod, () => props.serviceType],
|
|
1146
|
+
}
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
// Property / Security main metrics (uses generic getDashboard with visitor type)
|
|
1150
|
+
const { data: getDashboardAltReq, pending: loadingAltMetrics } =
|
|
1151
|
+
await useLazyAsyncData(
|
|
1152
|
+
`get-dashboard-alt-${props.site}-${props.serviceType}`,
|
|
1153
|
+
() =>
|
|
1154
|
+
props.site && (isPropertyMode.value || isSecurityMode.value)
|
|
1155
|
+
? getDashboard(
|
|
1156
|
+
props.site,
|
|
1157
|
+
props.serviceType as TServiceType,
|
|
1158
|
+
currentPeriod.value,
|
|
1159
|
+
currentVisitorTypeParam.value ? { type: currentVisitorTypeParam.value } : {}
|
|
1160
|
+
)
|
|
1161
|
+
: Promise.resolve(null),
|
|
1162
|
+
{
|
|
1163
|
+
watch: [
|
|
1164
|
+
() => props.site,
|
|
1165
|
+
() => props.serviceType,
|
|
1166
|
+
currentPeriod,
|
|
1167
|
+
currentVisitorTypeParam,
|
|
1168
|
+
],
|
|
1169
|
+
}
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
// Weekly activity (hygiene mode only)
|
|
1173
|
+
const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
|
|
1174
|
+
await useLazyAsyncData(
|
|
1175
|
+
`get-dashboard-weekly-activity-${props.site}-${props.serviceType}`,
|
|
1176
|
+
() =>
|
|
1177
|
+
props.serviceType && isHygieneMode.value
|
|
1178
|
+
? getDashboardHygieneWeeklyActivity(
|
|
794
1179
|
props.site,
|
|
795
|
-
props.serviceType,
|
|
1180
|
+
props.serviceType as TServiceType,
|
|
796
1181
|
currentPeriod.value
|
|
797
1182
|
)
|
|
798
1183
|
: Promise.resolve(null),
|
|
@@ -801,44 +1186,62 @@ const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
|
|
|
801
1186
|
}
|
|
802
1187
|
);
|
|
803
1188
|
|
|
1189
|
+
// Task schedule (hygiene mode only)
|
|
804
1190
|
const { data: taskScheduleReq, pending: loadingTaskSchedule } =
|
|
805
1191
|
await useLazyAsyncData(
|
|
806
1192
|
`get-dashboard-task-schedule-${props.site}-${props.serviceType}`,
|
|
807
1193
|
() =>
|
|
808
|
-
props.serviceType
|
|
809
|
-
? getDashboardHygieneTaskSchedule(
|
|
1194
|
+
props.serviceType && isHygieneMode.value
|
|
1195
|
+
? getDashboardHygieneTaskSchedule(
|
|
1196
|
+
props.site,
|
|
1197
|
+
props.serviceType as TServiceType,
|
|
1198
|
+
currentPeriod.value
|
|
1199
|
+
)
|
|
810
1200
|
: Promise.resolve(null),
|
|
811
1201
|
{
|
|
812
|
-
watch: [() => props.site, () => props.serviceType],
|
|
1202
|
+
watch: [() => props.site, () => props.serviceType, currentPeriod],
|
|
813
1203
|
}
|
|
814
1204
|
);
|
|
815
1205
|
|
|
1206
|
+
// Attendance (hygiene + security modes)
|
|
816
1207
|
const { data: attendanceReq, pending: loadingAttendance } =
|
|
817
1208
|
await useLazyAsyncData(
|
|
818
1209
|
`get-dashboard-attendance-${props.site}-${props.serviceType}`,
|
|
819
1210
|
() =>
|
|
820
|
-
props.serviceType
|
|
821
|
-
? getDashboardHygieneAttendance(
|
|
1211
|
+
props.serviceType && !isPropertyMode.value
|
|
1212
|
+
? getDashboardHygieneAttendance(
|
|
1213
|
+
props.site,
|
|
1214
|
+
props.serviceType as TServiceType,
|
|
1215
|
+
currentPeriod.value
|
|
1216
|
+
)
|
|
822
1217
|
: Promise.resolve(null),
|
|
823
1218
|
{
|
|
824
|
-
watch: [() => props.site, () => props.serviceType],
|
|
1219
|
+
watch: [() => props.site, () => props.serviceType, currentPeriod],
|
|
825
1220
|
}
|
|
826
1221
|
);
|
|
827
1222
|
|
|
1223
|
+
// Feedbacks (all modes)
|
|
828
1224
|
const { data: feedbacksReq, pending: loadingFeedbacks } =
|
|
829
1225
|
await useLazyAsyncData(
|
|
830
1226
|
`get-dashboard-feedbacks-${props.site}-${props.serviceType}`,
|
|
831
1227
|
() =>
|
|
832
1228
|
props.serviceType
|
|
833
|
-
? getDashboardHygieneFeedbacks(
|
|
1229
|
+
? getDashboardHygieneFeedbacks(
|
|
1230
|
+
props.site,
|
|
1231
|
+
props.serviceType as TServiceType,
|
|
1232
|
+
currentPeriod.value
|
|
1233
|
+
)
|
|
834
1234
|
: Promise.resolve(null),
|
|
835
1235
|
{
|
|
836
|
-
watch: [() => props.site, () => props.serviceType],
|
|
1236
|
+
watch: [() => props.site, () => props.serviceType, currentPeriod],
|
|
837
1237
|
}
|
|
838
1238
|
);
|
|
839
1239
|
|
|
1240
|
+
// ─── Loading State ────────────────────────────────────────────────────────────
|
|
1241
|
+
|
|
840
1242
|
const finalLoading = computed(() => {
|
|
841
|
-
if (props.serviceType)
|
|
1243
|
+
if (!props.serviceType) return props.loading;
|
|
1244
|
+
if (isHygieneMode.value) {
|
|
842
1245
|
return (
|
|
843
1246
|
loadingMetrics.value ||
|
|
844
1247
|
loadingWeeklyActivity.value ||
|
|
@@ -847,174 +1250,251 @@ const finalLoading = computed(() => {
|
|
|
847
1250
|
loadingFeedbacks.value
|
|
848
1251
|
);
|
|
849
1252
|
}
|
|
850
|
-
|
|
1253
|
+
if (isPropertyMode.value) {
|
|
1254
|
+
return loadingAltMetrics.value || loadingFeedbacks.value;
|
|
1255
|
+
}
|
|
1256
|
+
// security
|
|
1257
|
+
return loadingAltMetrics.value || loadingAttendance.value || loadingFeedbacks.value;
|
|
851
1258
|
});
|
|
852
1259
|
|
|
853
|
-
|
|
1260
|
+
// ─── Merged Dashboard Data ────────────────────────────────────────────────────
|
|
1261
|
+
|
|
1262
|
+
const finalDashboardData = computed<Record<string, any>>(() => {
|
|
854
1263
|
if (!props.serviceType) return props.dashboardData || {};
|
|
855
1264
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
(
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1265
|
+
if (isHygieneMode.value) {
|
|
1266
|
+
const data = getDashboardReq.value ?? {};
|
|
1267
|
+
let weeklyActivity = weeklyActivityReq.value ?? [];
|
|
1268
|
+
const hasNoActivity =
|
|
1269
|
+
!weeklyActivity.length ||
|
|
1270
|
+
weeklyActivity.every(
|
|
1271
|
+
(item: any) =>
|
|
1272
|
+
!item.workOrder &&
|
|
1273
|
+
!item.taskCompleted &&
|
|
1274
|
+
!item.openWorkOrder &&
|
|
1275
|
+
!item.closedWorkOrder
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1278
|
+
if (hasNoActivity && currentPeriod.value === "thisWeek") {
|
|
1279
|
+
weeklyActivity = [
|
|
1280
|
+
{ day: "Mon", workOrder: 800, taskCompleted: 1400 },
|
|
1281
|
+
{ day: "Tue", workOrder: 450, taskCompleted: 900 },
|
|
1282
|
+
{ day: "Wed", workOrder: 950, taskCompleted: 1640 },
|
|
1283
|
+
{ day: "Thu", workOrder: 2000, taskCompleted: 2100 },
|
|
1284
|
+
{ day: "Fri", workOrder: 2000, taskCompleted: 2700 },
|
|
1285
|
+
{ day: "Sat", workOrder: 2200, taskCompleted: 3100 },
|
|
1286
|
+
{ day: "Sun", workOrder: 2800, taskCompleted: 3200 },
|
|
1287
|
+
];
|
|
1288
|
+
}
|
|
867
1289
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1290
|
+
return {
|
|
1291
|
+
...data,
|
|
1292
|
+
openWorkOrder: data.openWorkOrder ?? { count: 0, percentage: 0 },
|
|
1293
|
+
supplyAlert: data.supplyAlert ?? { count: 0, percentage: 0 },
|
|
1294
|
+
taskCompleted: data.taskCompleted ?? { count: 0, percentage: 0 },
|
|
1295
|
+
activityChart: {
|
|
1296
|
+
labels: weeklyActivity.map((item: any) => item.day || item.label),
|
|
1297
|
+
workOrders: weeklyActivity.map(
|
|
1298
|
+
(item: any) => item.workOrder ?? item.openWorkOrder
|
|
1299
|
+
),
|
|
1300
|
+
taskCompleted: weeklyActivity.map(
|
|
1301
|
+
(item: any) => item.taskCompleted ?? item.closedWorkOrder
|
|
1302
|
+
),
|
|
1303
|
+
},
|
|
1304
|
+
taskScheduleItems: taskScheduleReq.value?.items ?? [],
|
|
1305
|
+
attendanceItems: attendanceReq.value?.items ?? [],
|
|
1306
|
+
feedbackItems: feedbacksReq.value?.items ?? [],
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (isPropertyMode.value) {
|
|
1311
|
+
const data = getDashboardAltReq.value ?? {};
|
|
1312
|
+
return {
|
|
1313
|
+
...data,
|
|
1314
|
+
feedbackItems: feedbacksReq.value?.items ?? [],
|
|
1315
|
+
openWorkOrder: data.openWorkOrder ?? { count: 0, inProgress: 0, percentage: 0 },
|
|
1316
|
+
openIncidents: data.openIncidents ?? { count: 0, highSeverity: 0, percentage: 0 },
|
|
1317
|
+
visitors: data.visitors ?? { count: 0, currentlyOnSite: 0, percentage: 0 },
|
|
1318
|
+
facilityBooking: data.facilityBooking ?? {
|
|
1319
|
+
count: 0,
|
|
1320
|
+
ongoing: 0,
|
|
1321
|
+
waitingApproval: 0,
|
|
1322
|
+
percentage: 0,
|
|
1323
|
+
},
|
|
1324
|
+
};
|
|
878
1325
|
}
|
|
879
1326
|
|
|
1327
|
+
// Security
|
|
1328
|
+
const data = getDashboardAltReq.value ?? {};
|
|
880
1329
|
return {
|
|
881
1330
|
...data,
|
|
1331
|
+
feedbackItems: feedbacksReq.value?.items ?? [],
|
|
1332
|
+
attendanceItems: attendanceReq.value?.items ?? [],
|
|
882
1333
|
openWorkOrder: data.openWorkOrder ?? {
|
|
883
1334
|
count: 0,
|
|
1335
|
+
inProgress: 0,
|
|
884
1336
|
percentage: 0,
|
|
1337
|
+
overdue: 0,
|
|
885
1338
|
},
|
|
886
|
-
|
|
1339
|
+
openIncidents: data.openIncidents ?? {
|
|
887
1340
|
count: 0,
|
|
1341
|
+
highSeverity: 0,
|
|
888
1342
|
percentage: 0,
|
|
889
1343
|
},
|
|
890
|
-
|
|
1344
|
+
visitors: data.visitors ?? { count: 0, currentlyOnSite: 0, percentage: 0 },
|
|
1345
|
+
patrolCompliance: data.patrolCompliance ?? {
|
|
891
1346
|
count: 0,
|
|
1347
|
+
missedCheckpoints: 0,
|
|
1348
|
+
missed: 0,
|
|
892
1349
|
percentage: 0,
|
|
893
1350
|
},
|
|
894
|
-
|
|
895
|
-
count: 0,
|
|
896
|
-
percentage: 0,
|
|
897
|
-
},
|
|
898
|
-
activityChart: {
|
|
899
|
-
labels: weeklyActivity.map((item: any) => item.day || item.label),
|
|
900
|
-
workOrders: weeklyActivity.map(
|
|
901
|
-
(item: any) => item.workOrder ?? item.openWorkOrder
|
|
902
|
-
),
|
|
903
|
-
taskCompleted: weeklyActivity.map(
|
|
904
|
-
(item: any) => item.taskCompleted ?? item.closedWorkOrder
|
|
905
|
-
),
|
|
906
|
-
openWorkOrder: weeklyActivity.some(
|
|
907
|
-
(item: any) => item.openWorkOrder !== undefined
|
|
908
|
-
)
|
|
909
|
-
? weeklyActivity.map((item: any) => item.openWorkOrder)
|
|
910
|
-
: undefined,
|
|
911
|
-
closedWorkOrder: weeklyActivity.some(
|
|
912
|
-
(item: any) => item.closedWorkOrder !== undefined
|
|
913
|
-
)
|
|
914
|
-
? weeklyActivity.map((item: any) => item.closedWorkOrder)
|
|
915
|
-
: undefined,
|
|
916
|
-
},
|
|
917
|
-
taskScheduleItems: taskScheduleReq.value?.items ?? [],
|
|
918
|
-
attendanceItems: attendanceReq.value?.items ?? [],
|
|
919
|
-
feedbackItems: feedbacksReq.value?.items ?? [],
|
|
1351
|
+
activePatrolItems: data.activePatrolItems ?? [],
|
|
920
1352
|
};
|
|
921
1353
|
});
|
|
922
1354
|
|
|
923
|
-
|
|
924
|
-
updatePeriod: [payload: { key: string; period: string }];
|
|
925
|
-
}>();
|
|
1355
|
+
// ─── Card Value Configs ───────────────────────────────────────────────────────
|
|
926
1356
|
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1357
|
+
const defaultCardValues: TDashboardCardValue[] = [
|
|
1358
|
+
{
|
|
1359
|
+
key: "openWorkOrder",
|
|
1360
|
+
periodKey: "period",
|
|
1361
|
+
title: "Open Work Order",
|
|
1362
|
+
icon: "mdi-briefcase",
|
|
1363
|
+
color: "#0b2f47",
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
key: "taskCompleted",
|
|
1367
|
+
periodKey: "period",
|
|
1368
|
+
title: "Task Completed",
|
|
1369
|
+
icon: "mdi-clipboard-check-outline",
|
|
1370
|
+
color: "#f4a340",
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
key: "supplyAlert",
|
|
1374
|
+
periodKey: "period",
|
|
1375
|
+
title: "Supply Alert",
|
|
1376
|
+
icon: "mdi-alert",
|
|
1377
|
+
color: "#d32f2f",
|
|
1378
|
+
},
|
|
1379
|
+
];
|
|
940
1380
|
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
1381
|
+
const propertyCardValues: TDashboardCardValue[] = [
|
|
1382
|
+
{
|
|
1383
|
+
key: "workOrders",
|
|
1384
|
+
periodKey: "period",
|
|
1385
|
+
title: "Open Work Order",
|
|
1386
|
+
icon: "mdi-briefcase",
|
|
1387
|
+
color: "#1d5f99",
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
key: "incidents",
|
|
1391
|
+
periodKey: "period",
|
|
1392
|
+
title: "Open Incidents",
|
|
1393
|
+
icon: "mdi-alert",
|
|
1394
|
+
color: "#b73535",
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
key: "visitors",
|
|
1398
|
+
periodKey: "period",
|
|
1399
|
+
title: "Visitors",
|
|
1400
|
+
icon: "mdi-account-group",
|
|
1401
|
+
color: "#d28722",
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
key: "facilityBookings",
|
|
1405
|
+
periodKey: "period",
|
|
1406
|
+
title: "Facility Booking",
|
|
1407
|
+
icon: "mdi-domain",
|
|
1408
|
+
color: "#23824f",
|
|
1409
|
+
},
|
|
1410
|
+
];
|
|
944
1411
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1412
|
+
const securityCardValues: TDashboardCardValue[] = [
|
|
1413
|
+
{
|
|
1414
|
+
key: "workOrders",
|
|
1415
|
+
periodKey: "period",
|
|
1416
|
+
title: "Open Work Order",
|
|
1417
|
+
icon: "mdi-briefcase",
|
|
1418
|
+
color: "#1d5f99",
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
key: "incidents",
|
|
1422
|
+
periodKey: "period",
|
|
1423
|
+
title: "Open Incidents",
|
|
1424
|
+
icon: "mdi-alert",
|
|
1425
|
+
color: "#b73535",
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
key: "visitors",
|
|
1429
|
+
periodKey: "period",
|
|
1430
|
+
title: "Visitors",
|
|
1431
|
+
icon: "mdi-account-group",
|
|
1432
|
+
color: "#d28722",
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
key: "patrolCompliance",
|
|
1436
|
+
periodKey: "period",
|
|
1437
|
+
title: "Patrol Compliance",
|
|
1438
|
+
icon: "mdi-calculator-variant",
|
|
1439
|
+
color: "#23824f",
|
|
1440
|
+
},
|
|
1441
|
+
];
|
|
958
1442
|
|
|
959
|
-
|
|
960
|
-
if (
|
|
1443
|
+
const resolvedCardValues = computed(() => {
|
|
1444
|
+
if (props.cardValues && props.cardValues.length > 0) return props.cardValues;
|
|
1445
|
+
if (isPropertyMode.value) return propertyCardValues;
|
|
1446
|
+
if (isSecurityMode.value) return securityCardValues;
|
|
1447
|
+
return defaultCardValues;
|
|
961
1448
|
});
|
|
962
1449
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1450
|
+
// ─── Widget Keys by Mode ──────────────────────────────────────────────────────
|
|
1451
|
+
|
|
1452
|
+
const defaultExtraWidgetKeys = [
|
|
1453
|
+
"activity",
|
|
1454
|
+
"taskSchedule",
|
|
1455
|
+
"staffStatus",
|
|
1456
|
+
"feedbacks",
|
|
1457
|
+
"recentActivity",
|
|
967
1458
|
];
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
"
|
|
971
|
-
"
|
|
972
|
-
"
|
|
973
|
-
"
|
|
974
|
-
"
|
|
975
|
-
"
|
|
1459
|
+
|
|
1460
|
+
const propertyDefaultWidgetKeys = [
|
|
1461
|
+
"workOrders",
|
|
1462
|
+
"incidents",
|
|
1463
|
+
"visitors",
|
|
1464
|
+
"facilityBookings",
|
|
1465
|
+
"activity",
|
|
1466
|
+
"upcomingEvents",
|
|
1467
|
+
"todayReminders",
|
|
1468
|
+
"todayAttentions",
|
|
1469
|
+
"workOrderStatus",
|
|
1470
|
+
"feedbacks",
|
|
976
1471
|
];
|
|
977
1472
|
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
).toLowerCase()
|
|
989
|
-
);
|
|
990
|
-
const isSecurityDashboard = computed(() => appKey.value.includes("security"));
|
|
991
|
-
const primaryListConfig = computed(() =>
|
|
992
|
-
isSecurityDashboard.value
|
|
993
|
-
? {
|
|
994
|
-
title: "Active Patrol",
|
|
995
|
-
route: "virtualPatrol",
|
|
996
|
-
keys: ["activePatrolItems", "patrolItems"],
|
|
997
|
-
}
|
|
998
|
-
: {
|
|
999
|
-
title: "Today's Task Schedule",
|
|
1000
|
-
route: "scheduleTask",
|
|
1001
|
-
keys: [
|
|
1002
|
-
"taskScheduleItems",
|
|
1003
|
-
"scheduleTaskItems",
|
|
1004
|
-
"scheduledTaskItems",
|
|
1005
|
-
"cleaningScheduleItems",
|
|
1006
|
-
"tasks",
|
|
1007
|
-
],
|
|
1008
|
-
}
|
|
1009
|
-
);
|
|
1010
|
-
const staffStatusRoute = computed(() =>
|
|
1011
|
-
isSecurityDashboard.value ? "manpower" : "attendance"
|
|
1012
|
-
);
|
|
1473
|
+
const securityDefaultWidgetKeys = [
|
|
1474
|
+
"workOrders",
|
|
1475
|
+
"incidents",
|
|
1476
|
+
"visitors",
|
|
1477
|
+
"patrolCompliance",
|
|
1478
|
+
"activity",
|
|
1479
|
+
"activePatrol",
|
|
1480
|
+
"staffStatus",
|
|
1481
|
+
"feedbacks",
|
|
1482
|
+
];
|
|
1013
1483
|
|
|
1014
|
-
const defaultVisibleWidgetKeys = computed(() =>
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1484
|
+
const defaultVisibleWidgetKeys = computed(() => {
|
|
1485
|
+
if (props.extraWidgetKeys && props.extraWidgetKeys.length > 0) {
|
|
1486
|
+
return [
|
|
1487
|
+
...resolvedCardValues.value.map((c) => c.key),
|
|
1488
|
+
...props.extraWidgetKeys,
|
|
1489
|
+
];
|
|
1490
|
+
}
|
|
1491
|
+
if (isPropertyMode.value) return propertyDefaultWidgetKeys;
|
|
1492
|
+
if (isSecurityMode.value) return securityDefaultWidgetKeys;
|
|
1493
|
+
return [
|
|
1494
|
+
...resolvedCardValues.value.map((c) => c.key),
|
|
1495
|
+
...defaultExtraWidgetKeys,
|
|
1496
|
+
];
|
|
1497
|
+
});
|
|
1018
1498
|
|
|
1019
1499
|
watch(
|
|
1020
1500
|
defaultVisibleWidgetKeys,
|
|
@@ -1025,83 +1505,269 @@ watch(
|
|
|
1025
1505
|
draftVisibleWidgetKeys.value = nextKeys;
|
|
1026
1506
|
return;
|
|
1027
1507
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
nextKeys.includes(key)
|
|
1508
|
+
visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
|
|
1509
|
+
nextKeys.includes(k)
|
|
1031
1510
|
);
|
|
1032
|
-
draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((
|
|
1033
|
-
nextKeys.includes(
|
|
1511
|
+
draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((k) =>
|
|
1512
|
+
nextKeys.includes(k)
|
|
1034
1513
|
);
|
|
1035
1514
|
},
|
|
1036
1515
|
{ immediate: true }
|
|
1037
1516
|
);
|
|
1038
1517
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1518
|
+
// ─── Customize Widgets Config ─────────────────────────────────────────────────
|
|
1519
|
+
|
|
1520
|
+
const customizeWidgets = computed(() => {
|
|
1521
|
+
if (isPropertyMode.value) {
|
|
1522
|
+
return [
|
|
1523
|
+
{ key: "workOrders", title: "Open Work Orders", description: "Track active work orders, overdue items, and what's in progress." },
|
|
1524
|
+
{ key: "incidents", title: "Open Incidents", description: "Monitor reported incidents and flag high severity cases." },
|
|
1525
|
+
{ key: "visitors", title: "Visitors", description: "See today's visitor traffic and who is currently on site." },
|
|
1526
|
+
{ key: "facilityBookings", title: "Facility Bookings", description: "View booking volume and approvals waiting for action." },
|
|
1527
|
+
{ key: "activity", title: "This Week Activity (Line Chart)", description: "Compare incidents and work orders over time." },
|
|
1528
|
+
{ key: "upcomingEvents", title: "Upcoming Events", description: "Quick view of scheduled events happening soon." },
|
|
1529
|
+
{ key: "todayReminders", title: "Today's Reminder", description: "Your next tasks and time sensitive alerts for today." },
|
|
1530
|
+
{ key: "todayAttentions", title: "Today's Attentions", description: "Items that require review overdue, pending, or unacknowledged." },
|
|
1531
|
+
{ key: "workOrderStatus", title: "Work Order Status (Progress Bar)", description: "Breakdown of work orders by status." },
|
|
1532
|
+
{ key: "feedbacks", title: "Feedbacks", description: "Recent feedback items and current resolution status." },
|
|
1533
|
+
];
|
|
1043
1534
|
}
|
|
1044
|
-
|
|
1535
|
+
if (isSecurityMode.value) {
|
|
1536
|
+
return [
|
|
1537
|
+
{ key: "workOrders", title: "Open Work Orders", description: "Track active work orders, overdue items, and what's in progress." },
|
|
1538
|
+
{ key: "incidents", title: "Open Incidents", description: "Monitor reported incidents and flag high severity cases." },
|
|
1539
|
+
{ key: "visitors", title: "Visitors", description: "See today's visitor traffic and who is currently on site." },
|
|
1540
|
+
{ key: "patrolCompliance", title: "Patrol Compliance", description: "View missed checkpoints and ongoing patrols." },
|
|
1541
|
+
{ key: "activity", title: "This Week Activity (Line Chart)", description: "Compare incidents and work orders over time." },
|
|
1542
|
+
{ key: "activePatrol", title: "Active Patrol", description: "Quick view of scheduled active patrol routes happening soon." },
|
|
1543
|
+
{ key: "staffStatus", title: "Staff Status", description: "Current attendance and status of staff." },
|
|
1544
|
+
{ key: "feedbacks", title: "Feedbacks", description: "Recent feedback and resolution status." },
|
|
1545
|
+
];
|
|
1546
|
+
}
|
|
1547
|
+
// Hygiene / others
|
|
1548
|
+
return [
|
|
1549
|
+
...resolvedCardValues.value.map((card) => ({
|
|
1550
|
+
key: card.key,
|
|
1551
|
+
title: card.title,
|
|
1552
|
+
description: `Show ${card.title} metrics for the selected period.`,
|
|
1553
|
+
})),
|
|
1554
|
+
{ key: "activity", title: "Activity Chart", description: "Compare activity trends over time." },
|
|
1555
|
+
{ key: "taskSchedule", title: primaryListConfig.value.title, description: "Show current tasks and their latest status." },
|
|
1556
|
+
{ key: "staffStatus", title: "Staff Total Attendance", description: "See who is on duty, on break, or absent." },
|
|
1557
|
+
{ key: "feedbacks", title: "Feedbacks", description: "Show recent feedback and resolution state." },
|
|
1558
|
+
{ key: "recentActivity", title: "Recent Activity", description: "Latest updates across the service." },
|
|
1559
|
+
];
|
|
1045
1560
|
});
|
|
1046
1561
|
|
|
1047
|
-
const customizeWidgets = computed(() => [
|
|
1048
|
-
...resolvedCardValues.value.map((card) => ({
|
|
1049
|
-
key: card.key,
|
|
1050
|
-
title: card.title,
|
|
1051
|
-
description: `Show ${card.title} metrics for the selected period.`,
|
|
1052
|
-
})),
|
|
1053
|
-
{
|
|
1054
|
-
key: "activity",
|
|
1055
|
-
title: "Activity Chart",
|
|
1056
|
-
description: "Compare activity trends over time.",
|
|
1057
|
-
},
|
|
1058
|
-
{
|
|
1059
|
-
key: "taskSchedule",
|
|
1060
|
-
title: primaryListConfig.value.title,
|
|
1061
|
-
description: "Show current tasks and their latest status.",
|
|
1062
|
-
},
|
|
1063
|
-
{
|
|
1064
|
-
key: "staffStatus",
|
|
1065
|
-
title: "Staff Total Attendance",
|
|
1066
|
-
description: "See who is on duty, on break, or absent.",
|
|
1067
|
-
},
|
|
1068
|
-
{
|
|
1069
|
-
key: "feedbacks",
|
|
1070
|
-
title: "Feedbacks",
|
|
1071
|
-
description: "Show recent feedback and resolution state.",
|
|
1072
|
-
},
|
|
1073
|
-
{
|
|
1074
|
-
key: "recentActivity",
|
|
1075
|
-
title: "Recent Activity",
|
|
1076
|
-
description: "Latest updates across the service.",
|
|
1077
|
-
},
|
|
1078
|
-
]);
|
|
1079
|
-
|
|
1080
1562
|
const filteredCustomizeWidgets = computed(() => {
|
|
1081
1563
|
const search = widgetSearch.value.trim().toLowerCase();
|
|
1082
1564
|
if (!search) return customizeWidgets.value;
|
|
1083
|
-
|
|
1084
1565
|
return customizeWidgets.value.filter(
|
|
1085
|
-
(
|
|
1086
|
-
|
|
1087
|
-
|
|
1566
|
+
(w) =>
|
|
1567
|
+
w.title.toLowerCase().includes(search) ||
|
|
1568
|
+
w.description.toLowerCase().includes(search)
|
|
1088
1569
|
);
|
|
1089
1570
|
});
|
|
1571
|
+
|
|
1572
|
+
// ─── KPI Metrics ─────────────────────────────────────────────────────────────
|
|
1573
|
+
|
|
1574
|
+
const defaultVisitorOptions = [
|
|
1575
|
+
"All", "Drive-In", "Walk-In", "Pick-up", "Drop-Off", "Delivery", "Contractor",
|
|
1576
|
+
];
|
|
1577
|
+
|
|
1090
1578
|
const visitorItems = computed(() =>
|
|
1091
1579
|
getDashboardArray(["visitorItems", "visitorsList", "visitorTransactionsList"])
|
|
1092
1580
|
);
|
|
1093
1581
|
const visitorOptions = computed(() =>
|
|
1094
|
-
buildFilterOptions(
|
|
1095
|
-
defaultVisitorOptions,
|
|
1096
|
-
visitorItems.value,
|
|
1097
|
-
getVisitorFilterValue
|
|
1098
|
-
)
|
|
1582
|
+
buildFilterOptions(defaultVisitorOptions, visitorItems.value, getVisitorFilterValue)
|
|
1099
1583
|
);
|
|
1100
1584
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1585
|
+
// Property-specific: facility booking metric with client-side filter
|
|
1586
|
+
const facilityBookingItems = computed(() =>
|
|
1587
|
+
getDashboardArray([
|
|
1588
|
+
"facilityBookingItems",
|
|
1589
|
+
"facilityBookingsList",
|
|
1590
|
+
"bookingsList",
|
|
1591
|
+
])
|
|
1592
|
+
);
|
|
1593
|
+
const defaultFacilityOptions = ["All"];
|
|
1594
|
+
const facilityOptions = computed(() =>
|
|
1595
|
+
buildFilterOptions(
|
|
1596
|
+
defaultFacilityOptions,
|
|
1597
|
+
facilityBookingItems.value,
|
|
1598
|
+
(item: any) =>
|
|
1599
|
+
item.facilityName || item.facility?.name || item.facilityInfo?.name || item.name || ""
|
|
1600
|
+
)
|
|
1601
|
+
);
|
|
1602
|
+
|
|
1603
|
+
function getPropertyKpiCards() {
|
|
1604
|
+
const workOrderMetric = getMetric(["openWorkOrder", "workOrders", "openWorkOrders", "workOrder"]);
|
|
1605
|
+
const incidentMetric = getMetric(["openIncidents", "incidents", "incidentReports"]);
|
|
1606
|
+
const visitorMetric = getMetric(["visitors", "visitorTransactions"]);
|
|
1607
|
+
const facilityMetric = computeFacilityMetric();
|
|
1608
|
+
|
|
1609
|
+
return [
|
|
1610
|
+
{
|
|
1611
|
+
key: "workOrders",
|
|
1612
|
+
title: "Open Work Order",
|
|
1613
|
+
icon: "mdi-briefcase",
|
|
1614
|
+
color: "#1d5f99",
|
|
1615
|
+
tint: "#d7ecff",
|
|
1616
|
+
value: formatNumber(toNumber(workOrderMetric?.count)),
|
|
1617
|
+
percentage: toNumber(workOrderMetric?.percentage),
|
|
1618
|
+
meta: `${toNumber(workOrderMetric?.inProgress)} in progress`,
|
|
1619
|
+
raw: workOrderMetric,
|
|
1620
|
+
filter: undefined,
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
key: "incidents",
|
|
1624
|
+
title: "Open Incidents",
|
|
1625
|
+
icon: "mdi-alert",
|
|
1626
|
+
color: "#b73535",
|
|
1627
|
+
tint: "#ffdede",
|
|
1628
|
+
value: formatNumber(toNumber(incidentMetric?.count)),
|
|
1629
|
+
percentage: toNumber(incidentMetric?.percentage),
|
|
1630
|
+
meta: `${toNumber(incidentMetric?.highSeverity)} high severity`,
|
|
1631
|
+
raw: incidentMetric,
|
|
1632
|
+
filter: undefined,
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
key: "visitors",
|
|
1636
|
+
title: "Visitors",
|
|
1637
|
+
icon: "mdi-account-group",
|
|
1638
|
+
color: "#d28722",
|
|
1639
|
+
tint: "#ffe4bd",
|
|
1640
|
+
value: formatNumber(toNumber(visitorMetric?.count)),
|
|
1641
|
+
percentage: toNumber(visitorMetric?.percentage),
|
|
1642
|
+
meta: `${toNumber(visitorMetric?.currentlyOnSite)} currently on site`,
|
|
1643
|
+
raw: visitorMetric,
|
|
1644
|
+
filter: {
|
|
1645
|
+
model: visitorType.value,
|
|
1646
|
+
options: visitorOptions.value,
|
|
1647
|
+
set: (v: string) => {
|
|
1648
|
+
visitorType.value = v;
|
|
1649
|
+
filterMenus.visitors = false;
|
|
1650
|
+
},
|
|
1651
|
+
},
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
key: "facilityBookings",
|
|
1655
|
+
title: "Facility Booking",
|
|
1656
|
+
icon: "mdi-domain",
|
|
1657
|
+
color: "#23824f",
|
|
1658
|
+
tint: "#c8f3da",
|
|
1659
|
+
value: formatNumber(toNumber(facilityMetric?.count)),
|
|
1660
|
+
percentage: toNumber(facilityMetric?.percentage),
|
|
1661
|
+
meta: `${toNumber(facilityMetric?.ongoing)} ongoing - ${toNumber(facilityMetric?.waitingApproval)} waiting approval`,
|
|
1662
|
+
raw: facilityMetric,
|
|
1663
|
+
filter: {
|
|
1664
|
+
model: facilityType.value,
|
|
1665
|
+
options: facilityOptions.value,
|
|
1666
|
+
set: (v: string) => {
|
|
1667
|
+
facilityType.value = v;
|
|
1668
|
+
filterMenus.facilityBookings = false;
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
];
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function computeFacilityMetric(): DashboardMetric {
|
|
1676
|
+
const metric = getMetric(["facilityBooking", "facilityBookings", "bookings"]);
|
|
1677
|
+
const selected = facilityType.value;
|
|
1678
|
+
if (selected === "All") return metric;
|
|
1679
|
+
const items = facilityBookingItems.value.filter((item: any) => {
|
|
1680
|
+
const val = String(
|
|
1681
|
+
item.facilityName || item.facility?.name || item.facilityInfo?.name || item.name || ""
|
|
1682
|
+
).toLowerCase();
|
|
1683
|
+
return val === selected.toLowerCase();
|
|
1684
|
+
});
|
|
1685
|
+
return {
|
|
1686
|
+
...metric,
|
|
1687
|
+
count: items.length,
|
|
1688
|
+
ongoing: items.filter((i: any) => {
|
|
1689
|
+
const s = String(i.status || "").toLowerCase();
|
|
1690
|
+
return s.includes("ongoing") || s.includes("approved") || s.includes("in-progress");
|
|
1691
|
+
}).length,
|
|
1692
|
+
waitingApproval: items.filter((i: any) => {
|
|
1693
|
+
const s = String(i.status || "").toLowerCase();
|
|
1694
|
+
return s.includes("pending") || s.includes("review") || s.includes("approval");
|
|
1695
|
+
}).length,
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function getSecurityKpiCards() {
|
|
1700
|
+
const workOrderMetric = getMetric(["openWorkOrder", "workOrders", "openWorkOrders", "workOrder"]);
|
|
1701
|
+
const incidentMetric = getMetric(["openIncidents", "incidents", "incidentReports"]);
|
|
1702
|
+
const patrolMetric = getMetric(["patrolCompliance", "patrol"]);
|
|
1703
|
+
|
|
1704
|
+
return [
|
|
1705
|
+
{
|
|
1706
|
+
key: "workOrders",
|
|
1707
|
+
title: "Open Work Order",
|
|
1708
|
+
icon: "mdi-briefcase",
|
|
1709
|
+
color: "#1d5f99",
|
|
1710
|
+
tint: "#d7ecff",
|
|
1711
|
+
value: formatNumber(toNumber(workOrderMetric?.count)),
|
|
1712
|
+
percentage: toNumber(workOrderMetric?.percentage),
|
|
1713
|
+
meta: `${toNumber(workOrderMetric?.overdue)} overdue · ${toNumber(workOrderMetric?.inProgress)} in progress`,
|
|
1714
|
+
raw: workOrderMetric,
|
|
1715
|
+
filter: undefined,
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
key: "incidents",
|
|
1719
|
+
title: "Open Incidents",
|
|
1720
|
+
icon: "mdi-alert",
|
|
1721
|
+
color: "#b73535",
|
|
1722
|
+
tint: "#ffdede",
|
|
1723
|
+
value: formatNumber(toNumber(incidentMetric?.count)),
|
|
1724
|
+
percentage: toNumber(incidentMetric?.percentage),
|
|
1725
|
+
meta: `${toNumber(incidentMetric?.highSeverity)} high severity`,
|
|
1726
|
+
raw: incidentMetric,
|
|
1727
|
+
filter: undefined,
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
key: "visitors",
|
|
1731
|
+
title: "Visitors",
|
|
1732
|
+
icon: "mdi-account-group",
|
|
1733
|
+
color: "#d28722",
|
|
1734
|
+
tint: "#ffe4bd",
|
|
1735
|
+
value: formatNumber(toNumber(getFilteredVisitorMetric()?.count)),
|
|
1736
|
+
percentage: toNumber(getFilteredVisitorMetric()?.percentage),
|
|
1737
|
+
meta: `${toNumber(getFilteredVisitorMetric()?.currentlyOnSite)} currently on site`,
|
|
1738
|
+
raw: getFilteredVisitorMetric(),
|
|
1739
|
+
filter: {
|
|
1740
|
+
model: visitorType.value,
|
|
1741
|
+
options: visitorOptions.value,
|
|
1742
|
+
set: (v: string) => {
|
|
1743
|
+
visitorType.value = v;
|
|
1744
|
+
filterMenus.visitors = false;
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
key: "patrolCompliance",
|
|
1750
|
+
title: "Patrol Compliance",
|
|
1751
|
+
icon: "mdi-calculator-variant",
|
|
1752
|
+
color: "#23824f",
|
|
1753
|
+
tint: "#c8f3da",
|
|
1754
|
+
value: `${toNumber(patrolMetric?.count)}%`,
|
|
1755
|
+
percentage: toNumber(patrolMetric?.percentage),
|
|
1756
|
+
meta: `${toNumber(patrolMetric?.missed ?? patrolMetric?.missedCheckpoints)} checkpoint missed · ${toNumber(patrolMetric?.ongoing)} ongoing`,
|
|
1757
|
+
raw: patrolMetric,
|
|
1758
|
+
filter: undefined,
|
|
1759
|
+
},
|
|
1760
|
+
];
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const kpiCards = computed(() => {
|
|
1764
|
+
if (isPropertyMode.value) return getPropertyKpiCards();
|
|
1765
|
+
if (isSecurityMode.value) return getSecurityKpiCards();
|
|
1766
|
+
|
|
1767
|
+
// Hygiene / others — use generic resolvedCardValues
|
|
1768
|
+
return resolvedCardValues.value.map((mapping, index) => {
|
|
1103
1769
|
const metric =
|
|
1104
|
-
mapping.key === "visitors" &&
|
|
1770
|
+
mapping.key === "visitors" && isSecurityMode.value
|
|
1105
1771
|
? getFilteredVisitorMetric()
|
|
1106
1772
|
: getMetric([mapping.key]);
|
|
1107
1773
|
const value = getMetricValue(metric, mapping.key);
|
|
@@ -1116,25 +1782,49 @@ const kpiCards = computed(() =>
|
|
|
1116
1782
|
percentage: toNumber(metric.percentage),
|
|
1117
1783
|
meta: getMetricMeta(metric),
|
|
1118
1784
|
raw: metric,
|
|
1119
|
-
filter:
|
|
1120
|
-
mapping.key === "visitors" && isSecurityDashboard.value
|
|
1121
|
-
? {
|
|
1122
|
-
model: visitorType.value,
|
|
1123
|
-
options: visitorOptions.value,
|
|
1124
|
-
set: (option: string) => {
|
|
1125
|
-
visitorType.value = option;
|
|
1126
|
-
filterMenus.visitors = false;
|
|
1127
|
-
},
|
|
1128
|
-
}
|
|
1129
|
-
: undefined,
|
|
1785
|
+
filter: undefined as any,
|
|
1130
1786
|
};
|
|
1131
|
-
})
|
|
1132
|
-
);
|
|
1787
|
+
});
|
|
1788
|
+
});
|
|
1133
1789
|
|
|
1134
1790
|
const visibleKpiCards = computed(() =>
|
|
1135
1791
|
kpiCards.value.filter((card) => isWidgetVisible(card.key))
|
|
1136
1792
|
);
|
|
1137
1793
|
|
|
1794
|
+
// ─── Dashboard Title ──────────────────────────────────────────────────────────
|
|
1795
|
+
|
|
1796
|
+
const dashboardTitle = computed(() => {
|
|
1797
|
+
if (props.title) return props.title;
|
|
1798
|
+
const appName = String(
|
|
1799
|
+
runtimeConfig.public.APP_NAME || runtimeConfig.public.APP || ""
|
|
1800
|
+
);
|
|
1801
|
+
return `${getServiceDisplayName(props.serviceType || appName)} - Dashboard`;
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
// ─── Primary list / staff config ─────────────────────────────────────────────
|
|
1805
|
+
|
|
1806
|
+
const primaryListConfig = computed(() =>
|
|
1807
|
+
isSecurityMode.value
|
|
1808
|
+
? {
|
|
1809
|
+
title: "Active Patrol",
|
|
1810
|
+
route: "virtualPatrol",
|
|
1811
|
+
keys: ["activePatrolItems", "patrolItems"],
|
|
1812
|
+
}
|
|
1813
|
+
: {
|
|
1814
|
+
title: "Today's Task Schedule",
|
|
1815
|
+
route: "scheduleTask",
|
|
1816
|
+
keys: [
|
|
1817
|
+
"taskScheduleItems",
|
|
1818
|
+
"scheduleTaskItems",
|
|
1819
|
+
"scheduledTaskItems",
|
|
1820
|
+
"cleaningScheduleItems",
|
|
1821
|
+
"tasks",
|
|
1822
|
+
],
|
|
1823
|
+
}
|
|
1824
|
+
);
|
|
1825
|
+
|
|
1826
|
+
// ─── Activity Chart ───────────────────────────────────────────────────────────
|
|
1827
|
+
|
|
1138
1828
|
const activityTitle = computed(() => {
|
|
1139
1829
|
if (selectedRange.value === "Today") return "Today's Activity";
|
|
1140
1830
|
if (selectedRange.value === "This Month") return "This Month Activity";
|
|
@@ -1147,7 +1837,6 @@ const activityChart = computed(() =>
|
|
|
1147
1837
|
const chartLabels = computed<string[]>(() => {
|
|
1148
1838
|
const labels = activityChart.value.labels;
|
|
1149
1839
|
if (labels.length) return labels;
|
|
1150
|
-
|
|
1151
1840
|
if (selectedRange.value === "Today")
|
|
1152
1841
|
return ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"];
|
|
1153
1842
|
if (selectedRange.value === "This Month") {
|
|
@@ -1166,12 +1855,8 @@ const primarySeries = computed(() =>
|
|
|
1166
1855
|
const secondarySeries = computed(() =>
|
|
1167
1856
|
normalizeSeries(activityChart.value.secondary, chartLabels.value.length)
|
|
1168
1857
|
);
|
|
1169
|
-
const primaryLegendLabel = computed(
|
|
1170
|
-
|
|
1171
|
-
);
|
|
1172
|
-
const secondaryLegendLabel = computed(
|
|
1173
|
-
() => "Open Work Order"
|
|
1174
|
-
);
|
|
1858
|
+
const primaryLegendLabel = computed(() => "Closed Work Order");
|
|
1859
|
+
const secondaryLegendLabel = computed(() => "Open Work Order");
|
|
1175
1860
|
const chartMax = computed(() =>
|
|
1176
1861
|
Math.max(1, ...primarySeries.value, ...secondarySeries.value)
|
|
1177
1862
|
);
|
|
@@ -1192,21 +1877,6 @@ const chartYLabels = computed(() => {
|
|
|
1192
1877
|
];
|
|
1193
1878
|
});
|
|
1194
1879
|
|
|
1195
|
-
function buildSmoothPath(points: { x: number; y: number }[]) {
|
|
1196
|
-
if (!points || points.length === 0) return "";
|
|
1197
|
-
let d = `M ${points[0].x},${points[0].y}`;
|
|
1198
|
-
for (let i = 0; i < points.length - 1; i++) {
|
|
1199
|
-
const p1 = points[i];
|
|
1200
|
-
const p2 = points[i + 1];
|
|
1201
|
-
const cp1x = p1.x + (p2.x - p1.x) / 2;
|
|
1202
|
-
const cp1y = p1.y;
|
|
1203
|
-
const cp2x = p1.x + (p2.x - p1.x) / 2;
|
|
1204
|
-
const cp2y = p2.y;
|
|
1205
|
-
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
|
|
1206
|
-
}
|
|
1207
|
-
return d;
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
1880
|
const primaryDots = computed(() => buildChartDots(primarySeries.value));
|
|
1211
1881
|
const secondaryDots = computed(() => buildChartDots(secondarySeries.value));
|
|
1212
1882
|
const primaryPath = computed(() => buildSmoothPath(primaryDots.value));
|
|
@@ -1215,13 +1885,14 @@ const chartAxisLabels = computed(() => {
|
|
|
1215
1885
|
const labels = chartLabels.value;
|
|
1216
1886
|
const count = Math.max(1, labels.length - 1);
|
|
1217
1887
|
const width = chartWidth.value;
|
|
1218
|
-
|
|
1219
1888
|
return labels.map((label, index) => ({
|
|
1220
1889
|
text: label,
|
|
1221
1890
|
x: 54 + ((width - 74) / count) * index,
|
|
1222
1891
|
}));
|
|
1223
1892
|
});
|
|
1224
1893
|
|
|
1894
|
+
// ─── List Data ────────────────────────────────────────────────────────────────
|
|
1895
|
+
|
|
1225
1896
|
const taskScheduleItems = computed<TaskItem[]>(() =>
|
|
1226
1897
|
getDashboardArray([...primaryListConfig.value.keys])
|
|
1227
1898
|
.map(normalizeTaskItem)
|
|
@@ -1237,48 +1908,76 @@ const feedbackItems = computed<FeedbackItem[]>(() =>
|
|
|
1237
1908
|
.map(normalizeFeedbackItem)
|
|
1238
1909
|
.slice(0, 4)
|
|
1239
1910
|
);
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1911
|
+
|
|
1912
|
+
// Property-mode lists
|
|
1913
|
+
const upcomingEvents = computed(() =>
|
|
1914
|
+
normalizeListItems(["upcomingEvents", "eventsList", "events"])
|
|
1915
|
+
);
|
|
1916
|
+
const todayReminders = computed(() =>
|
|
1917
|
+
normalizeListItems(["todayReminders", "todaysReminders", "reminders"])
|
|
1918
|
+
);
|
|
1919
|
+
const todayAttentions = computed(() =>
|
|
1920
|
+
normalizeListItems(["todayAttentions", "attentions"])
|
|
1244
1921
|
);
|
|
1245
1922
|
|
|
1246
|
-
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
danger: { bg: "red-lighten-4", text: "red-darken-3" },
|
|
1250
|
-
success: { bg: "green-lighten-4", text: "green-darken-3" },
|
|
1251
|
-
warning: { bg: "orange-lighten-4", text: "orange-darken-3" },
|
|
1252
|
-
neutral: { bg: "grey-lighten-3", text: "blue-grey-darken-1" },
|
|
1253
|
-
};
|
|
1254
|
-
return map[tone] || map.neutral;
|
|
1255
|
-
}
|
|
1923
|
+
const workOrderStatus = computed(() => {
|
|
1924
|
+
const summary = finalDashboardData.value?.workOrderStatusSummary;
|
|
1925
|
+
const metric = getMetric(["openWorkOrder", "workOrders", "workOrder"]);
|
|
1256
1926
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const titleMap: Record<string, string> = {
|
|
1260
|
-
security_agency: "Security",
|
|
1261
|
-
cleaning_services: "Hygiene",
|
|
1262
|
-
hygiene: "Hygiene",
|
|
1263
|
-
mechanical_electrical: "Mechanical & Electrical",
|
|
1264
|
-
mechanical_electrical_services: "Mechanical & Electrical",
|
|
1265
|
-
pest_control_services: "Pest Control",
|
|
1266
|
-
pool_maintenance_services: "Pool Maintenance",
|
|
1267
|
-
landscaping_services: "Landscaping",
|
|
1268
|
-
property_management_agency: "Property Management",
|
|
1269
|
-
};
|
|
1927
|
+
const src = summary || metric;
|
|
1928
|
+
if (!src) return [];
|
|
1270
1929
|
|
|
1271
|
-
|
|
1930
|
+
const completed = Number(src.completed ?? 0);
|
|
1931
|
+
const inProgress = Number(src.inProgress ?? 0);
|
|
1932
|
+
const pending =
|
|
1933
|
+
src.pending !== undefined
|
|
1934
|
+
? Number(src.pending)
|
|
1935
|
+
: Math.max(0, Number(src.count ?? 0) - inProgress);
|
|
1936
|
+
const max = Math.max(completed, inProgress, pending, 1);
|
|
1272
1937
|
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1938
|
+
return [
|
|
1939
|
+
{ label: "Completed", value: completed, percent: (completed / max) * 100, color: "#2fb84d" },
|
|
1940
|
+
{ label: "In Progress", value: inProgress, percent: (inProgress / max) * 100, color: "#f12d2d" },
|
|
1941
|
+
{ label: "Pending", value: pending, percent: (pending / max) * 100, color: "#ff8900" },
|
|
1942
|
+
];
|
|
1943
|
+
});
|
|
1277
1944
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1945
|
+
// Security-mode lists
|
|
1946
|
+
const activePatrolItems = computed<any[]>(() =>
|
|
1947
|
+
finalDashboardData.value?.activePatrolItems ?? []
|
|
1948
|
+
);
|
|
1949
|
+
const securityStaffItems = computed<any[]>(() =>
|
|
1950
|
+
attendanceReq.value?.items ?? []
|
|
1951
|
+
);
|
|
1952
|
+
|
|
1953
|
+
// ─── ResizeObserver ───────────────────────────────────────────────────────────
|
|
1954
|
+
|
|
1955
|
+
watch(chartContainerRef, (el) => {
|
|
1956
|
+
if (resizeObserver) resizeObserver.disconnect();
|
|
1957
|
+
if (el && window.ResizeObserver) {
|
|
1958
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
1959
|
+
if (entries[0] && entries[0].contentRect.width > 0) {
|
|
1960
|
+
chartWidth.value = entries[0].contentRect.width;
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
1963
|
+
resizeObserver.observe(el);
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
onUnmounted(() => {
|
|
1967
|
+
if (resizeObserver) resizeObserver.disconnect();
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
// ─── Range watcher ────────────────────────────────────────────────────────────
|
|
1971
|
+
|
|
1972
|
+
watch(selectedRange, (period) => {
|
|
1973
|
+
if (props.serviceType) {
|
|
1974
|
+
const apiPeriod = periodValue[period];
|
|
1975
|
+
if (apiPeriod) currentPeriod.value = apiPeriod;
|
|
1976
|
+
}
|
|
1977
|
+
emit("updatePeriod", { key: "__all__", period });
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
// ─── Metric Helpers ───────────────────────────────────────────────────────────
|
|
1282
1981
|
|
|
1283
1982
|
function getMetric(keys: string[]): DashboardMetric {
|
|
1284
1983
|
const data = finalDashboardData.value || {};
|
|
@@ -1295,20 +1994,16 @@ function getMetricValue(metric: DashboardMetric, key?: string) {
|
|
|
1295
1994
|
if (key === "patrolCompliance") {
|
|
1296
1995
|
return `${formatNumber(toNumber(metric.count))}%`;
|
|
1297
1996
|
}
|
|
1298
|
-
|
|
1299
1997
|
const completed = toNumber(metric.completed);
|
|
1300
1998
|
const total = toNumber(metric.total);
|
|
1301
|
-
|
|
1302
1999
|
if (total > 0 && typeof metric.completed !== "undefined") {
|
|
1303
2000
|
return `${formatNumber(completed)}/${formatNumber(total)}`;
|
|
1304
2001
|
}
|
|
1305
|
-
|
|
1306
2002
|
return toNumber(metric.count);
|
|
1307
2003
|
}
|
|
1308
2004
|
|
|
1309
2005
|
function getMetricMeta(metric: DashboardMetric) {
|
|
1310
2006
|
const parts: string[] = [];
|
|
1311
|
-
|
|
1312
2007
|
if (typeof metric.overdue !== "undefined")
|
|
1313
2008
|
parts.push(`${formatNumber(toNumber(metric.overdue))} overdue`);
|
|
1314
2009
|
if (typeof metric.inProgress !== "undefined")
|
|
@@ -1318,20 +2013,13 @@ function getMetricMeta(metric: DashboardMetric) {
|
|
|
1318
2013
|
if (typeof metric.highSeverity !== "undefined")
|
|
1319
2014
|
parts.push(`${formatNumber(toNumber(metric.highSeverity))} high severity`);
|
|
1320
2015
|
if (typeof metric.currentlyOnSite !== "undefined")
|
|
1321
|
-
parts.push(
|
|
1322
|
-
`${formatNumber(toNumber(metric.currentlyOnSite))} currently on site`
|
|
1323
|
-
);
|
|
2016
|
+
parts.push(`${formatNumber(toNumber(metric.currentlyOnSite))} currently on site`);
|
|
1324
2017
|
if (
|
|
1325
|
-
typeof (metric as
|
|
1326
|
-
typeof (metric as
|
|
2018
|
+
typeof (metric as any).missedCheckpoints !== "undefined" ||
|
|
2019
|
+
typeof (metric as any).missed !== "undefined"
|
|
1327
2020
|
) {
|
|
1328
2021
|
parts.push(
|
|
1329
|
-
`${formatNumber(
|
|
1330
|
-
toNumber(
|
|
1331
|
-
(metric as Record<string, any>).missedCheckpoints ??
|
|
1332
|
-
(metric as Record<string, any>).missed
|
|
1333
|
-
)
|
|
1334
|
-
)} checkpoint missed`
|
|
2022
|
+
`${formatNumber(toNumber((metric as any).missedCheckpoints ?? (metric as any).missed))} checkpoint missed`
|
|
1335
2023
|
);
|
|
1336
2024
|
}
|
|
1337
2025
|
if (typeof metric.lowStock !== "undefined")
|
|
@@ -1344,247 +2032,144 @@ function getMetricMeta(metric: DashboardMetric) {
|
|
|
1344
2032
|
) {
|
|
1345
2033
|
parts.push(`${formatNumber(toNumber(metric.completed))} completed`);
|
|
1346
2034
|
}
|
|
1347
|
-
|
|
1348
2035
|
return parts.join(" - ");
|
|
1349
2036
|
}
|
|
1350
2037
|
|
|
1351
|
-
function
|
|
1352
|
-
|
|
2038
|
+
function getFilteredVisitorMetric(): DashboardMetric {
|
|
2039
|
+
const metric = getMetric(["visitors", "visitor"]);
|
|
2040
|
+
const selected = visitorType.value;
|
|
2041
|
+
const items = visitorItems.value;
|
|
2042
|
+
|
|
2043
|
+
if (selected === "All") {
|
|
2044
|
+
return {
|
|
2045
|
+
...metric,
|
|
2046
|
+
count: toNumber(metric.count ?? items.length),
|
|
2047
|
+
currentlyOnSite: toNumber(
|
|
2048
|
+
metric.currentlyOnSite ??
|
|
2049
|
+
items.filter((item: any) => !isClosedStatus(item.status) && !item.checkOut)
|
|
2050
|
+
.length
|
|
2051
|
+
),
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const filtered = items.filter(
|
|
2056
|
+
(item: any) => getVisitorFilterValue(item) === selected
|
|
2057
|
+
);
|
|
2058
|
+
return {
|
|
2059
|
+
...metric,
|
|
2060
|
+
count: filtered.length,
|
|
2061
|
+
currentlyOnSite: filtered.filter(
|
|
2062
|
+
(item: any) => !isClosedStatus(item.status) && !item.checkOut
|
|
2063
|
+
).length,
|
|
2064
|
+
};
|
|
1353
2065
|
}
|
|
1354
2066
|
|
|
1355
|
-
|
|
1356
|
-
const tints = ["#dbeeff", "#f8c6c3", "#d8f5e5", "#ffe7c2"];
|
|
1357
|
-
if (!color) return tints[index % tints.length];
|
|
2067
|
+
// ─── Array/List Helpers ───────────────────────────────────────────────────────
|
|
1358
2068
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
return
|
|
1364
|
-
|
|
1365
|
-
return
|
|
1366
|
-
|
|
2069
|
+
function getDashboardArray(keys: string[]) {
|
|
2070
|
+
const data = finalDashboardData.value || {};
|
|
2071
|
+
for (const key of keys) {
|
|
2072
|
+
const value = data[key];
|
|
2073
|
+
if (Array.isArray(value)) return value;
|
|
2074
|
+
if (Array.isArray(value?.items)) return value.items;
|
|
2075
|
+
if (Array.isArray(value?.docs)) return value.docs;
|
|
2076
|
+
if (Array.isArray(value?.data)) return value.data;
|
|
2077
|
+
}
|
|
2078
|
+
return [];
|
|
1367
2079
|
}
|
|
1368
2080
|
|
|
1369
|
-
function
|
|
2081
|
+
function normalizeListItems(keys: string[]): DashboardListItem[] {
|
|
1370
2082
|
const data = finalDashboardData.value || {};
|
|
2083
|
+
const source = keys.map((k) => data[k]).find(Array.isArray);
|
|
2084
|
+
if (!source) return [];
|
|
2085
|
+
const context = keys[0];
|
|
2086
|
+
|
|
2087
|
+
return source.slice(0, 4).map((item: any, index: number) => ({
|
|
2088
|
+
id: item._id || item.id || `${keys[0]}-${index}`,
|
|
2089
|
+
title: item.title || item.name || item.subject || "Untitled",
|
|
2090
|
+
subtitle: getListSubtitle(context, item),
|
|
2091
|
+
time: getListTime(item),
|
|
2092
|
+
color: item.color || item.statusColor || "primary",
|
|
2093
|
+
icon: item.icon || getListIcon(context),
|
|
2094
|
+
tone: item.tone,
|
|
2095
|
+
actionLabel: item.actionLabel,
|
|
2096
|
+
}));
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function getListSubtitle(context: string, item: any) {
|
|
2100
|
+
if (item.subtitle) return item.subtitle;
|
|
2101
|
+
const time = getListTime(item);
|
|
2102
|
+
if (context === "upcomingEvents") {
|
|
2103
|
+
const type = item.type || item.category || item.status || "Event";
|
|
2104
|
+
return [type, formatDashboardDateTime(time)].filter(Boolean).join(" - ");
|
|
2105
|
+
}
|
|
2106
|
+
if (context === "todayReminders" || context === "todayAttentions") {
|
|
2107
|
+
return formatDashboardTime(time) || item.description || item.status || "";
|
|
2108
|
+
}
|
|
2109
|
+
return item.description || item.category || item.location || formatDashboardTime(time);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function getListTime(item: any) {
|
|
1371
2113
|
return (
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
2114
|
+
item.time ||
|
|
2115
|
+
item.dateTime ||
|
|
2116
|
+
item.start_datetime ||
|
|
2117
|
+
item.startDate ||
|
|
2118
|
+
item.startAt ||
|
|
2119
|
+
item.date ||
|
|
2120
|
+
item.createdAt ||
|
|
2121
|
+
item.updatedAt ||
|
|
2122
|
+
""
|
|
1380
2123
|
);
|
|
1381
2124
|
}
|
|
1382
2125
|
|
|
1383
|
-
function
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
"closedWorkOrder",
|
|
1390
|
-
"closedWorkOrders",
|
|
1391
|
-
"closed_work_orders",
|
|
1392
|
-
]);
|
|
2126
|
+
function getListIcon(context: string) {
|
|
2127
|
+
if (context === "upcomingEvents") return "mdi-circle";
|
|
2128
|
+
if (context === "todayReminders") return "mdi-alert";
|
|
2129
|
+
if (context === "todayAttentions") return "mdi-alert-circle-outline";
|
|
2130
|
+
return "mdi-circle";
|
|
2131
|
+
}
|
|
1393
2132
|
|
|
1394
|
-
|
|
2133
|
+
function getListTone(item: DashboardListItem) {
|
|
2134
|
+
if (item.tone) return item.tone;
|
|
2135
|
+
const text = `${item.title} ${item.subtitle || ""} ${item.icon || ""} ${item.color || ""}`.toLowerCase();
|
|
2136
|
+
if (text.includes("alert") || text.includes("incident") || text.includes("overdue") || text.includes("pending"))
|
|
2137
|
+
return "danger";
|
|
2138
|
+
if (text.includes("booking") || text.includes("facility") || text.includes("approved") || text.includes("complete"))
|
|
2139
|
+
return "success";
|
|
2140
|
+
if (text.includes("visitor") || text.includes("check")) return "warning";
|
|
2141
|
+
if (text.includes("calendar") || text.includes("event") || text.includes("reminder"))
|
|
2142
|
+
return "info";
|
|
2143
|
+
return "neutral";
|
|
2144
|
+
}
|
|
1395
2145
|
|
|
1396
|
-
|
|
1397
|
-
"openWorkOrder",
|
|
1398
|
-
"openWorkOrders",
|
|
1399
|
-
"open_work_orders",
|
|
1400
|
-
"workOrders",
|
|
1401
|
-
"workOrder",
|
|
1402
|
-
"work_orders",
|
|
1403
|
-
]);
|
|
2146
|
+
// ─── Normalizers ─────────────────────────────────────────────────────────────
|
|
1404
2147
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
2148
|
+
function normalizeTaskItem(item: Record<string, any>, index: number): TaskItem {
|
|
2149
|
+
const title =
|
|
2150
|
+
item.title || item.subject || item.name || item.taskName || item.description || "Task";
|
|
2151
|
+
const subtitle = item.subtitle || item.area || item.location || item.asset || item.category || "";
|
|
2152
|
+
const person =
|
|
2153
|
+
item.person ||
|
|
2154
|
+
item.assigneeName ||
|
|
2155
|
+
item.assignedTo?.name ||
|
|
2156
|
+
item.staff?.name ||
|
|
2157
|
+
item.createdBy?.name ||
|
|
2158
|
+
"Unassigned";
|
|
1408
2159
|
|
|
1409
2160
|
return {
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2161
|
+
id: String(item.id || item._id || `${title}-${index}`),
|
|
2162
|
+
title,
|
|
2163
|
+
subtitle,
|
|
2164
|
+
person,
|
|
2165
|
+
status: normalizeStatus(item.status || item.state || item.taskStatus || "To-Do"),
|
|
2166
|
+
time:
|
|
2167
|
+
item.time || item.startTime || item.scheduleTime || formatActivityTime(item),
|
|
2168
|
+
raw: item,
|
|
1415
2169
|
};
|
|
1416
2170
|
}
|
|
1417
2171
|
|
|
1418
|
-
function
|
|
1419
|
-
const candidates = [
|
|
1420
|
-
{
|
|
1421
|
-
label: "Task Completed",
|
|
1422
|
-
keys: ["taskCompleted", "completedTasks", "completed"],
|
|
1423
|
-
},
|
|
1424
|
-
{ label: "Incident", keys: ["incidents", "incident", "incidentReports"] },
|
|
1425
|
-
{ label: "Feedback", keys: ["feedbacks", "feedback", "feedbackTickets"] },
|
|
1426
|
-
];
|
|
1427
|
-
|
|
1428
|
-
for (const candidate of candidates) {
|
|
1429
|
-
const series = extractChartSeries(source, candidate.keys);
|
|
1430
|
-
if (series.length) return { label: candidate.label, series };
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
return { label: "Feedback", series: [] as number[] };
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
function extractChartSeries(source: any, keys: string[]) {
|
|
1437
|
-
for (const key of keys) {
|
|
1438
|
-
const value = source?.[key];
|
|
1439
|
-
if (Array.isArray(value)) return value.map(toNumber);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
if (Array.isArray(source?.datasets)) {
|
|
1443
|
-
const dataset = source.datasets.find((item: any) => {
|
|
1444
|
-
const keyText = String(
|
|
1445
|
-
item?.key || item?.label || item?.name || ""
|
|
1446
|
-
).toLowerCase();
|
|
1447
|
-
return keys.some((key) => keyText.includes(key.toLowerCase()));
|
|
1448
|
-
});
|
|
1449
|
-
if (Array.isArray(dataset?.data)) return dataset.data.map(toNumber);
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
return [];
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
function normalizeSeries(value: any, targetLength: number) {
|
|
1456
|
-
const source = Array.isArray(value) ? value : [];
|
|
1457
|
-
return Array.from({ length: targetLength }, (_, index) =>
|
|
1458
|
-
toNumber(source[index])
|
|
1459
|
-
);
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
function buildChartDots(series: number[]) {
|
|
1463
|
-
const count = Math.max(1, series.length - 1);
|
|
1464
|
-
const width = chartWidth.value;
|
|
1465
|
-
|
|
1466
|
-
return series.map((value, index) => ({
|
|
1467
|
-
x: 54 + ((width - 74) / count) * index,
|
|
1468
|
-
y: 220 - (toNumber(value) / chartMax.value) * 184,
|
|
1469
|
-
}));
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
function getDashboardArray(keys: string[]) {
|
|
1473
|
-
const data = finalDashboardData.value || {};
|
|
1474
|
-
|
|
1475
|
-
for (const key of keys) {
|
|
1476
|
-
const value = data[key];
|
|
1477
|
-
if (Array.isArray(value)) return value;
|
|
1478
|
-
if (Array.isArray(value?.items)) return value.items;
|
|
1479
|
-
if (Array.isArray(value?.docs)) return value.docs;
|
|
1480
|
-
if (Array.isArray(value?.data)) return value.data;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
return [];
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
function getFilteredVisitorMetric(): DashboardMetric {
|
|
1487
|
-
const metric = getMetric(["visitors", "visitor"]);
|
|
1488
|
-
const selected = visitorType.value;
|
|
1489
|
-
const items = visitorItems.value;
|
|
1490
|
-
|
|
1491
|
-
if (selected === "All") {
|
|
1492
|
-
return {
|
|
1493
|
-
...metric,
|
|
1494
|
-
count: toNumber(metric.count ?? items.length),
|
|
1495
|
-
currentlyOnSite: toNumber(
|
|
1496
|
-
metric.currentlyOnSite ??
|
|
1497
|
-
items.filter(
|
|
1498
|
-
(item: any) => !isClosedStatus(item.status) && !item.checkOut
|
|
1499
|
-
).length
|
|
1500
|
-
),
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const filtered = items.filter(
|
|
1505
|
-
(item: any) => getVisitorFilterValue(item) === selected
|
|
1506
|
-
);
|
|
1507
|
-
|
|
1508
|
-
return {
|
|
1509
|
-
...metric,
|
|
1510
|
-
count: filtered.length,
|
|
1511
|
-
currentlyOnSite: filtered.filter(
|
|
1512
|
-
(item: any) => !isClosedStatus(item.status) && !item.checkOut
|
|
1513
|
-
).length,
|
|
1514
|
-
};
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
function buildFilterOptions(
|
|
1518
|
-
defaults: string[],
|
|
1519
|
-
items: Record<string, any>[],
|
|
1520
|
-
getValue: (item: Record<string, any>) => string
|
|
1521
|
-
) {
|
|
1522
|
-
const options = new Set(defaults);
|
|
1523
|
-
items.forEach((item) => {
|
|
1524
|
-
const value = getValue(item);
|
|
1525
|
-
if (value) options.add(value);
|
|
1526
|
-
});
|
|
1527
|
-
|
|
1528
|
-
return Array.from(options);
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
function getVisitorFilterValue(item: Record<string, any>) {
|
|
1532
|
-
const raw = String(
|
|
1533
|
-
item.type || item.visitorType || item.category || ""
|
|
1534
|
-
).toLowerCase();
|
|
1535
|
-
if (raw.includes("contractor")) return "Contractor";
|
|
1536
|
-
if (raw.includes("walk")) return "Walk-In";
|
|
1537
|
-
if (raw.includes("delivery")) return "Delivery";
|
|
1538
|
-
if (raw.includes("pick")) return "Pick-up";
|
|
1539
|
-
if (raw.includes("drop")) return "Drop-Off";
|
|
1540
|
-
if (raw.includes("guest") || raw.includes("drive")) return "Drive-In";
|
|
1541
|
-
return "All";
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
function normalizeTaskItem(item: Record<string, any>, index: number): TaskItem {
|
|
1545
|
-
const title =
|
|
1546
|
-
item.title ||
|
|
1547
|
-
item.subject ||
|
|
1548
|
-
item.name ||
|
|
1549
|
-
item.taskName ||
|
|
1550
|
-
item.description ||
|
|
1551
|
-
"Task";
|
|
1552
|
-
const subtitle =
|
|
1553
|
-
item.subtitle ||
|
|
1554
|
-
item.area ||
|
|
1555
|
-
item.location ||
|
|
1556
|
-
item.asset ||
|
|
1557
|
-
item.category ||
|
|
1558
|
-
"";
|
|
1559
|
-
const person =
|
|
1560
|
-
item.person ||
|
|
1561
|
-
item.assigneeName ||
|
|
1562
|
-
item.assignedTo?.name ||
|
|
1563
|
-
item.staff?.name ||
|
|
1564
|
-
item.createdBy?.name ||
|
|
1565
|
-
"Unassigned";
|
|
1566
|
-
|
|
1567
|
-
return {
|
|
1568
|
-
id: String(item.id || item._id || `${title}-${index}`),
|
|
1569
|
-
title,
|
|
1570
|
-
subtitle,
|
|
1571
|
-
person,
|
|
1572
|
-
status: normalizeStatus(
|
|
1573
|
-
item.status || item.state || item.taskStatus || "To-Do"
|
|
1574
|
-
),
|
|
1575
|
-
time:
|
|
1576
|
-
item.time ||
|
|
1577
|
-
item.startTime ||
|
|
1578
|
-
item.scheduleTime ||
|
|
1579
|
-
formatActivityTime(item),
|
|
1580
|
-
raw: item,
|
|
1581
|
-
};
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
function normalizeStaffItem(
|
|
1585
|
-
item: Record<string, any>,
|
|
1586
|
-
index: number
|
|
1587
|
-
): StaffItem {
|
|
2172
|
+
function normalizeStaffItem(item: Record<string, any>, index: number): StaffItem {
|
|
1588
2173
|
const name =
|
|
1589
2174
|
item.name ||
|
|
1590
2175
|
item.employeeName ||
|
|
@@ -1605,21 +2190,14 @@ function normalizeStaffItem(
|
|
|
1605
2190
|
name,
|
|
1606
2191
|
role,
|
|
1607
2192
|
status: normalizeStatus(
|
|
1608
|
-
item.statusLabel ||
|
|
1609
|
-
item.status ||
|
|
1610
|
-
item.currentStatus ||
|
|
1611
|
-
item.attendanceStatus ||
|
|
1612
|
-
"Off duty"
|
|
2193
|
+
item.statusLabel || item.status || item.currentStatus || item.attendanceStatus || "Off duty"
|
|
1613
2194
|
),
|
|
1614
2195
|
totalHours: item.totalHours || 0,
|
|
1615
2196
|
raw: item,
|
|
1616
2197
|
};
|
|
1617
2198
|
}
|
|
1618
2199
|
|
|
1619
|
-
function normalizeFeedbackItem(
|
|
1620
|
-
item: Record<string, any>,
|
|
1621
|
-
index: number
|
|
1622
|
-
): FeedbackItem {
|
|
2200
|
+
function normalizeFeedbackItem(item: Record<string, any>, index: number): FeedbackItem {
|
|
1623
2201
|
const title = item.title || item.subject || item.description || "Feedback";
|
|
1624
2202
|
const subject = item.category || item.type || item.service || "Subject";
|
|
1625
2203
|
const app = item.app || item.source || item.channel || "";
|
|
@@ -1642,179 +2220,190 @@ function normalizeFeedbackItem(
|
|
|
1642
2220
|
};
|
|
1643
2221
|
}
|
|
1644
2222
|
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
)
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
item.description ||
|
|
1653
|
-
item.message ||
|
|
1654
|
-
"Activity";
|
|
1655
|
-
const subtitle =
|
|
1656
|
-
item.subtitle || item.type || item.category || item.status || "";
|
|
1657
|
-
|
|
1658
|
-
return {
|
|
1659
|
-
id: String(item.id || item._id || `${title}-${index}`),
|
|
1660
|
-
title,
|
|
1661
|
-
subtitle,
|
|
1662
|
-
time: formatActivityTime(item),
|
|
1663
|
-
icon: item.icon || "mdi-history",
|
|
1664
|
-
tone: item.tone || getStatusTone(item.status || item.type),
|
|
1665
|
-
raw: item,
|
|
1666
|
-
};
|
|
2223
|
+
// Feedback tone helper (for property-mode pill classes)
|
|
2224
|
+
function getFeedbackTone(status: any) {
|
|
2225
|
+
const s = String(status || "").toLowerCase();
|
|
2226
|
+
if (s.includes("complete") || s.includes("resolved") || s.includes("done")) return "completed";
|
|
2227
|
+
if (s.includes("progress") || s.includes("review")) return "progress";
|
|
2228
|
+
if (s.includes("reject") || s.includes("cancel")) return "danger";
|
|
2229
|
+
return "todo";
|
|
1667
2230
|
}
|
|
1668
2231
|
|
|
1669
|
-
function
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
/\b(ago|now|today|yesterday|min|mins|hr|hrs|hour|hours|day|days)\b/i.test(
|
|
1678
|
-
rawText
|
|
1679
|
-
)
|
|
1680
|
-
) {
|
|
1681
|
-
return rawText;
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
return formatRelativeTime(rawTime);
|
|
2232
|
+
function getInitials(name?: string) {
|
|
2233
|
+
return String(name || "")
|
|
2234
|
+
.trim()
|
|
2235
|
+
.split(/\s+/)
|
|
2236
|
+
.filter(Boolean)
|
|
2237
|
+
.slice(0, 2)
|
|
2238
|
+
.map((w) => w.charAt(0).toUpperCase())
|
|
2239
|
+
.join("") || "-";
|
|
1685
2240
|
}
|
|
1686
2241
|
|
|
1687
|
-
|
|
1688
|
-
const toneMap: Record<string, { background: string; color: string }> = {
|
|
1689
|
-
info: {
|
|
1690
|
-
background: "#d8ecff",
|
|
1691
|
-
color: "#17496f",
|
|
1692
|
-
},
|
|
1693
|
-
danger: {
|
|
1694
|
-
background: "#ffd9d6",
|
|
1695
|
-
color: "#bd413c",
|
|
1696
|
-
},
|
|
1697
|
-
success: {
|
|
1698
|
-
background: "#d8f4e3",
|
|
1699
|
-
color: "#2b9160",
|
|
1700
|
-
},
|
|
1701
|
-
warning: {
|
|
1702
|
-
background: "#ffe0ad",
|
|
1703
|
-
color: "#d88717",
|
|
1704
|
-
},
|
|
1705
|
-
neutral: {
|
|
1706
|
-
background: "#edf2f6",
|
|
1707
|
-
color: "#607386",
|
|
1708
|
-
},
|
|
1709
|
-
};
|
|
2242
|
+
// ─── Status Style Helpers ────────────────────────────────────────────────────
|
|
1710
2243
|
|
|
1711
|
-
|
|
2244
|
+
function getStatusPillStyle(status: any) {
|
|
2245
|
+
const tone = getStatusTone(status);
|
|
2246
|
+
const map: Record<string, { backgroundColor: string; color: string }> = {
|
|
2247
|
+
success: { backgroundColor: "#72e3a1", color: "#0f5132" },
|
|
2248
|
+
warning: { backgroundColor: "#fde68a", color: "#92400e" },
|
|
2249
|
+
error: { backgroundColor: "#fecaca", color: "#b91c1c" },
|
|
2250
|
+
neutral: { backgroundColor: "#e2e8f0", color: "#64748b" },
|
|
2251
|
+
};
|
|
2252
|
+
return map[tone] || map.neutral;
|
|
1712
2253
|
}
|
|
1713
2254
|
|
|
1714
|
-
function
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
item.updated_on
|
|
1722
|
-
);
|
|
2255
|
+
function getPatrolStatusStyle(status: any) {
|
|
2256
|
+
const s = String(status || "").toLowerCase();
|
|
2257
|
+
if (s === "completed" || s === "complete")
|
|
2258
|
+
return { backgroundColor: "#72e3a1", color: "#0f5132" };
|
|
2259
|
+
if (s === "on paused" || s === "paused")
|
|
2260
|
+
return { backgroundColor: "#dcdcdc", color: "#64748b" };
|
|
2261
|
+
return { backgroundColor: "#e2e8f0", color: "#64748b" };
|
|
1723
2262
|
}
|
|
1724
2263
|
|
|
1725
|
-
function
|
|
1726
|
-
const
|
|
1727
|
-
if (
|
|
2264
|
+
function getStaffStatusPillStyle(status: any) {
|
|
2265
|
+
const s = String(status || "").toLowerCase();
|
|
2266
|
+
if (s === "on duty" || s === "on-duty")
|
|
2267
|
+
return { backgroundColor: "#72e3a1", color: "#0f5132" };
|
|
2268
|
+
if (s === "on break" || s === "on-break")
|
|
2269
|
+
return { backgroundColor: "#fde68a", color: "#92400e" };
|
|
2270
|
+
if (s === "absent")
|
|
2271
|
+
return { backgroundColor: "#fecaca", color: "#b91c1c" };
|
|
2272
|
+
return { backgroundColor: "#e2e8f0", color: "#64748b" };
|
|
2273
|
+
}
|
|
1728
2274
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2275
|
+
function getStatusProgressStyle(item: { percent: number; color: string }) {
|
|
2276
|
+
const percent = Math.min(100, Math.max(0, Number(item.percent) || 0));
|
|
2277
|
+
return { backgroundColor: item.color, width: `${percent}%` };
|
|
1732
2278
|
}
|
|
1733
2279
|
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
if (
|
|
1750
|
-
normalized.includes("overdue") ||
|
|
1751
|
-
normalized.includes("urgent") ||
|
|
1752
|
-
normalized.includes("high")
|
|
1753
|
-
)
|
|
1754
|
-
return "danger";
|
|
1755
|
-
return "neutral";
|
|
2280
|
+
// ─── Chart Helpers ────────────────────────────────────────────────────────────
|
|
2281
|
+
|
|
2282
|
+
function buildSmoothPath(points: { x: number; y: number }[]) {
|
|
2283
|
+
if (!points || points.length === 0) return "";
|
|
2284
|
+
let d = `M ${points[0].x},${points[0].y}`;
|
|
2285
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
2286
|
+
const p1 = points[i];
|
|
2287
|
+
const p2 = points[i + 1];
|
|
2288
|
+
const cp1x = p1.x + (p2.x - p1.x) / 2;
|
|
2289
|
+
const cp1y = p1.y;
|
|
2290
|
+
const cp2x = p1.x + (p2.x - p1.x) / 2;
|
|
2291
|
+
const cp2y = p2.y;
|
|
2292
|
+
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
|
|
2293
|
+
}
|
|
2294
|
+
return d;
|
|
1756
2295
|
}
|
|
1757
2296
|
|
|
1758
|
-
function
|
|
1759
|
-
const
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
)
|
|
1765
|
-
return "success";
|
|
1766
|
-
if (normalized.includes("break")) return "warning";
|
|
1767
|
-
if (normalized.includes("absent") || normalized.includes("off"))
|
|
1768
|
-
return "danger";
|
|
1769
|
-
return "neutral";
|
|
2297
|
+
function buildChartDots(series: number[]) {
|
|
2298
|
+
const count = Math.max(1, series.length - 1);
|
|
2299
|
+
const width = chartWidth.value;
|
|
2300
|
+
return series.map((value, index) => ({
|
|
2301
|
+
x: 54 + ((width - 74) / count) * index,
|
|
2302
|
+
y: 220 - (toNumber(value) / chartMax.value) * 184,
|
|
2303
|
+
}));
|
|
1770
2304
|
}
|
|
1771
2305
|
|
|
1772
|
-
function
|
|
1773
|
-
const
|
|
2306
|
+
function getActivityChartSource() {
|
|
2307
|
+
const data = finalDashboardData.value || {};
|
|
1774
2308
|
return (
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
2309
|
+
data.activityChart ||
|
|
2310
|
+
data.statistic?.activityChart ||
|
|
2311
|
+
data.statistics?.activityChart ||
|
|
2312
|
+
data.statisticChart ||
|
|
2313
|
+
data.statisticsChart ||
|
|
2314
|
+
data.activityStatistics ||
|
|
2315
|
+
data.activityStatistic ||
|
|
2316
|
+
{}
|
|
1782
2317
|
);
|
|
1783
2318
|
}
|
|
1784
2319
|
|
|
1785
|
-
function
|
|
1786
|
-
|
|
2320
|
+
function normalizeActivityChart(source: any) {
|
|
2321
|
+
const labels = Array.isArray(source?.labels)
|
|
2322
|
+
? source.labels.map((l: any) => String(l))
|
|
2323
|
+
: [];
|
|
2324
|
+
const secondary = extractChartSeries(source, ["closedWorkOrder", "closedWorkOrders", "closed_work_orders"]);
|
|
2325
|
+
const hasClosedSeries = secondary.length > 0;
|
|
2326
|
+
const primary = extractChartSeries(source, ["openWorkOrder", "openWorkOrders", "open_work_orders", "workOrders", "workOrder", "work_orders"]);
|
|
2327
|
+
const secondaryCandidate = hasClosedSeries
|
|
2328
|
+
? { label: "Closed Work Order", series: secondary }
|
|
2329
|
+
: getSecondaryChartSeries(source);
|
|
2330
|
+
|
|
2331
|
+
return {
|
|
2332
|
+
labels,
|
|
2333
|
+
primary,
|
|
2334
|
+
secondary: secondaryCandidate.series,
|
|
2335
|
+
primaryLabel: hasClosedSeries ? "Open Work Order" : "Work Order",
|
|
2336
|
+
secondaryLabel: secondaryCandidate.label,
|
|
2337
|
+
};
|
|
1787
2338
|
}
|
|
1788
2339
|
|
|
1789
|
-
function
|
|
1790
|
-
|
|
1791
|
-
|
|
2340
|
+
function getSecondaryChartSeries(source: any) {
|
|
2341
|
+
const candidates = [
|
|
2342
|
+
{ label: "Task Completed", keys: ["taskCompleted", "completedTasks", "completed"] },
|
|
2343
|
+
{ label: "Incident", keys: ["incidents", "incident", "incidentReports"] },
|
|
2344
|
+
{ label: "Feedback", keys: ["feedbacks", "feedback", "feedbackTickets"] },
|
|
2345
|
+
];
|
|
2346
|
+
for (const candidate of candidates) {
|
|
2347
|
+
const series = extractChartSeries(source, candidate.keys);
|
|
2348
|
+
if (series.length) return { label: candidate.label, series };
|
|
2349
|
+
}
|
|
2350
|
+
return { label: "Feedback", series: [] as number[] };
|
|
1792
2351
|
}
|
|
1793
2352
|
|
|
1794
|
-
function
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
2353
|
+
function extractChartSeries(source: any, keys: string[]) {
|
|
2354
|
+
for (const key of keys) {
|
|
2355
|
+
const value = source?.[key];
|
|
2356
|
+
if (Array.isArray(value)) return value.map(toNumber);
|
|
2357
|
+
}
|
|
2358
|
+
if (Array.isArray(source?.datasets)) {
|
|
2359
|
+
const dataset = source.datasets.find((item: any) => {
|
|
2360
|
+
const keyText = String(item?.key || item?.label || item?.name || "").toLowerCase();
|
|
2361
|
+
return keys.some((key) => keyText.includes(key.toLowerCase()));
|
|
2362
|
+
});
|
|
2363
|
+
if (Array.isArray(dataset?.data)) return dataset.data.map(toNumber);
|
|
2364
|
+
}
|
|
2365
|
+
return [];
|
|
1798
2366
|
}
|
|
1799
2367
|
|
|
1800
|
-
function
|
|
1801
|
-
|
|
2368
|
+
function normalizeSeries(value: any, targetLength: number) {
|
|
2369
|
+
const source = Array.isArray(value) ? value : [];
|
|
2370
|
+
return Array.from({ length: targetLength }, (_, index) => toNumber(source[index]));
|
|
1802
2371
|
}
|
|
1803
2372
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2373
|
+
// ─── Filter Helpers ───────────────────────────────────────────────────────────
|
|
2374
|
+
|
|
2375
|
+
function buildFilterOptions(
|
|
2376
|
+
defaults: string[],
|
|
2377
|
+
items: Record<string, any>[],
|
|
2378
|
+
getValue: (item: Record<string, any>) => string
|
|
2379
|
+
) {
|
|
2380
|
+
const options = new Set(defaults);
|
|
2381
|
+
items.forEach((item) => {
|
|
2382
|
+
const value = getValue(item);
|
|
2383
|
+
if (value) options.add(value);
|
|
2384
|
+
});
|
|
2385
|
+
return Array.from(options);
|
|
1808
2386
|
}
|
|
1809
2387
|
|
|
1810
|
-
function
|
|
1811
|
-
|
|
1812
|
-
|
|
2388
|
+
function getVisitorFilterValue(item: Record<string, any>) {
|
|
2389
|
+
const raw = String(item.type || item.visitorType || item.category || "").toLowerCase();
|
|
2390
|
+
if (raw.includes("contractor")) return "Contractor";
|
|
2391
|
+
if (raw.includes("walk")) return "Walk-In";
|
|
2392
|
+
if (raw.includes("delivery")) return "Delivery";
|
|
2393
|
+
if (raw.includes("pick")) return "Pick-up";
|
|
2394
|
+
if (raw.includes("drop")) return "Drop-Off";
|
|
2395
|
+
if (raw.includes("guest") || raw.includes("drive")) return "Drive-In";
|
|
2396
|
+
return "All";
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// ─── Routing ─────────────────────────────────────────────────────────────────
|
|
2400
|
+
|
|
2401
|
+
function dashboardPath(path: string) {
|
|
2402
|
+
return `/${props.orgId}/${props.site}/${path}`;
|
|
1813
2403
|
}
|
|
1814
2404
|
|
|
1815
2405
|
function goToDashboardRoute(routeKey: string) {
|
|
1816
2406
|
if (!props.orgId || !props.site) return;
|
|
1817
|
-
|
|
1818
2407
|
const pathMap: Record<string, string> = {
|
|
1819
2408
|
attendance: "attendance",
|
|
1820
2409
|
feedbacks: "feedbacks",
|
|
@@ -1822,8 +2411,11 @@ function goToDashboardRoute(routeKey: string) {
|
|
|
1822
2411
|
virtualPatrol: "virtual-patrol/logs",
|
|
1823
2412
|
manpower: "manpower-monitoring/attendance",
|
|
1824
2413
|
incidentReports: "incident-reports",
|
|
1825
|
-
|
|
1826
|
-
|
|
2414
|
+
"virtual-patrol": "virtual-patrol",
|
|
2415
|
+
"work-orders": "work-orders",
|
|
2416
|
+
"event-mgmt": "event-mgmt",
|
|
2417
|
+
calendar: "calendar",
|
|
2418
|
+
"incident-reports": "incident-reports",
|
|
1827
2419
|
};
|
|
1828
2420
|
const path = pathMap[routeKey] || routeKey;
|
|
1829
2421
|
router.push(`/${props.orgId}/${props.site}/${path}`);
|
|
@@ -1834,13 +2426,11 @@ function goToListItem(item: TaskItem | FeedbackItem, routeKey: string) {
|
|
|
1834
2426
|
goToDashboardRoute(routeKey);
|
|
1835
2427
|
return;
|
|
1836
2428
|
}
|
|
1837
|
-
|
|
1838
2429
|
const id = item.raw?._id || item.raw?.id || item.id;
|
|
1839
2430
|
if (!id || String(id).includes("-")) {
|
|
1840
2431
|
goToDashboardRoute(routeKey);
|
|
1841
2432
|
return;
|
|
1842
2433
|
}
|
|
1843
|
-
|
|
1844
2434
|
const pathMap: Record<string, string> = {
|
|
1845
2435
|
feedbacks: "feedbacks",
|
|
1846
2436
|
scheduleTask: "schedule-task",
|
|
@@ -1849,34 +2439,494 @@ function goToListItem(item: TaskItem | FeedbackItem, routeKey: string) {
|
|
|
1849
2439
|
router.push(`/${props.orgId}/${props.site}/${path}/${id}`);
|
|
1850
2440
|
}
|
|
1851
2441
|
|
|
2442
|
+
// ─── Widget Management ────────────────────────────────────────────────────────
|
|
1852
2443
|
|
|
2444
|
+
function isWidgetVisible(key: string) {
|
|
2445
|
+
return visibleWidgetKeys.value.includes(key);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function openCustomizeDialog() {
|
|
2449
|
+
draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
|
|
2450
|
+
customizeDialog.value = true;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function cancelCustomize() {
|
|
2454
|
+
draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
|
|
2455
|
+
widgetSearch.value = "";
|
|
2456
|
+
customizeDialog.value = false;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function resetCustomizeDraft() {
|
|
2460
|
+
draftVisibleWidgetKeys.value = [...defaultVisibleWidgetKeys.value];
|
|
2461
|
+
}
|
|
1853
2462
|
|
|
1854
|
-
function
|
|
1855
|
-
|
|
2463
|
+
function submitCustomize() {
|
|
2464
|
+
visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
|
|
2465
|
+
widgetSearch.value = "";
|
|
2466
|
+
customizeDialog.value = false;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
function notifyExport(format: string) {
|
|
2470
|
+
snackbar.text = `${format} export is being prepared.`;
|
|
2471
|
+
snackbar.visible = true;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// ─── Card Color Helpers ───────────────────────────────────────────────────────
|
|
2475
|
+
|
|
2476
|
+
function getCardColor(index: number) {
|
|
2477
|
+
return ["#155178", "#9f2f29", "#1f8a56", "#b96d17"][index % 4];
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function getCardTint(color: string, index: number) {
|
|
2481
|
+
const tints = ["#dbeeff", "#f8c6c3", "#d8f5e5", "#ffe7c2"];
|
|
2482
|
+
if (!color) return tints[index % tints.length];
|
|
2483
|
+
const n = color.toLowerCase();
|
|
2484
|
+
if (n.includes("f44336") || n.includes("d32f2f")) return "#f8c6c3";
|
|
2485
|
+
if (n.includes("4caf50") || n.includes("43a047")) return "#d8f5e5";
|
|
2486
|
+
if (n.includes("ff9800") || n.includes("ffa726")) return "#ffe7c2";
|
|
2487
|
+
return "#dbeeff";
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// ─── Generic Helpers ─────────────────────────────────────────────────────────
|
|
2491
|
+
|
|
2492
|
+
function getServiceDisplayName(value: string) {
|
|
2493
|
+
const normalized = value.toLowerCase();
|
|
2494
|
+
const titleMap: Record<string, string> = {
|
|
2495
|
+
security_agency: "Security",
|
|
2496
|
+
cleaning_services: "Hygiene",
|
|
2497
|
+
hygiene: "Hygiene",
|
|
2498
|
+
mechanical_electrical: "Mechanical & Electrical",
|
|
2499
|
+
mechanical_electrical_services: "Mechanical & Electrical",
|
|
2500
|
+
pest_control_services: "Pest Control",
|
|
2501
|
+
pool_maintenance_services: "Pool Maintenance",
|
|
2502
|
+
landscaping_services: "Landscaping",
|
|
2503
|
+
property_management_agency: "Property Management",
|
|
2504
|
+
};
|
|
2505
|
+
if (titleMap[normalized]) return titleMap[normalized];
|
|
2506
|
+
const cleaned = normalized
|
|
2507
|
+
.replace(/_services|_agency/g, "")
|
|
2508
|
+
.replace(/[_-]+/g, " ")
|
|
2509
|
+
.trim();
|
|
2510
|
+
return cleaned
|
|
2511
|
+
? cleaned.replace(/\b\w/g, (char) => char.toUpperCase())
|
|
2512
|
+
: "Service";
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function formatNumber(value: any) {
|
|
2516
|
+
const n = Number(value ?? 0);
|
|
2517
|
+
return new Intl.NumberFormat("en-US").format(Number.isFinite(n) ? n : 0);
|
|
1856
2518
|
}
|
|
1857
2519
|
|
|
1858
2520
|
function toNumber(value: any) {
|
|
1859
|
-
const
|
|
1860
|
-
return Number.isFinite(
|
|
2521
|
+
const n = Number(value ?? 0);
|
|
2522
|
+
return Number.isFinite(n) ? n : 0;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function normalizeStatus(value: any) {
|
|
2526
|
+
const text = String(value || "").trim();
|
|
2527
|
+
if (!text) return "To-Do";
|
|
2528
|
+
return text.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function getStatusTone(status: any) {
|
|
2532
|
+
const s = String(status || "").toLowerCase();
|
|
2533
|
+
if (s.includes("complete") || s.includes("done") || s.includes("resolved") || s.includes("closed"))
|
|
2534
|
+
return "success";
|
|
2535
|
+
if (s.includes("progress") || s.includes("active") || s.includes("ongoing"))
|
|
2536
|
+
return "info";
|
|
2537
|
+
if (s.includes("overdue") || s.includes("urgent") || s.includes("high"))
|
|
2538
|
+
return "danger";
|
|
2539
|
+
if (s.includes("warning") || s.includes("reject") || s.includes("cancel"))
|
|
2540
|
+
return "error";
|
|
2541
|
+
return "neutral";
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function isClosedStatus(status: any) {
|
|
2545
|
+
const s = String(status || "").toLowerCase();
|
|
2546
|
+
return (
|
|
2547
|
+
s.includes("complete") ||
|
|
2548
|
+
s.includes("cancel") ||
|
|
2549
|
+
s.includes("reject") ||
|
|
2550
|
+
s.includes("closed") ||
|
|
2551
|
+
s.includes("deleted") ||
|
|
2552
|
+
s.includes("check-out") ||
|
|
2553
|
+
s.includes("checkout")
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function formatActivityTime(item: Record<string, any>) {
|
|
2558
|
+
const rawTime =
|
|
2559
|
+
item.time ||
|
|
2560
|
+
item.createdAt ||
|
|
2561
|
+
item.updatedAt ||
|
|
2562
|
+
item.date ||
|
|
2563
|
+
item.startTime ||
|
|
2564
|
+
item.scheduleTime;
|
|
2565
|
+
return formatRelativeTime(rawTime);
|
|
1861
2566
|
}
|
|
1862
2567
|
|
|
1863
2568
|
function formatRelativeTime(value: any) {
|
|
1864
2569
|
if (!value) return "";
|
|
1865
|
-
|
|
1866
2570
|
const date = new Date(value);
|
|
1867
2571
|
if (Number.isNaN(date.getTime())) return "";
|
|
1868
|
-
|
|
1869
2572
|
const diffMs = Date.now() - date.getTime();
|
|
1870
2573
|
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
|
1871
2574
|
if (diffMinutes < 60) {
|
|
1872
2575
|
const minutes = diffMinutes || 1;
|
|
1873
2576
|
return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
|
|
1874
2577
|
}
|
|
1875
|
-
|
|
1876
2578
|
const diffHours = Math.round(diffMinutes / 60);
|
|
1877
2579
|
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? "" : "s"} ago`;
|
|
1878
|
-
|
|
1879
2580
|
const diffDays = Math.round(diffHours / 24);
|
|
1880
2581
|
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
|
|
1881
2582
|
}
|
|
2583
|
+
|
|
2584
|
+
function formatDashboardDateTime(value?: string) {
|
|
2585
|
+
if (!value) return "";
|
|
2586
|
+
const date = new Date(value);
|
|
2587
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
2588
|
+
return `${date.toLocaleDateString("en-GB", {
|
|
2589
|
+
day: "2-digit",
|
|
2590
|
+
month: "2-digit",
|
|
2591
|
+
year: "numeric",
|
|
2592
|
+
})}, ${date.toLocaleTimeString("en-GB", {
|
|
2593
|
+
hour: "2-digit",
|
|
2594
|
+
minute: "2-digit",
|
|
2595
|
+
hour12: false,
|
|
2596
|
+
})}`;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function formatDashboardTime(value?: string) {
|
|
2600
|
+
if (!value) return "";
|
|
2601
|
+
const date = new Date(value);
|
|
2602
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
2603
|
+
const now = Date.now();
|
|
2604
|
+
const diff = date.getTime() - now;
|
|
2605
|
+
const abs = Math.abs(diff);
|
|
2606
|
+
const minute = 60 * 1000;
|
|
2607
|
+
const hour = 60 * minute;
|
|
2608
|
+
const day = 24 * hour;
|
|
2609
|
+
if (abs < minute) return diff >= 0 ? "In a moment" : "Just now";
|
|
2610
|
+
if (abs < hour) {
|
|
2611
|
+
const minutes = Math.max(1, Math.round(abs / minute));
|
|
2612
|
+
return diff >= 0 ? `In ${minutes} minutes` : `${minutes} min ago`;
|
|
2613
|
+
}
|
|
2614
|
+
if (abs < day) {
|
|
2615
|
+
const hours = Math.max(1, Math.round(abs / hour));
|
|
2616
|
+
return diff >= 0 ? `In ${hours} hours` : `${hours} hr ago`;
|
|
2617
|
+
}
|
|
2618
|
+
return formatDashboardDateTime(value);
|
|
2619
|
+
}
|
|
1882
2620
|
</script>
|
|
2621
|
+
|
|
2622
|
+
<style scoped>
|
|
2623
|
+
/* ── Figma Panel Styles (Property & Security modes) ────────────────────────── */
|
|
2624
|
+
.dashboard-card {
|
|
2625
|
+
background: #fff;
|
|
2626
|
+
border: 1px solid #d9e0e7;
|
|
2627
|
+
border-radius: 8px;
|
|
2628
|
+
box-shadow: 0 1px 2px rgba(15, 35, 54, 0.04);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
.dashboard-section-grid {
|
|
2632
|
+
margin-top: 14px;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
.dashboard-section-grid > .v-col {
|
|
2636
|
+
display: flex;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
.dashboard-panel {
|
|
2640
|
+
display: flex;
|
|
2641
|
+
flex-direction: column;
|
|
2642
|
+
height: 100%;
|
|
2643
|
+
min-height: 274px;
|
|
2644
|
+
padding: 28px 28px 26px;
|
|
2645
|
+
width: 100%;
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
.figma-panel {
|
|
2649
|
+
border-color: #dde4ea;
|
|
2650
|
+
box-shadow: none;
|
|
2651
|
+
min-height: 276px;
|
|
2652
|
+
padding: 26px 28px 24px;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
.figma-panel-header {
|
|
2656
|
+
align-items: center;
|
|
2657
|
+
display: flex;
|
|
2658
|
+
gap: 16px;
|
|
2659
|
+
justify-content: space-between;
|
|
2660
|
+
min-height: 18px;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
.figma-panel-header h2 {
|
|
2664
|
+
color: #18324a;
|
|
2665
|
+
font-size: 12px;
|
|
2666
|
+
font-weight: 900;
|
|
2667
|
+
letter-spacing: 0;
|
|
2668
|
+
line-height: 1.2;
|
|
2669
|
+
margin: 0;
|
|
2670
|
+
text-transform: uppercase;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
.figma-panel-action,
|
|
2674
|
+
.figma-row-action {
|
|
2675
|
+
appearance: none;
|
|
2676
|
+
background: transparent;
|
|
2677
|
+
border: 0;
|
|
2678
|
+
color: #1976d2;
|
|
2679
|
+
cursor: pointer;
|
|
2680
|
+
font-size: 12px;
|
|
2681
|
+
font-weight: 800;
|
|
2682
|
+
line-height: 1;
|
|
2683
|
+
padding: 0;
|
|
2684
|
+
white-space: nowrap;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
.figma-panel-body {
|
|
2688
|
+
flex: 1;
|
|
2689
|
+
margin-top: 20px;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
.figma-list {
|
|
2693
|
+
display: grid;
|
|
2694
|
+
gap: 10px;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
.figma-list-row {
|
|
2698
|
+
align-items: center;
|
|
2699
|
+
background: #fff;
|
|
2700
|
+
border: 1px solid #e1e7ed;
|
|
2701
|
+
border-radius: 6px;
|
|
2702
|
+
display: grid;
|
|
2703
|
+
gap: 10px;
|
|
2704
|
+
grid-template-columns: 34px minmax(0, 1fr) minmax(38px, auto);
|
|
2705
|
+
min-height: 52px;
|
|
2706
|
+
padding: 8px 12px;
|
|
2707
|
+
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
.figma-list-row:hover {
|
|
2711
|
+
border-color: #d5e0ea;
|
|
2712
|
+
box-shadow: 0 6px 14px rgba(15, 44, 69, 0.05);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
.figma-list-main {
|
|
2716
|
+
min-width: 0;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
.figma-list-main strong {
|
|
2720
|
+
color: #152f47;
|
|
2721
|
+
display: block;
|
|
2722
|
+
font-size: 12px;
|
|
2723
|
+
font-weight: 800;
|
|
2724
|
+
line-height: 1.25;
|
|
2725
|
+
overflow: hidden;
|
|
2726
|
+
text-overflow: ellipsis;
|
|
2727
|
+
white-space: nowrap;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
.figma-list-main p {
|
|
2731
|
+
color: #7b8895;
|
|
2732
|
+
font-size: 11px;
|
|
2733
|
+
font-weight: 700;
|
|
2734
|
+
line-height: 1.3;
|
|
2735
|
+
margin: 3px 0 0;
|
|
2736
|
+
overflow: hidden;
|
|
2737
|
+
text-overflow: ellipsis;
|
|
2738
|
+
white-space: nowrap;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
.figma-row-action {
|
|
2742
|
+
justify-self: end;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
.figma-event-dot {
|
|
2746
|
+
align-self: center;
|
|
2747
|
+
border-radius: 50%;
|
|
2748
|
+
display: inline-block;
|
|
2749
|
+
height: 20px;
|
|
2750
|
+
justify-self: center;
|
|
2751
|
+
width: 20px;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
.figma-event-dot-info,
|
|
2755
|
+
.figma-event-dot-neutral {
|
|
2756
|
+
background: #c9e6ff;
|
|
2757
|
+
border: 2px solid #77bef9;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
.figma-event-dot-success {
|
|
2761
|
+
background: #2fb84d;
|
|
2762
|
+
border: 2px solid #c9efd4;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
.figma-event-dot-danger {
|
|
2766
|
+
background: #ffd9d6;
|
|
2767
|
+
border: 2px solid #f9a6a0;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
.figma-event-dot-warning {
|
|
2771
|
+
background: #ffe9c7;
|
|
2772
|
+
border: 2px solid #f9c97c;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
.figma-icon-badge {
|
|
2776
|
+
align-items: center;
|
|
2777
|
+
border-radius: 50%;
|
|
2778
|
+
display: inline-flex;
|
|
2779
|
+
height: 28px;
|
|
2780
|
+
justify-content: center;
|
|
2781
|
+
justify-self: center;
|
|
2782
|
+
width: 28px;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
.figma-icon-badge-danger { background: #ffe0de; color: #d72f2f; }
|
|
2786
|
+
.figma-icon-badge-info { background: #e8e2ff; color: #6d4bd8; }
|
|
2787
|
+
.figma-icon-badge-success { background: #ddf7e6; color: #229852; }
|
|
2788
|
+
.figma-icon-badge-warning { background: #ffeed4; color: #c57916; }
|
|
2789
|
+
.figma-icon-badge-neutral { background: #edf2f6; color: #607386; }
|
|
2790
|
+
|
|
2791
|
+
.figma-status-stack {
|
|
2792
|
+
display: grid;
|
|
2793
|
+
gap: 22px;
|
|
2794
|
+
padding: 6px 2px 0;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
.figma-status-row {}
|
|
2798
|
+
|
|
2799
|
+
.figma-status-label {
|
|
2800
|
+
align-items: center;
|
|
2801
|
+
color: #263d53;
|
|
2802
|
+
display: flex;
|
|
2803
|
+
font-size: 12px;
|
|
2804
|
+
font-weight: 800;
|
|
2805
|
+
justify-content: space-between;
|
|
2806
|
+
line-height: 1.25;
|
|
2807
|
+
margin-bottom: 10px;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
.figma-status-label strong {
|
|
2811
|
+
color: #18324a;
|
|
2812
|
+
font-size: 12px;
|
|
2813
|
+
font-weight: 900;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
.figma-progress-track {
|
|
2817
|
+
background: #edf2f6;
|
|
2818
|
+
border-radius: 999px;
|
|
2819
|
+
height: 6px;
|
|
2820
|
+
overflow: hidden;
|
|
2821
|
+
width: 100%;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
.figma-progress-fill {
|
|
2825
|
+
border-radius: inherit;
|
|
2826
|
+
display: block;
|
|
2827
|
+
height: 100%;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
/* ── Property Feedback List ────────────────────────────────────────────────── */
|
|
2831
|
+
.feedback-list {
|
|
2832
|
+
display: grid;
|
|
2833
|
+
gap: 9px;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
.feedback-row {
|
|
2837
|
+
align-items: center;
|
|
2838
|
+
background: #fff;
|
|
2839
|
+
border: 1px solid #e0e5ea;
|
|
2840
|
+
border-radius: 8px;
|
|
2841
|
+
display: grid;
|
|
2842
|
+
gap: 16px;
|
|
2843
|
+
grid-template-columns: minmax(180px, 225px) minmax(130px, 175px) 100px auto;
|
|
2844
|
+
min-height: 66px;
|
|
2845
|
+
padding: 12px 14px;
|
|
2846
|
+
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
.feedback-row:hover {
|
|
2850
|
+
border-color: #d8e3ec;
|
|
2851
|
+
box-shadow: 0 6px 14px rgba(15, 44, 69, 0.04);
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
.feedback-summary { min-width: 0; }
|
|
2855
|
+
|
|
2856
|
+
.feedback-summary strong {
|
|
2857
|
+
color: #132b43;
|
|
2858
|
+
display: block;
|
|
2859
|
+
font-size: 12px;
|
|
2860
|
+
font-weight: 800;
|
|
2861
|
+
line-height: 1.25;
|
|
2862
|
+
overflow: hidden;
|
|
2863
|
+
text-overflow: ellipsis;
|
|
2864
|
+
white-space: nowrap;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
.feedback-summary p {
|
|
2868
|
+
color: #888;
|
|
2869
|
+
font-size: 12px;
|
|
2870
|
+
font-weight: 500;
|
|
2871
|
+
line-height: 1.35;
|
|
2872
|
+
margin: 5px 0 0;
|
|
2873
|
+
overflow: hidden;
|
|
2874
|
+
text-overflow: ellipsis;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
.feedback-person {
|
|
2878
|
+
align-items: center;
|
|
2879
|
+
display: flex;
|
|
2880
|
+
gap: 8px;
|
|
2881
|
+
min-width: 0;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
.feedback-person span {
|
|
2885
|
+
font-size: 13px;
|
|
2886
|
+
overflow: hidden;
|
|
2887
|
+
text-overflow: ellipsis;
|
|
2888
|
+
white-space: nowrap;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
.feedback-avatar {
|
|
2892
|
+
align-items: center;
|
|
2893
|
+
background: #d1e8ff;
|
|
2894
|
+
border-radius: 50%;
|
|
2895
|
+
color: #1d5f99;
|
|
2896
|
+
display: inline-flex;
|
|
2897
|
+
flex-shrink: 0;
|
|
2898
|
+
font-size: 11px;
|
|
2899
|
+
font-weight: 800;
|
|
2900
|
+
height: 30px;
|
|
2901
|
+
justify-content: center;
|
|
2902
|
+
width: 30px;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
.feedback-status-pill {
|
|
2906
|
+
border-radius: 20px;
|
|
2907
|
+
font-size: 12px;
|
|
2908
|
+
font-weight: 600;
|
|
2909
|
+
padding: 4px 10px;
|
|
2910
|
+
text-align: center;
|
|
2911
|
+
white-space: nowrap;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
.feedback-status-pill-completed { background: #72e3a1; color: #0f5132; }
|
|
2915
|
+
.feedback-status-pill-progress { background: #fde68a; color: #92400e; }
|
|
2916
|
+
.feedback-status-pill-danger { background: #fecaca; color: #b91c1c; }
|
|
2917
|
+
.feedback-status-pill-todo { background: #e2e8f0; color: #64748b; }
|
|
2918
|
+
|
|
2919
|
+
.feedback-view-button {
|
|
2920
|
+
appearance: none;
|
|
2921
|
+
background: transparent;
|
|
2922
|
+
border: 0;
|
|
2923
|
+
color: #2196f3;
|
|
2924
|
+
cursor: pointer;
|
|
2925
|
+
font-size: 12px;
|
|
2926
|
+
font-weight: 800;
|
|
2927
|
+
justify-self: end;
|
|
2928
|
+
line-height: 1;
|
|
2929
|
+
padding: 0;
|
|
2930
|
+
white-space: nowrap;
|
|
2931
|
+
}
|
|
2932
|
+
</style>
|