@7365admin1/layer-common 3.0.7 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/components/CameraMain.vue +17 -1
  3. package/components/DashboardMain.vue +1854 -848
  4. package/components/EntryPassInformation.vue +60 -0
  5. package/components/HidAccessLogDashboard.vue +1691 -0
  6. package/components/HidEnabledGate.vue +51 -0
  7. package/components/HidIdentityMapping.vue +975 -0
  8. package/components/HidQrCodeConfiguration.vue +618 -0
  9. package/components/HidReaderForm.vue +246 -0
  10. package/components/HidReaderManagement.vue +1233 -0
  11. package/components/HidServiceSettingsPanel.vue +150 -0
  12. package/components/HidUserEnrollment.vue +1274 -0
  13. package/components/Layout/NavigationDrawer.vue +31 -1
  14. package/components/MemberInformation.vue +23 -15
  15. package/components/NavigationItem.vue +11 -4
  16. package/components/OnlineFormFill.vue +34 -0
  17. package/components/PassInformation.vue +73 -34
  18. package/components/SiteSettings.vue +60 -116
  19. package/components/VisitorForm.vue +815 -290
  20. package/components/VisitorFormSelection.vue +12 -2
  21. package/components/VisitorManagement.vue +652 -205
  22. package/composables/useCommonPermission.ts +59 -0
  23. package/composables/useDashboard.ts +46 -1
  24. package/composables/useHidAmico.ts +152 -0
  25. package/composables/useHidNavigation.ts +106 -0
  26. package/composables/useMember.ts +7 -1
  27. package/composables/useOptionalServices.ts +118 -0
  28. package/composables/useSettingsPermission.ts +138 -0
  29. package/composables/useSiteSettings.ts +1 -1
  30. package/composables/useUser.ts +1 -2
  31. package/nuxt.config.ts +1 -1
  32. package/package.json +3 -2
  33. package/pages/[org]/[site]/access-mgmt/access-logs/index.vue +21 -0
  34. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +21 -0
  35. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +21 -0
  36. package/pages/[org]/[site]/access-mgmt/hid-users/index.vue +21 -0
  37. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +21 -0
  38. package/types/local.d.ts +2 -1
  39. package/types/visitor.d.ts +4 -2
@@ -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,6 +54,7 @@
53
54
  divided
54
55
  variant="outlined"
55
56
  class="bg-white"
57
+ style="border-radius: 8px; border-color: #d9e1e8"
56
58
  >
57
59
  <v-btn
58
60
  v-for="range in rangeOptions"
@@ -62,8 +64,8 @@
62
64
  size="small"
63
65
  :style="
64
66
  selectedRange === range.value
65
- ? { backgroundColor: '#0b2f47', color: '#ffffff' }
66
- : {}
67
+ ? { backgroundColor: '#e2e8f0', color: '#0b2f47', fontWeight: '600' }
68
+ : { backgroundColor: 'transparent', color: '#64748b' }
67
69
  "
68
70
  >
69
71
  {{ range.label }}
@@ -79,6 +81,7 @@
79
81
  class="mb-4"
80
82
  />
81
83
 
84
+ <!-- ── KPI Cards ───────────────────────────────────────────────────────── -->
82
85
  <v-row dense class="mb-1 align-stretch">
83
86
  <v-col
84
87
  v-for="card in visibleKpiCards"
@@ -86,6 +89,7 @@
86
89
  cols="12"
87
90
  sm="6"
88
91
  md="4"
92
+ lg="3"
89
93
  class="d-flex"
90
94
  >
91
95
  <v-card flat border rounded="lg" class="pa-4 flex-grow-1" style="min-height: 158px">
@@ -104,9 +108,10 @@
104
108
  v-bind="menuProps"
105
109
  variant="outlined"
106
110
  density="compact"
107
- class="text-none text-caption"
111
+ class="text-none text-caption px-2"
108
112
  append-icon="mdi-chevron-down"
109
- size="x-small"
113
+ height="26"
114
+ rounded="pill"
110
115
  >
111
116
  {{ card.filter.model }}
112
117
  </v-btn>
@@ -122,9 +127,7 @@
122
127
  >
123
128
  <template #prepend>
124
129
  <v-icon
125
- :icon="
126
- card.filter.model === option ? 'mdi-check' : 'mdi-blank'
127
- "
130
+ :icon="card.filter.model === option ? 'mdi-check' : 'mdi-blank'"
128
131
  size="16"
129
132
  />
130
133
  </template>
@@ -140,9 +143,7 @@
140
143
  {{ card.title }}
141
144
  </p>
142
145
  <template
143
- v-if="
144
- card.key === 'supplyAlert' && Array.isArray(card.raw?.items)
145
- "
146
+ v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
146
147
  >
147
148
  <div class="d-flex flex-column ga-2 mt-2">
148
149
  <div
@@ -157,10 +158,9 @@
157
158
  </template>
158
159
  <template v-else>
159
160
  <div class="d-flex align-center mb-3" style="min-height: 31px">
160
- <span
161
- class="text-h5 font-weight-black text-blue-grey-darken-4"
162
- >{{ card.value }}</span
163
- >
161
+ <span class="text-h5 font-weight-black text-blue-grey-darken-4">
162
+ {{ card.value }}
163
+ </span>
164
164
  </div>
165
165
  <div class="d-flex align-center flex-wrap ga-2">
166
166
  <span v-if="card.meta" class="text-caption text-blue-grey">{{
@@ -190,6 +190,7 @@
190
190
  </v-col>
191
191
  </v-row>
192
192
 
193
+ <!-- ── Activity Chart (all modes) ────────────────────────────────────── -->
193
194
  <v-card
194
195
  v-if="isWidgetVisible('activity')"
195
196
  flat
@@ -283,12 +284,7 @@
283
284
  >
284
285
  <span
285
286
  class="rounded-circle"
286
- style="
287
- width: 12px;
288
- height: 12px;
289
- display: inline-block;
290
- background-color: #ff9b9b;
291
- "
287
+ style="width: 12px; height: 12px; display: inline-block; background-color: #ff9b9b"
292
288
  />
293
289
  {{ primaryLegendLabel }}
294
290
  </span>
@@ -297,12 +293,7 @@
297
293
  >
298
294
  <span
299
295
  class="rounded-circle"
300
- style="
301
- width: 12px;
302
- height: 12px;
303
- display: inline-block;
304
- background-color: #e53935;
305
- "
296
+ style="width: 12px; height: 12px; display: inline-block; background-color: #e53935"
306
297
  />
307
298
  {{ secondaryLegendLabel }}
308
299
  </span>
@@ -312,7 +303,8 @@
312
303
  <DashboardEmptyState v-else />
313
304
  </v-card>
314
305
 
315
- <v-row dense class="mt-3 align-stretch">
306
+ <!-- ── Hygiene / M&E / Landscape / Pest / Pool — task + attendance ────── -->
307
+ <v-row v-if="isHygieneMode" dense class="mt-3 align-stretch">
316
308
  <v-col
317
309
  v-if="isWidgetVisible('taskSchedule')"
318
310
  cols="12"
@@ -342,21 +334,14 @@
342
334
  </div>
343
335
  <div class="text-caption text-blue-grey mt-1 text-truncate">
344
336
  {{ item.subtitle
345
- }}<template v-if="item.subtitle && item.time">
346
- &bull; </template
337
+ }}<template v-if="item.subtitle && item.time"> &bull; </template
347
338
  >{{ item.time }}
348
339
  </div>
349
340
  </div>
350
341
  <div
351
342
  class="d-flex align-center ga-2 text-caption text-blue-grey"
352
343
  >
353
- <v-avatar
354
- size="30"
355
- color="blue-grey-lighten-4"
356
- class="text-caption font-weight-bold text-blue-grey-darken-2"
357
- >
358
- {{ getInitials(item.person) }}
359
- </v-avatar>
344
+ <AvatarMain :name="item.person" :size="30" />
360
345
  <span class="text-truncate" style="max-width: 100px">{{
361
346
  item.person
362
347
  }}</span>
@@ -394,7 +379,7 @@
394
379
  <DashboardPanel
395
380
  title="Staff's Total Attendance"
396
381
  action-label="View All"
397
- @action="goToDashboardRoute(staffStatusRoute)"
382
+ @action="goToDashboardRoute('attendance')"
398
383
  >
399
384
  <div v-if="staffStatusItems.length" class="d-flex flex-column ga-3">
400
385
  <v-card
@@ -405,14 +390,8 @@
405
390
  rounded="lg"
406
391
  class="pa-3"
