@7365admin1/layer-common 3.2.2-staging.182 → 3.2.2-staging.188

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.
@@ -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>
@@ -254,6 +255,27 @@
254
255
  class="mb-4"
255
256
  />
256
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
+
257
279
  <!-- ── KPI Cards ───────────────────────────────────────────────────────── -->
258
280
  <v-row dense class="mb-1 align-stretch">
259
281
  <v-col
@@ -332,7 +354,11 @@
332
354
  <template
333
355
  v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
334
356
  >
335
- <div class="d-flex flex-column ga-2">
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">
336
362
  <div
337
363
  v-for="item in card.raw.items"
338
364
  :key="item._id || item.name"
@@ -342,6 +368,7 @@
342
368
  <span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
343
369
  </div>
344
370
  </div>
371
+ <DashboardEmptyState v-else compact />
345
372
  </template>
346
373
  </AppKpiTile>
347
374
  </v-col>
@@ -488,7 +515,7 @@
488
515
  </div>
489
516
  </div>
490
517
 
491
- <DashboardEmptyState v-else />
518
+ <DashboardEmptyState v-else :error="chartFailed" />
492
519
  </v-card>
493
520
 
494
521
  <!-- ── Hygiene / M&E / Landscape / Pest / Pool — task + attendance ────── -->
@@ -533,7 +560,7 @@
533
560
  </button>
534
561
  </div>
535
562
  </div>
536
- <DashboardEmptyState v-else />
563
+ <DashboardEmptyState v-else :error="taskScheduleFailed" />
537
564
  </DashboardPanel>
538
565
  </v-col>
539
566
 
@@ -565,7 +592,7 @@
565
592
  <span class="figma-row-figure">{{ item.totalHours || 0 }} hrs</span>
566
593
  </div>
567
594
  </div>
568
- <DashboardEmptyState v-else />
595
+ <DashboardEmptyState v-else :error="attendanceFailed" />
569
596
  </DashboardPanel>
570
597
  </v-col>
571
598
 
@@ -599,7 +626,7 @@
599
626
  </button>
600
627
  </div>
601
628
  </div>
602
- <DashboardEmptyState v-else />
629
+ <DashboardEmptyState v-else :error="feedbacksFailed" />
603
630
  </DashboardPanel>
604
631
  </v-col>
605
632
  </v-row>
@@ -644,7 +671,7 @@
644
671
  </button>
645
672
  </div>
646
673
  </div>
647
- <DashboardEmptyState v-else />
674
+ <DashboardEmptyState v-else :error="metricsFailed" />
648
675
  </div>
649
676
  </div>
650
677
  </v-col>
@@ -689,7 +716,7 @@
689
716
  </button>
690
717
  </div>
691
718
  </div>
692
- <DashboardEmptyState v-else />
719
+ <DashboardEmptyState v-else :error="metricsFailed" />
693
720
  </div>
694
721
  </div>
695
722
  </v-col>
@@ -738,7 +765,7 @@
738
765
  </button>
739
766
  </div>
740
767
  </div>
741
- <DashboardEmptyState v-else />
768
+ <DashboardEmptyState v-else :error="metricsFailed" />
742
769
  </div>
743
770
  </div>
744
771
  </v-col>
@@ -774,7 +801,7 @@
774
801
  </div>
775
802
  </div>
776
803
  </div>
777
- <DashboardEmptyState v-else />
804
+ <DashboardEmptyState v-else :error="metricsFailed" />
778
805
  </div>
779
806
  </div>
780
807
  </v-col>
@@ -822,7 +849,7 @@
822
849
  </button>
823
850
  </div>
824
851
  </div>
825
- <DashboardEmptyState v-else />
852
+ <DashboardEmptyState v-else :error="feedbacksFailed" />
826
853
  </DashboardPanel>
827
854
  </v-col>
828
855
  </v-row>
@@ -899,7 +926,7 @@
899
926
  </div>
900
927
  </div>
901
928
  </div>
902
- <DashboardEmptyState v-else />
929
+ <DashboardEmptyState v-else :error="metricsFailed" />
903
930
  </div>
904
931
  </div>
905
932
  </v-col>
@@ -941,7 +968,7 @@
941
968
  <StatusChip :status="item.status || 'On duty'" pill />
942
969
  </div>
943
970
  </div>
944
- <DashboardEmptyState v-else />
971
+ <DashboardEmptyState v-else :error="attendanceFailed" />
945
972
  </div>
946
973
  </div>
947
974
  </v-col>
