@7365admin1/layer-common 3.2.7 → 3.2.8-staging.201

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 (283) hide show
  1. package/assets/css/primitives.css +1113 -0
  2. package/assets/css/screens.css +1097 -0
  3. package/assets/css/shell.css +258 -0
  4. package/assets/css/tokens.css +236 -0
  5. package/components/AcceptDialog.vue +17 -18
  6. package/components/AccessCard/AvailableStats.vue +24 -16
  7. package/components/AccessCardAddForm.vue +42 -42
  8. package/components/AccessCardAssignToUnitForm.vue +13 -21
  9. package/components/AccessCardDeleteDialog.vue +41 -39
  10. package/components/AccessCardDetailsDialog.vue +125 -79
  11. package/components/AccessCardPreviewDialog.vue +58 -84
  12. package/components/AccessCardQrTagging.vue +60 -63
  13. package/components/AccessCardReplaceForm.vue +27 -23
  14. package/components/AccessManagement.vue +86 -85
  15. package/components/AddEqupmentForm.vue +18 -22
  16. package/components/AddPassKeyToVisitor.vue +95 -73
  17. package/components/AppButton.vue +62 -0
  18. package/components/AppCard.vue +20 -0
  19. package/components/AppDateField.vue +49 -0
  20. package/components/AppField.vue +76 -0
  21. package/components/AppKpiTile.vue +65 -0
  22. package/components/AppSegmented.vue +38 -0
  23. package/components/AppSelect.vue +247 -0
  24. package/components/AreaChecklistHistoryLogs.vue +51 -44
  25. package/components/AreaChecklistHistoryMain.vue +14 -7
  26. package/components/AreaFormDialog.vue +71 -59
  27. package/components/AreaMain.vue +73 -107
  28. package/components/AttachmentsViewer.vue +84 -22
  29. package/components/AttendanceCheckInOutDialog.vue +85 -88
  30. package/components/AttendanceDetailsDialog.vue +57 -63
  31. package/components/AttendanceMain.vue +4 -3
  32. package/components/AttendanceMapSearchDialog.vue +42 -44
  33. package/components/AttendanceSettingsDialog.vue +79 -63
  34. package/components/Avatar/Main.vue +54 -2
  35. package/components/BillingMain.vue +26 -30
  36. package/components/BtnUploadFile.vue +53 -11
  37. package/components/BuildingForm.vue +34 -27
  38. package/components/BuildingManagement/buildings.vue +39 -39
  39. package/components/BuildingManagement/units.vue +26 -42
  40. package/components/BuildingUnitFormAdd.vue +20 -23
  41. package/components/BuildingUnitFormEdit.vue +8 -4
  42. package/components/BulletinBoardForm.vue +19 -24
  43. package/components/BulletinBoardManagement.vue +49 -57
  44. package/components/BulletinBoardView.vue +9 -6
  45. package/components/BulletinExpirationChip.vue +13 -4
  46. package/components/Button/Close.vue +2 -2
  47. package/components/CameraForm.vue +21 -22
  48. package/components/CameraMain.vue +50 -49
  49. package/components/Card/DeleteConfirmation.vue +24 -22
  50. package/components/Card/MemberInfoSummary.vue +28 -3
  51. package/components/Card/Toggle.vue +15 -3
  52. package/components/CardToolbar.vue +37 -0
  53. package/components/Carousel.vue +56 -36
  54. package/components/Chat/Bubbles.vue +43 -14
  55. package/components/Chat/Information.vue +49 -32
  56. package/components/Chat/ListCard.vue +38 -9
  57. package/components/Chat/Message.vue +41 -15
  58. package/components/Chat/Navigation.vue +27 -15
  59. package/components/Chat/SkeletonLoader.vue +9 -2
  60. package/components/ChecklistItemRow.vue +25 -1
  61. package/components/CleaningScheduleMain.vue +44 -49
  62. package/components/ClientDetailForm.vue +81 -56
  63. package/components/ClientMain.vue +92 -82
  64. package/components/ConfirmDialog.vue +14 -8
  65. package/components/Container/Standard.vue +9 -7
  66. package/components/CreateSubsciptionPlan.vue +148 -89
  67. package/components/DashboardEmptyState.vue +48 -3
  68. package/components/DashboardMain.vue +924 -676
  69. package/components/DashboardPanel.vue +84 -21
  70. package/components/DeliveryCompany.vue +34 -20
  71. package/components/Dialog/DeleteConfirmation.vue +20 -21
  72. package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
  73. package/components/Dialog/ReusablePrompt.vue +30 -29
  74. package/components/Dialog/UpdateMoreAction.vue +62 -59
  75. package/components/DocumentForm.vue +14 -21
  76. package/components/DocumentManagement.vue +162 -108
  77. package/components/DocumentViewer.vue +29 -12
  78. package/components/DrawImage.vue +29 -21
  79. package/components/EntryPass/QrTemplatePreview.vue +27 -16
  80. package/components/EntryPassInformation.vue +40 -10
  81. package/components/EntryPassMain.vue +49 -27
  82. package/components/EquipmentItemMain.vue +84 -97
  83. package/components/EquipmentManagementMain.vue +81 -103
  84. package/components/Facility/BookingSetup.vue +43 -52
  85. package/components/Feedback/Form.vue +26 -12
  86. package/components/FeedbackMain.vue +35 -49
  87. package/components/FileInput.vue +75 -58
  88. package/components/FileInputWithList.vue +59 -12
  89. package/components/FormDialog.vue +60 -21
  90. package/components/HidAccessLogDashboard.vue +512 -490
  91. package/components/HidCameraCaptureDialog.vue +216 -0
  92. package/components/HidEnabledGate.vue +67 -9
  93. package/components/HidIntercomManagement.vue +1322 -653
  94. package/components/HidQrCodeConfiguration.vue +1030 -258
  95. package/components/HidReaderForm.vue +142 -59
  96. package/components/HidReaderManagement.vue +211 -323
  97. package/components/HidServiceSettingsPanel.vue +44 -13
  98. package/components/HidUserEnrollment.vue +1185 -412
  99. package/components/HygieneUpdateMoreAction.vue +44 -44
  100. package/components/IncidentReport/Authorities.vue +30 -18
  101. package/components/IncidentReport/IncidentInformation.vue +10 -10
  102. package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
  103. package/components/IncidentReport/affectedEntities.vue +41 -20
  104. package/components/Input/Date.vue +3 -0
  105. package/components/Input/DatePicker.vue +1 -2
  106. package/components/Input/DateTimePicker.vue +1 -1
  107. package/components/Input/File.vue +55 -10
  108. package/components/Input/FileV2.vue +39 -6
  109. package/components/Input/InputPhoneNumberV2.vue +2 -1
  110. package/components/Input/ListGroupSelection.vue +56 -8
  111. package/components/Input/NRICNumber.vue +1 -0
  112. package/components/Input/NewDate.vue +1 -0
  113. package/components/Input/Number.vue +1 -0
  114. package/components/Input/Password.vue +1 -0
  115. package/components/Input/PhoneNumber.vue +21 -1
  116. package/components/Input/VehicleNumber.vue +1 -0
  117. package/components/InputLabel.vue +2 -2
  118. package/components/InvitationClientForm.vue +21 -19
  119. package/components/InvitationForm.vue +15 -21
  120. package/components/InvitationMain.vue +46 -61
  121. package/components/Layout/Header.vue +263 -36
  122. package/components/Layout/NavigationDrawer.vue +300 -30
  123. package/components/ListItem.vue +33 -3
  124. package/components/LocalPagination.vue +27 -3
  125. package/components/ManageChecklistMain.vue +136 -86
  126. package/components/MemberInformation.vue +32 -16
  127. package/components/MemberMain.vue +113 -93
  128. package/components/MyAttendanceMain.vue +3 -5
  129. package/components/NavigationItem.vue +83 -3
  130. package/components/Nfc/NFCPatrolReportMain.vue +47 -33
  131. package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
  132. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  133. package/components/Nfc/NFCPatrolSettings.vue +14 -4
  134. package/components/Nfc/NFCTagForm.vue +22 -8
  135. package/components/Nfc/NFCTagMain.vue +41 -43
  136. package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
  137. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
  138. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
  139. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
  140. package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
  141. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
  142. package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
  143. package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
  144. package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
  145. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
  146. package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
  147. package/components/NotificationSettings.vue +373 -0
  148. package/components/NumberSettingField.vue +9 -2
  149. package/components/OnlineFormConfigurationForm.vue +725 -237
  150. package/components/OnlineFormFill.vue +213 -106
  151. package/components/OnlineFormRenderPreview.vue +25 -30
  152. package/components/OnlineFormsConfiguration.vue +113 -109
  153. package/components/OrgViewDialog.vue +34 -16
  154. package/components/OvernightParkingAvailability.vue +49 -26
  155. package/components/OvernightParkingManagement.vue +78 -18
  156. package/components/PageHeader.vue +43 -0
  157. package/components/PassInformation.vue +109 -53
  158. package/components/PasswordConfirmation.vue +44 -26
  159. package/components/PeopleForm.vue +41 -20
  160. package/components/PhotoUpload.vue +110 -48
  161. package/components/PlaceholderComponent.vue +35 -19
  162. package/components/PlanActionsMenu.vue +12 -2
  163. package/components/PlanStatusBadge.vue +7 -8
  164. package/components/PlateNumberDisplay.vue +9 -6
  165. package/components/QrTemplate/PrintDialog.vue +66 -43
  166. package/components/RolePermissionFormCreate.vue +104 -79
  167. package/components/RolePermissionFormPreviewUpdate.vue +111 -109
  168. package/components/RolePermissionMain.vue +183 -81
  169. package/components/ScanVisitorQRCode.vue +99 -35
  170. package/components/ScheduleAreaMain.vue +34 -59
  171. package/components/ScheduleTaskForm.vue +61 -42
  172. package/components/ScheduleTaskMain.vue +56 -69
  173. package/components/SearchVehicleNumberUser.vue +39 -37
  174. package/components/ServiceProviderFormCreate.vue +29 -20
  175. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  176. package/components/ServiceProviderMain.vue +601 -432
  177. package/components/Signature.vue +40 -34
  178. package/components/SignaturePad.vue +5 -29
  179. package/components/SiteSettings.vue +41 -11
  180. package/components/SiteSettingsWorkOrder.vue +61 -30
  181. package/components/SlideCardGroup.vue +35 -11
  182. package/components/Snackbar.vue +36 -3
  183. package/components/SpecificAttr.vue +18 -8
  184. package/components/StatusChip.vue +156 -0
  185. package/components/StockCard.vue +17 -12
  186. package/components/SubscriptionPlanTable.vue +207 -140
  187. package/components/SwitchContext.vue +201 -9
  188. package/components/TableHygiene.vue +36 -46
  189. package/components/TableList.vue +10 -10
  190. package/components/TableListSecondary.vue +105 -54
  191. package/components/TableMain.vue +251 -78
  192. package/components/TableV3.vue +61 -70
  193. package/components/TiptapEditor.vue +33 -40
  194. package/components/TiptapEditorQr.vue +5 -2
  195. package/components/Tooltip/Info.vue +69 -24
  196. package/components/UnitMain.vue +40 -60
  197. package/components/UnitPersonCard.vue +42 -13
  198. package/components/VehicleAddSelection.vue +48 -32
  199. package/components/VehicleForm.vue +112 -129
  200. package/components/VehicleManagement.vue +26 -12
  201. package/components/VehicleUpdateMoreAction.vue +59 -46
  202. package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
  203. package/components/VisitorForm.vue +774 -210
  204. package/components/VisitorFormSelection.vue +40 -37
  205. package/components/VisitorManagement.vue +239 -82
  206. package/components/VisitorPassKeyQRScanner.vue +65 -18
  207. package/components/VisitorSearchResultSummary.vue +60 -16
  208. package/components/VisitorSocketPopUp.vue +22 -14
  209. package/components/VisitorsReportPreview.vue +44 -29
  210. package/components/WorkOrder/Create.vue +26 -17
  211. package/components/WorkOrder/ListView.vue +38 -7
  212. package/components/WorkOrder/Main.vue +62 -59
  213. package/composables/useFeedback.ts +3 -3
  214. package/composables/useHidAmico.ts +269 -0
  215. package/composables/useHidNavigation.ts +2 -9
  216. package/composables/useMember.ts +3 -3
  217. package/composables/useNFCPatrolDailyReport.ts +13 -0
  218. package/composables/useNFCPatrolMonthlyReport.ts +22 -56
  219. package/composables/useNFCPatrolReport.ts +13 -0
  220. package/composables/useNotificationPreference.ts +87 -0
  221. package/composables/useOnlineFormPages.ts +2 -0
  222. package/composables/useRole.ts +22 -0
  223. package/composables/useServiceProvider.ts +3 -3
  224. package/composables/useServiceProviderInvitation.ts +145 -0
  225. package/composables/useSipWebPhone.ts +336 -0
  226. package/composables/useSubscriptionPlan.ts +62 -0
  227. package/composables/useThemePreference.ts +63 -0
  228. package/composables/useVisitor.ts +6 -1
  229. package/composables/useWebUsb.ts +127 -37
  230. package/composables/useWorkOrder.ts +17 -3
  231. package/error.vue +30 -6
  232. package/nuxt.config.ts +36 -0
  233. package/package.json +25 -4
  234. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
  235. package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
  236. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  237. package/pages/notification-settings.vue +19 -0
  238. package/pages/payment-method-linked.vue +34 -10
  239. package/pages/require-customer.vue +48 -10
  240. package/pages/require-organization-membership.vue +32 -8
  241. package/pages/unauthorized.vue +29 -4
  242. package/plugins/secure-member.client.ts +21 -56
  243. package/plugins/vuetify.ts +69 -2
  244. package/types/nfc-patrol-report.ts +3 -2
  245. package/types/site.d.ts +71 -0
  246. package/types/subscription.ts +18 -11
  247. package/utils/breadcrumb.test.ts +108 -0
  248. package/utils/breadcrumb.ts +82 -0
  249. package/utils/dashboard.test.ts +118 -0
  250. package/utils/dashboard.ts +53 -0
  251. package/utils/data.test.ts +95 -0
  252. package/utils/data.ts +40 -2
  253. package/utils/hid-discover.test.ts +102 -0
  254. package/utils/list-page.test.ts +60 -0
  255. package/utils/list-page.ts +38 -0
  256. package/utils/materialSymbols.ts +363 -0
  257. package/utils/nav-sections.test.ts +190 -0
  258. package/utils/nav-sections.ts +151 -0
  259. package/utils/nfc-patrol-report.test.ts +130 -0
  260. package/utils/nfc-patrol-report.ts +101 -0
  261. package/utils/role.test.ts +69 -0
  262. package/utils/role.ts +10 -0
  263. package/utils/route-name.test.ts +85 -0
  264. package/utils/route-name.ts +24 -0
  265. package/utils/status.test.ts +110 -0
  266. package/utils/status.ts +135 -0
  267. package/utils/table-card.test.ts +84 -0
  268. package/utils/theme-aa-ledger.ts +148 -0
  269. package/utils/theme.test.ts +485 -7
  270. package/utils/theme.ts +295 -110
  271. package/.changeset/README.md +0 -8
  272. package/.changeset/config.json +0 -11
  273. package/.editorconfig +0 -12
  274. package/.github/workflows/main.yml +0 -17
  275. package/.github/workflows/publish-staging.yml +0 -47
  276. package/.github/workflows/publish.yml +0 -39
  277. package/PUBLISHING.md +0 -269
  278. package/components/HidIdentityMapping.vue +0 -975
  279. package/components/ScheduleTaskAreaFormDialog.vue +0 -144
  280. package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
  281. package/components/ScheduleTastTicketMain.vue +0 -182
  282. package/components/TableWithButton.vue +0 -94
  283. package/test/visitor-socket.test.mjs +0 -36
@@ -21,7 +21,7 @@
21
21
  <template
22
22
  v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
23
23
  >
24
- <div class="d-flex flex-column ga-2 mt-2">
24
+ <div v-if="card.raw.items.length" class="d-flex flex-column ga-2 mt-2">
25
25
  <div
26
26
  v-for="item in card.raw.items"
27
27
  :key="item._id || item.name"