407
392
  >
408
- <div class="d-flex align-center ga-3">
409
- <v-avatar
410
- size="34"
411
- color="blue-grey-lighten-4"
412
- class="text-caption font-weight-bold text-blue-grey-darken-2 flex-shrink-0"
413
- >
414
- {{ getInitials(item.name) }}
415
- </v-avatar>
393
+ <div class="d-flex align-start ga-3">
394
+ <AvatarMain :name="item.name" :size="32" />
416
395
  <div class="flex-grow-1" style="min-width: 0">
417
396
  <div
418
397
  class="text-body-2 font-weight-bold text-blue-grey-darken-4 text-truncate"
@@ -445,65 +424,445 @@
445
424
  action-label="View All"
446
425
  @action="goToDashboardRoute('feedbacks')"
447
426
  >
448
- <div v-if="feedbackItems.length" class="d-flex flex-column ga-3">
449
- <v-card
427
+ <div v-if="feedbackItems.length" class="figma-list mt-2">
428
+ <div
450
429
  v-for="item in feedbackItems"
451
430
  :key="item.id"
452
- flat
453
- border
454
- rounded="lg"
455
- class="pa-3"
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)"
456
433
  >
457
- <div class="d-flex align-center flex-wrap ga-3">
458
- <div class="flex-grow-1" style="min-width: 0">
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>
440
+ </div>
441
+
442
+ <div style="flex: 1; display: flex; align-items: center; gap: 12px">
443
+ <AvatarMain :name="item.person" :size="36" />
444
+ <span style="font-size: 14px; color: #64748b">{{ item.person }}</span>
445
+ </div>
446
+
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" }}
453
+ </div>
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>
504
+ </div>
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">
459
734
  <div
460
- class="text-body-2 font-weight-bold text-blue-grey-darken-4 text-truncate"
735
+ :style="getPatrolStatusStyle(item.status)"
736
+ style="padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; text-transform: capitalize"
461
737
  >
462
- {{ item.title }}
738
+ {{ item.status }}
463
739
  </div>
464
- <div class="text-caption text-blue-grey mt-1 text-truncate">
465
- {{ item.subtitle }}
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>
466
793
  </div>
467
794
  </div>
468
- <div
469
- class="d-flex align-center ga-2 text-caption text-blue-grey"
470
- >
471
- <v-avatar
472
- size="30"
473
- color="blue-grey-lighten-4"
474
- class="text-caption font-weight-bold text-blue-grey-darken-2"
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"
475
800
  >
476
- {{ getInitials(item.person) }}
477
- </v-avatar>
478
- <span class="text-truncate" style="max-width: 100px">{{
479
- item.person
480
- }}</span>
801
+ {{ item.status || "On duty" }}
802
+ </div>
481
803
  </div>
482
- <v-chip
483
- size="small"
484
- :color="getStatusTone(item.status)"
485
- variant="tonal"
486
- label
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
487
829
  >
488
- {{ item.status }}
489
- </v-chip>
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" }}
844
+ </div>
845
+ </div>
846
+
847
+ <div style="flex: 0 0 auto; text-align: right">
490
848
  <v-btn
491
849
  variant="outlined"
492
- color="primary"
850
+ class="text-none font-weight-medium"
493
851
  size="small"
494
- class="text-none font-weight-bold"
852
+ color="#3b82f6"
853
+ style="border-color: #e2e8f0; border-radius: 8px; padding: 0 16px; height: 32px"
495
854
  @click="goToListItem(item, 'feedbacks')"
855
+ >View</v-btn
496
856
  >
497
- View
498
- </v-btn>
499
857
  </div>
500
- </v-card>
858
+ </div>
501
859
  </div>
502
860
  <DashboardEmptyState v-else />
503
861
  </DashboardPanel>
504
862
  </v-col>
505
863
  </v-row>
506
864
 
865
+ <!-- ── Customize Dialog ───────────────────────────────────────────────── -->
507
866
  <v-dialog v-model="customizeDialog" max-width="720" persistent scrollable>
508
867
  <v-card rounded="lg">
509
868
  <v-card-title
@@ -616,6 +975,8 @@
616
975
  </template>
617
976
 
618
977
  <script setup lang="ts">
