@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.
- package/assets/css/primitives.css +1113 -0
- package/assets/css/screens.css +1097 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +236 -0
- package/components/AcceptDialog.vue +17 -18
- package/components/AccessCard/AvailableStats.vue +24 -16
- package/components/AccessCardAddForm.vue +42 -42
- package/components/AccessCardAssignToUnitForm.vue +13 -21
- package/components/AccessCardDeleteDialog.vue +41 -39
- package/components/AccessCardDetailsDialog.vue +125 -79
- package/components/AccessCardPreviewDialog.vue +58 -84
- package/components/AccessCardQrTagging.vue +60 -63
- package/components/AccessCardReplaceForm.vue +27 -23
- package/components/AccessManagement.vue +86 -85
- package/components/AddEqupmentForm.vue +18 -22
- package/components/AddPassKeyToVisitor.vue +95 -73
- package/components/AppButton.vue +62 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +49 -0
- package/components/AppField.vue +76 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +247 -0
- package/components/AreaChecklistHistoryLogs.vue +51 -44
- package/components/AreaChecklistHistoryMain.vue +14 -7
- package/components/AreaFormDialog.vue +71 -59
- package/components/AreaMain.vue +73 -107
- package/components/AttachmentsViewer.vue +84 -22
- package/components/AttendanceCheckInOutDialog.vue +85 -88
- package/components/AttendanceDetailsDialog.vue +57 -63
- package/components/AttendanceMain.vue +4 -3
- package/components/AttendanceMapSearchDialog.vue +42 -44
- package/components/AttendanceSettingsDialog.vue +79 -63
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BtnUploadFile.vue +53 -11
- package/components/BuildingForm.vue +34 -27
- package/components/BuildingManagement/buildings.vue +39 -39
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BuildingUnitFormAdd.vue +20 -23
- package/components/BuildingUnitFormEdit.vue +8 -4
- package/components/BulletinBoardForm.vue +19 -24
- package/components/BulletinBoardManagement.vue +49 -57
- package/components/BulletinBoardView.vue +9 -6
- package/components/BulletinExpirationChip.vue +13 -4
- package/components/Button/Close.vue +2 -2
- package/components/CameraForm.vue +21 -22
- package/components/CameraMain.vue +50 -49
- package/components/Card/DeleteConfirmation.vue +24 -22
- package/components/Card/MemberInfoSummary.vue +28 -3
- package/components/Card/Toggle.vue +15 -3
- package/components/CardToolbar.vue +37 -0
- package/components/Carousel.vue +56 -36
- package/components/Chat/Bubbles.vue +43 -14
- package/components/Chat/Information.vue +49 -32
- package/components/Chat/ListCard.vue +38 -9
- package/components/Chat/Message.vue +41 -15
- package/components/Chat/Navigation.vue +27 -15
- package/components/Chat/SkeletonLoader.vue +9 -2
- package/components/ChecklistItemRow.vue +25 -1
- package/components/CleaningScheduleMain.vue +44 -49
- package/components/ClientDetailForm.vue +81 -56
- package/components/ClientMain.vue +92 -82
- package/components/ConfirmDialog.vue +14 -8
- package/components/Container/Standard.vue +9 -7
- package/components/CreateSubsciptionPlan.vue +148 -89
- package/components/DashboardEmptyState.vue +48 -3
- package/components/DashboardMain.vue +924 -676
- package/components/DashboardPanel.vue +84 -21
- package/components/DeliveryCompany.vue +34 -20
- package/components/Dialog/DeleteConfirmation.vue +20 -21
- package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
- package/components/Dialog/ReusablePrompt.vue +30 -29
- package/components/Dialog/UpdateMoreAction.vue +62 -59
- package/components/DocumentForm.vue +14 -21
- package/components/DocumentManagement.vue +162 -108
- package/components/DocumentViewer.vue +29 -12
- package/components/DrawImage.vue +29 -21
- package/components/EntryPass/QrTemplatePreview.vue +27 -16
- package/components/EntryPassInformation.vue +40 -10
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +84 -97
- package/components/EquipmentManagementMain.vue +81 -103
- package/components/Facility/BookingSetup.vue +43 -52
- package/components/Feedback/Form.vue +26 -12
- package/components/FeedbackMain.vue +35 -49
- package/components/FileInput.vue +75 -58
- package/components/FileInputWithList.vue +59 -12
- package/components/FormDialog.vue +60 -21
- package/components/HidAccessLogDashboard.vue +512 -490
- package/components/HidCameraCaptureDialog.vue +216 -0
- package/components/HidEnabledGate.vue +67 -9
- package/components/HidIntercomManagement.vue +1322 -653
- package/components/HidQrCodeConfiguration.vue +1030 -258
- package/components/HidReaderForm.vue +142 -59
- package/components/HidReaderManagement.vue +211 -323
- package/components/HidServiceSettingsPanel.vue +44 -13
- package/components/HidUserEnrollment.vue +1185 -412
- package/components/HygieneUpdateMoreAction.vue +44 -44
- package/components/IncidentReport/Authorities.vue +30 -18
- package/components/IncidentReport/IncidentInformation.vue +10 -10
- package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
- package/components/IncidentReport/affectedEntities.vue +41 -20
- package/components/Input/Date.vue +3 -0
- package/components/Input/DatePicker.vue +1 -2
- package/components/Input/DateTimePicker.vue +1 -1
- package/components/Input/File.vue +55 -10
- package/components/Input/FileV2.vue +39 -6
- package/components/Input/InputPhoneNumberV2.vue +2 -1
- package/components/Input/ListGroupSelection.vue +56 -8
- package/components/Input/NRICNumber.vue +1 -0
- package/components/Input/NewDate.vue +1 -0
- package/components/Input/Number.vue +1 -0
- package/components/Input/Password.vue +1 -0
- package/components/Input/PhoneNumber.vue +21 -1
- package/components/Input/VehicleNumber.vue +1 -0
- package/components/InputLabel.vue +2 -2
- package/components/InvitationClientForm.vue +21 -19
- package/components/InvitationForm.vue +15 -21
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +263 -36
- package/components/Layout/NavigationDrawer.vue +300 -30
- package/components/ListItem.vue +33 -3
- package/components/LocalPagination.vue +27 -3
- package/components/ManageChecklistMain.vue +136 -86
- package/components/MemberInformation.vue +32 -16
- package/components/MemberMain.vue +113 -93
- package/components/MyAttendanceMain.vue +3 -5
- package/components/NavigationItem.vue +83 -3
- package/components/Nfc/NFCPatrolReportMain.vue +47 -33
- package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
- package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
- package/components/Nfc/NFCPatrolSettings.vue +14 -4
- package/components/Nfc/NFCTagForm.vue +22 -8
- package/components/Nfc/NFCTagMain.vue +41 -43
- package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
- package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
- package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
- package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
- package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
- package/components/NotificationSettings.vue +373 -0
- package/components/NumberSettingField.vue +9 -2
- package/components/OnlineFormConfigurationForm.vue +725 -237
- package/components/OnlineFormFill.vue +213 -106
- package/components/OnlineFormRenderPreview.vue +25 -30
- package/components/OnlineFormsConfiguration.vue +113 -109
- package/components/OrgViewDialog.vue +34 -16
- package/components/OvernightParkingAvailability.vue +49 -26
- package/components/OvernightParkingManagement.vue +78 -18
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +109 -53
- package/components/PasswordConfirmation.vue +44 -26
- package/components/PeopleForm.vue +41 -20
- package/components/PhotoUpload.vue +110 -48
- package/components/PlaceholderComponent.vue +35 -19
- package/components/PlanActionsMenu.vue +12 -2
- package/components/PlanStatusBadge.vue +7 -8
- package/components/PlateNumberDisplay.vue +9 -6
- package/components/QrTemplate/PrintDialog.vue +66 -43
- package/components/RolePermissionFormCreate.vue +104 -79
- package/components/RolePermissionFormPreviewUpdate.vue +111 -109
- package/components/RolePermissionMain.vue +183 -81
- package/components/ScanVisitorQRCode.vue +99 -35
- package/components/ScheduleAreaMain.vue +34 -59
- package/components/ScheduleTaskForm.vue +61 -42
- package/components/ScheduleTaskMain.vue +56 -69
- package/components/SearchVehicleNumberUser.vue +39 -37
- package/components/ServiceProviderFormCreate.vue +29 -20
- package/components/ServiceProviderInvitationPrompt.vue +155 -0
- package/components/ServiceProviderMain.vue +601 -432
- package/components/Signature.vue +40 -34
- package/components/SignaturePad.vue +5 -29
- package/components/SiteSettings.vue +41 -11
- package/components/SiteSettingsWorkOrder.vue +61 -30
- package/components/SlideCardGroup.vue +35 -11
- package/components/Snackbar.vue +36 -3
- package/components/SpecificAttr.vue +18 -8
- package/components/StatusChip.vue +156 -0
- package/components/StockCard.vue +17 -12
- package/components/SubscriptionPlanTable.vue +207 -140
- package/components/SwitchContext.vue +201 -9
- package/components/TableHygiene.vue +36 -46
- package/components/TableList.vue +10 -10
- package/components/TableListSecondary.vue +105 -54
- package/components/TableMain.vue +251 -78
- package/components/TableV3.vue +61 -70
- package/components/TiptapEditor.vue +33 -40
- package/components/TiptapEditorQr.vue +5 -2
- package/components/Tooltip/Info.vue +69 -24
- package/components/UnitMain.vue +40 -60
- package/components/UnitPersonCard.vue +42 -13
- package/components/VehicleAddSelection.vue +48 -32
- package/components/VehicleForm.vue +112 -129
- package/components/VehicleManagement.vue +26 -12
- package/components/VehicleUpdateMoreAction.vue +59 -46
- package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
- package/components/VisitorForm.vue +774 -210
- package/components/VisitorFormSelection.vue +40 -37
- package/components/VisitorManagement.vue +239 -82
- package/components/VisitorPassKeyQRScanner.vue +65 -18
- package/components/VisitorSearchResultSummary.vue +60 -16
- package/components/VisitorSocketPopUp.vue +22 -14
- package/components/VisitorsReportPreview.vue +44 -29
- package/components/WorkOrder/Create.vue +26 -17
- package/components/WorkOrder/ListView.vue +38 -7
- package/components/WorkOrder/Main.vue +62 -59
- package/composables/useFeedback.ts +3 -3
- package/composables/useHidAmico.ts +269 -0
- package/composables/useHidNavigation.ts +2 -9
- package/composables/useMember.ts +3 -3
- package/composables/useNFCPatrolDailyReport.ts +13 -0
- package/composables/useNFCPatrolMonthlyReport.ts +22 -56
- package/composables/useNFCPatrolReport.ts +13 -0
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useRole.ts +22 -0
- package/composables/useServiceProvider.ts +3 -3
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +336 -0
- package/composables/useSubscriptionPlan.ts +62 -0
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/composables/useWebUsb.ts +127 -37
- package/composables/useWorkOrder.ts +17 -3
- package/error.vue +30 -6
- package/nuxt.config.ts +36 -0
- package/package.json +25 -4
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/pages/notification-settings.vue +19 -0
- package/pages/payment-method-linked.vue +34 -10
- package/pages/require-customer.vue +48 -10
- package/pages/require-organization-membership.vue +32 -8
- package/pages/unauthorized.vue +29 -4
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +69 -2
- package/types/nfc-patrol-report.ts +3 -2
- package/types/site.d.ts +71 -0
- package/types/subscription.ts +18 -11
- 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/data.test.ts +95 -0
- package/utils/data.ts +40 -2
- package/utils/hid-discover.test.ts +102 -0
- package/utils/list-page.test.ts +60 -0
- package/utils/list-page.ts +38 -0
- package/utils/materialSymbols.ts +363 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/nfc-patrol-report.test.ts +130 -0
- package/utils/nfc-patrol-report.ts +101 -0
- package/utils/role.test.ts +69 -0
- package/utils/role.ts +10 -0
- package/utils/route-name.test.ts +85 -0
- package/utils/route-name.ts +24 -0
- package/utils/status.test.ts +110 -0
- package/utils/status.ts +135 -0
- package/utils/table-card.test.ts +84 -0
- package/utils/theme-aa-ledger.ts +148 -0
- package/utils/theme.test.ts +485 -7
- package/utils/theme.ts +295 -110
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.editorconfig +0 -12
- package/.github/workflows/main.yml +0 -17
- package/.github/workflows/publish-staging.yml +0 -47
- package/.github/workflows/publish.yml +0 -39
- package/PUBLISHING.md +0 -269
- package/components/HidIdentityMapping.vue +0 -975
- package/components/ScheduleTaskAreaFormDialog.vue +0 -144
- package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
- package/components/ScheduleTastTicketMain.vue +0 -182
- package/components/TableWithButton.vue +0 -94
- package/test/visitor-socket.test.mjs +0 -36
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { buildWorkOrderStatus } from "./dashboard.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE DEFECT THIS FILE EXISTS FOR.
|
|
8
|
+
*
|
|
9
|
+
* The Property Management "Work Order Status" panel read
|
|
10
|
+
* `workOrderStatusSummary` - a key no endpoint returns - and fell through to
|
|
11
|
+
* the `openWorkOrder` KPI metric, which carries no `completed` field. So
|
|
12
|
+
* Completed read 0 on every site on every day, and Pending was silently
|
|
13
|
+
* recomputed as (open work orders - in progress) instead of read.
|
|
14
|
+
*
|
|
15
|
+
* The shape below is the one `new-dashboard.repo.ts` actually sends.
|
|
16
|
+
*/
|
|
17
|
+
test("the server's breakdown is read, not recomputed", () => {
|
|
18
|
+
const rows = buildWorkOrderStatus({
|
|
19
|
+
pending: 20,
|
|
20
|
+
inProgress: 12,
|
|
21
|
+
completed: 55,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
rows.map((r) => [r.label, r.value]),
|
|
26
|
+
[
|
|
27
|
+
["Completed", 55],
|
|
28
|
+
["In Progress", 12],
|
|
29
|
+
["Pending", 20],
|
|
30
|
+
]
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The exact regression. Before the fix this same input drew
|
|
36
|
+
* Completed 0 / In Progress 12 / Pending 28.
|
|
37
|
+
*/
|
|
38
|
+
test("Completed is never zero when the server says it is not", () => {
|
|
39
|
+
const completed = buildWorkOrderStatus({
|
|
40
|
+
pending: 20,
|
|
41
|
+
inProgress: 12,
|
|
42
|
+
completed: 55,
|
|
43
|
+
}).find((r) => r.label === "Completed");
|
|
44
|
+
|
|
45
|
+
assert.equal(completed?.value, 55);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bars are share-of-largest-bucket, so the biggest is always full width
|
|
50
|
+
* and the rest are proportional to it. This is the widths the panel draws.
|
|
51
|
+
*/
|
|
52
|
+
test("each bar is its share of the largest bucket", () => {
|
|
53
|
+
const rows = buildWorkOrderStatus({ pending: 25, inProgress: 50, completed: 100 });
|
|
54
|
+
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
rows.map((r) => r.percent),
|
|
57
|
+
[100, 50, 25]
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Three empty buckets must draw three empty bars, not divide by zero. */
|
|
62
|
+
test("an all-zero breakdown does not divide by zero", () => {
|
|
63
|
+
const rows = buildWorkOrderStatus({ pending: 0, inProgress: 0, completed: 0 });
|
|
64
|
+
|
|
65
|
+
assert.equal(rows.length, 3);
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
assert.equal(row.value, 0);
|
|
68
|
+
assert.equal(row.percent, 0, row.label);
|
|
69
|
+
assert.ok(Number.isFinite(row.percent), row.label);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A partial payload is read for what it has and zeroed for what it does not. */
|
|
74
|
+
test("missing buckets read as zero rather than undefined", () => {
|
|
75
|
+
const rows = buildWorkOrderStatus({ completed: 7 });
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
rows.map((r) => r.value),
|
|
79
|
+
[7, 0, 0]
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* NO FALLBACK, DELIBERATELY. When the breakdown is absent there is nothing
|
|
85
|
+
* honest to draw, so the panel must show its empty state - not bars built out
|
|
86
|
+
* of a different metric. An empty array is what triggers that.
|
|
87
|
+
*/
|
|
88
|
+
test("no breakdown means no bars, never invented ones", () => {
|
|
89
|
+
assert.deepEqual(buildWorkOrderStatus(undefined), []);
|
|
90
|
+
assert.deepEqual(buildWorkOrderStatus(null), []);
|
|
91
|
+
assert.deepEqual(buildWorkOrderStatus(42 as never), []);
|
|
92
|
+
assert.deepEqual(buildWorkOrderStatus("55" as never), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The tone is a token NAME, not a colour. The template writes `var(--<tone>)`,
|
|
97
|
+
* so a tone that is not a real token silently paints nothing.
|
|
98
|
+
*/
|
|
99
|
+
test("every row carries a real design token name", () => {
|
|
100
|
+
const rows = buildWorkOrderStatus({ pending: 1, inProgress: 2, completed: 3 });
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
rows.map((r) => r.tone),
|
|
104
|
+
["ok", "err", "warn"]
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The buckets are the whole of the server's status report - it folds every
|
|
110
|
+
* unrecognised status into `pending` rather than dropping it - so nothing the
|
|
111
|
+
* server counted goes missing on the way to the screen.
|
|
112
|
+
*/
|
|
113
|
+
test("the three buckets account for the whole breakdown", () => {
|
|
114
|
+
const src = { pending: 20, inProgress: 12, completed: 55 };
|
|
115
|
+
const total = buildWorkOrderStatus(src).reduce((sum, r) => sum + r.value, 0);
|
|
116
|
+
|
|
117
|
+
assert.equal(total, src.pending + src.inProgress + src.completed);
|
|
118
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard arithmetic that is worth testing on its own.
|
|
3
|
+
*
|
|
4
|
+
* `DashboardMain.vue` is 4,800 lines of template and wiring, and none of it is
|
|
5
|
+
* reachable from `node --test`. The one piece with real arithmetic in it - the
|
|
6
|
+
* work-order status breakdown - lives here instead, so the defect that made
|
|
7
|
+
* "Completed" read zero on every Property Management dashboard has a test that
|
|
8
|
+
* fails if it ever comes back.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type TWorkOrderStatusSource = {
|
|
12
|
+
pending?: number;
|
|
13
|
+
inProgress?: number;
|
|
14
|
+
completed?: number;
|
|
15
|
+
} | null | undefined;
|
|
16
|
+
|
|
17
|
+
export type TWorkOrderStatusRow = {
|
|
18
|
+
label: string;
|
|
19
|
+
value: number;
|
|
20
|
+
/** Share of the largest bucket, 0-100 - the bar's width. */
|
|
21
|
+
percent: number;
|
|
22
|
+
/** A design token name, not a colour: the caller writes `var(--<tone>)`. */
|
|
23
|
+
tone: "ok" | "err" | "warn";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The three status bars, from the breakdown the server sends as
|
|
28
|
+
* `workOrderStatus` (new-dashboard.repo.ts, property branch).
|
|
29
|
+
*
|
|
30
|
+
* NO FALLBACK. The screen used to read `workOrderStatusSummary` - a key no
|
|
31
|
+
* endpoint returns - and fall through to the `openWorkOrder` KPI metric, which
|
|
32
|
+
* carries no `completed` field: Completed was therefore ALWAYS 0 and Pending
|
|
33
|
+
* was silently recomputed as (open work orders - in progress) instead of read.
|
|
34
|
+
* Without the real breakdown there is nothing honest to draw, so this returns
|
|
35
|
+
* an empty list and the panel shows its empty state.
|
|
36
|
+
*/
|
|
37
|
+
export function buildWorkOrderStatus(
|
|
38
|
+
src: TWorkOrderStatusSource
|
|
39
|
+
): TWorkOrderStatusRow[] {
|
|
40
|
+
if (!src || typeof src !== "object") return [];
|
|
41
|
+
|
|
42
|
+
const completed = Number(src.completed ?? 0);
|
|
43
|
+
const inProgress = Number(src.inProgress ?? 0);
|
|
44
|
+
const pending = Number(src.pending ?? 0);
|
|
45
|
+
// `1` floors the divisor: three empty buckets must not divide by zero.
|
|
46
|
+
const max = Math.max(completed, inProgress, pending, 1);
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
{ label: "Completed", value: completed, percent: (completed / max) * 100, tone: "ok" },
|
|
50
|
+
{ label: "In Progress", value: inProgress, percent: (inProgress / max) * 100, tone: "err" },
|
|
51
|
+
{ label: "Pending", value: pending, percent: (pending / max) * 100, tone: "warn" },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { findServiceProviderIdBySite, levelCount } from "./data.ts";
|
|
5
|
+
|
|
6
|
+
const SITE = "68f05fcc0c11062938dadada";
|
|
7
|
+
const OTHER_SITE = "68f05fcc0c11062938d00001";
|
|
8
|
+
const PROVIDER = "68f05fcc0c11062938d00002";
|
|
9
|
+
|
|
10
|
+
test("returns the provider that covers this site", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
findServiceProviderIdBySite(
|
|
13
|
+
"service-provider",
|
|
14
|
+
[{ _id: PROVIDER, siteId: SITE }],
|
|
15
|
+
SITE
|
|
16
|
+
),
|
|
17
|
+
PROVIDER
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a non-service-provider account is not scoped by provider at all", () => {
|
|
22
|
+
assert.equal(findServiceProviderIdBySite("customer", [], SITE), null);
|
|
23
|
+
assert.equal(findServiceProviderIdBySite("", undefined, SITE), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returning null here would drop the provider filter and show the whole site -
|
|
28
|
+
* another provider's work orders included. Refusing is the correct behaviour.
|
|
29
|
+
*/
|
|
30
|
+
test("refuses rather than falling back to an unscoped request", () => {
|
|
31
|
+
assert.throws(() =>
|
|
32
|
+
findServiceProviderIdBySite(
|
|
33
|
+
"service-provider",
|
|
34
|
+
[{ _id: PROVIDER, siteId: OTHER_SITE }],
|
|
35
|
+
SITE
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The thrown message is rendered verbatim in a toast on six applications, so it
|
|
42
|
+
* is part of the user interface. Assert what it SAYS, not that it throws: the
|
|
43
|
+
* old message passed a "throws" assertion while quoting a raw ObjectId at a
|
|
44
|
+
* security officer and naming no action.
|
|
45
|
+
*/
|
|
46
|
+
test("the message names an action and never quotes an internal id", () => {
|
|
47
|
+
for (const providers of [[{ _id: PROVIDER, siteId: OTHER_SITE }], [], undefined]) {
|
|
48
|
+
let message = "";
|
|
49
|
+
try {
|
|
50
|
+
findServiceProviderIdBySite("service-provider", providers, SITE);
|
|
51
|
+
assert.fail("expected it to refuse");
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
message = error.message;
|
|
54
|
+
}
|
|
55
|
+
assert.doesNotMatch(message, /[0-9a-f]{24}/i, `leaks an id: ${message}`);
|
|
56
|
+
assert.doesNotMatch(message, /siteId/i, `internal wording: ${message}`);
|
|
57
|
+
assert.match(message, /not linked to this site/i);
|
|
58
|
+
assert.match(message, /administrator/i);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An account with no serviceProviders field at all used to die on
|
|
64
|
+
* `undefined.find(...)` - a TypeError, which the toast renders as a raw
|
|
65
|
+
* JavaScript error instead of the sentence above.
|
|
66
|
+
*/
|
|
67
|
+
test("an account with no provider list still gets the sentence, not a TypeError", () => {
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => findServiceProviderIdBySite("service-provider", undefined, SITE),
|
|
70
|
+
(error: any) => error.constructor === Error && !/undefined/.test(error.message)
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A block whose record carries no `levels` array used to throw during render
|
|
76
|
+
* (`Cannot read properties of undefined (reading 'length')`), which blanks the
|
|
77
|
+
* entire blocks table - not just the offending row.
|
|
78
|
+
*/
|
|
79
|
+
test("a block with no levels field counts as zero instead of throwing", () => {
|
|
80
|
+
assert.equal(levelCount({ levels: undefined }), 0);
|
|
81
|
+
assert.equal(levelCount({}), 0);
|
|
82
|
+
assert.equal(levelCount(undefined), 0);
|
|
83
|
+
assert.equal(levelCount(null), 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a block with no levels field is not confused with a block that has none", () => {
|
|
87
|
+
assert.equal(levelCount({ levels: [] }), 0);
|
|
88
|
+
assert.equal(levelCount({ levels: [{ name: "1" }, { name: "2" }] }), 2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a non-array levels value is counted as zero, not as its own length", () => {
|
|
92
|
+
// The API has been seen to answer with a count rather than the array.
|
|
93
|
+
assert.equal(levelCount({ levels: 7 as unknown as [] }), 0);
|
|
94
|
+
assert.equal(levelCount({ levels: "12" as unknown as [] }), 0);
|
|
95
|
+
});
|
package/utils/data.ts
CHANGED
|
@@ -30,17 +30,40 @@ export const errorConverter = (data: any): string => {
|
|
|
30
30
|
return error;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A service-provider account can only be shown ITS OWN work orders, feedbacks
|
|
35
|
+
* and key logs, so every one of those screens scopes its request by the
|
|
36
|
+
* provider record that links this account to the site currently in the URL.
|
|
37
|
+
*
|
|
38
|
+
* When that link does not exist there is nothing to scope by, and returning
|
|
39
|
+
* null instead would drop the filter and show the WHOLE site - another
|
|
40
|
+
* provider's work orders included. So this still refuses, deliberately.
|
|
41
|
+
*
|
|
42
|
+
* What it refuses WITH is the part that matters. The message is thrown straight
|
|
43
|
+
* into a toast on six applications, so it is written for the person reading it:
|
|
44
|
+
* it says what is missing and what to do about it. It used to read
|
|
45
|
+
* "Service provider with siteId 68f0...dadada not found", which quotes an
|
|
46
|
+
* internal identifier at a security officer and names no action - the screen
|
|
47
|
+
* looked broken when the truth was simply that this site is not one of theirs.
|
|
48
|
+
*
|
|
49
|
+
* The site list in the sidebar offers EVERY site in the organisation, not only
|
|
50
|
+
* the ones this account covers, so an officer can walk into this by picking a
|
|
51
|
+
* neighbouring site. That is why the wording assumes an ordinary mistake and
|
|
52
|
+
* not a fault.
|
|
53
|
+
*/
|
|
33
54
|
export const findServiceProviderIdBySite = (
|
|
34
55
|
type: string,
|
|
35
56
|
serviceProviders: any,
|
|
36
57
|
siteId: string
|
|
37
58
|
) => {
|
|
38
59
|
if (type === "service-provider") {
|
|
39
|
-
const result = serviceProviders
|
|
60
|
+
const result = serviceProviders?.find(
|
|
40
61
|
(item: Record<string, any>) => item.siteId === siteId
|
|
41
62
|
);
|
|
42
63
|
if (!result?._id) {
|
|
43
|
-
throw new Error(
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Your service provider account is not linked to this site, so there is nothing to show here. Choose a site your company covers, or ask your administrator to link your company to this one."
|
|
66
|
+
);
|
|
44
67
|
}
|
|
45
68
|
return result?._id;
|
|
46
69
|
}
|
|
@@ -137,3 +160,18 @@ export const generateRefNumber = () => {
|
|
|
137
160
|
const ref = (timestamp + random).substring(0, 10).toUpperCase();
|
|
138
161
|
return ref;
|
|
139
162
|
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* HOW MANY LEVELS A BLOCK HAS.
|
|
166
|
+
*
|
|
167
|
+
* `levels` is optional on a building record - a block created before levels
|
|
168
|
+
* were captured, or one the list endpoint returns without them, simply has no
|
|
169
|
+
* such field. Two screens read `building.levels.length` directly, which throws
|
|
170
|
+
* a TypeError DURING RENDER: Vue tears the component down and the whole blocks
|
|
171
|
+
* table disappears, so one incomplete row blanks every other row with it.
|
|
172
|
+
*
|
|
173
|
+
* Counting in one place means both screens - and anything added later - answer
|
|
174
|
+
* "no levels recorded" with a zero instead of taking the table down.
|
|
175
|
+
*/
|
|
176
|
+
export const levelCount = (building?: { levels?: unknown } | null): number =>
|
|
177
|
+
Array.isArray(building?.levels) ? building.levels.length : 0;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import useHidAmico, { isUnknownSiteFieldError } from "../composables/useHidAmico.ts";
|
|
8
|
+
|
|
9
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
type Call = { url: string; body: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The composable calls the Nuxt auto-import `useNuxtApp().$api(...)`. Install a
|
|
15
|
+
* stub for it so the request body can be read without a Nuxt app.
|
|
16
|
+
*/
|
|
17
|
+
function stubApi(handler: (call: Call) => unknown) {
|
|
18
|
+
const calls: Call[] = [];
|
|
19
|
+
(globalThis as Record<string, unknown>).useNuxtApp = () => ({
|
|
20
|
+
$api: (url: string, options: { body: Record<string, unknown> }) => {
|
|
21
|
+
const call = { url, body: options.body };
|
|
22
|
+
calls.push(call);
|
|
23
|
+
return Promise.resolve(handler(call));
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return calls;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fetchError(message: string) {
|
|
30
|
+
return Object.assign(new Error("400 Bad Request"), { data: { message } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("discoverReader sends site in the request body", async () => {
|
|
34
|
+
const calls = stubApi(() => ({ data: { deviceId: "D1", portals: [] } }));
|
|
35
|
+
|
|
36
|
+
await useHidAmico().discoverReader({
|
|
37
|
+
baseUrl: "https://reader.example",
|
|
38
|
+
username: "operator",
|
|
39
|
+
password: "secret",
|
|
40
|
+
site: "0123456789abcdef01234567",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(calls.length, 1);
|
|
44
|
+
assert.equal(calls[0].url, "/api/access-management/hid/readers/discover");
|
|
45
|
+
assert.equal(calls[0].body.site, "0123456789abcdef01234567");
|
|
46
|
+
assert.equal(calls[0].body.baseUrl, "https://reader.example");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("discoverReader retries without site when the API predates core #1878", async () => {
|
|
50
|
+
const calls = stubApi((call) => {
|
|
51
|
+
if ("site" in call.body) throw fetchError('"site" is not allowed');
|
|
52
|
+
return { data: { deviceId: "D1", portals: [] } };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const response = await useHidAmico().discoverReader({
|
|
56
|
+
baseUrl: "https://reader.example",
|
|
57
|
+
username: "operator",
|
|
58
|
+
password: "secret",
|
|
59
|
+
site: "0123456789abcdef01234567",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(calls.length, 2);
|
|
63
|
+
assert.equal("site" in calls[1].body, false);
|
|
64
|
+
assert.equal(calls[1].body.baseUrl, "https://reader.example");
|
|
65
|
+
assert.deepEqual(response, { data: { deviceId: "D1", portals: [] } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("discoverReader does not retry a real refusal", async () => {
|
|
69
|
+
const calls = stubApi(() => {
|
|
70
|
+
throw fetchError("You are not authorized to manage readers for this site.");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await assert.rejects(
|
|
74
|
+
useHidAmico().discoverReader({
|
|
75
|
+
baseUrl: "https://reader.example",
|
|
76
|
+
username: "operator",
|
|
77
|
+
password: "secret",
|
|
78
|
+
site: "0123456789abcdef01234567",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
assert.equal(calls.length, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("isUnknownSiteFieldError only matches the unknown-key message", () => {
|
|
85
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"site" is not allowed')), true);
|
|
86
|
+
assert.equal(isUnknownSiteFieldError(new Error('"site" is not allowed')), true);
|
|
87
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"site" is required')), false);
|
|
88
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"baseUrl" is not allowed')), false);
|
|
89
|
+
assert.equal(isUnknownSiteFieldError(fetchError("Unauthorized")), false);
|
|
90
|
+
assert.equal(isUnknownSiteFieldError(null), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the reader form takes site as a prop and puts it in the discover call", () => {
|
|
94
|
+
const source = readFileSync(join(root, "components", "HidReaderForm.vue"), "utf8");
|
|
95
|
+
assert.match(source, /site:\s*\{\s*type:\s*String/);
|
|
96
|
+
assert.match(source, /discoverReader\(\{[\s\S]*?site:\s*props\.site[\s\S]*?\}\)/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("reader management passes its site prop down to the form", () => {
|
|
100
|
+
const source = readFileSync(join(root, "components", "HidReaderManagement.vue"), "utf8");
|
|
101
|
+
assert.match(source, /<HidReaderForm[\s\S]*?:site="site"[\s\S]*?\/>/);
|
|
102
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readListPage, NO_PAGE_RANGE } from "./list-page.ts";
|
|
4
|
+
|
|
5
|
+
const rows = (n: number, from = 1) =>
|
|
6
|
+
Array.from({ length: n }, (_, i) => ({ _id: `row-${from + i}` }));
|
|
7
|
+
|
|
8
|
+
describe("readListPage", () => {
|
|
9
|
+
it("keeps the server's total when it is bigger than the page - the failing case", () => {
|
|
10
|
+
// 47 announcements served 10 at a time. Deriving from the page gives
|
|
11
|
+
// "10" and one page, which is the defect this replaces.
|
|
12
|
+
const got = readListPage({ items: rows(10), pages: 5, pageRange: "1-10 of 47" });
|
|
13
|
+
|
|
14
|
+
assert.equal(got.items.length, 10);
|
|
15
|
+
assert.equal(got.pages, 5);
|
|
16
|
+
assert.equal(got.pageRange, "1-10 of 47");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps the server's page 2 range rather than re-slicing", () => {
|
|
20
|
+
const got = readListPage({ items: rows(10, 11), pages: 5, pageRange: "11-20 of 47" });
|
|
21
|
+
|
|
22
|
+
assert.equal(got.items.length, 10);
|
|
23
|
+
assert.equal(got.items[0]._id, "row-11");
|
|
24
|
+
assert.equal(got.pageRange, "11-20 of 47");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("passes a single page through unchanged", () => {
|
|
28
|
+
const got = readListPage({ items: rows(7), pages: 1, pageRange: "1-7 of 7" });
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
{ pages: got.pages, pageRange: got.pageRange, n: got.items.length },
|
|
32
|
+
{ pages: 1, pageRange: "1-7 of 7", n: 7 }
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reports a genuinely empty list as the server described it", () => {
|
|
37
|
+
const got = readListPage({ items: [], pages: 0, pageRange: "0 - 0 of 0" });
|
|
38
|
+
|
|
39
|
+
assert.deepEqual(got, { items: [], pages: 0, pageRange: "0 - 0 of 0" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not invent a zero when the request failed and there is no reply", () => {
|
|
43
|
+
for (const reply of [null, undefined]) {
|
|
44
|
+
const got = readListPage(reply);
|
|
45
|
+
|
|
46
|
+
assert.deepEqual(got.items, []);
|
|
47
|
+
assert.equal(got.pages, 0);
|
|
48
|
+
// "0 of 0" would be a statement about the data. This is not one.
|
|
49
|
+
assert.equal(got.pageRange, NO_PAGE_RANGE);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not fall back to the page length when the envelope omits its totals", () => {
|
|
54
|
+
const got = readListPage({ items: rows(10) });
|
|
55
|
+
|
|
56
|
+
assert.equal(got.pages, 0);
|
|
57
|
+
assert.equal(got.pageRange, NO_PAGE_RANGE);
|
|
58
|
+
assert.notEqual(got.pageRange, "1-10 of 10");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READING A PAGINATED LIST REPLY.
|
|
3
|
+
*
|
|
4
|
+
* Every paginated endpoint in this product answers with an envelope: the rows
|
|
5
|
+
* for the page that was asked for, plus `pages` and `pageRange` describing the
|
|
6
|
+
* WHOLE list. Screens kept re-deriving those two from the rows they had just
|
|
7
|
+
* been handed - `items.length` is the size of one page, not the size of the
|
|
8
|
+
* list - so a list of 47 announcements presented itself as 10 and its pager
|
|
9
|
+
* was told there was one page.
|
|
10
|
+
*
|
|
11
|
+
* This is the one place that reads the envelope, so the rule is stated once:
|
|
12
|
+
* take the server's own totals, and when the reply is missing (a failed
|
|
13
|
+
* request) say so with a placeholder rather than inventing a zero.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const NO_PAGE_RANGE = "-- - -- of --";
|
|
17
|
+
|
|
18
|
+
export type TListPageReply<T> = {
|
|
19
|
+
items?: T[] | null;
|
|
20
|
+
pages?: number | null;
|
|
21
|
+
pageRange?: string | null;
|
|
22
|
+
} | null | undefined;
|
|
23
|
+
|
|
24
|
+
export type TListPage<T> = {
|
|
25
|
+
items: T[];
|
|
26
|
+
pages: number;
|
|
27
|
+
pageRange: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function readListPage<T>(reply: TListPageReply<T>): TListPage<T> {
|
|
31
|
+
return {
|
|
32
|
+
items: reply?.items ?? [],
|
|
33
|
+
// The server's page COUNT for the whole list. Never ceil(items/limit).
|
|
34
|
+
pages: reply?.pages ?? 0,
|
|
35
|
+
// The server's own "1-10 of 47" string. Never rebuilt from the page.
|
|
36
|
+
pageRange: reply?.pageRange || NO_PAGE_RANGE,
|
|
37
|
+
};
|
|
38
|
+
}
|