@@ -31,6 +31,7 @@
31
31
  <span class="text-blue-grey">Balance: {{ item.qty }}</span>
32
32
  </div>
33
33
  </div>
34
+ <div v-else class="print-kpi-meta mt-2">No supplies recorded</div>
34
35
  </template>
35
36
  <template v-else>
36
37
  <div class="print-kpi-value">{{ card.value }}</div>
@@ -66,7 +67,7 @@
66
67
  :y="label.y"
67
68
  text-anchor="end"
68
69
  alignment-baseline="middle"
69
- style="fill: #7e8b96; font-size: 11px; font-weight: 500"
70
+ style="fill: #64748b; font-size: 11px; font-weight: 500"
70
71
  >
71
72
  {{ label.text }}
72
73
  </text>
@@ -112,14 +113,15 @@
112
113
  :x="label.x"
113
114
  y="246"
114
115
  text-anchor="middle"
115
- style="fill: #7e8b96; font-size: 12px; font-weight: 600"
116
+ style="fill: #64748b; font-size: 12px; font-weight: 600"
116
117
  >
117
118
  {{ label.text }}
118
119
  </text>
119
120
  </svg>
120
121
  <div class="d-flex align-center justify-center ga-5 mt-2">
121
122
  <span
122
- class="d-inline-flex align-center ga-2 text-caption font-weight-bold text-blue-grey"
123
+ class="d-inline-flex align-center ga-2 text-caption font-weight-bold"
124
+ style="color: #64748b"
123
125
  >
124
126
  <span
125
127
  class="rounded-circle"
@@ -133,7 +135,8 @@
133
135
  {{ primaryLegendLabel }}
134
136
  </span>
135
137
  <span
136
- class="d-inline-flex align-center ga-2 text-caption font-weight-bold text-blue-grey"
138
+ class="d-inline-flex align-center ga-2 text-caption font-weight-bold"
139
+ style="color: #64748b"
137
140
  >
138
141
  <span
139
142
  class="rounded-circle"
@@ -203,46 +206,36 @@
203
206
 
204
207
  <section v-else class="unified-dashboard pb-8">
205
208
  <!-- ── Header ──────────────────────────────────────────────────────────── -->
206
- <div
207
- class="d-flex flex-column flex-md-row align-md-start justify-space-between ga-5 mb-6"
209
+ <PageHeader
210
+ :title="dashboardTitle"
211
+ :subtitle="currentSiteName || 'Selected site'"
212
+ subtitle-icon="mdi-map-marker"
208
213
  >
209
- <div>
210
- <h1 class="text-h5 font-weight-black text-blue-grey-darken-4 mb-0">
211
- {{ dashboardTitle }}
212
- </h1>
213
- <div class="d-flex align-center text-blue-grey ga-1 mt-2 text-caption">
214
- <v-icon icon="mdi-map-marker" size="16" />
215
- <span>{{ currentSiteName || "Selected site" }}</span>
216
- </div>
217
- </div>
218
-
219
- <div
220
- v-if="showHeaderActions"
221
- class="d-flex flex-wrap align-center justify-md-end ga-3"
222
- >
223
- <v-btn
224
- variant="outlined"
225
- color="blue-grey-darken-1"
226
- class="text-none font-weight-bold"
227
- prepend-icon="mdi-tune-variant"
214
+ <!--
215
+ The design's three actions: two 38px ghost buttons and the accent
216
+ segmented control. Same dialog, same export menu, same range model -
217
+ `Customize Dashboard` and `Export` shorten to the labels the prototype
218
+ prints, and the export menu keeps both of its formats.
219
+ -->
220
+ <template v-if="showHeaderActions" #actions>
221
+ <AppButton
222
+ variant="ghost"
223
+ icon="mdi-tune-variant"
228
224
  @click="openCustomizeDialog"
229
225
  >
230
- Customize Dashboard
231
- </v-btn>
226
+ Customize
227
+ </AppButton>
232
228
 
233
229
  <v-menu>
234
230
  <template #activator="{ props: menuProps }">
235
- <v-btn
231
+ <AppButton
236
232
  v-bind="menuProps"
237
- variant="outlined"
238
- color="blue-grey-darken-1"
239
- class="text-none font-weight-bold"
240
- append-icon="mdi-chevron-down"
241
- :loading="isExporting"
233
+ variant="ghost"
234
+ icon="mdi-download-outline"
242
235
  :disabled="isExporting"
243
236
  >
244
237
  Export
245
- </v-btn>
238
+ </AppButton>
246
239
  </template>
247
240
 
248
241
  <v-list density="compact" border rounded="lg" elevation="3">
@@ -251,36 +244,9 @@
251
244
  </v-list>
252
245
  </v-menu>
253
246
 
254
- <v-btn-toggle
255
- v-model="selectedRange"
256
- mandatory
257
- density="comfortable"
258
- divided
259
- variant="outlined"
260
- class="bg-white"
261
- style="border-radius: 8px; border-color: #d9e1e8"
262
- >
263
- <v-btn
264
- v-for="range in rangeOptions"
265
- :key="range.value"
266
- :value="range.value"
267
- class="text-none"
268
- size="small"
269
- :style="
270
- selectedRange === range.value
271
- ? {
272
- backgroundColor: '#e2e8f0',
273
- color: '#0b2f47',
274
- fontWeight: '600',
275
- }
276
- : { backgroundColor: 'transparent', color: '#64748b' }
277
- "
278
- >
279
- {{ range.label }}
280
- </v-btn>
281
- </v-btn-toggle>
282
- </div>
283
- </div>
247
+ <AppSegmented v-model="selectedRange" :options="rangeOptions" />
248
+ </template>
249
+ </PageHeader>
284
250
 
285
251
  <v-progress-linear
286
252
  v-if="finalLoading"
@@ -289,6 +255,27 @@
289
255
  class="mb-4"
290
256
  />
291
257
 
258
+ <!--
259
+ SAY IT ONCE AT THE TOP, THEN AGAIN WHERE IT HAPPENED. A failed request
260
+ used to be invisible: zeros in the tiles, "No data to display" in the
261
+ lists, nothing else. This names what did not load; each tile and panel
262
+ below carries its own failure too, so nobody has to guess which zero is
263
+ real. `variant="tonal"` on purpose - Vuetify derives a WHITE label for a
264
+ filled error alert, which measures 3.60:1 in these themes.
265
+ -->
266
+ <v-alert
267
+ v-if="failedWidgetLabels.length"
268
+ type="error"
269
+ variant="tonal"
270
+ density="compact"
271
+ icon="mdi-cloud-alert-outline"
272
+ class="mb-4"
273
+ >
274
+ Some of this dashboard could not be loaded ({{
275
+ failedWidgetLabels.join(", ")
276
+ }}). The figures for it are missing, not zero — refresh to try again.
277
+ </v-alert>
278
+
292
279
  <!-- ── KPI Cards ───────────────────────────────────────────────────────── -->
293
280
  <v-row dense class="mb-1 align-stretch">
294
281
  <v-col
@@ -300,22 +287,24 @@
300
287
  lg="3"
301
288
  class="d-flex"
302
289
  >
303
- <v-card
304
- flat
305
- border
306
- rounded="lg"
307
- class="pa-4 flex-grow-1"
308
- style="min-height: 158px"
290
+ <!--
291
+ The same figures the tile has always shown, in the arrangement the
292
+ handoff draws them: icon and label on one row, the number with its
293
+ percentage pill beside it, the sub-line under both. The filter menu
294
+ and the facility "View All" keep their place at the end of the first
295
+ row and do exactly what they did.
296
+ -->
297
+ <AppKpiTile
298
+ :icon="card.icon"
299
+ :tint="card.tint"
300
+ :color="card.color"
301
+ :label="card.title"
302
+ :value="card.value"
303
+ :chip="kpiChipText(card)"
304
+ :down="Number(card.percentage) < 0"
305
+ :sub="card.meta"
309
306
  >
310
- <div class="d-flex align-start justify-space-between">
311
- <div
312
- class="d-inline-flex align-center justify-center rounded-lg"
313
- style="height: 28px; width: 28px; flex-shrink: 0"
314
- :style="{ backgroundColor: card.tint }"
315
- >
316
- <v-icon :icon="card.icon" :color="card.color" size="15" />
317
- </div>
318
-
307
+ <template v-if="card.filter || card.key === 'facilityBookings'" #action>
319
308
  <v-menu v-if="card.filter" v-model="filterMenus[card.key]">
320
309
  <template #activator="{ props: menuProps }">
321
310
  <v-btn
@@ -359,61 +348,29 @@
359
348
  >
360
349
  View All
361
350
  </button>
362
- </div>
351
+ </template>
363
352
 
364
- <div class="mt-6 w-100">
365
- <p
366
- class="text-caption font-weight-black text-uppercase text-blue-grey-darken-2 mb-3"
367
- >
368
- {{ card.title }}
369
- </p>
370
- <template
371
- v-if="
372
- card.key === 'supplyAlert' && Array.isArray(card.raw?.items)
373
- "
374
- >
375
- <div class="d-flex flex-column ga-2 mt-2">
376
- <div
377
- v-for="item in card.raw.items"
378
- :key="item._id || item.name"
379
- class="d-flex justify-space-between text-body-2 text-blue-grey-darken-4 font-weight-medium"
380
- >
381
- <span>{{ item.name }}</span>
382
- <span class="text-blue-grey">Balance: {{ item.qty }}</span>
383
- </div>
384
- </div>
385
- </template>
386
- <template v-else>
387
- <div class="d-flex align-center mb-3" style="min-height: 31px">
388
- <span class="text-h5 font-weight-black text-blue-grey-darken-4">
389
- {{ card.value }}
390
- </span>
391
- </div>
392
- <div class="d-flex align-center flex-wrap ga-2">
393
- <span v-if="card.meta" class="text-caption text-blue-grey">{{
394
- card.meta
395
- }}</span>
396
- <v-chip
397
- :color="card.percentage >= 0 ? 'success' : 'error'"
398
- size="x-small"
399
- variant="flat"
400
- class="font-weight-bold"
401
- >
402
- <v-icon
403
- :icon="
404
- card.percentage >= 0
405
- ? 'mdi-arrow-up-right'
406
- : 'mdi-arrow-down-right'
407
- "
408
- size="13"
409
- start
410
- />
411
- {{ Math.abs(card.percentage) }}%
412
- </v-chip>
353
+ <!-- The supply tile lists items instead of counting one figure. -->
354
+ <template
355
+ v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
356
+ >
357
+ <!--
358
+ An empty list used to render a title and a blank card - no number,
359
+ no words, nothing to read. The tile now says what it found.
360
+ -->
361
+ <div v-if="card.raw.items.length" class="d-flex flex-column ga-2">
362
+ <div
363
+ v-for="item in card.raw.items"
364
+ :key="item._id || item.name"
365
+ class="d-flex justify-space-between text-body-2 text-high-emphasis font-weight-medium"
366
+ >
367
+ <span>{{ item.name }}</span>
368
+ <span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
413
369
  </div>
414
- </template>
415
- </div>
416
- </v-card>
370
+ </div>
371
+ <DashboardEmptyState v-else compact />
372
+ </template>
373
+ </AppKpiTile>
417
374
  </v-col>
418
375
  </v-row>
419
376
 
@@ -421,16 +378,37 @@
421
378
  <v-card
422
379
  v-if="isWidgetVisible('activity')"
423
380
  flat
424
- border
425
- rounded="lg"
426
- class="px-5 pt-4 pb-3 mt-3"
381
+ class="dashboard-card px-5 pt-4 pb-3 mt-3"
427
382
  >
428
383
  <div class="d-flex align-center justify-space-between">
429
- <h2 class="text-body-1 font-weight-black text-blue-grey-darken-4 mb-0">
384
+ <h2 class="dashboard-card-title mb-0">
430
385
  {{ activityTitle }}
431
386
  </h2>
432
387
  </div>
433
388
 
389
+ <!--
390
+ THE TWO SERIES COLOURS STAY LITERAL, AND HERE IS WHY.
391
+
392
+ Everything else in this chart already follows the theme - the gridlines,
393
+ the axis labels and the ring around each dot are all Vuetify theme vars.
394
+ The two series are not, and they are the only chart colours in the file
395
+ that are still hexes.
396
+
397
+ They are left that way deliberately. The handoff names no chart palette
398
+ at all (searched: no "chart", no "legend", no "graph", and neither hex
399
+ appears anywhere in it), so the fallback rule is "use the token the
400
+ literal was already imitating". `#e53935` is plainly `--err`. `#ff9b9b`
401
+ is a second, softer red, and the palette has no second red - the only
402
+ other red token is `--err-bg`, a 10%-alpha FILL that would be invisible
403
+ as a 3px line. Re-pointing just one of them would collapse the pair in
404
+ dark mode, where `--err` is `rgb(229,115,115)` and would sit almost on
405
+ top of `#ff9b9b`'s `rgb(255,155,155)` - two lines the reader could no
406
+ longer tell apart. That is the same "do not merge two series into one
407
+ colour" trap as the Work Order Status bars below.
408
+
409
+ So: a two-series chart palette is missing from the design system, and
410
+ that is a designer question, not something to invent here.
411
+ -->
434
412
  <div v-if="hasChartData" class="mt-4" ref="chartContainerRef">
435
413
  <svg
436
414
  :viewBox="`0 0 ${chartWidth} 260`"
@@ -444,7 +422,7 @@
444
422
  :y1="tick"
445
423
  :x2="chartWidth - 20"
446
424
  :y2="tick"
447
- style="stroke: #e9eef2; stroke-width: 1"
425
+ style="stroke: rgba(var(--v-theme-on-surface), 0.12); stroke-width: 1"
448
426
  />
449
427
  <text
450
428
  v-for="label in chartYLabels"
@@ -453,7 +431,7 @@
453
431
  :y="label.y"
454
432
  text-anchor="end"
455
433
  alignment-baseline="middle"
456
- style="fill: #7e8b96; font-size: 11px; font-weight: 500"
434
+ style="fill: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); font-size: 11px; font-weight: 500"
457
435
  >
458
436
  {{ label.text }}
459
437
  </text>
@@ -483,7 +461,7 @@
483
461
  :cx="point.x"
484
462
  :cy="point.y"
485
463
  r="4"
486
- style="fill: #ff9b9b; stroke: #fff; stroke-width: 2"
464
+ style="fill: #ff9b9b; stroke: rgb(var(--v-theme-surface)); stroke-width: 2"
487
465
  />
488
466
  <circle
489
467
  v-for="point in secondaryDots"
@@ -491,7 +469,7 @@
491
469
  :cx="point.x"
492
470
  :cy="point.y"
493
471
  r="4"
494
- style="fill: #e53935; stroke: #fff; stroke-width: 2"
472
+ style="fill: #e53935; stroke: rgb(var(--v-theme-surface)); stroke-width: 2"
495
473
  />
496
474
  <text
497
475
  v-for="label in chartAxisLabels"
@@ -499,7 +477,7 @@
499
477
  :x="label.x"
500
478
  y="246"
501
479
  text-anchor="middle"
502
- style="fill: #7e8b96; font-size: 12px; font-weight: 600"
480
+ style="fill: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); font-size: 12px; font-weight: 600"
503
481
  >
504
482
  {{ label.text }}
505
483
  </text>
@@ -507,7 +485,7 @@
507
485
 
508
486
  <div class="d-flex align-center justify-center ga-5 mt-2">
509
487
  <span
510
- class="d-inline-flex align-center ga-2 text-caption font-weight-bold text-blue-grey"
488
+ class="d-inline-flex align-center ga-2 text-caption font-weight-bold text-medium-emphasis"
511
489
  >
512
490
  <span
513
491
  class="rounded-circle"
@@ -521,7 +499,7 @@
521
499
  {{ primaryLegendLabel }}
522
500
  </span>
523
501
  <span
524
- class="d-inline-flex align-center ga-2 text-caption font-weight-bold text-blue-grey"
502
+ class="d-inline-flex align-center ga-2 text-caption font-weight-bold text-medium-emphasis"
525
503
  >
526
504
  <span
527
505
  class="rounded-circle"
@@ -537,7 +515,7 @@
537
515
  </div>
538
516
  </div>
539
517
 
540
- <DashboardEmptyState v-else />
518
+ <DashboardEmptyState v-else :error="chartFailed" />
541
519
  </v-card>