978
+ // ─── Types ────────────────────────────────────────────────────────────────────
979
+
619
980
  type DashboardMetric = {
620
981
  count?: number;
621
982
  percentage?: number;
@@ -624,6 +985,10 @@ type DashboardMetric = {
624
985
  pending?: number;
625
986
  highSeverity?: number;
626
987
  currentlyOnSite?: number;
988
+ ongoing?: number;
989
+ waitingApproval?: number;
990
+ missed?: number;
991
+ missedCheckpoints?: number;
627
992
  lowStock?: number;
628
993
  outOfStock?: number;
629
994
  completed?: number;
@@ -638,7 +1003,8 @@ type DashboardListItem = {
638
1003
  time?: string;
639
1004
  icon?: string;
640
1005
  tone?: string;
641
- raw?: Record<string, any>;
1006
+ color?: string;
1007
+ actionLabel?: string;
642
1008
  };
643
1009
 
644
1010
  type TaskItem = {
@@ -669,132 +1035,49 @@ type FeedbackItem = {
669
1035
  raw?: Record<string, any>;
670
1036
  };
671
1037
 
1038
+ // ─── Props ────────────────────────────────────────────────────────────────────
1039
+
672
1040
  const props = defineProps({
673
- orgId: {
674
- type: String,
675
- default: "",
676
- },
677
- site: {
678
- type: String,
679
- default: "",
680
- },
681
- serviceType: {
682
- type: String,
683
- default: "",
684
- },
1041
+ orgId: { type: String, default: "" },
1042
+ site: { type: String, default: "" },
1043
+ serviceType: { type: String, default: "" },
685
1044
  dashboardData: {
686
1045
  type: Object as () => Record<string, any>,
687
1046
  default: () => ({}),
688
1047
  },
689
- loading: {
690
- type: Boolean,
691
- default: false,
692
- },
1048
+ loading: { type: Boolean, default: false },
693
1049
  cardPeriods: {
694
1050
  type: Object as () => Record<string, TDashboardValues>,
695
1051
  default: () => ({}),
696
1052
  },
697
- currentSiteName: {
698
- type: String,
699
- default: "",
700
- },
1053
+ currentSiteName: { type: String, default: "" },
701
1054
  cardValues: {
702
1055
  type: Array as () => TDashboardCardValue[],
703
1056
  default: () => [],
704
1057
  },
705
- title: {
706
- type: String,
707
- default: "",
708
- },
709
- extraWidgetKeys: {
710
- type: Array as () => string[],
711
- default: () => [],
712
- },
713
- showHeaderActions: {
714
- type: Boolean,
715
- default: true,
716
- },
1058
+ title: { type: String, default: "" },
1059
+ extraWidgetKeys: { type: Array as () => string[], default: () => [] },
1060
+ showHeaderActions: { type: Boolean, default: true },
717
1061
  });
718
1062
 
719
- const defaultCardValues: TDashboardCardValue[] = [
720
- {
721
- key: "openWorkOrder",
722
- periodKey: "period",
723
- title: "Open Work Order",
724
- icon: "mdi-briefcase",
725
- color: "#0b2f47",
726
- },
727
- {
728
- key: "taskCompleted",
729
- periodKey: "period",
730
- title: "Task Completed",
731
- icon: "mdi-clipboard-check-outline",
732
- color: "#f4a340",
733
- },
734
- {
735
- key: "supplyAlert",
736
- periodKey: "period",
737
- title: "Supply Alert",
738
- icon: "mdi-alert",
739
- color: "#d32f2f",
740
- },
741
- ];
742
-
743
- const landscapeCardValues: TDashboardCardValue[] = [
744
- {
745
- key: "openWorkOrder",
746
- periodKey: "period",
747
- title: "Open Work Order",
748
- icon: "mdi-briefcase",
749
- color: "#0b2f47",
750
- },
751
- {
752
- key: "taskCompleted",
753
- periodKey: "period",
754
- title: "Task Completed",
755
- icon: "mdi-clipboard-check-outline",
756
- color: "#f4a340",
757
- },
758
- {
759
- key: "supplyAlert",
760
- periodKey: "period",
761
- title: "Supply Alert",
762
- icon: "mdi-alert",
763
- color: "#d32f2f",
764
- },
765
- ];
1063
+ const emit = defineEmits<{
1064
+ updatePeriod: [payload: { key: string; period: string }];
1065
+ }>();
766
1066
 
767
- const defaultExtraWidgetKeys = [
768
- "activity",
769
- "taskSchedule",
770
- "staffStatus",
771
- "feedbacks",
772
- "recentActivity",
773
- ];
1067
+ // ─── Mode Detection ───────────────────────────────────────────────────────────
774
1068
 
775
- const resolvedCardValues = computed(() => {
776
- if (props.cardValues && props.cardValues.length > 0) return props.cardValues;
777
- if (props.serviceType === "landscaping_services") return landscapeCardValues;
778
- if (props.serviceType) return defaultCardValues;
779
- return [];
780
- });
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
+ );
781
1076
 
782
- const resolvedExtraWidgetKeys = computed(() => {
783
- if (props.extraWidgetKeys && props.extraWidgetKeys.length > 0)
784
- return props.extraWidgetKeys;
785
- if (props.serviceType) return defaultExtraWidgetKeys;
786
- return [];
787
- });
788
-
789
- const periodValue: Record<string, TDashboardValues> = {
790
- Today: "today",
791
- "This Week": "thisWeek",
792
- "This Month": "thisMonth",
793
- };
794
-
795
- const currentPeriod = ref<TDashboardValues>("today");
1077
+ // ─── Composables ──────────────────────────────────────────────────────────────
796
1078
 
797
1079
  const {
1080
+ getDashboard,
798
1081
  getDashboardHygiene,
799
1082
  getDashboardHygieneWeeklyActivity,
800
1083
  getDashboardHygieneTaskSchedule,
@@ -802,30 +1085,99 @@ const {
802
1085
  getDashboardHygieneFeedbacks,
803
1086
  } = useDashboard();
804
1087
 
1088
+ // ─── State ────────────────────────────────────────────────────────────────────
1089
+
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
805
1137
  const { data: getDashboardReq, pending: loadingMetrics } =
806
1138
  await useLazyAsyncData(
807
1139
  `get-dashboard-${props.site}-${props.serviceType}`,
808
1140
  () =>
809
- props.serviceType
810
- ? getDashboardHygiene(
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(
811
1156
  props.site,
1157
+ props.serviceType as TServiceType,
812
1158
  currentPeriod.value,
813
- props.serviceType
1159
+ currentVisitorTypeParam.value ? { type: currentVisitorTypeParam.value } : {}
814
1160
  )
815
1161
  : Promise.resolve(null),
816
1162
  {
817
- watch: [() => props.site, currentPeriod, () => props.serviceType],
1163
+ watch: [
1164
+ () => props.site,
1165
+ () => props.serviceType,
1166
+ currentPeriod,
1167
+ currentVisitorTypeParam,
1168
+ ],
818
1169
  }
819
1170
  );
820
1171
 
1172
+ // Weekly activity (hygiene mode only)
821
1173
  const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
822
1174
  await useLazyAsyncData(
823
1175
  `get-dashboard-weekly-activity-${props.site}-${props.serviceType}`,
824
1176
  () =>
825
- props.serviceType
1177
+ props.serviceType && isHygieneMode.value
826
1178
  ? getDashboardHygieneWeeklyActivity(
827
1179
  props.site,
828
- props.serviceType,
1180
+ props.serviceType as TServiceType,
829
1181
  currentPeriod.value
830
1182
  )
831
1183
  : Promise.resolve(null),
@@ -834,44 +1186,62 @@ const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
834
1186
  }
835
1187
  );
836
1188
 
1189
+ // Task schedule (hygiene mode only)
837
1190
  const { data: taskScheduleReq, pending: loadingTaskSchedule } =
838
1191
  await useLazyAsyncData(
839
1192
  `get-dashboard-task-schedule-${props.site}-${props.serviceType}`,
840
1193
  () =>
841
- props.serviceType
842
- ? getDashboardHygieneTaskSchedule(props.site, props.serviceType)
1194
+ props.serviceType && isHygieneMode.value
1195
+ ? getDashboardHygieneTaskSchedule(
1196
+ props.site,
1197
+ props.serviceType as TServiceType,
1198
+ currentPeriod.value
1199
+ )
843
1200
  : Promise.resolve(null),
844
1201
  {
845
- watch: [() => props.site, () => props.serviceType],
1202
+ watch: [() => props.site, () => props.serviceType, currentPeriod],
846
1203
  }
847
1204
  );
848
1205
 
1206
+ // Attendance (hygiene + security modes)
849
1207
  const { data: attendanceReq, pending: loadingAttendance } =
850
1208
  await useLazyAsyncData(
851
1209
  `get-dashboard-attendance-${props.site}-${props.serviceType}`,
852
1210
  () =>
853
- props.serviceType
854
- ? getDashboardHygieneAttendance(props.site, props.serviceType)
1211
+ props.serviceType && !isPropertyMode.value
1212
+ ? getDashboardHygieneAttendance(
1213
+ props.site,
1214
+ props.serviceType as TServiceType,
1215
+ currentPeriod.value
1216
+ )
855
1217
  : Promise.resolve(null),
856
1218
  {
857
- watch: [() => props.site, () => props.serviceType],
1219
+ watch: [() => props.site, () => props.serviceType, currentPeriod],
858
1220
  }
859
1221
  );
860
1222
 
1223
+ // Feedbacks (all modes)
861
1224
  const { data: feedbacksReq, pending: loadingFeedbacks } =
862
1225
  await useLazyAsyncData(
863
1226
  `get-dashboard-feedbacks-${props.site}-${props.serviceType}`,
864
1227
  () =>
865
1228
  props.serviceType
866
- ? getDashboardHygieneFeedbacks(props.site, props.serviceType)
1229
+ ? getDashboardHygieneFeedbacks(
1230
+ props.site,
1231
+ props.serviceType as TServiceType,
1232
+ currentPeriod.value
1233
+ )
867
1234
  : Promise.resolve(null),
868
1235
  {
869
- watch: [() => props.site, () => props.serviceType],
1236
+ watch: [() => props.site, () => props.serviceType, currentPeriod],
870
1237
  }
871
1238
  );
872
1239
 
1240
+ // ─── Loading State ────────────────────────────────────────────────────────────
1241
+
873
1242
  const finalLoading = computed(() => {
874
- if (props.serviceType) {
1243
+ if (!props.serviceType) return props.loading;
1244
+ if (isHygieneMode.value) {
875
1245
  return (
876
1246
  loadingMetrics.value ||
877
1247
  loadingWeeklyActivity.value ||
@@ -880,174 +1250,251 @@ const finalLoading = computed(() => {
880
1250
  loadingFeedbacks.value
881
1251
  );
882
1252
  }
883
- return props.loading;
1253
+ if (isPropertyMode.value) {
1254
+ return loadingAltMetrics.value || loadingFeedbacks.value;
1255
+ }
1256
+ // security
1257
+ return loadingAltMetrics.value || loadingAttendance.value || loadingFeedbacks.value;
884
1258
  });
885
1259
 
886
- const finalDashboardData = computed(() => {
1260
+ // ─── Merged Dashboard Data ────────────────────────────────────────────────────
1261
+
1262
+ const finalDashboardData = computed<Record<string, any>>(() => {
887
1263
  if (!props.serviceType) return props.dashboardData || {};
888
1264
 
889
- const data = getDashboardReq.value ?? {};
890
- let weeklyActivity = weeklyActivityReq.value ?? [];
891
- const hasNoActivity =
892
- !weeklyActivity.length ||
893
- weeklyActivity.every(
894
- (item: any) =>
895
- !item.workOrder &&
896
- !item.taskCompleted &&
897
- !item.openWorkOrder &&
898
- !item.closedWorkOrder
899
- );
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
+ }
900
1289
 
901
- if (hasNoActivity && currentPeriod.value === "thisWeek") {
902
- weeklyActivity = [
903
- { day: "Mon", workOrder: 800, taskCompleted: 1400 },
904
- { day: "Tue", workOrder: 450, taskCompleted: 900 },
905
- { day: "Wed", workOrder: 950, taskCompleted: 1640 },
906
- { day: "Thu", workOrder: 2000, taskCompleted: 2100 },
907
- { day: "Fri", workOrder: 2000, taskCompleted: 2700 },
908
- { day: "Sat", workOrder: 2200, taskCompleted: 3100 },
909
- { day: "Sun", workOrder: 2800, taskCompleted: 3200 },
910
- ];
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
+ };
911
1325
  }
912
1326
 
1327
+ // Security
1328
+ const data = getDashboardAltReq.value ?? {};
913
1329
  return {
914
1330
  ...data,
1331
+ feedbackItems: feedbacksReq.value?.items ?? [],
1332
+ attendanceItems: attendanceReq.value?.items ?? [],
915
1333
  openWorkOrder: data.openWorkOrder ?? {
916
1334
  count: 0,
1335
+ inProgress: 0,
917
1336
  percentage: 0,
1337
+ overdue: 0,
918
1338
  },
919
- supplyAlert: data.supplyAlert ?? {
920
- count: 0,
921
- percentage: 0,
922
- },
923
- taskCompleted: data.taskCompleted ?? {
1339
+ openIncidents: data.openIncidents ?? {
924
1340
  count: 0,
1341
+ highSeverity: 0,
925
1342
  percentage: 0,
926
1343
  },
927
- landscapeSchedule: data.landscapeSchedule ?? {
1344
+ visitors: data.visitors ?? { count: 0, currentlyOnSite: 0, percentage: 0 },
1345
+ patrolCompliance: data.patrolCompliance ?? {
928
1346
  count: 0,
1347
+ missedCheckpoints: 0,
1348
+ missed: 0,
929
1349
  percentage: 0,
930
1350
  },
931
- activityChart: {
932
- labels: weeklyActivity.map((item: any) => item.day || item.label),
933
- workOrders: weeklyActivity.map(
934
- (item: any) => item.workOrder ?? item.openWorkOrder
935
- ),
936
- taskCompleted: weeklyActivity.map(
937
- (item: any) => item.taskCompleted ?? item.closedWorkOrder
938
- ),
939
- openWorkOrder: weeklyActivity.some(
940
- (item: any) => item.openWorkOrder !== undefined
941
- )
942
- ? weeklyActivity.map((item: any) => item.openWorkOrder)
943
- : undefined,
944
- closedWorkOrder: weeklyActivity.some(
945
- (item: any) => item.closedWorkOrder !== undefined
946
- )
947
- ? weeklyActivity.map((item: any) => item.closedWorkOrder)
948
- : undefined,
949
- },
950
- taskScheduleItems: taskScheduleReq.value?.items ?? [],
951
- attendanceItems: attendanceReq.value?.items ?? [],
952
- feedbackItems: feedbacksReq.value?.items ?? [],
1351
+ activePatrolItems: data.activePatrolItems ?? [],
953
1352
  };
954
1353
  });
955
1354
 
956
- const emit = defineEmits<{
957
- updatePeriod: [payload: { key: string; period: string }];
958
- }>();
1355
+ // ─── Card Value Configs ───────────────────────────────────────────────────────
959
1356
 
960
- const router = useRouter();
961
- const runtimeConfig = useRuntimeConfig();
962
- const customizeDialog = ref(false);
963
- const widgetSearch = ref("");
964
- const selectedRange = ref("Today");
965
- const visitorType = ref("All");
966
- const visibleWidgetKeys = ref<string[]>([]);
967
- const draftVisibleWidgetKeys = ref<string[]>([]);
968
- const filterMenus = reactive<Record<string, boolean>>({});
969
- const snackbar = reactive({
970
- visible: false,
971
- text: "",
972
- });
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
+ ];
973
1380
 
974
- const chartContainerRef = ref<HTMLElement | null>(null);
975
- const chartWidth = ref(920);
976
- let resizeObserver: ResizeObserver | null = null;
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
+ ];
977
1411
 
978
- watch(chartContainerRef, (el) => {
979
- if (resizeObserver) {
980
- resizeObserver.disconnect();
981
- }
982
- if (el && window.ResizeObserver) {
983
- resizeObserver = new ResizeObserver((entries) => {
984
- if (entries[0] && entries[0].contentRect.width > 0) {
985
- chartWidth.value = entries[0].contentRect.width;
986
- }
987
- });
988
- resizeObserver.observe(el);
989
- }
990
- });
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
+ ];
991
1442
 
992
- onUnmounted(() => {
993
- if (resizeObserver) resizeObserver.disconnect();
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;
994
1448
  });
995
1449
 
996
- const rangeOptions = [
997
- { label: "Today", value: "Today" },
998
- { label: "Week", value: "This Week" },
999
- { label: "Month", value: "This Month" },
1450
+ // ─── Widget Keys by Mode ──────────────────────────────────────────────────────
1451
+
1452
+ const defaultExtraWidgetKeys = [
1453
+ "activity",
1454
+ "taskSchedule",
1455
+ "staffStatus",
1456
+ "feedbacks",
1457
+ "recentActivity",
1000
1458
  ];
1001
- const defaultVisitorOptions = [
1002
- "All",
1003
- "Drive-In",
1004
- "Walk-In",
1005
- "Pick-up",
1006
- "Drop-Off",
1007
- "Delivery",
1008
- "Contractor",
1459
+
1460
+ const propertyDefaultWidgetKeys = [
1461
+ "workOrders",
1462
+ "incidents",
1463
+ "visitors",
1464
+ "facilityBookings",
1465
+ "activity",
1466
+ "upcomingEvents",
1467
+ "todayReminders",
1468
+ "todayAttentions",
1469
+ "workOrderStatus",
1470
+ "feedbacks",
1009
1471
  ];
1010
1472
 
1011
- const dashboardTitle = computed(() => {
1012
- if (props.title) return props.title;
1013
- const appName = String(
1014
- runtimeConfig.public.APP_NAME || runtimeConfig.public.APP || ""
1015
- );
1016
- return `${getServiceDisplayName(appName)} - Dashboard`;
1017
- });
1018
- const appKey = computed(() =>
1019
- String(
1020
- runtimeConfig.public.APP || runtimeConfig.public.APP_NAME || ""
1021
- ).toLowerCase()
1022
- );
1023
- const isSecurityDashboard = computed(() => appKey.value.includes("security"));
1024
- const primaryListConfig = computed(() =>
1025
- isSecurityDashboard.value
1026
- ? {
1027
- title: "Active Patrol",
1028
- route: "virtualPatrol",
1029
- keys: ["activePatrolItems", "patrolItems"],
1030
- }
1031
- : {
1032
- title: "Today's Task Schedule",
1033
- route: "scheduleTask",
1034
- keys: [
1035
- "taskScheduleItems",
1036
- "scheduleTaskItems",
1037
- "scheduledTaskItems",
1038
- "cleaningScheduleItems",
1039
- "tasks",
1040
- ],
1041
- }
1042
- );
1043
- const staffStatusRoute = computed(() =>
1044
- isSecurityDashboard.value ? "manpower" : "attendance"
1045
- );
1473
+ const securityDefaultWidgetKeys = [
1474
+ "workOrders",
1475
+ "incidents",
1476
+ "visitors",
1477
+ "patrolCompliance",
1478
+ "activity",
1479
+ "activePatrol",
1480
+ "staffStatus",
1481
+ "feedbacks",
1482
+ ];
1046
1483
 
1047
- const defaultVisibleWidgetKeys = computed(() => [
1048
- ...resolvedCardValues.value.map((card) => card.key),
1049
- ...resolvedExtraWidgetKeys.value,
1050
- ]);
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
+ });
1051
1498
 
1052
1499
  watch(
1053
1500
  defaultVisibleWidgetKeys,
@@ -1058,84 +1505,270 @@ watch(
1058
1505
  draftVisibleWidgetKeys.value = nextKeys;
1059
1506
  return;
1060
1507
  }
1061
-
1062
- visibleWidgetKeys.value = visibleWidgetKeys.value.filter((key) =>
1063
- nextKeys.includes(key)
1508
+ visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
1509
+ nextKeys.includes(k)
1064
1510
  );
1065
- draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((key) =>
1066
- nextKeys.includes(key)
1511
+ draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((k) =>
1512
+ nextKeys.includes(k)
1067
1513
  );
1068
1514
  },
1069
1515
  { immediate: true }
1070
1516
  );
1071
1517
 
1072
- watch(selectedRange, (period) => {
1073
- if (props.serviceType) {
1074
- const apiPeriod = periodValue[period];
1075
- if (apiPeriod) currentPeriod.value = apiPeriod;
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
+ ];
1076
1534
  }
1077
- emit("updatePeriod", { key: "__all__", period });
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
+ ];
1078
1560
  });
1079
1561
 
1080
- const customizeWidgets = computed(() => [
1081
- ...resolvedCardValues.value.map((card) => ({
1082
- key: card.key,
1083
- title: card.title,
1084
- description: `Show ${card.title} metrics for the selected period.`,
1085
- })),
1086
- {
1087
- key: "activity",
1088
- title: "Activity Chart",
1089
- description: "Compare activity trends over time.",
1090
- },
1091
- {
1092
- key: "taskSchedule",
1093
- title: primaryListConfig.value.title,
1094
- description: "Show current tasks and their latest status.",
1095
- },
1096
- {
1097
- key: "staffStatus",
1098
- title: "Staff Total Attendance",
1099
- description: "See who is on duty, on break, or absent.",
1100
- },
1101
- {
1102
- key: "feedbacks",
1103
- title: "Feedbacks",
1104
- description: "Show recent feedback and resolution state.",
1105
- },
1106
- {
1107
- key: "recentActivity",
1108
- title: "Recent Activity",
1109
- description: "Latest updates across the service.",
1110
- },
1111
- ]);
1112
-
1113
1562
  const filteredCustomizeWidgets = computed(() => {
1114
1563
  const search = widgetSearch.value.trim().toLowerCase();
1115
1564
  if (!search) return customizeWidgets.value;
1116
-
1117
1565
  return customizeWidgets.value.filter(
1118
- (widget) =>
1119
- widget.title.toLowerCase().includes(search) ||
1120
- widget.description.toLowerCase().includes(search)
1566
+ (w) =>
1567
+ w.title.toLowerCase().includes(search) ||
1568
+ w.description.toLowerCase().includes(search)
1121
1569
  );
1122
1570
  });
1571
+
1572
+ // ─── KPI Metrics ─────────────────────────────────────────────────────────────
1573
+
1574
+ const defaultVisitorOptions = [
1575
+ "All", "Drive-In", "Walk-In", "Pick-up", "Drop-Off", "Delivery", "Contractor",
1576
+ ];
1577
+
1123
1578
  const visitorItems = computed(() =>
1124
1579
  getDashboardArray(["visitorItems", "visitorsList", "visitorTransactionsList"])
1125
1580
  );
1126
1581
  const visitorOptions = computed(() =>
1582
+ buildFilterOptions(defaultVisitorOptions, visitorItems.value, getVisitorFilterValue)
1583
+ );
1584
+
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(() =>
1127
1595
  buildFilterOptions(
1128
- defaultVisitorOptions,
1129
- visitorItems.value,
1130
- getVisitorFilterValue
1596
+ defaultFacilityOptions,
1597
+ facilityBookingItems.value,
1598
+ (item: any) =>
1599
+ item.facilityName || item.facility?.name || item.facilityInfo?.name || item.name || ""
1131
1600
  )
1132
1601
  );
1133
1602
 
1134
- const kpiCards = computed(() =>
1135
- resolvedCardValues.value.map((mapping, index) => {
1136
- const metric =
1137
- mapping.key === "visitors" && isSecurityDashboard.value
1138
- ? getFilteredVisitorMetric()
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) => {
1769
+ const metric =
1770
+ mapping.key === "visitors" && isSecurityMode.value
1771
+ ? getFilteredVisitorMetric()
1139
1772
  : getMetric([mapping.key]);
1140
1773
  const value = getMetricValue(metric, mapping.key);
1141
1774
 
@@ -1149,25 +1782,49 @@ const kpiCards = computed(() =>
1149
1782
  percentage: toNumber(metric.percentage),
1150
1783
  meta: getMetricMeta(metric),
1151
1784
  raw: metric,
1152
- filter:
1153
- mapping.key === "visitors" && isSecurityDashboard.value
1154
- ? {
1155
- model: visitorType.value,
1156
- options: visitorOptions.value,
1157
- set: (option: string) => {
1158
- visitorType.value = option;
1159
- filterMenus.visitors = false;
1160
- },
1161
- }
1162
- : undefined,
1785
+ filter: undefined as any,
1163
1786
  };
1164
- })
1165
- );
1787
+ });
1788
+ });
1166
1789
 