@@ -977,7 +1004,7 @@
977
1004
  </button>
978
1005
  </div>
979
1006
  </div>
980
- <DashboardEmptyState v-else />
1007
+ <DashboardEmptyState v-else :error="feedbacksFailed" />
981
1008
  </DashboardPanel>
982
1009
  </v-col>
983
1010
  </v-row>
@@ -1158,6 +1185,15 @@
1158
1185
  </template>
1159
1186
 
1160
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
+
1161
1197
  // ─── Types ────────────────────────────────────────────────────────────────────
1162
1198
 
1163
1199
  type DashboardMetric = {
@@ -1455,8 +1491,11 @@ const currentVisitorTypeParam = computed(
1455
1491
  // ─── Data Fetching ────────────────────────────────────────────────────────────
1456
1492
 
1457
1493
  // Hygiene / M&E / Landscape / Pest / Pool main metrics
1458
- const { data: getDashboardReq, pending: loadingMetrics } =
1459
- await useLazyAsyncData(
1494
+ const {
1495
+ data: getDashboardReq,
1496
+ pending: loadingMetrics,
1497
+ error: metricsError,
1498
+ } = await useLazyAsyncData(
1460
1499
  `get-dashboard-${props.site}-${props.serviceType}`,
1461
1500
  () =>
1462
1501
  props.serviceType && isHygieneMode.value
@@ -1472,8 +1511,11 @@ const { data: getDashboardReq, pending: loadingMetrics } =
1472
1511
  );
1473
1512
 
1474
1513
  // Property / Security main metrics (uses generic getDashboard with visitor type)
1475
- const { data: getDashboardAltReq, pending: loadingAltMetrics } =
1476
- await useLazyAsyncData(
1514
+ const {
1515
+ data: getDashboardAltReq,
1516
+ pending: loadingAltMetrics,
1517
+ error: altMetricsError,
1518
+ } = await useLazyAsyncData(
1477
1519
  `get-dashboard-alt-${props.site}-${props.serviceType}`,
1478
1520
  () =>
1479
1521
  props.site && (isPropertyMode.value || isSecurityMode.value)
@@ -1497,8 +1539,11 @@ const { data: getDashboardAltReq, pending: loadingAltMetrics } =
1497
1539
  );
1498
1540
 
1499
1541
  // Weekly activity (all modes)
1500
- const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
1501
- await useLazyAsyncData(
1542
+ const {
1543
+ data: weeklyActivityReq,
1544
+ pending: loadingWeeklyActivity,
1545
+ error: weeklyActivityError,
1546
+ } = await useLazyAsyncData(
1502
1547
  `get-dashboard-weekly-activity-${props.site}-${props.serviceType}`,
1503
1548
  () =>
1504
1549
  props.serviceType
@@ -1514,8 +1559,11 @@ const { data: weeklyActivityReq, pending: loadingWeeklyActivity } =
1514
1559
  );
1515
1560
 
1516
1561
  // Task schedule (hygiene mode only)
1517
- const { data: taskScheduleReq, pending: loadingTaskSchedule } =
1518
- await useLazyAsyncData(
1562
+ const {
1563
+ data: taskScheduleReq,
1564
+ pending: loadingTaskSchedule,
1565
+ error: taskScheduleError,
1566
+ } = await useLazyAsyncData(
1519
1567
  `get-dashboard-task-schedule-${props.site}-${props.serviceType}`,
1520
1568
  () =>
1521
1569
  props.serviceType && isHygieneMode.value
@@ -1531,8 +1579,11 @@ const { data: taskScheduleReq, pending: loadingTaskSchedule } =
1531
1579
  );
1532
1580
 
1533
1581
  // Attendance (hygiene + security modes)
1534
- const { data: attendanceReq, pending: loadingAttendance } =
1535
- await useLazyAsyncData(
1582
+ const {
1583
+ data: attendanceReq,
1584
+ pending: loadingAttendance,
1585
+ error: attendanceError,
1586
+ } = await useLazyAsyncData(
1536
1587
  `get-dashboard-attendance-${props.site}-${props.serviceType}`,
1537
1588
  () =>
1538
1589
  props.serviceType && !isPropertyMode.value
@@ -1548,8 +1599,11 @@ const { data: attendanceReq, pending: loadingAttendance } =
1548
1599
  );
1549
1600
 
1550
1601
  // Feedbacks (all modes)
1551
- const { data: feedbacksReq, pending: loadingFeedbacks } =
1552
- await useLazyAsyncData(
1602
+ const {
1603
+ data: feedbacksReq,
1604
+ pending: loadingFeedbacks,
1605
+ error: feedbacksError,
1606
+ } = await useLazyAsyncData(
1553
1607
  `get-dashboard-feedbacks-${props.site}-${props.serviceType}`,
1554
1608
  () =>
1555
1609
  props.serviceType
@@ -1593,35 +1647,64 @@ const finalLoading = computed(() => {
1593
1647
  );
1594
1648
  });
1595
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
+
1596
1687
  // ─── Merged Dashboard Data ────────────────────────────────────────────────────
1597
1688
 
1598
1689
  const finalDashboardData = computed<Record<string, any>>(() => {
1599
1690
  if (!props.serviceType) return props.dashboardData || {};
1600
1691
 
1601
- let weeklyActivity = weeklyActivityReq.value ?? [];
1602
- const hasNoActivity =
1603
- !weeklyActivity.length ||
1604
- weeklyActivity.every(
1605
- (item: any) =>
1606
- !item.workOrder &&
1607
- !item.taskCompleted &&
1608
- !item.openWorkOrder &&
1609
- !item.closedWorkOrder &&
1610
- !item.incidents &&
1611
- !item.visitors
1612
- );
1613
-
1614
- if (hasNoActivity && currentPeriod.value === "thisWeek") {
1615
- weeklyActivity = [
1616
- { day: "Mon", workOrder: 800, taskCompleted: 1400 },
1617
- { day: "Tue", workOrder: 450, taskCompleted: 900 },
1618
- { day: "Wed", workOrder: 950, taskCompleted: 1640 },
1619
- { day: "Thu", workOrder: 2000, taskCompleted: 2100 },
1620
- { day: "Fri", workOrder: 2000, taskCompleted: 2700 },
1621
- { day: "Sat", workOrder: 2200, taskCompleted: 3100 },
1622
- { day: "Sun", workOrder: 2800, taskCompleted: 3200 },
1623
- ];
1624
- }
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 ?? [];
1625
1708
 
1626
1709
  // `weekly-activity` returns each interval twice over: `openWorkOrder` and
1627
1710
  // `workOrder` are the same not-yet-completed count, `closedWorkOrder` and
@@ -1760,9 +1843,21 @@ const defaultCardValues: TDashboardCardValue[] = [
1760
1843
  {
1761
1844
  key: "supplyAlert",
1762
1845
  periodKey: "period",
1763
- title: "Supply Alert",
1764
- icon: "mdi-alert",
1765
- color: KPI_TONES.err.color,
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,
1766
1861
  },
1767
1862
  ];
1768
1863
 
@@ -1837,12 +1932,16 @@ const resolvedCardValues = computed(() => {
1837
1932
 
1838
1933
  // ─── Widget Keys by Mode ──────────────────────────────────────────────────────
1839
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.
1840
1940
  const defaultExtraWidgetKeys = [
1841
1941
  "activity",
1842
1942
  "taskSchedule",
1843
1943
  "staffStatus",
1844
1944
  "feedbacks",
1845
- "recentActivity",
1846
1945
  ];
1847
1946
 
1848
1947
  const propertyDefaultWidgetKeys = [
@@ -1869,6 +1968,36 @@ const securityDefaultWidgetKeys = [
1869
1968
  "feedbacks",
1870
1969
  ];
1871
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
+
1872
2001
  const defaultVisibleWidgetKeys = computed(() => {
1873
2002
  if (props.extraWidgetKeys && props.extraWidgetKeys.length > 0) {
1874
2003
  return [
@@ -1889,8 +2018,16 @@ watch(
1889
2018
  (keys) => {
1890
2019
  const nextKeys = [...keys];
1891
2020
  if (!visibleWidgetKeys.value.length) {
1892
- visibleWidgetKeys.value = nextKeys;
1893
- 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];
1894
2031
  return;
1895
2032
  }
1896
2033
  visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
@@ -2037,11 +2174,6 @@ const customizeWidgets = computed(() => {
2037
2174
  title: "Feedbacks",
2038
2175
  description: "Show recent feedback and resolution state.",
2039
2176
  },
2040
- {
2041
- key: "recentActivity",
2042
- title: "Recent Activity",
2043
- description: "Latest updates across the service.",
2044
- },
2045
2177
  ];
2046
2178
  });
2047
2179
 
@@ -2285,7 +2417,7 @@ function getSecurityKpiCards() {
2285
2417
  ];
2286
2418
  }
2287
2419
 
2288
- const kpiCards = computed(() => {
2420
+ const rawKpiCards = computed(() => {
2289
2421
  if (isPropertyMode.value) return getPropertyKpiCards();
2290
2422
  if (isSecurityMode.value) return getSecurityKpiCards();
2291
2423
 
@@ -2312,6 +2444,22 @@ const kpiCards = computed(() => {
2312
2444
  });
2313
2445
  });
2314
2446
 
2447
+ /**
2448
+ * A tile whose request failed shows an em dash and says so, never a zero.
2449
+ * `percentage: NaN` is what suppresses the pill - `kpiChipText()` returns ""
2450
+ * for anything non-finite - and `raw: {}` empties the supply list with it.
2451
+ */
2452
+ const kpiCards = computed(() => {
2453
+ if (!metricsFailed.value) return rawKpiCards.value;
2454
+ return rawKpiCards.value.map((card) => ({
2455
+ ...card,
2456
+ value: "—",
2457
+ percentage: NaN,
2458
+ meta: LOAD_FAILED_TEXT,
2459
+ raw: {} as any,
2460
+ }));
2461
+ });
2462
+
2315
2463
  const visibleKpiCards = computed(() =>
2316
2464
  kpiCards.value.filter((card) => isWidgetVisible(card.key))
2317
2465
  );
@@ -2534,42 +2682,15 @@ const todayAttentions = computed(() =>
2534
2682
  * is shared by every chip in the estate - or ruling that a chart series is not
2535
2683
  * a status chip. Both are design decisions, so neither is taken here.
2536
2684
  */
2537
- const workOrderStatus = computed(() => {
2538
- const summary = finalDashboardData.value?.workOrderStatusSummary;
2539
- const metric = getMetric(["openWorkOrder", "workOrders", "workOrder"]);
2540
-
2541
- const src = summary || metric;
2542
- if (!src) return [];
2543
-
2544
- const completed = Number(src.completed ?? 0);
2545
- const inProgress = Number(src.inProgress ?? 0);
2546
- const pending =
2547
- src.pending !== undefined
2548
- ? Number(src.pending)
2549
- : Math.max(0, Number(src.count ?? 0) - inProgress);
2550
- const max = Math.max(completed, inProgress, pending, 1);
2551
-
2552
- return [
2553
- {
2554
- label: "Completed",
2555
- value: completed,
2556
- percent: (completed / max) * 100,
2557
- color: "var(--ok)",
2558
- },
2559
- {
2560
- label: "In Progress",
2561
- value: inProgress,
2562
- percent: (inProgress / max) * 100,
2563
- color: "var(--err)",
2564
- },
2565
- {
2566
- label: "Pending",
2567
- value: pending,
2568
- percent: (pending / max) * 100,
2569
- color: "var(--warn)",
2570
- },
2571
- ];
2572
- });
2685
+ const workOrderStatus = computed(() =>
2686
+ // The arithmetic and the "no breakdown, no bars" rule live in
2687
+ // `utils/dashboard.ts` so they have a test; the tone -> token mapping stays
2688
+ // here because it is this template's colour question, not the server's.
2689
+ buildWorkOrderStatus(finalDashboardData.value?.workOrderStatus).map((row) => ({
2690
+ ...row,
2691
+ color: `var(--${row.tone})`,
2692
+ }))
2693
+ );
2573
2694
 
2574
2695
  // Security-mode lists
2575
2696
  const activePatrolItems = computed<any[]>(
@@ -2762,7 +2883,21 @@ onUnmounted(() => {
2762
2883
  watch(selectedRange, (period) => {
2763
2884
  if (props.serviceType) {
2764
2885
  const apiPeriod = periodValue[period];
2765
- if (apiPeriod) currentPeriod.value = apiPeriod;
2886
+ if (apiPeriod) {
2887
+ currentPeriod.value = apiPeriod;
2888
+ /**
2889
+ * The component already READ `?period=` on mount and never wrote it
2890
+ * back, so a refresh - or a link sent to a colleague - silently returned
2891
+ * to Today while the button still said This Month. Writing it closes
2892
+ * both. `replace`, not `push`: changing the range is not a navigation
2893
+ * step the Back button should have to walk through.
2894
+ *
2895
+ * What each range MEANS is untouched here.
2896
+ */
2897
+ if (route.query.period !== apiPeriod) {
2898
+ router.replace({ query: { ...route.query, period: apiPeriod } });
2899
+ }
2900
+ }
2766
2901
  }
2767
2902
  emit("updatePeriod", { key: "__all__", period });
2768
2903
  });
@@ -3341,6 +3476,9 @@ function resetCustomizeDraft() {
3341
3476
 
3342
3477
  function submitCustomize() {
3343
3478
  visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
3479
+ // The write is the whole point - see `widgetPrefsCookie`. Saving on Submit
3480
+ // rather than on every switch keeps Cancel meaning cancel.
3481
+ widgetPrefsCookie.value = [...draftVisibleWidgetKeys.value];
3344
3482
  widgetSearch.value = "";
3345
3483
  customizeDialog.value = false;
3346
3484
  }
@@ -3440,6 +3578,13 @@ async function exportDashboard(format: "PDF" | "Word") {
3440
3578
  <body>
3441
3579
  <h1>${props.title || dashboardTitle.value}</h1>
3442
3580
  <div class="subtitle">Site: ${siteName} | Period: ${periodLabel}</div>
3581
+ ${
3582
+ failedWidgetLabels.value.length
3583
+ ? `<div class="subtitle" style="color:#b73535;">Incomplete report: ${failedWidgetLabels.value.join(
3584
+ ", "
3585
+ )} could not be loaded. Any figure shown as — is missing, not zero.</div>`
3586
+ : ""
3587
+ }
3443
3588
  `;
3444
3589
 
3445
3590
  if (visibleKpiCards.value && visibleKpiCards.value.length > 0) {
@@ -3452,6 +3597,9 @@ async function exportDashboard(format: "PDF" | "Word") {
3452
3597
  let kpiValueHtml = `<div class="kpi-value">${card.value}</div>`;
3453
3598
  if (card.key === "supplyAlert" && Array.isArray(card.raw?.items)) {
3454
3599
  kpiValueHtml = '<div style="font-size: 9pt; margin-top: 4px;">';
3600
+ if (!card.raw.items.length) {
3601
+ kpiValueHtml += "<div>No supplies recorded</div>";
3602
+ }
3455
3603
  card.raw.items.forEach((item: any) => {
3456
3604
  kpiValueHtml += `<div><strong>${item.name}</strong>: Balance ${item.qty}</div>`;
3457
3605
  });
@@ -89,6 +89,8 @@
89
89
  </template>
90
90
 
91
91
  <script setup lang="ts">
92
+ import { moduleNameFromRoute } from "../../utils/breadcrumb";
93
+
92
94
  const props = defineProps({
93
95
  hideNavIcon: {
94
96
  type: Boolean,
@@ -142,39 +144,12 @@ const siteName = computed(() =>
142
144
  const route = useRoute();
143
145
 
144
146
  /**
145
- * The module's name comes off the route name, which the applications build
146
- * from their own folder structure: `org-site-work-orders` is Work Orders.
147
- * Leading parameter segments (`org`, `site`) are skipped and the name stops at
148
- * the first one that follows, so a detail route (`...-feedbacks-id`) and a
149
- * status route (`...-members-status`) both name their module rather than their
150
- * argument.
147
+ * The page's name comes off the route name, which the applications build from
148
+ * their own folder structure: `org-site-work-orders` is Work Orders. The rule
149
+ * itself lives in `utils/breadcrumb.ts`, where it is unit-tested against every
150
+ * route shape the eleven applications actually have.
151
151
  */
152
- const ACRONYMS: Record<string, string> = {
153
- cctv: "CCTV",
154
- hid: "HID",
155
- dob: "DOB",
156
- anpr: "ANPR",
157
- nfc: "NFC",
158
- qr: "QR",
159
- soa: "SOA",
160
- sp: "SP",
161
- id: "ID",
162
- };
163
-
164
- const moduleName = computed(() => {
165
- const segments = String(route.name ?? "").split("-").filter(Boolean);
166
- const params = new Set(Object.keys(route.params ?? {}));
167
-
168
- let i = 0;
169
- while (i < segments.length && params.has(segments[i])) i++;
170
-
171
- const words: string[] = [];
172
- while (i < segments.length && !params.has(segments[i])) words.push(segments[i++]);
173
-
174
- return words
175
- .map((w) => ACRONYMS[w] ?? w.charAt(0).toUpperCase() + w.slice(1))
176
- .join(" ");
177
- });
152
+ const moduleName = computed(() => moduleNameFromRoute(route));
178
153
 
179
154
  const profile = computed(() => {
180
155
  return `/api/public/${currentUser.value?.profile}`;