542
520
 
543
521
  <!-- ── Hygiene / M&E / Landscape / Pest / Pool — task + attendance ────── -->
@@ -553,58 +531,36 @@
553
531
  action-label="View All"
554
532
  @action="goToDashboardRoute(primaryListConfig.route)"
555
533
  >
556
- <div v-if="taskScheduleItems.length" class="d-flex flex-column ga-3">
557
- <v-card
534
+ <div v-if="taskScheduleItems.length" class="figma-list">
535
+ <div
558
536
  v-for="item in taskScheduleItems"
559
537
  :key="item.id"
560
- flat
561
- border
562
- rounded="lg"
563
- class="pa-3"
538
+ class="figma-list-row figma-list-row--wide"
564
539
  >
565
- <div class="d-flex align-center flex-wrap ga-3">
566
- <div class="flex-grow-1" style="min-width: 0">
567
- <div
568
- class="text-body-2 font-weight-bold text-blue-grey-darken-4 text-truncate"
569
- >
570
- {{ item.title }}
571
- </div>
572
- <div class="text-caption text-blue-grey mt-1 text-truncate">
573
- {{ item.subtitle
574
- }}<template v-if="item.subtitle && item.time">
575
- &bull; </template
576
- >{{ item.time }}
577
- </div>
578
- </div>
579
- <div
580
- class="d-flex align-center ga-2 text-caption text-blue-grey"
581
- >
582
- <AvatarMain :name="item.person" :size="30" />
583
- <span class="text-truncate" style="max-width: 100px">{{
584
- item.person
585
- }}</span>
586
- </div>
587
- <v-chip
588
- size="small"
589
- :color="getStatusTone(item.status)"
590
- variant="tonal"
591
- label
592
- >
593
- {{ item.status }}
594
- </v-chip>
595
- <v-btn
596
- variant="outlined"
597
- color="primary"
598
- size="small"
599
- class="text-none font-weight-bold"
600
- @click="goToListItem(item, primaryListConfig.route)"
601
- >
602
- View
603
- </v-btn>
540
+ <div class="figma-list-main">
541
+ <strong>{{ item.title }}</strong>
542
+ <p v-if="item.subtitle || item.time">
543
+ {{ item.subtitle
544
+ }}<template v-if="item.subtitle && item.time">
545
+ &bull; </template
546
+ >{{ item.time }}
547
+ </p>
604
548
  </div>
605
- </v-card>
549
+ <div class="figma-list-person">
550
+ <AvatarMain :name="item.person" :size="30" />
551
+ <span>{{ item.person }}</span>
552
+ </div>
553
+ <StatusChip :status="item.status" pill />
554
+ <button
555
+ class="figma-row-action"
556
+ type="button"
557
+ @click="goToListItem(item, primaryListConfig.route)"
558
+ >
559
+ View
560
+ </button>
561
+ </div>
606
562
  </div>
607
- <DashboardEmptyState v-else />
563
+ <DashboardEmptyState v-else :error="taskScheduleFailed" />
608
564
  </DashboardPanel>
609
565
  </v-col>
610
566
 
@@ -619,40 +575,24 @@
619
575
  action-label="View All"
620
576
  @action="goToDashboardRoute('attendance')"
621
577
  >
622
- <div v-if="staffStatusItems.length" class="d-flex flex-column ga-3">
623
- <v-card
578
+ <div v-if="staffStatusItems.length" class="figma-list">
579
+ <div
624
580
  v-for="item in staffStatusItems"
625
581
  :key="item.id"
626
- flat
627
- border
628
- rounded="lg"
629
- class="pa-3"
582
+ class="figma-list-row"
630
583
  >
631
- <div class="d-flex align-start ga-3">
632
- <AvatarMain :name="item.name" :size="32" />
633
- <div class="flex-grow-1" style="min-width: 0">
634
- <div
635
- class="text-body-2 font-weight-bold text-blue-grey-darken-4 text-truncate"
636
- >
637
- {{ item.name }}
638
- </div>
639
- <div class="text-caption text-blue-grey mt-1 text-truncate">
640
- {{ item.role }}
641
- </div>
642
- </div>
643
- <v-chip
644
- size="small"
645
- color="blue-grey-darken-1"
646
- variant="tonal"
647
- label
648
- class="ml-auto flex-shrink-0 font-weight-bold"
649
- >
650
- {{ item.totalHours || 0 }} hrs
651
- </v-chip>
584
+ <AvatarMain :name="item.name" :size="32" />
585
+ <div class="figma-list-main">
586
+ <strong>{{ item.name }}</strong>
587
+ <p v-if="item.role">{{ item.role }}</p>
652
588
  </div>
653
- </v-card>
589
+ <!-- Hours are a FIGURE, not a status: no tone to read, so the
590
+ design's neutral pill rather than a StatusChip, which would
591
+ have coloured "8 hrs" as if it were a word it knows. -->
592
+ <span class="figma-row-figure">{{ item.totalHours || 0 }} hrs</span>
593
+ </div>
654
594
  </div>
655
- <DashboardEmptyState v-else />
595
+ <DashboardEmptyState v-else :error="attendanceFailed" />
656
596
  </DashboardPanel>
657
597
  </v-col>
658
598
 
@@ -662,79 +602,31 @@
662
602
  action-label="View All"
663
603
  @action="goToDashboardRoute('feedbacks')"
664
604
  >
665
- <div v-if="feedbackItems.length" class="figma-list mt-2">
605
+ <div v-if="feedbackItems.length" class="figma-list">
666
606
  <div
667
607
  v-for="item in feedbackItems"
668
608
  :key="item.id"
669
- class="d-flex align-center justify-space-between"
670
- style="
671
- padding: 16px 20px;
672
- border: 1px solid #e2e8f0;
673
- border-radius: 12px;
674
- margin-bottom: 12px;
675
- background-color: #ffffff;
676
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
677
- "
609
+ class="figma-list-row figma-list-row--wide"
678
610
  >
679
- <div style="flex: 1; min-width: 140px">
680
- <strong
681
- style="
682
- display: block;
683
- font-size: 15px;
684
- font-weight: 700;
685
- color: #0b2f47;
686
- margin-bottom: 4px;
687
- "
688
- >{{ item.title }}</strong
689
- >
690
- <span style="font-size: 13px; color: #94a3b8">{{
691
- item.subtitle
692
- }}</span>
693
- </div>
694
-
695
- <div
696
- style="flex: 1; display: flex; align-items: center; gap: 12px"
697
- >
698
- <AvatarMain :name="item.person" :size="36" />
699
- <span style="font-size: 14px; color: #64748b">{{
700
- item.person
701
- }}</span>
702
- </div>
703
-
704
- <div style="flex: 1; display: flex; justify-content: center">
705
- <div
706
- :style="getStatusPillStyle(item.status)"
707
- style="
708
- padding: 6px 16px;
709
- border-radius: 20px;
710
- font-size: 13px;
711
- font-weight: 500;
712
- text-transform: capitalize;
713
- "
714
- >
715
- {{ item.status || "To-Do" }}
716
- </div>
611
+ <div class="figma-list-main">
612
+ <strong>{{ item.title }}</strong>
613
+ <p v-if="item.subtitle">{{ item.subtitle }}</p>
717
614
  </div>
718
-
719
- <div style="flex: 0 0 auto; text-align: right">
720
- <v-btn
721
- variant="outlined"
722
- class="text-none font-weight-medium"
723
- size="small"
724
- color="#3b82f6"
725
- style="
726
- border-color: #e2e8f0;
727
- border-radius: 8px;
728
- padding: 0 16px;
729
- height: 32px;
730
- "
731
- @click="goToListItem(item, 'feedbacks')"
732
- >View</v-btn
733
- >
615
+ <div class="figma-list-person">
616
+ <AvatarMain :name="item.person" :size="30" />
617
+ <span>{{ item.person }}</span>
734
618
  </div>
619
+ <StatusChip :status="item.status || 'To-Do'" pill />
620
+ <button
621
+ class="figma-row-action"
622
+ type="button"
623
+ @click="goToListItem(item, 'feedbacks')"
624
+ >
625
+ View
626
+ </button>
735
627
  </div>
736
628
  </div>
737
- <DashboardEmptyState v-else />
629
+ <DashboardEmptyState v-else :error="feedbacksFailed" />
738
630
  </DashboardPanel>
739
631
  </v-col>
740
632
  </v-row>
@@ -779,7 +671,7 @@
779
671
  </button>
780
672
  </div>
781
673
  </div>
782
- <DashboardEmptyState v-else />
674
+ <DashboardEmptyState v-else :error="metricsFailed" />
783
675
  </div>
784
676
  </div>
785
677
  </v-col>
@@ -824,7 +716,7 @@
824
716
  </button>
825
717
  </div>
826
718
  </div>
827
- <DashboardEmptyState v-else />
719
+ <DashboardEmptyState v-else :error="metricsFailed" />
828
720
  </div>
829
721
  </div>
830
722
  </v-col>
@@ -832,7 +724,7 @@
832
724
  <v-col v-if="isWidgetVisible('todayAttentions')" cols="12" lg="6">
833
725
  <div class="dashboard-card dashboard-panel figma-panel">
834
726
  <div class="figma-panel-header">
835
- <h2>Today's Attentions</h2>
727
+ <h2>Needs Attention</h2>
836
728
  </div>
837
729
  <div class="figma-panel-body">
838
730
  <div v-if="todayAttentions.length" class="figma-list">
@@ -873,7 +765,7 @@
873
765
  </button>
874
766
  </div>
875
767
  </div>
876
- <DashboardEmptyState v-else />
768
+ <DashboardEmptyState v-else :error="metricsFailed" />
877
769
  </div>
878
770
  </div>
879
771
  </v-col>
@@ -909,7 +801,7 @@
909
801
  </div>
910
802
  </div>
911
803
  </div>
912
- <DashboardEmptyState v-else />
804
+ <DashboardEmptyState v-else :error="metricsFailed" />
913
805
  </div>
914
806
  </div>
915
807
  </v-col>
@@ -957,7 +849,7 @@
957
849
  </button>
958
850
  </div>
959
851
  </div>
960
- <DashboardEmptyState v-else />
852
+ <DashboardEmptyState v-else :error="feedbacksFailed" />
961
853
  </DashboardPanel>
962
854
  </v-col>
963
855
  </v-row>
@@ -967,7 +859,7 @@
967
859
  <v-col v-if="isWidgetVisible('activePatrol')" cols="12" lg="6">
968
860
  <div class="dashboard-card dashboard-panel figma-panel">
969
861
  <div class="figma-panel-header">
970
- <h2>ACTIVE PATROL</h2>
862
+ <h2>Active Patrol</h2>
971
863
  <button
972
864
  class="figma-panel-action"
973
865
  type="button"
@@ -1022,24 +914,19 @@
1022
914
  </div>
1023
915
 
1024
916
  <div class="active-patrol-action">
1025
- <v-btn
1026
- variant="outlined"
1027
- class="text-none font-weight-medium"
1028
- size="small"
1029
- color="#3b82f6"
1030
- style="
1031
- border-color: #e2e8f0;
1032
- border-radius: 8px;
1033
- padding: 0 16px;
1034
- height: 32px;
1035
- "
917
+ <!-- Chrome only: the route and the Virtual Patrol flow
918
+ behind it are unchanged. -->
919
+ <button
920
+ class="figma-row-action"
921
+ type="button"
1036
922
  @click="goToDashboardRoute('virtual-patrol')"
1037
- >View</v-btn
1038
923
  >
924
+ View
925
+ </button>
1039
926
  </div>
1040
927
  </div>
1041
928
  </div>
1042
- <DashboardEmptyState v-else />
929
+ <DashboardEmptyState v-else :error="metricsFailed" />
1043
930
  </div>
1044
931
  </div>
1045
932
  </v-col>
@@ -1047,7 +934,7 @@
1047
934
  <v-col v-if="isWidgetVisible('staffStatus')" cols="12" lg="6">
1048
935
  <div class="dashboard-card dashboard-panel figma-panel">
1049
936
  <div class="figma-panel-header">
1050
- <h2>STAFF STATUS</h2>
937
+ <h2>Staff Status</h2>
1051
938
  <button
1052
939
  class="figma-panel-action"
1053
940
  type="button"
@@ -1057,65 +944,31 @@
1057
944
  </button>
1058
945
  </div>
1059
946
  <div class="figma-panel-body">
1060
- <div v-if="securityStaffItems.length" class="figma-list mt-2">
947
+ <div v-if="securityStaffItems.length" class="figma-list">
1061
948
  <div
1062
949
  v-for="item in securityStaffItems"
1063
950
  :key="item._id || item.id"
1064
- class="d-flex align-center justify-space-between"
1065
- style="
1066
- padding: 16px 20px;
1067
- border: 1px solid #e2e8f0;
1068
- border-radius: 12px;
1069
- margin-bottom: 12px;
1070
- background-color: #ffffff;
1071
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
1072
- "
951
+ class="figma-list-row"
1073
952
  >
1074
- <div style="display: flex; align-items: center; gap: 16px">
1075
- <AvatarMain
1076
- :name="
1077
- item.personName ||
1078
- item.employeeName ||
1079
- item.userName ||
1080
- 'Unassigned'
1081
- "
1082
- :size="40"
1083
- />
1084
- <div>
1085
- <strong
1086
- style="
1087
- display: block;
1088
- font-size: 15px;
1089
- font-weight: 700;
1090
- color: #0b2f47;
1091
- margin-bottom: 4px;
1092
- "
1093
- >{{
1094
- item.userName || item.personName || "Unassigned"
1095
- }}</strong
1096
- >
1097
- <span style="font-size: 13px; color: #94a3b8">{{
1098
- item.role || item.designation || "Officer"
1099
- }}</span>
1100
- </div>
1101
- </div>
1102
-
1103
- <div style="flex: 0 0 auto">
1104
- <div
1105
- :style="getStaffStatusPillStyle(item.status)"
1106
- style="
1107
- padding: 6px 16px;
1108
- border-radius: 20px;
1109
- font-size: 13px;
1110
- font-weight: 500;
1111
- "
1112
- >
1113
- {{ item.status || "On duty" }}
1114
- </div>
953
+ <AvatarMain
954
+ :name="
955
+ item.personName ||
956
+ item.employeeName ||
957
+ item.userName ||
958
+ 'Unassigned'
959
+ "
960
+ :size="32"
961
+ />
962
+ <div class="figma-list-main">
963
+ <strong>{{
964
+ item.userName || item.personName || "Unassigned"
965
+ }}</strong>
966
+ <p>{{ item.role || item.designation || "Officer" }}</p>
1115
967
  </div>
968
+ <StatusChip :status="item.status || 'On duty'" pill />
1116
969
  </div>
1117
970
  </div>
1118
- <DashboardEmptyState v-else />
971
+ <DashboardEmptyState v-else :error="attendanceFailed" />
1119
972
  </div>
1120
973
  </div>
1121
974
  </v-col>
@@ -1127,79 +980,31 @@
1127
980
  action-label="View All"
1128
981
  @action="goToDashboardRoute('feedbacks')"
1129
982
  >
1130
- <div v-if="feedbackItems.length" class="figma-list mt-2">
983
+ <div v-if="feedbackItems.length" class="figma-list">
1131
984
  <div
1132
985
  v-for="item in feedbackItems"
1133
986
  :key="item.id"
1134
- class="d-flex align-center justify-space-between"
1135
- style="
1136
- padding: 16px 20px;
1137
- border: 1px solid #e2e8f0;
1138
- border-radius: 12px;
1139
- margin-bottom: 12px;
1140
- background-color: #ffffff;
1141
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
1142
- "
987
+ class="figma-list-row figma-list-row--wide"
1143
988
  >