1167
1790
  const visibleKpiCards = computed(() =>
1168
1791
  kpiCards.value.filter((card) => isWidgetVisible(card.key))
1169
1792
  );
1170
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
+
1171
1828
  const activityTitle = computed(() => {
1172
1829
  if (selectedRange.value === "Today") return "Today's Activity";
1173
1830
  if (selectedRange.value === "This Month") return "This Month Activity";
@@ -1180,7 +1837,6 @@ const activityChart = computed(() =>
1180
1837
  const chartLabels = computed<string[]>(() => {
1181
1838
  const labels = activityChart.value.labels;
1182
1839
  if (labels.length) return labels;
1183
-
1184
1840
  if (selectedRange.value === "Today")
1185
1841
  return ["12 AM", "4 AM", "8 AM", "12 PM", "4 PM", "8 PM"];
1186
1842
  if (selectedRange.value === "This Month") {
@@ -1199,12 +1855,8 @@ const primarySeries = computed(() =>
1199
1855
  const secondarySeries = computed(() =>
1200
1856
  normalizeSeries(activityChart.value.secondary, chartLabels.value.length)
1201
1857
  );
1202
- const primaryLegendLabel = computed(
1203
- () => activityChart.value.primaryLabel || "Work Order"
1204
- );
1205
- const secondaryLegendLabel = computed(
1206
- () => activityChart.value.secondaryLabel || "Feedback"
1207
- );
1858
+ const primaryLegendLabel = computed(() => "Closed Work Order");
1859
+ const secondaryLegendLabel = computed(() => "Open Work Order");
1208
1860
  const chartMax = computed(() =>
1209
1861
  Math.max(1, ...primarySeries.value, ...secondarySeries.value)
1210
1862
  );
@@ -1225,21 +1877,6 @@ const chartYLabels = computed(() => {
1225
1877
  ];
1226
1878
  });
1227
1879
 
1228
- function buildSmoothPath(points: { x: number; y: number }[]) {
1229
- if (!points || points.length === 0) return "";
1230
- let d = `M ${points[0].x},${points[0].y}`;
1231
- for (let i = 0; i < points.length - 1; i++) {
1232
- const p1 = points[i];
1233
- const p2 = points[i + 1];
1234
- const cp1x = p1.x + (p2.x - p1.x) / 2;
1235
- const cp1y = p1.y;
1236
- const cp2x = p1.x + (p2.x - p1.x) / 2;
1237
- const cp2y = p2.y;
1238
- d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`;
1239
- }
1240
- return d;
1241
- }
1242
-
1243
1880
  const primaryDots = computed(() => buildChartDots(primarySeries.value));
1244
1881
  const secondaryDots = computed(() => buildChartDots(secondarySeries.value));
1245
1882
  const primaryPath = computed(() => buildSmoothPath(primaryDots.value));
@@ -1248,13 +1885,14 @@ const chartAxisLabels = computed(() => {
1248
1885
  const labels = chartLabels.value;
1249
1886
  const count = Math.max(1, labels.length - 1);
1250
1887
  const width = chartWidth.value;
1251
-
1252
1888
  return labels.map((label, index) => ({
1253
1889
  text: label,
1254
1890
  x: 54 + ((width - 74) / count) * index,
1255
1891
  }));
1256
1892
  });
1257
1893
 
1894
+ // ─── List Data ────────────────────────────────────────────────────────────────
1895
+
1258
1896
  const taskScheduleItems = computed<TaskItem[]>(() =>
1259
1897
  getDashboardArray([...primaryListConfig.value.keys])
1260
1898
  .map(normalizeTaskItem)
@@ -1270,48 +1908,76 @@ const feedbackItems = computed<FeedbackItem[]>(() =>
1270
1908
  .map(normalizeFeedbackItem)
1271
1909
  .slice(0, 4)
1272
1910
  );
1273
- const recentActivities = computed<DashboardListItem[]>(() =>
1274
- getDashboardArray(["recentActivities", "activityItems"])
1275
- .map(normalizeActivityItem)
1276
- .slice(0, 3)
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"])
1277
1921
  );
1278
1922
 
1279
- function getToneColor(tone: string) {
1280
- const map: Record<string, { bg: string; text: string }> = {
1281
- info: { bg: "blue-lighten-4", text: "blue-darken-3" },
1282
- danger: { bg: "red-lighten-4", text: "red-darken-3" },
1283
- success: { bg: "green-lighten-4", text: "green-darken-3" },
1284
- warning: { bg: "orange-lighten-4", text: "orange-darken-3" },
1285
- neutral: { bg: "grey-lighten-3", text: "blue-grey-darken-1" },
1286
- };
1287
- return map[tone] || map.neutral;
1288
- }
1923
+ const workOrderStatus = computed(() => {
1924
+ const summary = finalDashboardData.value?.workOrderStatusSummary;
1925
+ const metric = getMetric(["openWorkOrder", "workOrders", "workOrder"]);
1289
1926
 
1290
- function getServiceDisplayName(value: string) {
1291
- const normalized = value.toLowerCase();
1292
- const titleMap: Record<string, string> = {
1293
- security_agency: "Security",
1294
- cleaning_services: "Hygiene",
1295
- hygiene: "Hygiene",
1296
- mechanical_electrical: "Mechanical & Electrical",
1297
- mechanical_electrical_services: "Mechanical & Electrical",
1298
- pest_control_services: "Pest Control",
1299
- pool_maintenance_services: "Pool Maintenance",
1300
- landscaping_services: "Landscaping",
1301
- property_management_agency: "Property Management",
1302
- };
1927
+ const src = summary || metric;
1928
+ if (!src) return [];
1303
1929
 
1304
- if (titleMap[normalized]) return titleMap[normalized];
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);
1305
1937
 
1306
- const cleaned = normalized
1307
- .replace(/_services|_agency/g, "")
1308
- .replace(/[_-]+/g, " ")
1309
- .trim();
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
+ });
1310
1944
 
1311
- return cleaned
1312
- ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase())
1313
- : "Service";
1314
- }
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 ───────────────────────────────────────────────────────────
1315
1981
 
1316
1982
  function getMetric(keys: string[]): DashboardMetric {
1317
1983
  const data = finalDashboardData.value || {};
@@ -1328,20 +1994,16 @@ function getMetricValue(metric: DashboardMetric, key?: string) {
1328
1994
  if (key === "patrolCompliance") {
1329
1995
  return `${formatNumber(toNumber(metric.count))}%`;
1330
1996
  }
1331
-
1332
1997
  const completed = toNumber(metric.completed);
1333
1998
  const total = toNumber(metric.total);
1334
-
1335
1999
  if (total > 0 && typeof metric.completed !== "undefined") {
1336
2000
  return `${formatNumber(completed)}/${formatNumber(total)}`;
1337
2001
  }
1338
-
1339
2002
  return toNumber(metric.count);
1340
2003
  }
1341
2004
 
1342
2005
  function getMetricMeta(metric: DashboardMetric) {
1343
2006
  const parts: string[] = [];
1344
-
1345
2007
  if (typeof metric.overdue !== "undefined")
1346
2008
  parts.push(`${formatNumber(toNumber(metric.overdue))} overdue`);
1347
2009
  if (typeof metric.inProgress !== "undefined")
@@ -1351,20 +2013,13 @@ function getMetricMeta(metric: DashboardMetric) {
1351
2013
  if (typeof metric.highSeverity !== "undefined")
1352
2014
  parts.push(`${formatNumber(toNumber(metric.highSeverity))} high severity`);
1353
2015
  if (typeof metric.currentlyOnSite !== "undefined")
1354
- parts.push(
1355
- `${formatNumber(toNumber(metric.currentlyOnSite))} currently on site`
1356
- );
2016
+ parts.push(`${formatNumber(toNumber(metric.currentlyOnSite))} currently on site`);
1357
2017
  if (
1358
- typeof (metric as Record<string, any>).missedCheckpoints !== "undefined" ||
1359
- typeof (metric as Record<string, any>).missed !== "undefined"
2018
+ typeof (metric as any).missedCheckpoints !== "undefined" ||
2019
+ typeof (metric as any).missed !== "undefined"
1360
2020
  ) {
1361
2021
  parts.push(
1362
- `${formatNumber(
1363
- toNumber(
1364
- (metric as Record<string, any>).missedCheckpoints ??
1365
- (metric as Record<string, any>).missed
1366
- )
1367
- )} checkpoint missed`
2022
+ `${formatNumber(toNumber((metric as any).missedCheckpoints ?? (metric as any).missed))} checkpoint missed`
1368
2023
  );
1369
2024
  }
1370
2025
  if (typeof metric.lowStock !== "undefined")
@@ -1377,218 +2032,123 @@ function getMetricMeta(metric: DashboardMetric) {
1377
2032
  ) {
1378
2033
  parts.push(`${formatNumber(toNumber(metric.completed))} completed`);
1379
2034
  }
1380
-
1381
2035
  return parts.join(" - ");
1382
2036
  }
1383
2037
 
1384
- function getCardColor(index: number) {
1385
- return ["#155178", "#9f2f29", "#1f8a56", "#b96d17"][index % 4];
1386
- }
1387
-
1388
- function getCardTint(color: string, index: number) {
1389
- const tints = ["#dbeeff", "#f8c6c3", "#d8f5e5", "#ffe7c2"];
1390
- if (!color) return tints[index % tints.length];
2038
+ function getFilteredVisitorMetric(): DashboardMetric {
2039
+ const metric = getMetric(["visitors", "visitor"]);
2040
+ const selected = visitorType.value;
2041
+ const items = visitorItems.value;
1391
2042
 
1392
- const normalized = color.toLowerCase();
1393
- if (normalized.includes("f44336") || normalized.includes("d32f2f"))
1394
- return "#f8c6c3";
1395
- if (normalized.includes("4caf50") || normalized.includes("43a047"))
1396
- return "#d8f5e5";
1397
- if (normalized.includes("ff9800") || normalized.includes("ffa726"))
1398
- return "#ffe7c2";
1399
- return "#dbeeff";
1400
- }
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
+ }
1401
2054
 
1402
- function getActivityChartSource() {
1403
- const data = finalDashboardData.value || {};
1404
- return (
1405
- data.activityChart ||
1406
- data.statistic?.activityChart ||
1407
- data.statistics?.activityChart ||
1408
- data.statisticChart ||
1409
- data.statisticsChart ||
1410
- data.activityStatistics ||
1411
- data.activityStatistic ||
1412
- {}
2055
+ const filtered = items.filter(
2056
+ (item: any) => getVisitorFilterValue(item) === selected
1413
2057
  );
1414
- }
1415
-
1416
- function normalizeActivityChart(source: any) {
1417
- const labels = Array.isArray(source?.labels)
1418
- ? source.labels.map((label: any) => String(label))
1419
- : [];
1420
-
1421
- const secondary = extractChartSeries(source, [
1422
- "closedWorkOrder",
1423
- "closedWorkOrders",
1424
- "closed_work_orders",
1425
- ]);
1426
-
1427
- const hasClosedSeries = secondary.length > 0;
1428
-
1429
- const primary = extractChartSeries(source, [
1430
- "openWorkOrder",
1431
- "openWorkOrders",
1432
- "open_work_orders",
1433
- "workOrders",
1434
- "workOrder",
1435
- "work_orders",
1436
- ]);
1437
-
1438
- const secondaryCandidate = hasClosedSeries
1439
- ? { label: "Closed Work Order", series: secondary }
1440
- : getSecondaryChartSeries(source);
1441
-
1442
2058
  return {
1443
- labels,
1444
- primary,
1445
- secondary: secondaryCandidate.series,
1446
- primaryLabel: hasClosedSeries ? "Open Work Order" : "Work Order",
1447
- secondaryLabel: secondaryCandidate.label,
2059
+ ...metric,
2060
+ count: filtered.length,
2061
+ currentlyOnSite: filtered.filter(
2062
+ (item: any) => !isClosedStatus(item.status) && !item.checkOut
2063
+ ).length,
1448
2064
  };
1449
2065
  }
1450
2066
 
1451
- function getSecondaryChartSeries(source: any) {
1452
- const candidates = [
1453
- {
1454
- label: "Task Completed",
1455
- keys: ["taskCompleted", "completedTasks", "completed"],
1456
- },
1457
- { label: "Incident", keys: ["incidents", "incident", "incidentReports"] },
1458
- { label: "Feedback", keys: ["feedbacks", "feedback", "feedbackTickets"] },
1459
- ];
2067
+ // ─── Array/List Helpers ───────────────────────────────────────────────────────
1460
2068
 
1461
- for (const candidate of candidates) {
1462
- const series = extractChartSeries(source, candidate.keys);
1463
- if (series.length) return { label: candidate.label, series };
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;
1464
2077
  }
2078
+ return [];
2079
+ }
1465
2080
 
1466
- return { label: "Feedback", series: [] as number[] };
2081
+ function normalizeListItems(keys: string[]): DashboardListItem[] {
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
+ }));
1467
2097
  }
1468
2098
 
1469
- function extractChartSeries(source: any, keys: string[]) {
1470
- for (const key of keys) {
1471
- const value = source?.[key];
1472
- if (Array.isArray(value)) return value.map(toNumber);
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(" - ");
1473
2105
  }
1474
-
1475
- if (Array.isArray(source?.datasets)) {
1476
- const dataset = source.datasets.find((item: any) => {
1477
- const keyText = String(
1478
- item?.key || item?.label || item?.name || ""
1479
- ).toLowerCase();
1480
- return keys.some((key) => keyText.includes(key.toLowerCase()));
1481
- });
1482
- if (Array.isArray(dataset?.data)) return dataset.data.map(toNumber);
2106
+ if (context === "todayReminders" || context === "todayAttentions") {
2107
+ return formatDashboardTime(time) || item.description || item.status || "";
1483
2108
  }
1484
-
1485
- return [];
2109
+ return item.description || item.category || item.location || formatDashboardTime(time);
1486
2110
  }
1487
2111
 
1488
- function normalizeSeries(value: any, targetLength: number) {
1489
- const source = Array.isArray(value) ? value : [];
1490
- return Array.from({ length: targetLength }, (_, index) =>
1491
- toNumber(source[index])
2112
+ function getListTime(item: any) {
2113
+ return (
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
+ ""
1492
2123
  );
1493
2124
  }
1494
2125
 
1495
- function buildChartDots(series: number[]) {
1496
- const count = Math.max(1, series.length - 1);
1497
- const width = chartWidth.value;
1498
-
1499
- return series.map((value, index) => ({
1500
- x: 54 + ((width - 74) / count) * index,
1501
- y: 220 - (toNumber(value) / chartMax.value) * 184,
1502
- }));
1503
- }
1504
-
1505
- function getDashboardArray(keys: string[]) {
1506
- const data = finalDashboardData.value || {};
1507
-
1508
- for (const key of keys) {
1509
- const value = data[key];
1510
- if (Array.isArray(value)) return value;
1511
- if (Array.isArray(value?.items)) return value.items;
1512
- if (Array.isArray(value?.docs)) return value.docs;
1513
- if (Array.isArray(value?.data)) return value.data;
1514
- }
1515
-
1516
- return [];
1517
- }
1518
-
1519
- function getFilteredVisitorMetric(): DashboardMetric {
1520
- const metric = getMetric(["visitors", "visitor"]);
1521
- const selected = visitorType.value;
1522
- const items = visitorItems.value;
1523
-
1524
- if (selected === "All") {
1525
- return {
1526
- ...metric,
1527
- count: toNumber(metric.count ?? items.length),
1528
- currentlyOnSite: toNumber(
1529
- metric.currentlyOnSite ??
1530
- items.filter(
1531
- (item: any) => !isClosedStatus(item.status) && !item.checkOut
1532
- ).length
1533
- ),
1534
- };
1535
- }
1536
-
1537
- const filtered = items.filter(
1538
- (item: any) => getVisitorFilterValue(item) === selected
1539
- );
1540
-
1541
- return {
1542
- ...metric,
1543
- count: filtered.length,
1544
- currentlyOnSite: filtered.filter(
1545
- (item: any) => !isClosedStatus(item.status) && !item.checkOut
1546
- ).length,
1547
- };
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";
1548
2131
  }
1549
2132
 
1550
- function buildFilterOptions(
1551
- defaults: string[],
1552
- items: Record<string, any>[],
1553
- getValue: (item: Record<string, any>) => string
1554
- ) {
1555
- const options = new Set(defaults);
1556
- items.forEach((item) => {
1557
- const value = getValue(item);
1558
- if (value) options.add(value);
1559
- });
1560
-
1561
- return Array.from(options);
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";
1562
2144
  }
1563
2145
 
1564
- function getVisitorFilterValue(item: Record<string, any>) {
1565
- const raw = String(
1566
- item.type || item.visitorType || item.category || ""
1567
- ).toLowerCase();
1568
- if (raw.includes("contractor")) return "Contractor";
1569
- if (raw.includes("walk")) return "Walk-In";
1570
- if (raw.includes("delivery")) return "Delivery";
1571
- if (raw.includes("pick")) return "Pick-up";
1572
- if (raw.includes("drop")) return "Drop-Off";
1573
- if (raw.includes("guest") || raw.includes("drive")) return "Drive-In";
1574
- return "All";
1575
- }
2146
+ // ─── Normalizers ─────────────────────────────────────────────────────────────
1576
2147
 
1577
2148
  function normalizeTaskItem(item: Record<string, any>, index: number): TaskItem {
1578
2149
  const title =
1579
- item.title ||
1580
- item.subject ||
1581
- item.name ||
1582
- item.taskName ||
1583
- item.description ||
1584
- "Task";
1585
- const subtitle =
1586
- item.subtitle ||
1587
- item.area ||
1588
- item.location ||
1589
- item.asset ||
1590
- item.category ||
1591
- "";
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 || "";
1592
2152
  const person =
1593
2153
  item.person ||
1594
2154
  item.assigneeName ||
@@ -1602,22 +2162,14 @@ function normalizeTaskItem(item: Record<string, any>, index: number): TaskItem {
1602
2162
  title,
1603
2163
  subtitle,
1604
2164
  person,
1605
- status: normalizeStatus(
1606
- item.status || item.state || item.taskStatus || "To-Do"
1607
- ),
2165
+ status: normalizeStatus(item.status || item.state || item.taskStatus || "To-Do"),
1608
2166
  time:
1609
- item.time ||
1610
- item.startTime ||
1611
- item.scheduleTime ||
1612
- formatActivityTime(item),
2167
+ item.time || item.startTime || item.scheduleTime || formatActivityTime(item),
1613
2168
  raw: item,
1614
2169
  };
1615
2170
  }
1616
2171
 
1617
- function normalizeStaffItem(
1618
- item: Record<string, any>,
1619
- index: number
1620
- ): StaffItem {
2172
+ function normalizeStaffItem(item: Record<string, any>, index: number): StaffItem {
1621
2173
  const name =
1622
2174
  item.name ||
1623
2175
  item.employeeName ||
@@ -1638,21 +2190,14 @@ function normalizeStaffItem(
1638
2190
  name,
1639
2191
  role,
1640
2192
  status: normalizeStatus(
1641
- item.statusLabel ||
1642
- item.status ||
1643
- item.currentStatus ||
1644
- item.attendanceStatus ||
1645
- "Off duty"
2193
+ item.statusLabel || item.status || item.currentStatus || item.attendanceStatus || "Off duty"
1646
2194
  ),
1647
2195
  totalHours: item.totalHours || 0,
1648
2196
  raw: item,
1649
2197
  };
1650
2198
  }
1651
2199
 
1652
- function normalizeFeedbackItem(
1653
- item: Record<string, any>,
1654
- index: number
1655
- ): FeedbackItem {
2200
+ function normalizeFeedbackItem(item: Record<string, any>, index: number): FeedbackItem {
1656
2201
  const title = item.title || item.subject || item.description || "Feedback";
1657
2202
  const subject = item.category || item.type || item.service || "Subject";
1658
2203
  const app = item.app || item.source || item.channel || "";
@@ -1675,179 +2220,190 @@ function normalizeFeedbackItem(
1675
2220
  };
1676
2221
  }
1677
2222
 
1678
- function normalizeActivityItem(
1679
- item: Record<string, any>,
1680
- index: number
1681
- ): DashboardListItem {
1682
- const title =
1683
- item.title ||
1684
- item.subject ||
1685
- item.description ||
1686
- item.message ||
1687
- "Activity";
1688
- const subtitle =
1689
- item.subtitle || item.type || item.category || item.status || "";
1690
-
1691
- return {
1692
- id: String(item.id || item._id || `${title}-${index}`),
1693
- title,
1694
- subtitle,
1695
- time: formatActivityTime(item),
1696
- icon: item.icon || "mdi-history",
1697
- tone: item.tone || getStatusTone(item.status || item.type),
1698
- raw: item,
1699
- };
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";
1700
2230
  }
1701
2231
 
1702
- function formatActivityTime(item: Record<string, any>) {
1703
- if (item.displayTime) return String(item.displayTime);
1704
-
1705
- const rawTime = item.time || getItemTime(item);
1706
- const rawText = String(rawTime || "");
1707
-
1708
- if (!rawText) return "";
1709
- if (
1710
- /\b(ago|now|today|yesterday|min|mins|hr|hrs|hour|hours|day|days)\b/i.test(
1711
- rawText
1712
- )
1713
- ) {
1714
- return rawText;
1715
- }
1716
-
1717
- 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("") || "-";
1718
2240
  }
1719
2241
 
1720
- function getActivityToneStyle(tone: string) {
1721
- const toneMap: Record<string, { background: string; color: string }> = {
1722
- info: {
1723
- background: "#d8ecff",
1724
- color: "#17496f",
1725
- },
1726
- danger: {
1727
- background: "#ffd9d6",
1728
- color: "#bd413c",
1729
- },
1730
- success: {
1731
- background: "#d8f4e3",
1732
- color: "#2b9160",
1733
- },
1734
- warning: {
1735
- background: "#ffe0ad",
1736
- color: "#d88717",
1737
- },
1738
- neutral: {
1739
- background: "#edf2f6",
1740
- color: "#607386",
1741
- },
1742
- };
2242
+ // ─── Status Style Helpers ────────────────────────────────────────────────────
1743
2243
 
1744
- return toneMap[tone] || toneMap.neutral;
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;
1745
2253
  }
1746
2254
 
1747
- function getItemTime(item: Record<string, any>) {
1748
- return (
1749
- item.createdAt ||
1750
- item.updatedAt ||
1751
- item.date ||
1752
- item.time ||
1753
- item.created_on ||
1754
- item.updated_on
1755
- );
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" };
1756
2262
  }
1757
2263
 
1758
- function normalizeStatus(value: any) {
1759
- const text = String(value || "").trim();
1760
- if (!text) return "To-Do";
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
+ }
1761
2274
 
1762
- return text
1763
- .replace(/[_-]+/g, " ")
1764
- .replace(/\b\w/g, (char) => char.toUpperCase());
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}%` };
1765
2278
  }
1766
2279
 
1767
- function getStatusTone(status: any) {
1768
- const normalized = String(status || "").toLowerCase();
1769
- if (
1770
- normalized.includes("complete") ||
1771
- normalized.includes("done") ||
1772
- normalized.includes("resolved") ||
1773
- normalized.includes("closed")
1774
- )
1775
- return "success";
1776
- if (
1777
- normalized.includes("progress") ||
1778
- normalized.includes("active") ||
1779
- normalized.includes("ongoing")
1780
- )
1781
- return "info";
1782
- if (
1783
- normalized.includes("overdue") ||
1784
- normalized.includes("urgent") ||
1785
- normalized.includes("high")
1786
- )
1787
- return "danger";
1788
- 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;
1789
2295
  }
1790
2296
 
1791
- function getStaffTone(status: any) {
1792
- const normalized = String(status || "").toLowerCase();
1793
- if (
1794
- normalized.includes("duty") ||
1795
- normalized.includes("present") ||
1796
- normalized.includes("active")
1797
- )
1798
- return "success";
1799
- if (normalized.includes("break")) return "warning";
1800
- if (normalized.includes("absent") || normalized.includes("off"))
1801
- return "danger";
1802
- 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
+ }));
1803
2304
  }
1804
2305
 
1805
- function isClosedStatus(status: any) {
1806
- const normalized = String(status || "").toLowerCase();
2306
+ function getActivityChartSource() {
2307
+ const data = finalDashboardData.value || {};
1807
2308
  return (
1808
- normalized.includes("complete") ||
1809
- normalized.includes("cancel") ||
1810
- normalized.includes("reject") ||
1811
- normalized.includes("closed") ||
1812
- normalized.includes("deleted") ||
1813
- normalized.includes("check-out") ||
1814
- normalized.includes("checkout")
2309
+ data.activityChart ||
2310
+ data.statistic?.activityChart ||
2311
+ data.statistics?.activityChart ||
2312
+ data.statisticChart ||
2313
+ data.statisticsChart ||
2314
+ data.activityStatistics ||
2315
+ data.activityStatistic ||
2316
+ {}
1815
2317
  );
1816
2318
  }
1817
2319
 
1818
- function isWidgetVisible(key: string) {
1819
- return visibleWidgetKeys.value.includes(key);
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
+ };
1820
2338
  }
1821
2339
 
1822
- function openCustomizeDialog() {
1823
- draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
1824
- customizeDialog.value = true;
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[] };
1825
2351
  }
1826
2352
 
1827
- function cancelCustomize() {
1828
- draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
1829
- widgetSearch.value = "";
1830
- customizeDialog.value = false;
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 [];
1831
2366
  }
1832
2367
 
1833
- function resetCustomizeDraft() {
1834
- draftVisibleWidgetKeys.value = [...defaultVisibleWidgetKeys.value];
2368
+ function normalizeSeries(value: any, targetLength: number) {
2369
+ const source = Array.isArray(value) ? value : [];
2370
+ return Array.from({ length: targetLength }, (_, index) => toNumber(source[index]));
1835
2371
  }
1836
2372
 
1837
- function submitCustomize() {
1838
- visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
1839
- widgetSearch.value = "";
1840
- customizeDialog.value = false;
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);
1841
2386
  }
1842
2387
 
1843
- function notifyExport(format: string) {
1844
- snackbar.text = `${format} export is being prepared.`;
1845
- snackbar.visible = true;
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}`;
1846
2403
  }
1847
2404
 
1848
2405
  function goToDashboardRoute(routeKey: string) {
1849
2406
  if (!props.orgId || !props.site) return;
1850
-
1851
2407
  const pathMap: Record<string, string> = {
1852
2408
  attendance: "attendance",
1853
2409
  feedbacks: "feedbacks",
@@ -1855,8 +2411,11 @@ function goToDashboardRoute(routeKey: string) {
1855
2411
  virtualPatrol: "virtual-patrol/logs",
1856
2412
  manpower: "manpower-monitoring/attendance",
1857
2413
  incidentReports: "incident-reports",
1858
- visitorManagement: "visitor-management",
1859
- workOrders: "work-orders",
2414
+ "virtual-patrol": "virtual-patrol",
2415
+ "work-orders": "work-orders",
2416
+ "event-mgmt": "event-mgmt",
2417
+ calendar: "calendar",
2418
+ "incident-reports": "incident-reports",
1860
2419
  };
1861
2420
  const path = pathMap[routeKey] || routeKey;
1862
2421
  router.push(`/${props.orgId}/${props.site}/${path}`);
@@ -1867,13 +2426,11 @@ function goToListItem(item: TaskItem | FeedbackItem, routeKey: string) {
1867
2426
  goToDashboardRoute(routeKey);
1868
2427
  return;
1869
2428
  }
1870
-
1871
2429
  const id = item.raw?._id || item.raw?.id || item.id;
1872
2430
  if (!id || String(id).includes("-")) {
1873
2431
  goToDashboardRoute(routeKey);
1874
2432
  return;
1875
2433
  }
1876
-
1877
2434
  const pathMap: Record<string, string> = {
1878
2435
  feedbacks: "feedbacks",
1879
2436
  scheduleTask: "schedule-task",
@@ -1882,45 +2439,494 @@ function goToListItem(item: TaskItem | FeedbackItem, routeKey: string) {
1882
2439
  router.push(`/${props.orgId}/${props.site}/${path}/${id}`);
1883
2440
  }
1884
2441
 
1885
- function getInitials(name: string) {
1886
- const parts = String(name || "")
1887
- .trim()
1888
- .split(/\s+/)
1889
- .filter(Boolean);
1890
- if (!parts.length) return "?";
1891
- return parts
1892
- .slice(0, 2)
1893
- .map((part) => part[0])
1894
- .join("")
1895
- .toUpperCase();
2442
+ // ─── Widget Management ────────────────────────────────────────────────────────
2443
+
2444
+ function isWidgetVisible(key: string) {
2445
+ return visibleWidgetKeys.value.includes(key);
1896
2446
  }
1897
2447
 
1898
- function formatNumber(value: number) {
1899
- return new Intl.NumberFormat("en-US").format(toNumber(value));
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
+ }
2462
+
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);
1900
2518
  }
1901
2519
 
1902
2520
  function toNumber(value: any) {
1903
- const numberValue = Number(value ?? 0);
1904
- return Number.isFinite(numberValue) ? numberValue : 0;
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);
1905
2566
  }
1906
2567
 
1907
2568
  function formatRelativeTime(value: any) {
1908
2569
  if (!value) return "";
1909
-
1910
2570
  const date = new Date(value);
1911
2571
  if (Number.isNaN(date.getTime())) return "";
1912
-
1913
2572
  const diffMs = Date.now() - date.getTime();
1914
2573
  const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
1915
2574
  if (diffMinutes < 60) {
1916
2575
  const minutes = diffMinutes || 1;
1917
2576
  return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
1918
2577
  }
1919
-
1920
2578
  const diffHours = Math.round(diffMinutes / 60);
1921
2579
  if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? "" : "s"} ago`;
1922
-
1923
2580
  const diffDays = Math.round(diffHours / 24);
1924
2581
  return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
1925
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
+ }
1926
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>