@7365admin1/layer-common 3.2.2-staging.183 → 3.2.2-staging.189
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/assets/css/primitives.css +22 -0
- package/assets/css/screens.css +10 -1
- package/components/DashboardEmptyState.vue +37 -2
- package/components/DashboardMain.vue +289 -115
- package/components/Layout/Header.vue +7 -32
- package/components/SwitchContext.vue +30 -1
- package/package.json +1 -1
- package/utils/breadcrumb.test.ts +108 -0
- package/utils/breadcrumb.ts +82 -0
- package/utils/dashboard.test.ts +118 -0
- package/utils/dashboard.ts +53 -0
- package/utils/theme-aa-ledger.ts +9 -3
- package/utils/theme.test.ts +88 -4
|
@@ -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
|
-
|
|
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 {
|
|
1459
|
-
|
|
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 {
|
|
1476
|
-
|
|
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 {
|
|
1501
|
-
|
|
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 {
|
|
1518
|
-
|
|
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 {
|
|
1535
|
-
|
|
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 {
|
|
1552
|
-
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
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) =>
|
|
@@ -1923,7 +2060,7 @@ const customizeWidgets = computed(() => {
|
|
|
1923
2060
|
key: "visitors",
|
|
1924
2061
|
title: "Visitors",
|
|
1925
2062
|
description:
|
|
1926
|
-
"See today's visitor traffic and
|
|
2063
|
+
"See today's visitor traffic, and how many passes are still not checked out.",
|
|
1927
2064
|
},
|
|
1928
2065
|
{
|
|
1929
2066
|
key: "facilityBookings",
|
|
@@ -1980,7 +2117,7 @@ const customizeWidgets = computed(() => {
|
|
|
1980
2117
|
key: "visitors",
|
|
1981
2118
|
title: "Visitors",
|
|
1982
2119
|
description:
|
|
1983
|
-
"See today's visitor traffic and
|
|
2120
|
+
"See today's visitor traffic, and how many passes are still not checked out.",
|
|
1984
2121
|
},
|
|
1985
2122
|
{
|
|
1986
2123
|
key: "patrolCompliance",
|
|
@@ -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
|
|
|
@@ -2156,7 +2288,7 @@ function getPropertyKpiCards() {
|
|
|
2156
2288
|
color: KPI_TONES.info.color,
|
|
2157
2289
|
tint: KPI_TONES.info.tint,
|
|
2158
2290
|
value: formatNumber(toNumber(workOrderMetric?.count)),
|
|
2159
|
-
percentage:
|
|
2291
|
+
percentage: toNumberOrNaN(workOrderMetric?.percentage),
|
|
2160
2292
|
meta: getMetricMeta(workOrderMetric),
|
|
2161
2293
|
raw: workOrderMetric,
|
|
2162
2294
|
filter: undefined,
|
|
@@ -2168,7 +2300,7 @@ function getPropertyKpiCards() {
|
|
|
2168
2300
|
color: KPI_TONES.err.color,
|
|
2169
2301
|
tint: KPI_TONES.err.tint,
|
|
2170
2302
|
value: formatNumber(toNumber(incidentMetric?.count)),
|
|
2171
|
-
percentage:
|
|
2303
|
+
percentage: toNumberOrNaN(incidentMetric?.percentage),
|
|
2172
2304
|
meta: getMetricMeta(incidentMetric),
|
|
2173
2305
|
raw: incidentMetric,
|
|
2174
2306
|
filter: undefined,
|
|
@@ -2180,7 +2312,7 @@ function getPropertyKpiCards() {
|
|
|
2180
2312
|
color: KPI_TONES.warn.color,
|
|
2181
2313
|
tint: KPI_TONES.warn.tint,
|
|
2182
2314
|
value: formatNumber(toNumber(visitorMetric?.count)),
|
|
2183
|
-
percentage:
|
|
2315
|
+
percentage: toNumberOrNaN(visitorMetric?.percentage),
|
|
2184
2316
|
meta: getMetricMeta(visitorMetric),
|
|
2185
2317
|
raw: visitorMetric,
|
|
2186
2318
|
filter: {
|
|
@@ -2199,7 +2331,7 @@ function getPropertyKpiCards() {
|
|
|
2199
2331
|
color: KPI_TONES.ok.color,
|
|
2200
2332
|
tint: KPI_TONES.ok.tint,
|
|
2201
2333
|
value: formatNumber(toNumber(facilityMetric?.count)),
|
|
2202
|
-
percentage:
|
|
2334
|
+
percentage: toNumberOrNaN(facilityMetric?.percentage),
|
|
2203
2335
|
meta: getMetricMeta(facilityMetric),
|
|
2204
2336
|
raw: facilityMetric,
|
|
2205
2337
|
filter: undefined,
|
|
@@ -2234,7 +2366,7 @@ function getSecurityKpiCards() {
|
|
|
2234
2366
|
color: KPI_TONES.info.color,
|
|
2235
2367
|
tint: KPI_TONES.info.tint,
|
|
2236
2368
|
value: formatNumber(toNumber(workOrderMetric?.count)),
|
|
2237
|
-
percentage:
|
|
2369
|
+
percentage: toNumberOrNaN(workOrderMetric?.percentage),
|
|
2238
2370
|
meta: getMetricMeta(workOrderMetric),
|
|
2239
2371
|
raw: workOrderMetric,
|
|
2240
2372
|
filter: undefined,
|
|
@@ -2246,7 +2378,7 @@ function getSecurityKpiCards() {
|
|
|
2246
2378
|
color: KPI_TONES.err.color,
|
|
2247
2379
|
tint: KPI_TONES.err.tint,
|
|
2248
2380
|
value: formatNumber(toNumber(incidentMetric?.count)),
|
|
2249
|
-
percentage:
|
|
2381
|
+
percentage: toNumberOrNaN(incidentMetric?.percentage),
|
|
2250
2382
|
meta: getMetricMeta(incidentMetric),
|
|
2251
2383
|
raw: incidentMetric,
|
|
2252
2384
|
filter: undefined,
|
|
@@ -2258,7 +2390,7 @@ function getSecurityKpiCards() {
|
|
|
2258
2390
|
color: KPI_TONES.warn.color,
|
|
2259
2391
|
tint: KPI_TONES.warn.tint,
|
|
2260
2392
|
value: formatNumber(toNumber(visitorMetric?.count)),
|
|
2261
|
-
percentage:
|
|
2393
|
+
percentage: toNumberOrNaN(visitorMetric?.percentage),
|
|
2262
2394
|
meta: getMetricMeta(visitorMetric),
|
|
2263
2395
|
raw: visitorMetric,
|
|
2264
2396
|
filter: {
|
|
@@ -2276,8 +2408,10 @@ function getSecurityKpiCards() {
|
|
|
2276
2408
|
icon: "mdi-office-building",
|
|
2277
2409
|
color: KPI_TONES.ok.color,
|
|
2278
2410
|
tint: KPI_TONES.ok.tint,
|
|
2279
|
-
|
|
2280
|
-
|
|
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),
|
|
2281
2415
|
meta: getMetricMeta(patrolMetric),
|
|
2282
2416
|
raw: patrolMetric,
|
|
2283
2417
|
filter: undefined,
|
|
@@ -2285,7 +2419,7 @@ function getSecurityKpiCards() {
|
|
|
2285
2419
|
];
|
|
2286
2420
|
}
|
|
2287
2421
|
|
|
2288
|
-
const
|
|
2422
|
+
const rawKpiCards = computed(() => {
|
|
2289
2423
|
if (isPropertyMode.value) return getPropertyKpiCards();
|
|
2290
2424
|
if (isSecurityMode.value) return getSecurityKpiCards();
|
|
2291
2425
|
|
|
@@ -2304,7 +2438,7 @@ const kpiCards = computed(() => {
|
|
|
2304
2438
|
tint: getCardTint(mapping.color, index),
|
|
2305
2439
|
color: mapping.color || getCardColor(index),
|
|
2306
2440
|
value: typeof value === "string" ? value : formatNumber(value),
|
|
2307
|
-
percentage:
|
|
2441
|
+
percentage: toNumberOrNaN(metric.percentage),
|
|
2308
2442
|
meta: getMetricMeta(metric),
|
|
2309
2443
|
raw: metric,
|
|
2310
2444
|
filter: undefined as any,
|
|
@@ -2312,6 +2446,22 @@ const kpiCards = computed(() => {
|
|
|
2312
2446
|
});
|
|
2313
2447
|
});
|
|
2314
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
|
+
|
|
2315
2465
|
const visibleKpiCards = computed(() =>
|
|
2316
2466
|
kpiCards.value.filter((card) => isWidgetVisible(card.key))
|
|
2317
2467
|
);
|
|
@@ -2534,42 +2684,15 @@ const todayAttentions = computed(() =>
|
|
|
2534
2684
|
* is shared by every chip in the estate - or ruling that a chart series is not
|
|
2535
2685
|
* a status chip. Both are design decisions, so neither is taken here.
|
|
2536
2686
|
*/
|
|
2537
|
-
const workOrderStatus = computed(() =>
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
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
|
-
});
|
|
2687
|
+
const workOrderStatus = computed(() =>
|
|
2688
|
+
// The arithmetic and the "no breakdown, no bars" rule live in
|
|
2689
|
+
// `utils/dashboard.ts` so they have a test; the tone -> token mapping stays
|
|
2690
|
+
// here because it is this template's colour question, not the server's.
|
|
2691
|
+
buildWorkOrderStatus(finalDashboardData.value?.workOrderStatus).map((row) => ({
|
|
2692
|
+
...row,
|
|
2693
|
+
color: `var(--${row.tone})`,
|
|
2694
|
+
}))
|
|
2695
|
+
);
|
|
2573
2696
|
|
|
2574
2697
|
// Security-mode lists
|
|
2575
2698
|
const activePatrolItems = computed<any[]>(
|
|
@@ -2762,7 +2885,21 @@ onUnmounted(() => {
|
|
|
2762
2885
|
watch(selectedRange, (period) => {
|
|
2763
2886
|
if (props.serviceType) {
|
|
2764
2887
|
const apiPeriod = periodValue[period];
|
|
2765
|
-
if (apiPeriod)
|
|
2888
|
+
if (apiPeriod) {
|
|
2889
|
+
currentPeriod.value = apiPeriod;
|
|
2890
|
+
/**
|
|
2891
|
+
* The component already READ `?period=` on mount and never wrote it
|
|
2892
|
+
* back, so a refresh - or a link sent to a colleague - silently returned
|
|
2893
|
+
* to Today while the button still said This Month. Writing it closes
|
|
2894
|
+
* both. `replace`, not `push`: changing the range is not a navigation
|
|
2895
|
+
* step the Back button should have to walk through.
|
|
2896
|
+
*
|
|
2897
|
+
* What each range MEANS is untouched here.
|
|
2898
|
+
*/
|
|
2899
|
+
if (route.query.period !== apiPeriod) {
|
|
2900
|
+
router.replace({ query: { ...route.query, period: apiPeriod } });
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2766
2903
|
}
|
|
2767
2904
|
emit("updatePeriod", { key: "__all__", period });
|
|
2768
2905
|
});
|
|
@@ -2782,7 +2919,9 @@ function getMetric(keys: string[]): DashboardMetric {
|
|
|
2782
2919
|
|
|
2783
2920
|
function getMetricValue(metric: DashboardMetric, key?: string) {
|
|
2784
2921
|
if (key === "patrolCompliance") {
|
|
2785
|
-
|
|
2922
|
+
const rate = toNumberOrNaN(metric.count);
|
|
2923
|
+
// `null` = no checkpoint was due in this period. Not 0%, not 100%.
|
|
2924
|
+
return Number.isFinite(rate) ? `${formatNumber(rate)}%` : "—";
|
|
2786
2925
|
}
|
|
2787
2926
|
const completed = toNumber(metric.completed);
|
|
2788
2927
|
const total = toNumber(metric.total);
|
|
@@ -2800,11 +2939,18 @@ function getMetricMeta(metric: DashboardMetric) {
|
|
|
2800
2939
|
parts.push(`${formatNumber(toNumber(metric.inProgress))} in progress`);
|
|
2801
2940
|
if (typeof metric.pending !== "undefined")
|
|
2802
2941
|
parts.push(`${formatNumber(toNumber(metric.pending))} pending`);
|
|
2803
|
-
|
|
2942
|
+
// `null` here is the API saying incident severity is not recorded at all.
|
|
2943
|
+
// Printing "0 high severity" would answer a question nothing measured.
|
|
2944
|
+
if (metric.highSeverity !== undefined && metric.highSeverity !== null)
|
|
2804
2945
|
parts.push(`${formatNumber(toNumber(metric.highSeverity))} high severity`);
|
|
2805
|
-
|
|
2946
|
+
// NOT a live occupancy figure. The server counts every visitor transaction
|
|
2947
|
+
// with a check-in and no check-out, with NO date bound, and nothing in the
|
|
2948
|
+
// product ever closes a stale one - so a pass left open years ago is still
|
|
2949
|
+
// in here. Say what it counts. Same wording the Visitor Management screen
|
|
2950
|
+
// already uses for the same filter ("Not Checked Out").
|
|
2951
|
+
if (metric.currentlyOnSite !== undefined && metric.currentlyOnSite !== null)
|
|
2806
2952
|
parts.push(
|
|
2807
|
-
`${formatNumber(toNumber(metric.currentlyOnSite))}
|
|
2953
|
+
`${formatNumber(toNumber(metric.currentlyOnSite))} not checked out (all time)`
|
|
2808
2954
|
);
|
|
2809
2955
|
if (
|
|
2810
2956
|
typeof (metric as any).missedCheckpoints !== "undefined" ||
|
|
@@ -3341,6 +3487,9 @@ function resetCustomizeDraft() {
|
|
|
3341
3487
|
|
|
3342
3488
|
function submitCustomize() {
|
|
3343
3489
|
visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
|
|
3490
|
+
// The write is the whole point - see `widgetPrefsCookie`. Saving on Submit
|
|
3491
|
+
// rather than on every switch keeps Cancel meaning cancel.
|
|
3492
|
+
widgetPrefsCookie.value = [...draftVisibleWidgetKeys.value];
|
|
3344
3493
|
widgetSearch.value = "";
|
|
3345
3494
|
customizeDialog.value = false;
|
|
3346
3495
|
}
|
|
@@ -3440,6 +3589,13 @@ async function exportDashboard(format: "PDF" | "Word") {
|
|
|
3440
3589
|
<body>
|
|
3441
3590
|
<h1>${props.title || dashboardTitle.value}</h1>
|
|
3442
3591
|
<div class="subtitle">Site: ${siteName} | Period: ${periodLabel}</div>
|
|
3592
|
+
${
|
|
3593
|
+
failedWidgetLabels.value.length
|
|
3594
|
+
? `<div class="subtitle" style="color:#b73535;">Incomplete report: ${failedWidgetLabels.value.join(
|
|
3595
|
+
", "
|
|
3596
|
+
)} could not be loaded. Any figure shown as — is missing, not zero.</div>`
|
|
3597
|
+
: ""
|
|
3598
|
+
}
|
|
3443
3599
|
`;
|
|
3444
3600
|
|
|
3445
3601
|
if (visibleKpiCards.value && visibleKpiCards.value.length > 0) {
|
|
@@ -3452,6 +3608,9 @@ async function exportDashboard(format: "PDF" | "Word") {
|
|
|
3452
3608
|
let kpiValueHtml = `<div class="kpi-value">${card.value}</div>`;
|
|
3453
3609
|
if (card.key === "supplyAlert" && Array.isArray(card.raw?.items)) {
|
|
3454
3610
|
kpiValueHtml = '<div style="font-size: 9pt; margin-top: 4px;">';
|
|
3611
|
+
if (!card.raw.items.length) {
|
|
3612
|
+
kpiValueHtml += "<div>No supplies recorded</div>";
|
|
3613
|
+
}
|
|
3455
3614
|
card.raw.items.forEach((item: any) => {
|
|
3456
3615
|
kpiValueHtml += `<div><strong>${item.name}</strong>: Balance ${item.qty}</div>`;
|
|
3457
3616
|
});
|
|
@@ -3757,6 +3916,21 @@ function toNumber(value: any) {
|
|
|
3757
3916
|
return Number.isFinite(n) ? n : 0;
|
|
3758
3917
|
}
|
|
3759
3918
|
|
|
3919
|
+
/**
|
|
3920
|
+
* A figure the server may report as "not measured".
|
|
3921
|
+
*
|
|
3922
|
+
* `toNumber()` turns `null` into `0`, which is wrong for anything the API
|
|
3923
|
+
* deliberately sends as `null` - a trend with no prior period to compare
|
|
3924
|
+
* against, a compliance rate with no checkpoint due, a severity the incident
|
|
3925
|
+
* form never records. Those must read as absent, not as zero. `NaN` is what
|
|
3926
|
+
* the tile already treats as "no pill" / "no figure".
|
|
3927
|
+
*/
|
|
3928
|
+
function toNumberOrNaN(value: any) {
|
|
3929
|
+
if (value === null || value === undefined || value === "") return NaN;
|
|
3930
|
+
const n = Number(value);
|
|
3931
|
+
return Number.isFinite(n) ? n : NaN;
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3760
3934
|
function normalizeStatus(value: any) {
|
|
3761
3935
|
const text = String(value || "").trim();
|
|
3762
3936
|
if (!text) return "To-Do";
|