1144
- <div style="flex: 1; min-width: 140px">
1145
- <strong
1146
- style="
1147
- display: block;
1148
- font-size: 15px;
1149
- font-weight: 700;
1150
- color: #0b2f47;
1151
- margin-bottom: 4px;
1152
- "
1153
- >{{ item.title }}</strong
1154
- >
1155
- <span style="font-size: 13px; color: #94a3b8">{{
1156
- item.subtitle
1157
- }}</span>
1158
- </div>
1159
-
1160
- <div
1161
- style="flex: 1; display: flex; align-items: center; gap: 12px"
1162
- >
1163
- <AvatarMain :name="item.person" :size="36" />
1164
- <span style="font-size: 14px; color: #64748b">{{
1165
- item.person
1166
- }}</span>
1167
- </div>
1168
-
1169
- <div style="flex: 1; display: flex; justify-content: center">
1170
- <div
1171
- :style="getStatusPillStyle(item.status)"
1172
- style="
1173
- padding: 6px 16px;
1174
- border-radius: 20px;
1175
- font-size: 13px;
1176
- font-weight: 500;
1177
- text-transform: capitalize;
1178
- "
1179
- >
1180
- {{ item.status || "To-Do" }}
1181
- </div>
989
+ <div class="figma-list-main">
990
+ <strong>{{ item.title }}</strong>
991
+ <p v-if="item.subtitle">{{ item.subtitle }}</p>
1182
992
  </div>
1183
-
1184
- <div style="flex: 0 0 auto; text-align: right">
1185
- <v-btn
1186
- variant="outlined"
1187
- class="text-none font-weight-medium"
1188
- size="small"
1189
- color="#3b82f6"
1190
- style="
1191
- border-color: #e2e8f0;
1192
- border-radius: 8px;
1193
- padding: 0 16px;
1194
- height: 32px;
1195
- "
1196
- @click="goToListItem(item, 'feedbacks')"
1197
- >View</v-btn
1198
- >
993
+ <div class="figma-list-person">
994
+ <AvatarMain :name="item.person" :size="30" />
995
+ <span>{{ item.person }}</span>
1199
996
  </div>
997
+ <StatusChip :status="item.status || 'To-Do'" pill />
998
+ <button
999
+ class="figma-row-action"
1000
+ type="button"
1001
+ @click="goToListItem(item, 'feedbacks')"
1002
+ >
1003
+ View
1004
+ </button>
1200
1005
  </div>
1201
1006
  </div>
1202
- <DashboardEmptyState v-else />
1007
+ <DashboardEmptyState v-else :error="feedbacksFailed" />
1203
1008
  </DashboardPanel>
1204
1009
  </v-col>
1205
1010
  </v-row>
@@ -1211,10 +1016,10 @@
1211
1016
  class="d-flex align-start justify-space-between pa-6 pb-4 border-b"
1212
1017
  >
1213
1018
  <div>
1214
- <div class="text-h6 font-weight-black text-blue-grey-darken-4">
1019
+ <div class="text-h6 font-weight-black text-high-emphasis">
1215
1020
  Customize Dashboard
1216
1021
  </div>
1217
- <div class="text-caption text-blue-grey mt-1">
1022
+ <div class="text-caption text-medium-emphasis mt-1">
1218
1023
  Select which widgets to display on your dashboard
1219
1024
  </div>
1220
1025
  </div>
@@ -1240,7 +1045,6 @@
1240
1045
  <div class="d-flex justify-end mt-3 mb-1">
1241
1046
  <v-btn
1242
1047
  variant="text"
1243
- color="blue-grey-darken-4"
1244
1048
  size="small"
1245
1049
  class="text-none font-weight-bold"
1246
1050
  prepend-icon="mdi-refresh"
@@ -1262,11 +1066,11 @@
1262
1066
  <div class="d-flex align-center justify-space-between ga-4">
1263
1067
  <div>
1264
1068
  <div
1265
- class="text-body-2 font-weight-black text-blue-grey-darken-4"
1069
+ class="text-body-2 font-weight-black text-high-emphasis"
1266
1070
  >
1267
1071
  {{ widget.title }}
1268
1072
  </div>
1269
- <div class="text-caption text-blue-grey mt-1">
1073
+ <div class="text-caption text-medium-emphasis mt-1">
1270
1074
  {{ widget.description }}
1271
1075
  </div>
1272
1076
  </div>
@@ -1292,7 +1096,7 @@
1292
1096
  <v-card-actions class="pa-6 pt-4 border-t justify-end ga-3">
1293
1097
  <v-btn
1294
1098
  variant="text"
1295
- class="text-none font-weight-bold text-blue-grey"
1099
+ class="text-none font-weight-bold text-medium-emphasis"
1296
1100
  @click="cancelCustomize"
1297
1101
  >
1298
1102
  Cancel
@@ -1320,7 +1124,7 @@
1320
1124
  <v-card-title
1321
1125
  class="d-flex align-center justify-space-between pa-6 pb-3"
1322
1126
  >
1323
- <div class="text-body-1 font-weight-black text-blue-grey-darken-4">
1127
+ <div class="text-body-1 font-weight-black text-high-emphasis">
1324
1128
  Facility Booking
1325
1129
  </div>
1326
1130
  <v-btn
@@ -1381,6 +1185,15 @@
1381
1185
  </template>
1382
1186
 
1383
1187
  <script setup lang="ts">
1188
+ // The one non-auto-imported thing this file needs: the cookie options type, so
1189
+ // `runtimeConfig.public.cookieConfig` (typed `unknown`) can be narrowed once
1190
+ // rather than at the call site. Same import `useThemePreference.ts` uses.
1191
+ import type { CookieOptions } from "#app";
1192
+ // Explicitly, not by auto-import: every other component in this layer reaches
1193
+ // its utils by relative path (`StatusChip.vue`, `CameraWall.vue`,
1194
+ // `plugins/vuetify.ts`), and a bare call resolved to nothing when driven.
1195
+ import { buildWorkOrderStatus } from "../utils/dashboard";
1196
+
1384
1197
  // ─── Types ────────────────────────────────────────────────────────────────────
1385
1198
 
1386
1199
  type DashboardMetric = {
@@ -1678,8 +1491,11 @@ const currentVisitorTypeParam = computed(
1678
1491
  // ─── Data Fetching ────────────────────────────────────────────────────────────
1679
1492
 
1680
1493
  // Hygiene / M&E / Landscape / Pest / Pool main metrics
1681
- const { data: getDashboardReq, pending: loadingMetrics } =
1682
- await useLazyAsyncData(
1494
+ const {
1495
+ data: getDashboardReq,
1496
+ pending: loadingMetrics,
1497
+ error: metricsError,
1498
+ } = await useLazyAsyncData(
1683
1499
  `get-dashboard-${props.site}-${props.serviceType}`,
1684
1500
  () =>
1685
1501
  props.serviceType && isHygieneMode.value
@@ -1695,8 +1511,11 @@ const { data: getDashboardReq, pending: loadingMetrics } =
1695
1511
  );
1696
1512
 
1697
1513
  // Property / Security main metrics (uses generic getDashboard with visitor type)
1698
- const { data: getDashboardAltReq, pending: loadingAltMetrics } =
1699
- await useLazyAsyncData(
1514
+ const {
1515
+ data: getDashboardAltReq,
1516
+ pending: loadingAltMetrics,
1517
+ error: altMetricsError,
1518
+ } = await useLazyAsyncData(
1700
1519
  `get-dashboard-alt-${props.site}-${props.serviceType}`,
1701
1520
  () =>
1702
1521
  props.site && (isPropertyMode.value || isSecurityMode.value)
@@ -1720,8 +1539,11 @@ const { data: getDashboardAltReq, pending: loadingAltMetrics } =
1720
1539
  );
1721
1540
 
1722
1541
  // Weekly activity (all modes)
1723
- const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
1724
- await useLazyAsyncData(
1542
+ const {
1543
+ data: weeklyActivityReq,
1544
+ pending: loadingWeeklyActivity,
1545
+ error: weeklyActivityError,
1546
+ } = await useLazyAsyncData(
1725
1547
  `get-dashboard-weekly-activity-${props.site}-${props.serviceType}`,
1726
1548
  () =>
1727
1549
  props.serviceType
@@ -1737,8 +1559,11 @@ const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
1737
1559
  );
1738
1560
 
1739
1561
  // Task schedule (hygiene mode only)
1740
- const { data: taskScheduleReq, pending: loadingTaskSchedule } =
1741
- await useLazyAsyncData(
1562
+ const {
1563
+ data: taskScheduleReq,
1564
+ pending: loadingTaskSchedule,
1565
+ error: taskScheduleError,
1566
+ } = await useLazyAsyncData(
1742
1567
  `get-dashboard-task-schedule-${props.site}-${props.serviceType}`,
1743
1568
  () =>
1744
1569
  props.serviceType && isHygieneMode.value
@@ -1754,8 +1579,11 @@ const { data: taskScheduleReq, pending: loadingTaskSchedule } =
1754
1579
  );
1755
1580
 
1756
1581
  // Attendance (hygiene + security modes)
1757
- const { data: attendanceReq, pending: loadingAttendance } =
1758
- await useLazyAsyncData(
1582
+ const {
1583
+ data: attendanceReq,
1584
+ pending: loadingAttendance,
1585
+ error: attendanceError,
1586
+ } = await useLazyAsyncData(
1759
1587
  `get-dashboard-attendance-${props.site}-${props.serviceType}`,
1760
1588
  () =>
1761
1589
  props.serviceType && !isPropertyMode.value
@@ -1771,8 +1599,11 @@ const { data: attendanceReq, pending: loadingAttendance } =
1771
1599
  );
1772
1600
 
1773
1601
  // Feedbacks (all modes)
1774
- const { data: feedbacksReq, pending: loadingFeedbacks } =
1775
- await useLazyAsyncData(
1602
+ const {
1603
+ data: feedbacksReq,
1604
+ pending: loadingFeedbacks,
1605
+ error: feedbacksError,
1606
+ } = await useLazyAsyncData(
1776
1607
  `get-dashboard-feedbacks-${props.site}-${props.serviceType}`,
1777
1608
  () =>
1778
1609
  props.serviceType
@@ -1816,44 +1647,82 @@ const finalLoading = computed(() => {
1816
1647
  );
1817
1648
  });
1818
1649
 
1650
+ // ─── Request Failures ─────────────────────────────────────────────────────────
1651
+
1652
+ /**
1653
+ * A DEAD API USED TO LOOK EXACTLY LIKE A QUIET DAY.
1654
+ *
1655
+ * Every request's result was read as `x.value ?? <zero>`, so a rejected call
1656
+ * produced the same screen as a site with nothing on it: every tile 0, every
1657
+ * trend +0%, every list "No data to display", and not one word to say the data
1658
+ * never arrived. That ambiguity has cost this estate days before now.
1659
+ *
1660
+ * `useLazyAsyncData` already hands back an `error` ref; these carry it to the
1661
+ * widgets that asked for the data, so a zero on this page is a measurement and
1662
+ * a failure says so.
1663
+ */
1664
+ const metricsFailed = computed(() =>
1665
+ Boolean(isHygieneMode.value ? metricsError.value : altMetricsError.value)
1666
+ );
1667
+ const chartFailed = computed(() => Boolean(weeklyActivityError.value));
1668
+ const taskScheduleFailed = computed(() => Boolean(taskScheduleError.value));
1669
+ const attendanceFailed = computed(() => Boolean(attendanceError.value));
1670
+ const feedbacksFailed = computed(() => Boolean(feedbacksError.value));
1671
+
1672
+ /** The header banner - one line, whatever combination failed. */
1673
+ const failedWidgetLabels = computed(() => {
1674
+ const labels: string[] = [];
1675
+ if (metricsFailed.value) labels.push("the summary tiles");
1676
+ if (chartFailed.value) labels.push("the activity chart");
1677
+ if (taskScheduleFailed.value && isHygieneMode.value)
1678
+ labels.push(primaryListConfig.value.title.toLowerCase());
1679
+ if (attendanceFailed.value && !isPropertyMode.value) labels.push("staff");
1680
+ if (feedbacksFailed.value) labels.push("feedbacks");
1681
+ return labels;
1682
+ });
1683
+
1684
+ /** The text a tile or panel shows in place of a figure it does not have. */
1685
+ const LOAD_FAILED_TEXT = "Couldn't load";
1686
+
1819
1687
  // ─── Merged Dashboard Data ────────────────────────────────────────────────────
1820
1688
 
1821
1689
  const finalDashboardData = computed<Record<string, any>>(() => {
1822
1690
  if (!props.serviceType) return props.dashboardData || {};
1823
1691
 
1824
- let weeklyActivity = weeklyActivityReq.value ?? [];
1825
- const hasNoActivity =
1826
- !weeklyActivity.length ||
1827
- weeklyActivity.every(
1828
- (item: any) =>
1829
- !item.workOrder &&
1830
- !item.taskCompleted &&
1831
- !item.openWorkOrder &&
1832
- !item.closedWorkOrder &&
1833
- !item.incidents &&
1834
- !item.visitors
1835
- );
1836
-
1837
- if (hasNoActivity && currentPeriod.value === "thisWeek") {
1838
- weeklyActivity = [
1839
- { day: "Mon", workOrder: 800, taskCompleted: 1400 },
1840
- { day: "Tue", workOrder: 450, taskCompleted: 900 },
1841
- { day: "Wed", workOrder: 950, taskCompleted: 1640 },
1842
- { day: "Thu", workOrder: 2000, taskCompleted: 2100 },
1843
- { day: "Fri", workOrder: 2000, taskCompleted: 2700 },
1844
- { day: "Sat", workOrder: 2200, taskCompleted: 3100 },
1845
- { day: "Sun", workOrder: 2800, taskCompleted: 3200 },
1846
- ];
1847
- }
1848
-
1692
+ /**
1693
+ * A QUIET WEEK IS NOT A BUSY ONE.
1694
+ *
1695
+ * This used to substitute a hard-coded seven-day curve - Mon 800/1400 through
1696
+ * Sun 2800/3200 - whenever the week's activity came back with nothing in it,
1697
+ * and draw it as real, y-axis to 3,200. The backend zero-fills one row per
1698
+ * day and never returns an empty array, so "nothing in it" is exactly what a
1699
+ * genuinely quiet site looks like; `?? []` made it exactly what a FAILED
1700
+ * request looks like too. And it left the screen: Export -> Word wrote those
1701
+ * invented figures into a document a manager can hand to a client.
1702
+ *
1703
+ * Deleted outright. An empty week now falls through to `hasChartData` and
1704
+ * renders the empty state, a failed one renders the error state, and the Word
1705
+ * export's chart table is already gated on the same `hasChartData`.
1706
+ */
1707
+ const weeklyActivity = weeklyActivityReq.value ?? [];
1708
+
1709
+ // `weekly-activity` returns each interval twice over: `openWorkOrder` and
1710
+ // `workOrder` are the same not-yet-completed count, `closedWorkOrder` and
1711
+ // `taskCompleted` the same completed count. Carry BOTH names - the Word export
1712
+ // reads `workOrders`/`taskCompleted`, and the open/closed pair is what tells
1713
+ // the chart legend these are work orders and not checklist tasks.
1714
+ const openSeries = weeklyActivity.map(
1715
+ (item: any) => item.workOrder ?? item.openWorkOrder
1716
+ );
1717
+ const closedSeries = weeklyActivity.map(
1718
+ (item: any) => item.taskCompleted ?? item.closedWorkOrder
1719
+ );
1849
1720
  const activityChart = {
1850
1721
  labels: weeklyActivity.map((item: any) => item.day || item.label),
1851
- workOrders: weeklyActivity.map(
1852
- (item: any) => item.workOrder ?? item.openWorkOrder
1853
- ),
1854
- taskCompleted: weeklyActivity.map(
1855
- (item: any) => item.taskCompleted ?? item.closedWorkOrder
1856
- ),
1722
+ workOrders: openSeries,
1723
+ taskCompleted: closedSeries,
1724
+ openWorkOrder: openSeries,
1725
+ closedWorkOrder: closedSeries,
1857
1726
  };
1858
1727
 
1859
1728
  if (isHygieneMode.value) {
@@ -1931,27 +1800,64 @@ const finalDashboardData = computed<Record<string, any>>(() => {
1931
1800
 
1932
1801
  // ─── Card Value Configs ───────────────────────────────────────────────────────
1933
1802
 
1803
+ /**
1804
+ * THE KPI TILE PALETTE.
1805
+ *
1806
+ * Was eleven hand-picked hexes restated in four places and blind to the theme:
1807
+ * the SAME orange glyph on the SAME pink tint in light and in dark, because a
1808
+ * literal cannot follow a theme. Its worst pairing measured 1.36:1 and the one
1809
+ * that actually shipped measured 1.74:1 - the lowest readings in the package,
1810
+ * on the first screen every account type opens.
1811
+ *
1812
+ * The handoff names no colour for a KPI tile. It DOES name four status tones
1813
+ * and their soft fills, and the hexes that were here were already those four
1814
+ * hues (`#1d5f99` blue, `#b73535` red, `#d28722` amber, `#23824f` green). So
1815
+ * each pair is re-pointed at the token it was imitating rather than given a
1816
+ * new colour. That also makes each tile the design's own chip pairing, which
1817
+ * is the ruling this project already follows.
1818
+ */
1819
+ const KPI_TONES = {
1820
+ info: { color: "var(--info)", tint: "var(--info-bg)" },
1821
+ err: { color: "var(--err)", tint: "var(--err-bg)" },
1822
+ ok: { color: "var(--ok)", tint: "var(--ok-bg)" },
1823
+ warn: { color: "var(--warn)", tint: "var(--warn-bg)" },
1824
+ } as const;
1825
+
1934
1826
  const defaultCardValues: TDashboardCardValue[] = [
1935
1827
  {
1936
1828
  key: "openWorkOrder",
1937
1829
  periodKey: "period",
1938
1830
  title: "Open Work Order",
1939
1831
  icon: "mdi-briefcase",
1940
- color: "#0b2f47",
1832
+ /** The same tile is `#1d5f99` blue in the other two branches - info. */
1833
+ color: KPI_TONES.info.color,
1941
1834
  },
1942
1835
  {
1943
1836
  key: "taskCompleted",
1944
1837
  periodKey: "period",
1945
1838
  title: "Task Completed",
1946
1839
  icon: "mdi-clipboard-check-outline",
1947
- color: "#f4a340",
1840
+ /** `utils/status.ts`: "completed" is `ok`. This was the 1.74:1 orange. */
1841
+ color: KPI_TONES.ok.color,
1948
1842
  },
1949
1843
  {
1950
1844
  key: "supplyAlert",
1951
1845
  periodKey: "period",
1952
- title: "Supply Alert",
1953
- icon: "mdi-alert",
1954
- color: "#d32f2f",
1846
+ /**
1847
+ * IT IS NOT AN ALERT, SO IT NO LONGER SAYS ALERT.
1848
+ *
1849
+ * The server returns the three lowest-quantity active supplies and NO
1850
+ * threshold of any kind (new-dashboard.repo.ts) - so 900 gloves appeared
1851
+ * under a red "SUPPLY ALERT" heading with a warning triangle, next to
1852
+ * items that genuinely were low, and nothing distinguished them. The title,
1853
+ * the icon and the tone now describe what the data is.
1854
+ *
1855
+ * A real low-stock alert needs a per-supply reorder level the server does
1856
+ * not hold. That is backend work and is deliberately NOT invented here.
1857
+ */
1858
+ title: "Lowest Stock",
1859
+ icon: "mdi-package-variant",
1860
+ color: KPI_TONES.info.color,
1955
1861
  },
1956
1862
  ];
1957
1863
 
@@ -1961,28 +1867,28 @@ const propertyCardValues: TDashboardCardValue[] = [
1961
1867
  periodKey: "period",
1962
1868
  title: "Open Work Order",
1963
1869
  icon: "mdi-briefcase",
1964
- color: "#1d5f99",
1870
+ color: KPI_TONES.info.color,
1965
1871
  },
1966
1872
  {
1967
1873
  key: "incidents",
1968
1874
  periodKey: "period",
1969
1875
  title: "Open Incidents",
1970
1876
  icon: "mdi-alert",
1971
- color: "#b73535",
1877
+ color: KPI_TONES.err.color,
1972
1878
  },
1973
1879
  {
1974
1880
  key: "visitors",
1975
1881
  periodKey: "period",
1976
1882
  title: "Visitors",
1977
1883
  icon: "mdi-account-multiple",
1978
- color: "#d28722",
1884
+ color: KPI_TONES.warn.color,
1979
1885
  },
1980
1886
  {
1981
1887
  key: "facilityBookings",
1982
1888
  periodKey: "period",
1983
1889
  title: "Facility Booking",
1984
1890
  icon: "mdi-office-building",
1985
- color: "#23824f",
1891
+ color: KPI_TONES.ok.color,
1986
1892
  },
1987
1893
  ];
1988
1894
 
@@ -1992,28 +1898,28 @@ const securityCardValues: TDashboardCardValue[] = [
1992
1898
  periodKey: "period",
1993
1899
  title: "Open Work Order",
1994
1900
  icon: "mdi-briefcase",
1995
- color: "#1d5f99",
1901
+ color: KPI_TONES.info.color,
1996
1902
  },
1997
1903
  {
1998
1904
  key: "incidents",
1999
1905
  periodKey: "period",
2000
1906
  title: "Open Incidents",
2001
1907
  icon: "mdi-alert",
2002
- color: "#b73535",
1908
+ color: KPI_TONES.err.color,
2003
1909
  },
2004
1910
  {
2005
1911
  key: "visitors",
2006
1912
  periodKey: "period",
2007
1913
  title: "Visitors",
2008
1914
  icon: "mdi-account-multiple",
2009
- color: "#d28722",
1915
+ color: KPI_TONES.warn.color,
2010
1916
  },
2011
1917
  {
2012
1918
  key: "patrolCompliance",
2013
1919
  periodKey: "period",
2014
1920
  title: "Patrol Compliance",
2015
1921
  icon: "mdi-office-building",
2016
- color: "#23824f",
1922
+ color: KPI_TONES.ok.color,
2017
1923
  },
2018
1924
  ];
2019
1925
 
@@ -2026,12 +1932,16 @@ const resolvedCardValues = computed(() => {
2026
1932
 
2027
1933
  // ─── Widget Keys by Mode ──────────────────────────────────────────────────────
2028
1934
 
1935
+ // "recentActivity" was in this list and in the Customize dialog, and NOTHING
1936
+ // renders it - `isWidgetVisible("recentActivity")` appears nowhere in the
1937
+ // template. Five apps offered a switch that did nothing whichever way it was
1938
+ // set. Removed rather than built: there is no Recent Activity panel anywhere in
1939
+ // the estate to connect it to, and inventing one is a new feature, not a fix.
2029
1940
  const defaultExtraWidgetKeys = [
2030
1941
  "activity",
2031
1942
  "taskSchedule",
2032
1943
  "staffStatus",
2033
1944
  "feedbacks",
2034
- "recentActivity",
2035
1945
  ];
2036
1946
 
2037
1947
  const propertyDefaultWidgetKeys = [
@@ -2058,6 +1968,36 @@ const securityDefaultWidgetKeys = [
2058
1968
  "feedbacks",
2059
1969
  ];
2060
1970
 
1971
+ /**
1972
+ * THE CUSTOMISE CHOICE, REMEMBERED.
1973
+ *
1974
+ * Hiding a widget worked and then forgot: nothing was written anywhere, so a
1975
+ * reload brought every panel back and the dialog was decoration.
1976
+ *
1977
+ * A COOKIE, and specifically the layer's own `cookieConfig` - the same
1978
+ * mechanism `sid`, `user`, `landing-page` and the light/dark preference already
1979
+ * travel on (see `composables/useThemePreference.ts` for the full reasoning).
1980
+ * localStorage would be per-origin, and this is one component shipped into
1981
+ * seven applications on seven hostnames. No new storage mechanism, and no
1982
+ * invented endpoint - there is no save API on the server for this.
1983
+ *
1984
+ * One cookie per dashboard MODE, because the three modes have different widget
1985
+ * sets and a security member's choice must not decide what a hygiene member
1986
+ * sees.
1987
+ */
1988
+ const widgetPrefsCookie = useCookie<string[] | null>(
1989
+ `dashboard-widgets-${
1990
+ isPropertyMode.value
1991
+ ? "property"
1992
+ : isSecurityMode.value
1993
+ ? "security"
1994
+ : "service"
1995
+ }`,
1996
+ runtimeConfig.public.cookieConfig as CookieOptions<string[] | null> & {
1997
+ readonly?: false;
1998
+ }
1999
+ );
2000
+
2061
2001
  const defaultVisibleWidgetKeys = computed(() => {
2062
2002
  if (props.extraWidgetKeys && props.extraWidgetKeys.length > 0) {
2063
2003
  return [
@@ -2078,8 +2018,16 @@ watch(
2078
2018
  (keys) => {
2079
2019
  const nextKeys = [...keys];
2080
2020
  if (!visibleWidgetKeys.value.length) {
2081
- visibleWidgetKeys.value = nextKeys;
2082
- draftVisibleWidgetKeys.value = nextKeys;
2021
+ // A remembered choice wins on first paint, filtered against the keys this
2022
+ // mode actually has so a stale cookie cannot resurrect a removed widget.
2023
+ // `Array.isArray` rather than a length check: "the user hid everything"
2024
+ // and "there is no cookie" are different answers.
2025
+ const remembered = Array.isArray(widgetPrefsCookie.value)
2026
+ ? widgetPrefsCookie.value.filter((k) => nextKeys.includes(k))
2027
+ : null;
2028
+ const initial = remembered ?? nextKeys;
2029
+ visibleWidgetKeys.value = initial;
2030
+ draftVisibleWidgetKeys.value = [...initial];
2083
2031
  return;
2084
2032
  }
2085
2033
  visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
@@ -2112,7 +2060,7 @@ const customizeWidgets = computed(() => {
2112
2060
  key: "visitors",
2113
2061
  title: "Visitors",
2114
2062
  description:
2115
- "See today's visitor traffic and who is currently on site.",
2063
+ "See today's visitor traffic, and how many passes are still not checked out.",
2116
2064
  },
2117
2065
  {
2118
2066
  key: "facilityBookings",
@@ -2136,9 +2084,9 @@ const customizeWidgets = computed(() => {
2136
2084
  },
2137
2085
  {
2138
2086
  key: "todayAttentions",
2139
- title: "Today's Attentions",
2087
+ title: "Needs Attention",
2140
2088
  description:
2141
- "Items that require review overdue, pending, or unacknowledged.",
2089
+ "Everything still pending or unacknowledged, not only today's.",
2142
2090
  },
2143
2091
  {
2144
2092
  key: "workOrderStatus",
@@ -2169,7 +2117,7 @@ const customizeWidgets = computed(() => {
2169
2117
  key: "visitors",
2170
2118
  title: "Visitors",
2171
2119
  description:
2172
- "See today's visitor traffic and who is currently on site.",
2120
+ "See today's visitor traffic, and how many passes are still not checked out.",
2173
2121
  },
2174
2122
  {
2175
2123
  key: "patrolCompliance",
@@ -2226,11 +2174,6 @@ const customizeWidgets = computed(() => {
2226
2174
  title: "Feedbacks",
2227
2175
  description: "Show recent feedback and resolution state.",
2228
2176
  },
2229
- {
2230
- key: "recentActivity",
2231
- title: "Recent Activity",
2232
- description: "Latest updates across the service.",
2233
- },
2234
2177
  ];
2235
2178
  });
2236
2179
 
@@ -2342,10 +2285,10 @@ function getPropertyKpiCards() {
2342
2285
  key: "workOrders",
2343
2286
  title: "Open Work Order",
2344
2287
  icon: "mdi-briefcase",
2345
- color: "#1d5f99",
2346
- tint: "#d7ecff",
2288
+ color: KPI_TONES.info.color,
2289
+ tint: KPI_TONES.info.tint,
2347
2290
  value: formatNumber(toNumber(workOrderMetric?.count)),
2348
- percentage: toNumber(workOrderMetric?.percentage),
2291
+ percentage: toNumberOrNaN(workOrderMetric?.percentage),
2349
2292
  meta: getMetricMeta(workOrderMetric),
2350
2293
  raw: workOrderMetric,
2351
2294
  filter: undefined,
@@ -2354,10 +2297,10 @@ function getPropertyKpiCards() {
2354
2297
  key: "incidents",
2355
2298
  title: "Open Incidents",
2356
2299
  icon: "mdi-alert",
2357
- color: "#b73535",
2358
- tint: "#ffdede",
2300
+ color: KPI_TONES.err.color,
2301
+ tint: KPI_TONES.err.tint,
2359
2302
  value: formatNumber(toNumber(incidentMetric?.count)),
2360
- percentage: toNumber(incidentMetric?.percentage),
2303
+ percentage: toNumberOrNaN(incidentMetric?.percentage),
2361
2304
  meta: getMetricMeta(incidentMetric),
2362
2305
  raw: incidentMetric,
2363
2306
  filter: undefined,
@@ -2366,10 +2309,10 @@ function getPropertyKpiCards() {
2366
2309
  key: "visitors",
2367
2310
  title: "Visitors",
2368
2311
  icon: "mdi-account-multiple",
2369
- color: "#d28722",
2370
- tint: "#ffe4bd",
2312
+ color: KPI_TONES.warn.color,
2313
+ tint: KPI_TONES.warn.tint,
2371
2314
  value: formatNumber(toNumber(visitorMetric?.count)),
2372
- percentage: toNumber(visitorMetric?.percentage),
2315
+ percentage: toNumberOrNaN(visitorMetric?.percentage),
2373
2316
  meta: getMetricMeta(visitorMetric),
2374
2317
  raw: visitorMetric,
2375
2318
  filter: {
@@ -2385,10 +2328,10 @@ function getPropertyKpiCards() {
2385
2328
  key: "facilityBookings",
2386
2329
  title: "Facility Booking",
2387
2330
  icon: "mdi-office-building",
2388
- color: "#23824f",
2389
- tint: "#c8f3da",
2331
+ color: KPI_TONES.ok.color,
2332
+ tint: KPI_TONES.ok.tint,
2390
2333
  value: formatNumber(toNumber(facilityMetric?.count)),
2391
- percentage: toNumber(facilityMetric?.percentage),
2334
+ percentage: toNumberOrNaN(facilityMetric?.percentage),
2392
2335
  meta: getMetricMeta(facilityMetric),
2393
2336
  raw: facilityMetric,
2394
2337
  filter: undefined,
@@ -2420,10 +2363,10 @@ function getSecurityKpiCards() {
2420
2363
  key: "workOrders",
2421
2364
  title: "Open Work Order",
2422
2365
  icon: "mdi-briefcase",
2423
- color: "#1d5f99",
2424
- tint: "#d7ecff",
2366
+ color: KPI_TONES.info.color,
2367
+ tint: KPI_TONES.info.tint,
2425
2368
  value: formatNumber(toNumber(workOrderMetric?.count)),
2426
- percentage: toNumber(workOrderMetric?.percentage),
2369
+ percentage: toNumberOrNaN(workOrderMetric?.percentage),
2427
2370
  meta: getMetricMeta(workOrderMetric),
2428
2371
  raw: workOrderMetric,
2429
2372
  filter: undefined,
@@ -2432,10 +2375,10 @@ function getSecurityKpiCards() {
2432
2375
  key: "incidents",
2433
2376
  title: "Open Incidents",
2434
2377
  icon: "mdi-alert",
2435
- color: "#b73535",
2436
- tint: "#ffdede",
2378
+ color: KPI_TONES.err.color,
2379
+ tint: KPI_TONES.err.tint,
2437
2380
  value: formatNumber(toNumber(incidentMetric?.count)),
2438
- percentage: toNumber(incidentMetric?.percentage),
2381
+ percentage: toNumberOrNaN(incidentMetric?.percentage),
2439
2382
  meta: getMetricMeta(incidentMetric),
2440
2383
  raw: incidentMetric,
2441
2384
  filter: undefined,
@@ -2444,10 +2387,10 @@ function getSecurityKpiCards() {
2444
2387
  key: "visitors",
2445
2388
  title: "Visitors",
2446
2389
  icon: "mdi-account-multiple",
2447
- color: "#d28722",
2448
- tint: "#ffe4bd",
2390
+ color: KPI_TONES.warn.color,
2391
+ tint: KPI_TONES.warn.tint,
2449
2392
  value: formatNumber(toNumber(visitorMetric?.count)),
2450
- percentage: toNumber(visitorMetric?.percentage),
2393
+ percentage: toNumberOrNaN(visitorMetric?.percentage),
2451
2394
  meta: getMetricMeta(visitorMetric),
2452
2395
  raw: visitorMetric,
2453
2396
  filter: {
@@ -2463,10 +2406,12 @@ function getSecurityKpiCards() {
2463
2406
  key: "patrolCompliance",
2464
2407
  title: "Patrol Compliance",
2465
2408
  icon: "mdi-office-building",
2466
- color: "#23824f",
2467
- tint: "#c8f3da",
2468
- value: `${toNumber(patrolMetric?.count)}%`,
2469
- percentage: toNumber(patrolMetric?.percentage),
2409
+ color: KPI_TONES.ok.color,
2410
+ tint: KPI_TONES.ok.tint,
2411
+ // Through the shared helper, so a `null` rate (no checkpoint was due)
2412
+ // reads as not-measured here too, not as 0%.
2413
+ value: getMetricValue(patrolMetric, "patrolCompliance"),
2414
+ percentage: toNumberOrNaN(patrolMetric?.percentage),
2470
2415
  meta: getMetricMeta(patrolMetric),
2471
2416
  raw: patrolMetric,
2472
2417
  filter: undefined,
@@ -2474,7 +2419,7 @@ function getSecurityKpiCards() {
2474
2419
  ];
2475
2420
  }
2476
2421
 
2477
- const kpiCards = computed(() => {
2422
+ const rawKpiCards = computed(() => {
2478
2423
  if (isPropertyMode.value) return getPropertyKpiCards();
2479
2424
  if (isSecurityMode.value) return getSecurityKpiCards();
2480
2425
 
@@ -2493,7 +2438,7 @@ const kpiCards = computed(() => {
2493
2438
  tint: getCardTint(mapping.color, index),
2494
2439
  color: mapping.color || getCardColor(index),
2495
2440
  value: typeof value === "string" ? value : formatNumber(value),
2496
- percentage: toNumber(metric.percentage),
2441
+ percentage: toNumberOrNaN(metric.percentage),
2497
2442
  meta: getMetricMeta(metric),
2498
2443
  raw: metric,
2499
2444
  filter: undefined as any,
@@ -2501,10 +2446,37 @@ const kpiCards = computed(() => {
2501
2446
  });
2502
2447
  });
2503
2448
 
2449
+ /**
2450
+ * A tile whose request failed shows an em dash and says so, never a zero.
2451
+ * `percentage: NaN` is what suppresses the pill - `kpiChipText()` returns ""
2452
+ * for anything non-finite - and `raw: {}` empties the supply list with it.
2453
+ */
2454
+ const kpiCards = computed(() => {
2455
+ if (!metricsFailed.value) return rawKpiCards.value;
2456
+ return rawKpiCards.value.map((card) => ({
2457
+ ...card,
2458
+ value: "—",
2459
+ percentage: NaN,
2460
+ meta: LOAD_FAILED_TEXT,
2461
+ raw: {} as any,
2462
+ }));
2463
+ });
2464
+
2504
2465
  const visibleKpiCards = computed(() =>
2505
2466
  kpiCards.value.filter((card) => isWidgetVisible(card.key))
2506
2467
  );
2507
2468
 
2469
+ /**
2470
+ * The percentage pill's label. Same number the chip has always shown, written
2471
+ * the way the handoff prints it - `+0%` / `-6.4%`, the sign carrying what the
2472
+ * arrow icon used to. A card without a percentage gets no pill.
2473
+ */
2474
+ function kpiChipText(card: Record<string, any>): string {
2475
+ const value = Number(card?.percentage);
2476
+ if (!Number.isFinite(value)) return "";
2477
+ return `${value < 0 ? "-" : "+"}${Math.abs(value)}%`;
2478
+ }
2479
+
2508
2480
  // ─── Dashboard Title ──────────────────────────────────────────────────────────
2509
2481
 
2510
2482
  const dashboardTitle = computed(() => {
@@ -2515,6 +2487,12 @@ const dashboardTitle = computed(() => {
2515
2487
  return `${getServiceDisplayName(props.serviceType || appName)} - Dashboard`;
2516
2488
  });
2517
2489
 
2490
+ const taskScheduleTitle = computed(() => {
2491
+ if (selectedRange.value === "This Month") return "This Month's Task Schedule";
2492
+ if (selectedRange.value === "This Week") return "This Week's Task Schedule";
2493
+ return "Today's Task Schedule";
2494
+ });
2495
+
2518
2496
  // ─── Primary list / staff config ─────────────────────────────────────────────
2519
2497
 
2520
2498
  const primaryListConfig = computed(() =>
@@ -2525,7 +2503,11 @@ const primaryListConfig = computed(() =>
2525
2503
  keys: ["activePatrolItems", "patrolItems"],
2526
2504
  }
2527
2505
  : {
2528
- title: "Today's Task Schedule",
2506
+ // The endpoint behind this panel takes the selected period
2507
+ // (`getServiceTodayTaskSchedule` -> `getDateRange(period)`), so on the
2508
+ // Week or Month range the list was a week's or a month's work under a
2509
+ // heading that said "Today's".
2510
+ title: taskScheduleTitle.value,
2529
2511
  route: "scheduleTask",
2530
2512
  keys: [
2531
2513
  "taskScheduleItems",
@@ -2576,8 +2558,12 @@ const primarySeries = computed(() =>
2576
2558
  const secondarySeries = computed(() =>
2577
2559
  normalizeSeries(activityChart.value.secondary, chartLabels.value.length)
2578
2560
  );
2579
- const primaryLegendLabel = computed(() => "Closed Work Order");
2580
- const secondaryLegendLabel = computed(() => "Open Work Order");
2561
+ // The legend must name the series it is drawn beside. These were hard-coded, and
2562
+ // hard-coded the wrong way round: the primary line is the OPEN work orders and
2563
+ // was labelled "Closed Work Order". `normalizeActivityChart()` already works out
2564
+ // what each series is from the payload it found - read that instead of guessing.
2565
+ const primaryLegendLabel = computed(() => activityChart.value.primaryLabel);
2566
+ const secondaryLegendLabel = computed(() => activityChart.value.secondaryLabel);
2581
2567
  const chartMax = computed(() =>
2582
2568
  Math.max(1, ...primarySeries.value, ...secondarySeries.value)
2583
2569
  );
@@ -2685,42 +2671,38 @@ const todayAttentions = computed(() =>
2685
2671
  normalizeListItems(["todayAttentions", "attentions"])
2686
2672
  );
2687
2673
 
2688
- const workOrderStatus = computed(() => {
2689
- const summary = finalDashboardData.value?.workOrderStatusSummary;
2690
- const metric = getMetric(["openWorkOrder", "workOrders", "workOrder"]);
2691
-
2692
- const src = summary || metric;
2693
- if (!src) return [];
2694
-
2695
- const completed = Number(src.completed ?? 0);
2696
- const inProgress = Number(src.inProgress ?? 0);
2697
- const pending =
2698
- src.pending !== undefined
2699
- ? Number(src.pending)
2700
- : Math.max(0, Number(src.count ?? 0) - inProgress);
2701
- const max = Math.max(completed, inProgress, pending, 1);
2702
-
2703
- return [
2704
- {
2705
- label: "Completed",
2706
- value: completed,
2707
- percent: (completed / max) * 100,
2708
- color: "#2fb84d",
2709
- },
2710
- {
2711
- label: "In Progress",
2712
- value: inProgress,
2713
- percent: (inProgress / max) * 100,
2714
- color: "#f12d2d",
2715
- },
2716
- {
2717
- label: "Pending",
2718
- value: pending,
2719
- percent: (pending / max) * 100,
2720
- color: "#ff8900",
2721
- },
2722
- ];
2723
- });
2674
+ /**
2675
+ * THE WORK ORDER STATUS BARS.
2676
+ *
2677
+ * Was three hand-picked hexes - `#2fb84d` green, `#f12d2d` red, `#ff8900`
2678
+ * orange - and blind to the theme: the SAME three bars in light and in dark,
2679
+ * because a literal cannot follow a theme. Each is re-pointed at the token
2680
+ * whose hue it was already imitating, which is the same treatment the KPI
2681
+ * tiles got, and which keeps all three bars distinguishable.
2682
+ *
2683
+ * ONE OF THEM DOES NOT AGREE WITH `utils/status.ts`, ON PURPOSE.
2684
+ * That file maps `completed -> ok` and `pending -> warn` (both honoured here),
2685
+ * but it also maps `in progress -> warn` - the SAME tone as `pending`. Applying
2686
+ * it literally would paint two of these three bars the same amber and make the
2687
+ * chart unreadable, which is a regression, not a fix. So "In Progress" keeps
2688
+ * the tone it is drawn in today (`--err`, the token `#f12d2d` was imitating)
2689
+ * and this stays a colour change with no behaviour change.
2690
+ *
2691
+ * That leaves a real inconsistency to settle: on this very page the "In
2692
+ * Progress" feedback CHIP is amber (`warn`) while this bar is red. Resolving it
2693
+ * means either giving `in progress` its own tone in `utils/status.ts` - which
2694
+ * is shared by every chip in the estate - or ruling that a chart series is not
2695
+ * a status chip. Both are design decisions, so neither is taken here.
2696
+ */
2697
+ const workOrderStatus = computed(() =>
2698
+ // The arithmetic and the "no breakdown, no bars" rule live in
2699
+ // `utils/dashboard.ts` so they have a test; the tone -> token mapping stays
2700
+ // here because it is this template's colour question, not the server's.
2701
+ buildWorkOrderStatus(finalDashboardData.value?.workOrderStatus).map((row) => ({
2702
+ ...row,
2703
+ color: `var(--${row.tone})`,
2704
+ }))
2705
+ );
2724
2706
 
2725
2707
  // Security-mode lists
2726
2708
  const activePatrolItems = computed<any[]>(
@@ -2808,7 +2790,7 @@ const printTableSections = computed<PrintTableSection[]>(() => {
2808
2790
  },
2809
2791
  {
2810
2792
  key: "todayAttentions",
2811
- title: "Today's Attentions",
2793
+ title: "Needs Attention",
2812
2794
  columns: [
2813
2795
  { key: "title", label: "Attention", width: "40%", strong: true },
2814
2796
  { key: "subtitle", label: "Details / Subtitle", width: "60%" },
@@ -2913,7 +2895,21 @@ onUnmounted(() => {
2913
2895
  watch(selectedRange, (period) => {
2914
2896
  if (props.serviceType) {
2915
2897
  const apiPeriod = periodValue[period];
2916
- if (apiPeriod) currentPeriod.value = apiPeriod;
2898
+ if (apiPeriod) {
2899
+ currentPeriod.value = apiPeriod;
2900
+ /**
2901
+ * The component already READ `?period=` on mount and never wrote it
2902
+ * back, so a refresh - or a link sent to a colleague - silently returned
2903
+ * to Today while the button still said This Month. Writing it closes
2904
+ * both. `replace`, not `push`: changing the range is not a navigation
2905
+ * step the Back button should have to walk through.
2906
+ *
2907
+ * What each range MEANS is untouched here.
2908
+ */
2909
+ if (route.query.period !== apiPeriod) {
2910
+ router.replace({ query: { ...route.query, period: apiPeriod } });
2911
+ }
2912
+ }
2917
2913
  }
2918
2914
  emit("updatePeriod", { key: "__all__", period });
2919
2915
  });
@@ -2933,7 +2929,9 @@ function getMetric(keys: string[]): DashboardMetric {
2933
2929
 
2934
2930
  function getMetricValue(metric: DashboardMetric, key?: string) {
2935
2931
  if (key === "patrolCompliance") {
2936
- return `${formatNumber(toNumber(metric.count))}%`;
2932
+ const rate = toNumberOrNaN(metric.count);
2933
+ // `null` = no checkpoint was due in this period. Not 0%, not 100%.
2934
+ return Number.isFinite(rate) ? `${formatNumber(rate)}%` : "—";
2937
2935
  }
2938
2936
  const completed = toNumber(metric.completed);
2939
2937
  const total = toNumber(metric.total);
@@ -2951,11 +2949,18 @@ function getMetricMeta(metric: DashboardMetric) {
2951
2949
  parts.push(`${formatNumber(toNumber(metric.inProgress))} in progress`);
2952
2950
  if (typeof metric.pending !== "undefined")
2953
2951
  parts.push(`${formatNumber(toNumber(metric.pending))} pending`);
2954
- if (typeof metric.highSeverity !== "undefined")
2952
+ // `null` here is the API saying incident severity is not recorded at all.
2953
+ // Printing "0 high severity" would answer a question nothing measured.
2954
+ if (metric.highSeverity !== undefined && metric.highSeverity !== null)
2955
2955
  parts.push(`${formatNumber(toNumber(metric.highSeverity))} high severity`);
2956
- if (typeof metric.currentlyOnSite !== "undefined")
2956
+ // NOT a live occupancy figure. The server counts every visitor transaction
2957
+ // with a check-in and no check-out, with NO date bound, and nothing in the
2958
+ // product ever closes a stale one - so a pass left open years ago is still
2959
+ // in here. Say what it counts. Same wording the Visitor Management screen
2960
+ // already uses for the same filter ("Not Checked Out").
2961
+ if (metric.currentlyOnSite !== undefined && metric.currentlyOnSite !== null)
2957
2962
  parts.push(
2958
- `${formatNumber(toNumber(metric.currentlyOnSite))} currently on site`
2963
+ `${formatNumber(toNumber(metric.currentlyOnSite))} not checked out (all time)`
2959
2964
  );
2960
2965
  if (
2961
2966
  typeof (metric as any).missedCheckpoints !== "undefined" ||
@@ -3251,34 +3256,18 @@ function getInitials(name?: string) {
3251
3256
 
3252
3257
  // ─── Status Style Helpers ────────────────────────────────────────────────────
3253
3258
 
3254
- function getStatusPillStyle(status: any) {
3255
- const tone = getStatusTone(status);
3256
- const map: Record<string, { backgroundColor: string; color: string }> = {
3257
- success: { backgroundColor: "#72e3a1", color: "#0f5132" },
3258
- warning: { backgroundColor: "#fde68a", color: "#92400e" },
3259
- error: { backgroundColor: "#fecaca", color: "#b91c1c" },
3260
- neutral: { backgroundColor: "#e2e8f0", color: "#64748b" },
3261
- };
3262
- return map[tone] || map.neutral;
3263
- }
3264
-
3259
+ /**
3260
+ * The pills are drawn by inline style, so the literal colours lived here rather
3261
+ * than in the stylesheet - which is why they survived the dark-mode repair and
3262
+ * still measured 3.86:1. Same three cases, same words, the design's tones.
3263
+ */
3265
3264
  function getPatrolStatusStyle(status: any) {
3266
3265
  const s = String(status || "").toLowerCase();
3267
3266
  if (s === "completed" || s === "complete")
3268
- return { backgroundColor: "#72e3a1", color: "#0f5132" };
3267
+ return { backgroundColor: "var(--ok-bg)", color: "var(--ok)" };
3269
3268
  if (s === "on paused" || s === "paused")
3270
- return { backgroundColor: "#dcdcdc", color: "#64748b" };
3271
- return { backgroundColor: "#e2e8f0", color: "#64748b" };
3272
- }
3273
-
3274
- function getStaffStatusPillStyle(status: any) {
3275
- const s = String(status || "").toLowerCase();
3276
- if (s === "on duty" || s === "on-duty")
3277
- return { backgroundColor: "#72e3a1", color: "#0f5132" };
3278
- if (s === "on break" || s === "on-break")
3279
- return { backgroundColor: "#fde68a", color: "#92400e" };
3280
- if (s === "absent") return { backgroundColor: "#fecaca", color: "#b91c1c" };
3281
- return { backgroundColor: "#e2e8f0", color: "#64748b" };
3269
+ return { backgroundColor: "var(--hover)", color: "var(--text2)" };
3270
+ return { backgroundColor: "var(--hover)", color: "var(--text2)" };
3282
3271
  }
3283
3272
 
3284
3273
  function getStatusProgressStyle(item: { percent: number; color: string }) {
@@ -3508,6 +3497,9 @@ function resetCustomizeDraft() {
3508
3497
 
3509
3498
  function submitCustomize() {
3510
3499
  visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
3500
+ // The write is the whole point - see `widgetPrefsCookie`. Saving on Submit
3501
+ // rather than on every switch keeps Cancel meaning cancel.
3502
+ widgetPrefsCookie.value = [...draftVisibleWidgetKeys.value];
3511
3503
  widgetSearch.value = "";
3512
3504
  customizeDialog.value = false;
3513
3505
  }
@@ -3607,6 +3599,13 @@ async function exportDashboard(format: "PDF" | "Word") {
3607
3599
  <body>
3608
3600
  <h1>${props.title || dashboardTitle.value}</h1>
3609
3601
  <div class="subtitle">Site: ${siteName} | Period: ${periodLabel}</div>
3602
+ ${
3603
+ failedWidgetLabels.value.length
3604
+ ? `<div class="subtitle" style="color:#b73535;">Incomplete report: ${failedWidgetLabels.value.join(
3605
+ ", "
3606
+ )} could not be loaded. Any figure shown as — is missing, not zero.</div>`
3607
+ : ""
3608
+ }
3610
3609
  `;
3611
3610
 
3612
3611
  if (visibleKpiCards.value && visibleKpiCards.value.length > 0) {
@@ -3619,6 +3618,9 @@ async function exportDashboard(format: "PDF" | "Word") {
3619
3618
  let kpiValueHtml = `<div class="kpi-value">${card.value}</div>`;
3620
3619
  if (card.key === "supplyAlert" && Array.isArray(card.raw?.items)) {
3621
3620
  kpiValueHtml = '<div style="font-size: 9pt; margin-top: 4px;">';
3621
+ if (!card.raw.items.length) {
3622
+ kpiValueHtml += "<div>No supplies recorded</div>";
3623
+ }
3622
3624
  card.raw.items.forEach((item: any) => {
3623
3625
  kpiValueHtml += `<div><strong>${item.name}</strong>: Balance ${item.qty}</div>`;
3624
3626
  });
@@ -3735,7 +3737,7 @@ async function exportDashboard(format: "PDF" | "Word") {
3735
3737
  }
3736
3738
  wordHtml += `</tbody></table>`;
3737
3739
 
3738
- wordHtml += `<div class="section-title">Today's Attentions</div>`;
3740
+ wordHtml += `<div class="section-title">Needs Attention</div>`;
3739
3741
  wordHtml += `<table class="data-table">`;
3740
3742
  wordHtml += `<thead><tr><th>Attention</th><th>Details / Subtitle</th></tr></thead><tbody>`;
3741
3743
  if (todayAttentions.value.length > 0) {
@@ -3860,18 +3862,33 @@ async function exportDashboard(format: "PDF" | "Word") {
3860
3862
 
3861
3863
  // ─── Card Color Helpers ───────────────────────────────────────────────────────
3862
3864
 
3865
+ /** The rotation an unstyled `cardValues` entry falls into - same four tones. */
3866
+ const KPI_ROTATION = [
3867
+ KPI_TONES.info,
3868
+ KPI_TONES.err,
3869
+ KPI_TONES.ok,
3870
+ KPI_TONES.warn,
3871
+ ] as const;
3872
+
3863
3873
  function getCardColor(index: number) {
3864
- return ["#155178", "#9f2f29", "#1f8a56", "#b96d17"][index % 4];
3874
+ return KPI_ROTATION[index % KPI_ROTATION.length].color;
3865
3875
  }
3866
3876
 
3877
+ /**
3878
+ * A tint for whatever colour the caller gave. `props.cardValues` comes from
3879
+ * eleven applications and still arrives as a raw hex in places, so the hex
3880
+ * matching stays - the token names are matched alongside it, not instead.
3881
+ */
3867
3882
  function getCardTint(color: string, index: number) {
3868
- const tints = ["#dbeeff", "#f8c6c3", "#d8f5e5", "#ffe7c2"];
3869
- if (!color) return tints[index % tints.length];
3883
+ if (!color) return KPI_ROTATION[index % KPI_ROTATION.length].tint;
3870
3884
  const n = color.toLowerCase();
3871
- if (n.includes("f44336") || n.includes("d32f2f")) return "#f8c6c3";
3872
- if (n.includes("4caf50") || n.includes("43a047")) return "#d8f5e5";
3873
- if (n.includes("ff9800") || n.includes("ffa726")) return "#ffe7c2";
3874
- return "#dbeeff";
3885
+ if (n.includes("--err") || n.includes("f44336") || n.includes("d32f2f"))
3886
+ return KPI_TONES.err.tint;
3887
+ if (n.includes("--ok") || n.includes("4caf50") || n.includes("43a047"))
3888
+ return KPI_TONES.ok.tint;
3889
+ if (n.includes("--warn") || n.includes("ff9800") || n.includes("ffa726"))
3890
+ return KPI_TONES.warn.tint;
3891
+ return KPI_TONES.info.tint;
3875
3892
  }
3876
3893
 
3877
3894
  // ─── Generic Helpers ─────────────────────────────────────────────────────────
@@ -3909,6 +3926,21 @@ function toNumber(value: any) {
3909
3926
  return Number.isFinite(n) ? n : 0;
3910
3927
  }
3911
3928
 
3929
+ /**
3930
+ * A figure the server may report as "not measured".
3931
+ *
3932
+ * `toNumber()` turns `null` into `0`, which is wrong for anything the API
3933
+ * deliberately sends as `null` - a trend with no prior period to compare
3934
+ * against, a compliance rate with no checkpoint due, a severity the incident
3935
+ * form never records. Those must read as absent, not as zero. `NaN` is what
3936
+ * the tile already treats as "no pill" / "no figure".
3937
+ */
3938
+ function toNumberOrNaN(value: any) {
3939
+ if (value === null || value === undefined || value === "") return NaN;
3940
+ const n = Number(value);
3941
+ return Number.isFinite(n) ? n : NaN;
3942
+ }
3943
+
3912
3944
  function normalizeStatus(value: any) {
3913
3945
  const text = String(value || "").trim();
3914
3946
  if (!text) return "To-Do";
@@ -4158,6 +4190,29 @@ function formatDashboardTime(value?: string) {
4158
4190
  text-transform: capitalize;
4159
4191
  }
4160
4192
 
4193
+ /* THESE FIVE ARE LITERAL ON PURPOSE - THE PRINT VIEW IS PAPER.
4194
+ *
4195
+ * `.print-dashboard-view` above pins its surface to `#ffffff` and its ink to
4196
+ * `#1e293b` in BOTH themes, because a printed page is white however the app is
4197
+ * themed. Putting the status tokens on these badges therefore did the opposite
4198
+ * of what tokenising usually does: it let the SCREEN theme reach a surface that
4199
+ * does not follow it. Measured on the print branch at 1440:
4200
+ *
4201
+ * light In Progress rgb(59,111,212) on rgb(235,241,251) 4.20:1
4202
+ * light Completed rgb(15,138,98) on rgb(231,243,239) 3.82:1
4203
+ * dark In Progress rgb(125,166,236) on rgb(239,244,253) 2.23:1
4204
+ * dark Completed rgb(65,199,149) on rgb(232,248,242) 1.93:1
4205
+ *
4206
+ * The dark rows are the defect: the dark-theme INK composited over the white
4207
+ * paper the print view forces, so anyone who exports the dashboard while the
4208
+ * app is in dark mode gets washed-out badges - 1.93:1, the lowest reading taken
4209
+ * on this component. So these go back to fixed pastels, and specifically to the
4210
+ * SAME five pairs the Word export writes into its own stylesheet further up, so
4211
+ * the on-page print view and the exported document finally match.
4212
+ *
4213
+ * Same rule as the signature pad: a surface that does not follow the theme must
4214
+ * not be painted with tokens that do.
4215
+ */
4161
4216
  .badge-success {
4162
4217
  background-color: #d1fae5;
4163
4218
  color: #065f46;
@@ -4184,11 +4239,70 @@ function formatDashboardTime(value?: string) {
4184
4239
  color: #475569;
4185
4240
  }
4186
4241
 
4242
+ /* ------------------------------------------------------------------ */
4243
+ /* PHASE 5 - the design's card, type and semantic tones. */
4244
+ /* */
4245
+ /* The dark-mode repair earlier today moved these surfaces off literal */
4246
+ /* white onto the Vuetify theme; this keeps that and takes the last */
4247
+ /* step, onto the design tokens themselves - so the dashboard's cards, */
4248
+ /* headings and pills are the same 14px card, the same Manrope and the */
4249
+ /* same four status tones as every other screen. */
4250
+ /* ------------------------------------------------------------------ */
4251
+
4187
4252
  .dashboard-card {
4188
- background: #fff;
4189
- border: 1px solid #d9e0e7;
4190
- border-radius: 8px;
4191
- box-shadow: 0 1px 2px rgba(15, 35, 54, 0.04);
4253
+ background: var(--card);
4254
+ border: 1px solid var(--border);
4255
+ border-radius: var(--r-card);
4256
+ box-shadow: var(--shadow);
4257
+ font-family: var(--font-sans);
4258
+ }
4259
+
4260
+ /* The KPI tile and the rows inside a panel are the same card, one step in. */
4261
+ .dashboard-kpi-card,
4262
+ .dashboard-row-card {
4263
+ background: var(--card);
4264
+ border: 1px solid var(--border);
4265
+ border-radius: var(--r-card);
4266
+ box-shadow: var(--shadow);
4267
+ font-family: var(--font-sans);
4268
+ }
4269
+
4270
+ .dashboard-title {
4271
+ font-family: var(--font-sans);
4272
+ font-size: var(--fs-h1);
4273
+ font-weight: var(--fw-h1);
4274
+ letter-spacing: var(--ls-h1);
4275
+ color: var(--text);
4276
+ }
4277
+
4278
+ .dashboard-card-title {
4279
+ font-family: var(--font-sans);
4280
+ font-size: var(--fs-card-title);
4281
+ font-weight: var(--fw-card-title);
4282
+ letter-spacing: 0;
4283
+ color: var(--text);
4284
+ }
4285
+
4286
+ /* 11px/800 uppercase over a 30px/800 number, tabular so the columns of
4287
+ figures line up - the design's KPI, and the same header type the table
4288
+ card already uses. */
4289
+ .dashboard-kpi-label {
4290
+ font-family: var(--font-sans);
4291
+ font-size: var(--fs-table-head);
4292
+ font-weight: var(--fw-table-head);
4293
+ letter-spacing: var(--ls-table-head);
4294
+ text-transform: uppercase;
4295
+ color: var(--muted);
4296
+ }
4297
+
4298
+ .dashboard-kpi-value {
4299
+ font-family: var(--font-sans);
4300
+ font-size: var(--fs-kpi);
4301
+ font-weight: var(--fw-kpi);
4302
+ letter-spacing: var(--ls-kpi);
4303
+ line-height: 1.1;
4304
+ color: var(--text);
4305
+ font-variant-numeric: tabular-nums;
4192
4306
  }
4193
4307
 
4194
4308
  .dashboard-section-grid {
@@ -4209,7 +4323,7 @@ function formatDashboardTime(value?: string) {
4209
4323
  }
4210
4324
 
4211
4325
  .figma-panel {
4212
- border-color: #dde4ea;
4326
+ border-color: rgba(var(--v-border-color), var(--v-border-opacity));
4213
4327
  box-shadow: none;
4214
4328
  min-height: 276px;
4215
4329
  padding: 26px 28px 24px;
@@ -4223,14 +4337,17 @@ function formatDashboardTime(value?: string) {
4223
4337
  min-height: 18px;
4224
4338
  }
4225
4339
 
4340
+ /* The panel heading is the design's card title: 15.5px/800, sentence case. It
4341
+ was a 12px/900 uppercase label from the earlier Figma. */
4226
4342
  .figma-panel-header h2 {
4227
- color: #18324a;
4228
- font-size: 12px;
4229
- font-weight: 900;
4343
+ color: var(--text);
4344
+ font-family: var(--font-sans);
4345
+ font-size: var(--fs-card-title);
4346
+ font-weight: var(--fw-card-title);
4230
4347
  letter-spacing: 0;
4231
4348
  line-height: 1.2;
4232
4349
  margin: 0;
4233
- text-transform: uppercase;
4350
+ text-transform: none;
4234
4351
  }
4235
4352
 
4236
4353
  .figma-panel-action,
@@ -4238,7 +4355,7 @@ function formatDashboardTime(value?: string) {
4238
4355
  appearance: none;
4239
4356
  background: transparent;
4240
4357
  border: 0;
4241
- color: #1976d2;
4358
+ color: rgb(var(--v-theme-primary));
4242
4359
  cursor: pointer;
4243
4360
  font-size: 12px;
4244
4361
  font-weight: 800;
@@ -4248,7 +4365,7 @@ function formatDashboardTime(value?: string) {
4248
4365
  }
4249
4366
 
4250
4367
  .facility-booking-modal {
4251
- border: 1px solid #d9e0e7;
4368
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
4252
4369
  box-shadow: 0 18px 48px rgba(15, 35, 54, 0.18);
4253
4370
  }
4254
4371
 
@@ -4269,7 +4386,7 @@ function formatDashboardTime(value?: string) {
4269
4386
  }
4270
4387
 
4271
4388
  .facility-booking-summary-label {
4272
- color: #1565c0;
4389
+ color: rgb(var(--v-theme-primary));
4273
4390
  font-size: 16px;
4274
4391
  font-weight: 500;
4275
4392
  line-height: 1.25;
@@ -4289,7 +4406,7 @@ function formatDashboardTime(value?: string) {
4289
4406
  }
4290
4407
 
4291
4408
  .facility-booking-summary-dots {
4292
- border-bottom: 2px dotted #9aa3ad;
4409
+ border-bottom: 2px dotted rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
4293
4410
  display: block;
4294
4411
  height: 1px;
4295
4412
  min-width: 32px;
@@ -4297,7 +4414,7 @@ function formatDashboardTime(value?: string) {
4297
4414
 
4298
4415
  .facility-booking-summary-value,
4299
4416
  .facility-booking-total-label {
4300
- color: #0b2f47;
4417
+ color: rgb(var(--v-theme-on-surface));
4301
4418
  font-size: 16px;
4302
4419
  font-weight: 800;
4303
4420
  line-height: 1.25;
@@ -4320,8 +4437,8 @@ function formatDashboardTime(value?: string) {
4320
4437
 
4321
4438
  .figma-list-row {
4322
4439
  align-items: center;
4323
- background: #fff;
4324
- border: 1px solid #e1e7ed;
4440
+ background: rgb(var(--v-theme-surface-bright));
4441
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
4325
4442
  border-radius: 6px;
4326
4443
  display: grid;
4327
4444
  gap: 10px;
@@ -4332,7 +4449,7 @@ function formatDashboardTime(value?: string) {
4332
4449
  }
4333
4450
 
4334
4451
  .figma-list-row:hover {
4335
- border-color: #d5e0ea;
4452
+ border-color: rgba(var(--v-border-color), var(--v-border-opacity));
4336
4453
  box-shadow: 0 6px 14px rgba(15, 44, 69, 0.05);
4337
4454
  }
4338
4455
 
@@ -4341,7 +4458,7 @@ function formatDashboardTime(value?: string) {
4341
4458
  }
4342
4459
 
4343
4460
  .figma-list-main strong {
4344
- color: #152f47;
4461
+ color: rgb(var(--v-theme-on-surface));
4345
4462
  display: block;
4346
4463
  font-size: 12px;
4347
4464
  font-weight: 800;
@@ -4352,7 +4469,7 @@ function formatDashboardTime(value?: string) {
4352
4469
  }
4353
4470
 
4354
4471
  .figma-list-main p {
4355
- color: #7b8895;
4472
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
4356
4473
  font-size: 11px;
4357
4474
  font-weight: 700;
4358
4475
  line-height: 1.3;
@@ -4366,6 +4483,61 @@ function formatDashboardTime(value?: string) {
4366
4483
  justify-self: end;
4367
4484
  }
4368
4485
 
4486
+ /* The service-vertical and security rows carry two more cells than the
4487
+ property rows do - who it is assigned to, and the status. Same row, four
4488
+ columns instead of three; there is no second row style. */
4489
+ .figma-list-row--wide {
4490
+ grid-template-columns:
4491
+ minmax(0, 1fr) minmax(0, auto) auto minmax(38px, auto);
4492
+ }
4493
+
4494
+ .figma-list-person {
4495
+ align-items: center;
4496
+ color: var(--muted);
4497
+ display: flex;
4498
+ font-family: var(--font-sans);
4499
+ font-size: 11px;
4500
+ font-weight: 700;
4501
+ gap: 8px;
4502
+ min-width: 0;
4503
+ }
4504
+
4505
+ .figma-list-person span {
4506
+ overflow: hidden;
4507
+ text-overflow: ellipsis;
4508
+ white-space: nowrap;
4509
+ }
4510
+
4511
+ /* A figure, not a status - the neutral pill, so a number is never coloured as
4512
+ if it were a word `utils/status.ts` recognised. */
4513
+ /* The same pill StatusChip draws for `tone-neutral` - `--hover` on `--text2`,
4514
+ 3px/10px, `--r-pill` - reproduced rather than reused because StatusChip's
4515
+ rules are scoped to its own file. */
4516
+ .figma-row-figure {
4517
+ background: var(--hover);
4518
+ border-radius: var(--r-pill);
4519
+ color: var(--text2);
4520
+ font-family: var(--font-sans);
4521
+ font-size: var(--fs-chip);
4522
+ font-weight: var(--fw-chip);
4523
+ justify-self: end;
4524
+ line-height: 1.4;
4525
+ padding: 3px 10px;
4526
+ white-space: nowrap;
4527
+ }
4528
+
4529
+ /* Below the design's tablet break the four columns will not hold a readable
4530
+ title, so the row stacks - the same thing the property rows do. */
4531
+ @media (max-width: 700px) {
4532
+ .figma-list-row--wide {
4533
+ grid-template-columns: minmax(0, 1fr) auto;
4534
+ }
4535
+
4536
+ .figma-list-row--wide .figma-list-main {
4537
+ grid-column: 1 / -1;
4538
+ }
4539
+ }
4540
+
4369
4541
  .figma-event-dot {
4370
4542
  align-self: center;
4371
4543
  border-radius: 50%;
@@ -4377,30 +4549,30 @@ function formatDashboardTime(value?: string) {
4377
4549
 
4378
4550
  .figma-event-dot-info,
4379
4551
  .figma-event-dot-neutral {
4380
- background: #c9e6ff;
4381
- border: 2px solid #77bef9;
4552
+ background: var(--info-bg);
4553
+ border: 2px solid var(--info);
4382
4554
  }
4383
4555
 
4384
4556
  .figma-event-dot-success {
4385
- background: #2fb84d;
4386
- border: 2px solid #c9efd4;
4557
+ background: var(--ok-bg);
4558
+ border: 2px solid var(--ok);
4387
4559
  }
4388
4560
 
4389
4561
  .figma-event-dot-danger {
4390
- background: #ffd9d6;
4391
- border: 2px solid #f9a6a0;
4562
+ background: var(--err-bg);
4563
+ border: 2px solid var(--err);
4392
4564
  }
4393
4565
 
4394
4566
  .figma-event-dot-warning {
4395
- background: #ffe9c7;
4396
- border: 2px solid #f9c97c;
4567
+ background: var(--warn-bg);
4568
+ border: 2px solid var(--warn);
4397
4569
  }
4398
4570
 
4399
4571
  .active-patrol-row {
4400
4572
  align-items: center;
4401
- background-color: #ffffff;
4402
- border: 1px solid #e2e8f0;
4403
- border-radius: 8px;
4573
+ background-color: var(--card);
4574
+ border: 1px solid var(--border);
4575
+ border-radius: var(--r-inner-lg);
4404
4576
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
4405
4577
  display: grid;
4406
4578
  gap: 10px;
@@ -4416,7 +4588,7 @@ function formatDashboardTime(value?: string) {
4416
4588
  }
4417
4589
 
4418
4590
  .active-patrol-main strong {
4419
- color: #0b2f47;
4591
+ color: rgb(var(--v-theme-on-surface));
4420
4592
  display: block;
4421
4593
  font-size: 13px;
4422
4594
  font-weight: 700;
@@ -4426,7 +4598,7 @@ function formatDashboardTime(value?: string) {
4426
4598
  }
4427
4599
 
4428
4600
  .active-patrol-main span {
4429
- color: #94a3b8;
4601
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
4430
4602
  display: block;
4431
4603
  font-size: 11px;
4432
4604
  line-height: 1.25;
@@ -4442,19 +4614,37 @@ function formatDashboardTime(value?: string) {
4442
4614
 
4443
4615
  .active-patrol-assignee {
4444
4616
  align-items: center;
4445
- background: #f8fafc;
4446
- border: 1px solid #e7edf3;
4617
+ background: rgba(var(--v-theme-on-surface), 0.05);
4618
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
4447
4619
  border-radius: 999px;
4448
4620
  display: inline-flex;
4449
- flex: 1 1 auto;
4621
+ /* `1 1 auto` let the chip GROW to fill the column, so one assignee drew as a
4622
+ full-width bordered box that reads as an empty text input. It is a chip -
4623
+ it shrinks, it does not grow. */
4624
+ flex: 0 1 auto;
4450
4625
  gap: 6px;
4451
4626
  max-width: 100%;
4452
4627
  min-width: 0;
4453
4628
  padding: 2px 8px 2px 3px;
4454
4629
  }
4455
4630
 
4631
+ /**
4632
+ * Carried from Phase 1: `AvatarMain` colours each person from their NAME, and
4633
+ * writes WHITE initials on whatever tint it picks - 2.34:1 on this dashboard.
4634
+ * That is right in a members list, where the colour tells two people apart,
4635
+ * and wrong on a dashboard, where the same face appears in four panels.
4636
+ *
4637
+ * The component is left alone - Phase 2 took exactly this route for the top
4638
+ * bar's own avatar - and the dashboard paints every avatar it draws with the
4639
+ * design's accent pair instead. One rule, all seven call sites.
4640
+ */
4641
+ .unified-dashboard :deep(.v-avatar) {
4642
+ background: var(--accent-soft) !important;
4643
+ color: var(--accent-text) !important;
4644
+ }
4645
+
4456
4646
  .active-patrol-assignee span {
4457
- color: #475569;
4647
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
4458
4648
  font-size: 11px;
4459
4649
  font-weight: 600;
4460
4650
  line-height: 1.2;
@@ -4484,10 +4674,10 @@ function formatDashboardTime(value?: string) {
4484
4674
 
4485
4675
  .active-patrol-more-pill {
4486
4676
  align-items: center;
4487
- background: #eef3f8;
4488
- border: 1px solid #dbe4ed;
4677
+ background: rgba(var(--v-theme-on-surface), 0.05);
4678
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
4489
4679
  border-radius: 999px;
4490
- color: #607386;
4680
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
4491
4681
  display: inline-flex;
4492
4682
  flex: 0 0 auto;
4493
4683
  font-size: 11px;
@@ -4507,11 +4697,11 @@ function formatDashboardTime(value?: string) {
4507
4697
  }
4508
4698
 
4509
4699
  .active-patrol-tooltip::after {
4510
- background: #0f2638;
4511
- border-radius: 6px;
4700
+ background: var(--text);
4701
+ border-radius: var(--r-inner-sm);
4512
4702
  bottom: calc(100% + 8px);
4513
- box-shadow: 0 8px 24px rgba(15, 38, 56, 0.18);
4514
- color: #ffffff;
4703
+ box-shadow: var(--shadow-menu);
4704
+ color: var(--card);
4515
4705
  content: attr(data-tooltip);
4516
4706
  font-size: 11px;
4517
4707
  font-weight: 600;
@@ -4541,6 +4731,28 @@ function formatDashboardTime(value?: string) {
4541
4731
  }
4542
4732
  }
4543
4733
 
4734
+ /**
4735
+ * Carried from Phase 1: the dashboard scrolled sideways at 360. This row is a
4736
+ * four-column grid whose last two columns are `max-content`, so on a phone the
4737
+ * assignee pill, the status pill and the action button cannot fit beside a
4738
+ * 120px title and the row pushes ~39px past the viewport.
4739
+ *
4740
+ * Below 600 the four columns become one. Nothing is hidden, dropped or
4741
+ * reordered - the same four blocks, stacked in the same order.
4742
+ */
4743
+ @media (max-width: 599.98px) {
4744
+ .active-patrol-row {
4745
+ grid-template-columns: minmax(0, 1fr);
4746
+ }
4747
+
4748
+ .active-patrol-action,
4749
+ .active-patrol-statuses,
4750
+ .active-patrol-assignees {
4751
+ justify-self: start;
4752
+ text-align: left;
4753
+ }
4754
+ }
4755
+
4544
4756
  .figma-icon-badge {
4545
4757
  align-items: center;
4546
4758
  border-radius: 50%;
@@ -4552,28 +4764,28 @@ function formatDashboardTime(value?: string) {
4552
4764
  }
4553
4765
 
4554
4766
  .figma-icon-badge-danger {
4555
- background: #ffe0de;
4556
- color: #d72f2f;
4767
+ background: var(--err-bg);
4768
+ color: var(--err);
4557
4769
  }
4558
4770
 
4559
4771
  .figma-icon-badge-info {
4560
- background: #e8e2ff;
4561
- color: #6d4bd8;
4772
+ background: var(--info-bg);
4773
+ color: var(--info);
4562
4774
  }
4563
4775
 
4564
4776
  .figma-icon-badge-success {
4565
- background: #ddf7e6;
4566
- color: #229852;
4777
+ background: var(--ok-bg);
4778
+ color: var(--ok);
4567
4779
  }
4568
4780
 
4569
4781
  .figma-icon-badge-warning {
4570
- background: #ffeed4;
4571
- color: #c57916;
4782
+ background: var(--warn-bg);
4783
+ color: var(--warn);
4572
4784
  }
4573
4785
 
4574
4786
  .figma-icon-badge-neutral {
4575
- background: #edf2f6;
4576
- color: #607386;
4787
+ background: var(--hover);
4788
+ color: var(--text2);
4577
4789
  }
4578
4790
 
4579
4791
  .figma-status-stack {
@@ -4587,7 +4799,7 @@ function formatDashboardTime(value?: string) {
4587
4799
 
4588
4800
  .figma-status-label {
4589
4801
  align-items: center;
4590
- color: #263d53;
4802
+ color: rgb(var(--v-theme-on-surface));
4591
4803
  display: flex;
4592
4804
  font-size: 12px;
4593
4805
  font-weight: 800;
@@ -4597,13 +4809,13 @@ function formatDashboardTime(value?: string) {
4597
4809
  }
4598
4810
 
4599
4811
  .figma-status-label strong {
4600
- color: #18324a;
4812
+ color: rgb(var(--v-theme-on-surface));
4601
4813
  font-size: 12px;
4602
4814
  font-weight: 900;
4603
4815
  }
4604
4816
 
4605
4817
  .figma-progress-track {
4606
- background: #edf2f6;
4818
+ background: rgba(var(--v-theme-on-surface), 0.08);
4607
4819
  border-radius: 999px;
4608
4820
  height: 6px;
4609
4821
  overflow: hidden;
@@ -4624,9 +4836,9 @@ function formatDashboardTime(value?: string) {
4624
4836
 
4625
4837
  .feedback-row {
4626
4838
  align-items: center;
4627
- background: #fff;
4628
- border: 1px solid #e0e5ea;
4629
- border-radius: 8px;
4839
+ background: var(--card);
4840
+ border: 1px solid var(--border);
4841
+ border-radius: var(--r-inner-lg);
4630
4842
  display: grid;
4631
4843
  gap: 16px;
4632
4844
  grid-template-columns: minmax(180px, 225px) minmax(130px, 175px) 100px auto;
@@ -4635,8 +4847,41 @@ function formatDashboardTime(value?: string) {
4635
4847
  transition: border-color 0.16s ease, box-shadow 0.16s ease;
4636
4848
  }
4637
4849
 
4850
+ /**
4851
+ * The property-mode feedback row is four flex children that each ask for at
4852
+ * least 140px, so on a 360px phone the status pill and the View button were
4853
+ * clipped by the card edge - visible, but cut in half. It wraps now, and below
4854
+ * 600 each block takes the full width: same four blocks, same order.
4855
+ */
4856
+ .dashboard-feedback-row {
4857
+ flex-wrap: wrap;
4858
+ gap: 12px;
4859
+ }
4860
+
4861
+ @media (max-width: 599.98px) {
4862
+ .dashboard-feedback-row > div {
4863
+ flex: 1 1 100% !important;
4864
+ justify-content: flex-start !important;
4865
+ text-align: left !important;
4866
+ }
4867
+ }
4868
+
4869
+ /* Same story as the patrol row: four fixed-ish columns need ~600px, so on a
4870
+ phone the status pill and the View button were clipped by the card edge.
4871
+ One column below 600, same blocks, same order. */
4872
+ @media (max-width: 599.98px) {
4873
+ .feedback-row {
4874
+ grid-template-columns: minmax(0, 1fr);
4875
+ }
4876
+
4877
+ .feedback-status-pill,
4878
+ .feedback-action {
4879
+ justify-self: start;
4880
+ }
4881
+ }
4882
+
4638
4883
  .feedback-row:hover {
4639
- border-color: #d8e3ec;
4884
+ border-color: rgba(var(--v-border-color), var(--v-border-opacity));
4640
4885
  box-shadow: 0 6px 14px rgba(15, 44, 69, 0.04);
4641
4886
  }
4642
4887
 
@@ -4645,7 +4890,7 @@ function formatDashboardTime(value?: string) {
4645
4890
  }
4646
4891
 
4647
4892
  .feedback-summary strong {
4648
- color: #132b43;
4893
+ color: rgb(var(--v-theme-on-surface));
4649
4894
  display: block;
4650
4895
  font-size: 12px;
4651
4896
  font-weight: 800;
@@ -4656,7 +4901,7 @@ function formatDashboardTime(value?: string) {
4656
4901
  }
4657
4902
 
4658
4903
  .feedback-summary p {
4659
- color: #888;
4904
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
4660
4905
  font-size: 12px;
4661
4906
  font-weight: 500;
4662
4907
  line-height: 1.35;
@@ -4679,11 +4924,13 @@ function formatDashboardTime(value?: string) {
4679
4924
  white-space: nowrap;
4680
4925
  }
4681
4926
 
4927
+ /* Carried from Phase 1: the initials measured 1.26:1 in dark mode, because the
4928
+ tint was a literal light blue whatever the theme. */
4682
4929
  .feedback-avatar {
4683
4930
  align-items: center;
4684
- background: #d1e8ff;
4931
+ background: var(--accent-soft);
4685
4932
  border-radius: 50%;
4686
- color: #1d5f99;
4933
+ color: var(--accent-text);
4687
4934
  display: inline-flex;
4688
4935
  flex-shrink: 0;
4689
4936
  font-size: 11px;
@@ -4702,31 +4949,32 @@ function formatDashboardTime(value?: string) {
4702
4949
  white-space: nowrap;
4703
4950
  }
4704
4951
 
4952
+ /* The other two carried Phase 1 items: hard-coded chips. */
4705
4953
  .feedback-status-pill-completed {
4706
- background: #72e3a1;
4707
- color: #0f5132;
4954
+ background: var(--ok-bg);
4955
+ color: var(--ok);
4708
4956
  }
4709
4957
 
4710
4958
  .feedback-status-pill-progress {
4711
- background: #fde68a;
4712
- color: #92400e;
4959
+ background: var(--warn-bg);
4960
+ color: var(--warn);
4713
4961
  }
4714
4962
 
4715
4963
  .feedback-status-pill-danger {
4716
- background: #fecaca;
4717
- color: #b91c1c;
4964
+ background: var(--err-bg);
4965
+ color: var(--err);
4718
4966
  }
4719
4967
 
4720
4968
  .feedback-status-pill-todo {
4721
- background: #e2e8f0;
4722
- color: #64748b;
4969
+ background: var(--hover);
4970
+ color: var(--text2);
4723
4971
  }
4724
4972
 
4725
4973
  .feedback-view-button {
4726
4974
  appearance: none;
4727
4975
  background: transparent;
4728
4976
  border: 0;
4729
- color: #2196f3;
4977
+ color: rgb(var(--v-theme-primary));
4730
4978
  cursor: pointer;
4731
4979
  font-size: 12px;
4732
4980
  font-weight: 800;