@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GROUPED SIDEBAR - one heading per section, each printed once.
|
|
3
|
+
*
|
|
4
|
+
* The design draws the sidebar in labelled sections (OVERVIEW, SERVICE DESK,
|
|
5
|
+
* PROPERTY, SECURITY OPERATIONS, COMMUNITY, WORKFORCE, ADMINISTRATION). Each
|
|
6
|
+
* application builds its own menu array in its `layouts/default.vue`, gated
|
|
7
|
+
* item by item on permissions.
|
|
8
|
+
*
|
|
9
|
+
* The applications do not agree on the order they list their modules in
|
|
10
|
+
* (Property Management interleaves five sections; Landscape puts Feedbacks
|
|
11
|
+
* after Equipment). Walking that order and printing a heading whenever the
|
|
12
|
+
* section changed - what this file used to do - made Property Management draw
|
|
13
|
+
* SERVICE DESK, PROPERTY and SECURITY OPERATIONS TWICE each. It reads as a
|
|
14
|
+
* bug, so the items are now GROUPED:
|
|
15
|
+
*
|
|
16
|
+
* - every item is collected under its section, and each heading is printed
|
|
17
|
+
* exactly once, in the order the design lists the sections (which is the
|
|
18
|
+
* order they are declared in the map below).
|
|
19
|
+
* - inside a section the application's own order is preserved, so items that
|
|
20
|
+
* belong together stay in the sequence their author chose.
|
|
21
|
+
* - a title this map has never heard of inherits the section of the item
|
|
22
|
+
* above it, so a new menu item added by any of the eleven applications is
|
|
23
|
+
* grouped with its neighbours rather than disappearing or forcing a stray
|
|
24
|
+
* heading.
|
|
25
|
+
* - an item with no section at all (an unmapped FIRST item) leads the rail
|
|
26
|
+
* with no heading, exactly as before.
|
|
27
|
+
*
|
|
28
|
+
* This changes what ORDER the items are drawn in and nothing else. No item is
|
|
29
|
+
* added, hidden, renamed, re-routed or re-gated - the array that comes out
|
|
30
|
+
* holds the same objects as the array that went in.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const NAV_SECTIONS: Record<string, string> = {
|
|
34
|
+
// OVERVIEW
|
|
35
|
+
Dashboard: "OVERVIEW",
|
|
36
|
+
Home: "OVERVIEW",
|
|
37
|
+
|
|
38
|
+
// SERVICE DESK
|
|
39
|
+
"Service Providers": "SERVICE DESK",
|
|
40
|
+
Feedbacks: "SERVICE DESK",
|
|
41
|
+
"Work Orders": "SERVICE DESK",
|
|
42
|
+
"Online Forms": "SERVICE DESK",
|
|
43
|
+
|
|
44
|
+
// SERVICE DELIVERY - the five service applications' own working modules
|
|
45
|
+
"Cleaning Schedule": "SERVICE DELIVERY",
|
|
46
|
+
"Landscape Schedule": "SERVICE DELIVERY",
|
|
47
|
+
"Pest Schedule": "SERVICE DELIVERY",
|
|
48
|
+
"Pool Schedule": "SERVICE DELIVERY",
|
|
49
|
+
"Technician Schedule": "SERVICE DELIVERY",
|
|
50
|
+
Area: "SERVICE DELIVERY",
|
|
51
|
+
Unit: "SERVICE DELIVERY",
|
|
52
|
+
"Schedule Task": "SERVICE DELIVERY",
|
|
53
|
+
|
|
54
|
+
// EQUIPMENT
|
|
55
|
+
"Equipment Management": "EQUIPMENT",
|
|
56
|
+
"Equipment Items": "EQUIPMENT",
|
|
57
|
+
|
|
58
|
+
// PROPERTY
|
|
59
|
+
"Vehicle Mgmt": "PROPERTY",
|
|
60
|
+
"Building Mgmt": "PROPERTY",
|
|
61
|
+
"Visitor Mgmt": "PROPERTY",
|
|
62
|
+
"Pass & Key Mgmt": "PROPERTY",
|
|
63
|
+
"Facility Mgmt": "PROPERTY",
|
|
64
|
+
"People Mgmt": "PROPERTY",
|
|
65
|
+
"Document Mgmt": "PROPERTY",
|
|
66
|
+
"Access Mgmt": "PROPERTY",
|
|
67
|
+
"Event Mgmt": "PROPERTY",
|
|
68
|
+
"Emergency Contacts": "PROPERTY",
|
|
69
|
+
"Online Billing": "PROPERTY",
|
|
70
|
+
"SOA Mgmt": "PROPERTY",
|
|
71
|
+
|
|
72
|
+
// SECURITY OPERATIONS
|
|
73
|
+
"HID Face Reader": "SECURITY OPERATIONS",
|
|
74
|
+
"Daily Occurrence Books": "SECURITY OPERATIONS",
|
|
75
|
+
Manpower: "SECURITY OPERATIONS",
|
|
76
|
+
"Virtual Patrol": "SECURITY OPERATIONS",
|
|
77
|
+
"NFC Patrol": "SECURITY OPERATIONS",
|
|
78
|
+
"Robot Mgmt": "SECURITY OPERATIONS",
|
|
79
|
+
"Incident Reports": "SECURITY OPERATIONS",
|
|
80
|
+
CCTV: "SECURITY OPERATIONS",
|
|
81
|
+
|
|
82
|
+
// COMMUNITY
|
|
83
|
+
"Bulletin Board": "COMMUNITY",
|
|
84
|
+
"Bulletin Videos": "COMMUNITY",
|
|
85
|
+
|
|
86
|
+
// WORKFORCE
|
|
87
|
+
Attendance: "WORKFORCE",
|
|
88
|
+
"My Attendance": "WORKFORCE",
|
|
89
|
+
|
|
90
|
+
// ADMINISTRATION
|
|
91
|
+
Invitations: "ADMINISTRATION",
|
|
92
|
+
"SP Approvals": "ADMINISTRATION",
|
|
93
|
+
Members: "ADMINISTRATION",
|
|
94
|
+
"Roles & Permissions": "ADMINISTRATION",
|
|
95
|
+
Settings: "ADMINISTRATION",
|
|
96
|
+
"Site Settings": "ADMINISTRATION",
|
|
97
|
+
Organizations: "ADMINISTRATION",
|
|
98
|
+
Users: "ADMINISTRATION",
|
|
99
|
+
"Promo Codes": "ADMINISTRATION",
|
|
100
|
+
|
|
101
|
+
// ACCOUNT - `web-app-account` is a personal-settings application, not a site
|
|
102
|
+
"Personal Info": "ACCOUNT",
|
|
103
|
+
"Payment Methods": "ACCOUNT",
|
|
104
|
+
|
|
105
|
+
// SERVICES - the account application's list of the products a user can enter
|
|
106
|
+
"Property Management": "SERVICES",
|
|
107
|
+
Security: "SERVICES",
|
|
108
|
+
Hygiene: "SERVICES",
|
|
109
|
+
"Mechanical & Electrical": "SERVICES",
|
|
110
|
+
"Pest Control Services": "SERVICES",
|
|
111
|
+
"Landscaping Services": "SERVICES",
|
|
112
|
+
"Pool Maintenance Services": "SERVICES",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type TSectionedNavItem<T> = { item: T; section: string };
|
|
116
|
+
|
|
117
|
+
/** The design's section order - the order they are declared above. */
|
|
118
|
+
export const SECTION_ORDER: string[] = [
|
|
119
|
+
...new Set(Object.values(NAV_SECTIONS)),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Groups the menu by section and tags each item with the heading to print
|
|
124
|
+
* above it, or "" when the heading is already printed. Same length, same
|
|
125
|
+
* objects; only the order changes.
|
|
126
|
+
*/
|
|
127
|
+
export function withSections<T extends { title?: string }>(
|
|
128
|
+
items: T[]
|
|
129
|
+
): Array<TSectionedNavItem<T>> {
|
|
130
|
+
// An unmapped title inherits the section of the item above it.
|
|
131
|
+
let current = "";
|
|
132
|
+
const resolved = items.map((item, index) => {
|
|
133
|
+
current = NAV_SECTIONS[String(item?.title ?? "")] ?? current;
|
|
134
|
+
return { item, section: current, index };
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Sectionless items (an unmapped first item) rank -1 and lead the rail.
|
|
138
|
+
// Inside a section the application's own order is kept.
|
|
139
|
+
const grouped = [...resolved].sort(
|
|
140
|
+
(a, b) =>
|
|
141
|
+
SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section) ||
|
|
142
|
+
a.index - b.index
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
let printed = "";
|
|
146
|
+
return grouped.map(({ item, section }) => {
|
|
147
|
+
const label = section === printed ? "" : section;
|
|
148
|
+
printed = section;
|
|
149
|
+
return { item, section: label };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { collectAllPages, mapLogsToMonthlyRows } from "./nfc-patrol-report.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE DEFECT THIS FILE EXISTS FOR.
|
|
8
|
+
*
|
|
9
|
+
* The yearly patrol-compliance table was built from `page: 1, limit: 100` of a
|
|
10
|
+
* paged endpoint and never looked at `pages`. A route patrolled daily produces
|
|
11
|
+
* ~300 runs a year, so months past the first page rendered "0 / 0% / 0 / 0%" -
|
|
12
|
+
* indistinguishable from a month in which every checkpoint was missed, in a
|
|
13
|
+
* table a manager can print and hand to a client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 12 months x 25 runs x (4 completed + 1 skipped). Every month is 80%/20%. */
|
|
17
|
+
function yearOfLogs() {
|
|
18
|
+
const logs: Record<string, any>[] = [];
|
|
19
|
+
for (let m = 0; m < 12; m++) {
|
|
20
|
+
for (let d = 1; d <= 25; d++) {
|
|
21
|
+
const checkPoints = [
|
|
22
|
+
{ status: "Completed" },
|
|
23
|
+
{ status: "Completed" },
|
|
24
|
+
{ status: "Completed" },
|
|
25
|
+
{ status: "Completed" },
|
|
26
|
+
{ status: "Skipped" },
|
|
27
|
+
];
|
|
28
|
+
logs.push({
|
|
29
|
+
date: `2026-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T08:00:00.000Z`,
|
|
30
|
+
checkPoints,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return logs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pagedFetcher(all: Record<string, any>[], limit = 100) {
|
|
38
|
+
const pages = Math.ceil(all.length / limit);
|
|
39
|
+
const seen: number[] = [];
|
|
40
|
+
return {
|
|
41
|
+
seen,
|
|
42
|
+
fetchPage: async (page: number) => {
|
|
43
|
+
seen.push(page);
|
|
44
|
+
return { items: all.slice((page - 1) * limit, page * limit), pages };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("every page the endpoint reports is read, not just the first", async () => {
|
|
50
|
+
const all = yearOfLogs();
|
|
51
|
+
const { fetchPage, seen } = pagedFetcher(all);
|
|
52
|
+
|
|
53
|
+
const items = await collectAllPages(fetchPage);
|
|
54
|
+
|
|
55
|
+
assert.equal(items.length, all.length, "all 300 runs collected");
|
|
56
|
+
assert.deepEqual(seen, [1, 2, 3], "walked every page the reply declared");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a full year reports the same rate in every month", async () => {
|
|
60
|
+
const items = await collectAllPages(pagedFetcher(yearOfLogs()).fetchPage);
|
|
61
|
+
const rows = mapLogsToMonthlyRows(items);
|
|
62
|
+
|
|
63
|
+
assert.equal(rows.length, 12);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
assert.equal(row.checked, 100, `${row.month} checked`);
|
|
66
|
+
assert.equal(row.missed, 25, `${row.month} missed`);
|
|
67
|
+
assert.equal(row.checkedPercentage, 80, `${row.month} % checked`);
|
|
68
|
+
assert.equal(row.missedPercentage, 20, `${row.month} % missed`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("only the first page would have zeroed the rest of the year", () => {
|
|
73
|
+
// What the screen used to do: one page of 100, the other 200 runs dropped.
|
|
74
|
+
const firstPageOnly = yearOfLogs().slice(0, 100);
|
|
75
|
+
const rows = mapLogsToMonthlyRows(firstPageOnly);
|
|
76
|
+
|
|
77
|
+
assert.equal(rows[0].checkedPercentage, 80, "January measured");
|
|
78
|
+
assert.equal(rows[4].checked, 0, "May had no runs on page 1");
|
|
79
|
+
assert.equal(
|
|
80
|
+
rows[4].checkedPercentage,
|
|
81
|
+
null,
|
|
82
|
+
"and it must NOT read 0% - nothing was measured",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a month with no checkpoint due reports no rate at all", () => {
|
|
87
|
+
const rows = mapLogsToMonthlyRows([]);
|
|
88
|
+
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
assert.equal(row.checked, 0);
|
|
91
|
+
assert.equal(row.missed, 0);
|
|
92
|
+
assert.equal(row.checkedPercentage, null);
|
|
93
|
+
assert.equal(row.missedPercentage, null);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a month where every checkpoint was missed still reports 0%", () => {
|
|
98
|
+
const rows = mapLogsToMonthlyRows([
|
|
99
|
+
{ date: "2026-03-04T08:00:00.000Z", checkPoints: [{ status: "Skipped" }, { status: "Skipped" }] },
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
assert.equal(rows[2].checkedPercentage, 0, "0% here is a real measurement");
|
|
103
|
+
assert.equal(rows[2].missedPercentage, 100);
|
|
104
|
+
assert.equal(rows[0].checkedPercentage, null, "January measured nothing");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a single-page reply is not re-fetched", async () => {
|
|
108
|
+
const { fetchPage, seen } = pagedFetcher(yearOfLogs().slice(0, 10));
|
|
109
|
+
await collectAllPages(fetchPage);
|
|
110
|
+
assert.deepEqual(seen, [1]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a reply with no page count is treated as one page", async () => {
|
|
114
|
+
const calls: number[] = [];
|
|
115
|
+
const items = await collectAllPages(async (page) => {
|
|
116
|
+
calls.push(page);
|
|
117
|
+
return { items: [{ date: "2026-01-02T00:00:00.000Z", checkPoints: [] }] };
|
|
118
|
+
});
|
|
119
|
+
assert.deepEqual(calls, [1]);
|
|
120
|
+
assert.equal(items.length, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("undated or malformed logs are skipped, not counted into January", () => {
|
|
124
|
+
const rows = mapLogsToMonthlyRows([
|
|
125
|
+
{ date: "not-a-date", checkPoints: [{ status: "Completed" }] },
|
|
126
|
+
{ checkPoints: [{ status: "Completed" }] },
|
|
127
|
+
]);
|
|
128
|
+
assert.equal(rows[0].checked, 0);
|
|
129
|
+
assert.equal(rows[0].checkedPercentage, null);
|
|
130
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patrol-report arithmetic that is worth testing on its own.
|
|
3
|
+
*
|
|
4
|
+
* The yearly compliance table used to be built from `page: 1, limit: 100` of a
|
|
5
|
+
* paged endpoint. A route patrolled daily produces far more than 100 runs a
|
|
6
|
+
* year, so every month past the first page rendered "0 / 0% / 0 / 0%" - eight
|
|
7
|
+
* months of measured zeros in a table a manager can print and hand to a
|
|
8
|
+
* client. The page walk and the month mapping live here so they have tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One page of the patrol-log endpoint's reply. */
|
|
12
|
+
export type TPatrolLogPage = {
|
|
13
|
+
items?: Record<string, any>[];
|
|
14
|
+
pages?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TPatrolMonthRow = {
|
|
18
|
+
month: string;
|
|
19
|
+
checked: number;
|
|
20
|
+
missed: number;
|
|
21
|
+
/** `null` when no checkpoint was due that month - a rate over nothing is not a measurement. */
|
|
22
|
+
checkedPercentage: number | null;
|
|
23
|
+
missedPercentage: number | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const MONTHS = [
|
|
27
|
+
"January",
|
|
28
|
+
"February",
|
|
29
|
+
"March",
|
|
30
|
+
"April",
|
|
31
|
+
"May",
|
|
32
|
+
"June",
|
|
33
|
+
"July",
|
|
34
|
+
"August",
|
|
35
|
+
"September",
|
|
36
|
+
"October",
|
|
37
|
+
"November",
|
|
38
|
+
"December",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read every page the endpoint says exists, not just the first.
|
|
43
|
+
*
|
|
44
|
+
* ponytail: sequential. A year of one route is a handful of pages, and firing
|
|
45
|
+
* them in parallel would only trade a few hundred ms for a burst on the API.
|
|
46
|
+
* `maxPages` is a runaway guard, not a product limit - if it ever bites, the
|
|
47
|
+
* caller is asking for a range the report was not designed for.
|
|
48
|
+
*/
|
|
49
|
+
export async function collectAllPages(
|
|
50
|
+
fetchPage: (page: number) => Promise<TPatrolLogPage>,
|
|
51
|
+
maxPages = 50,
|
|
52
|
+
): Promise<Record<string, any>[]> {
|
|
53
|
+
const first = await fetchPage(1);
|
|
54
|
+
const items = [...(first?.items ?? [])];
|
|
55
|
+
const pages = Math.min(Number(first?.pages) || 1, maxPages);
|
|
56
|
+
|
|
57
|
+
for (let page = 2; page <= pages; page++) {
|
|
58
|
+
const next = await fetchPage(page);
|
|
59
|
+
items.push(...(next?.items ?? []));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One row per calendar month. A month with no checkpoint due keeps its zero
|
|
67
|
+
* counts - zero checkpoints really were checked - but reports `null` for both
|
|
68
|
+
* percentages, because a percentage of nothing is not a measurement and "0%"
|
|
69
|
+
* reads exactly like total failure.
|
|
70
|
+
*/
|
|
71
|
+
export function mapLogsToMonthlyRows(
|
|
72
|
+
logs: Record<string, any>[],
|
|
73
|
+
): TPatrolMonthRow[] {
|
|
74
|
+
const rows: TPatrolMonthRow[] = MONTHS.map((month) => ({
|
|
75
|
+
month,
|
|
76
|
+
checked: 0,
|
|
77
|
+
missed: 0,
|
|
78
|
+
checkedPercentage: null,
|
|
79
|
+
missedPercentage: null,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
for (const log of logs) {
|
|
83
|
+
const monthIndex = new Date(log?.date).getMonth();
|
|
84
|
+
if (Number.isNaN(monthIndex)) continue;
|
|
85
|
+
|
|
86
|
+
for (const checkpoint of log?.checkPoints ?? []) {
|
|
87
|
+
if (checkpoint?.status === "Completed") rows[monthIndex].checked++;
|
|
88
|
+
else if (checkpoint?.status === "Skipped") rows[monthIndex].missed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const total = row.checked + row.missed;
|
|
94
|
+
if (total > 0) {
|
|
95
|
+
row.checkedPercentage = Math.round((row.checked / total) * 100);
|
|
96
|
+
row.missedPercentage = Math.round((row.missed / total) * 100);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
|
|
5
|
+
import { showSiteField } from "./role.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* QA on the admin app (PR #18) reported "no site options" on Create Role. The
|
|
9
|
+
* Site field is read-only by design - it shows the site a role belongs to -
|
|
10
|
+
* but an admin role has no site, so it drew permanently disabled and empty.
|
|
11
|
+
* `RolePermissionFormCreate` defaulted `siteState` to `true` and
|
|
12
|
+
* `RolePermissionMain` neither declared nor forwarded it, and its root is a
|
|
13
|
+
* `<div>`, so a fallthrough attribute could not reach the dialog either: no
|
|
14
|
+
* consuming app could switch the field off.
|
|
15
|
+
*/
|
|
16
|
+
test("an admin role never shows the site field", () => {
|
|
17
|
+
assert.equal(showSiteField(true, "admin"), false);
|
|
18
|
+
assert.equal(showSiteField(false, "admin"), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("every other role type keeps what the app asked for", () => {
|
|
22
|
+
for (const type of [
|
|
23
|
+
"app",
|
|
24
|
+
"organization",
|
|
25
|
+
"security_agency",
|
|
26
|
+
"cleaning_services",
|
|
27
|
+
]) {
|
|
28
|
+
assert.equal(showSiteField(true, type), true, type);
|
|
29
|
+
assert.equal(showSiteField(false, type), false, type);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("RolePermissionMain declares site-state and forwards it to the dialog", () => {
|
|
34
|
+
const src = readFileSync(
|
|
35
|
+
new URL("../components/RolePermissionMain.vue", import.meta.url),
|
|
36
|
+
"utf8"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
assert.match(src, /siteState:\s*\{/, "site-state must be a declared prop");
|
|
40
|
+
assert.match(
|
|
41
|
+
src,
|
|
42
|
+
/:site-state="siteFieldShown"/,
|
|
43
|
+
"the dialog must receive the resolved value, not a fallthrough attribute"
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The category expand toggle carried no class, so it fell back to raw Vuetify
|
|
49
|
+
* `inset` styling: a white thumb on a white card, measured 1.02:1 light /
|
|
50
|
+
* 1.04:1 dark against the dialog surface. `.app-switch` (primitives.css) is
|
|
51
|
+
* this layer's switch shape and is what makes the thumb visible. `inset` is
|
|
52
|
+
* removed with it - Vuetify's inset dimensions beat the `.app-switch` rules on
|
|
53
|
+
* source order and collapse the control to zero width.
|
|
54
|
+
*/
|
|
55
|
+
for (const file of [
|
|
56
|
+
"RolePermissionFormCreate.vue",
|
|
57
|
+
"RolePermissionFormPreviewUpdate.vue",
|
|
58
|
+
]) {
|
|
59
|
+
test(`${file} styles its category toggle with .app-switch`, () => {
|
|
60
|
+
const src = readFileSync(
|
|
61
|
+
new URL(`../components/${file}`, import.meta.url),
|
|
62
|
+
"utf8"
|
|
63
|
+
);
|
|
64
|
+
const sw = src.slice(src.indexOf("<v-switch"), src.indexOf("/>", src.indexOf("<v-switch")));
|
|
65
|
+
|
|
66
|
+
assert.ok(sw.includes('class="app-switch"'), "missing .app-switch");
|
|
67
|
+
assert.ok(!/\binset\b/.test(sw), "inset fights the .app-switch dimensions");
|
|
68
|
+
});
|
|
69
|
+
}
|
package/utils/role.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the Create-role dialog should show its (read-only) Site field.
|
|
3
|
+
*
|
|
4
|
+
* An admin role is org-wide, never site-scoped, so there is no site to show -
|
|
5
|
+
* the field could only ever render empty and permanently disabled, which is
|
|
6
|
+
* what QA reported on the admin app. Every other role type keeps whatever the
|
|
7
|
+
* consuming app asked for.
|
|
8
|
+
*/
|
|
9
|
+
export const showSiteField = (siteState: boolean, type: string): boolean =>
|
|
10
|
+
siteState && type !== "admin";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { pickRouteName } from "./route-name.ts";
|
|
8
|
+
|
|
9
|
+
const routerWith = (...names: string[]) => ({
|
|
10
|
+
hasRoute: (name: string) => names.includes(name),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("picks the first candidate this app actually has", () => {
|
|
14
|
+
const router = routerWith("org-site-keys-visitor-pass-add");
|
|
15
|
+
assert.equal(
|
|
16
|
+
pickRouteName(router, "org-site-keys-visitor-pass-add", "keys-visitor-pass-add"),
|
|
17
|
+
"org-site-keys-visitor-pass-add"
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("falls through to a later candidate for a flatter app", () => {
|
|
22
|
+
const router = routerWith("keys-visitor-pass-add");
|
|
23
|
+
assert.equal(
|
|
24
|
+
pickRouteName(router, "org-site-keys-visitor-pass-add", "keys-visitor-pass-add"),
|
|
25
|
+
"keys-visitor-pass-add"
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("candidate order wins when an app has both", () => {
|
|
30
|
+
const router = routerWith("keys-visitor-pass-add", "org-site-keys-visitor-pass-add");
|
|
31
|
+
assert.equal(
|
|
32
|
+
pickRouteName(router, "org-site-keys-visitor-pass-add", "keys-visitor-pass-add"),
|
|
33
|
+
"org-site-keys-visitor-pass-add"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("null when the app has no such page - the caller hides the control", () => {
|
|
38
|
+
assert.equal(pickRouteName(routerWith("org-site-work-orders"), "org-site-work-orders-id"), null);
|
|
39
|
+
assert.equal(pickRouteName(routerWith()), null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The five names two app-side audits found dead. Each is now resolved through
|
|
43
|
+
// `pickRouteName`, so none may reappear as a literal anywhere in the layer.
|
|
44
|
+
// `organizations-create` is deliberately absent: it is real in web-app-org and
|
|
45
|
+
// is only reached there, so it was never dead.
|
|
46
|
+
const DEAD_NAMES = [
|
|
47
|
+
"keys-visitor-pass-add",
|
|
48
|
+
"org-organizations-customers-add",
|
|
49
|
+
"work-order-details",
|
|
50
|
+
"org-site-service-provider-mgmt-billing",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
54
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
const path = join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) sourceFiles(path, out);
|
|
57
|
+
else if (/\.(vue|ts)$/.test(entry.name)) out.push(path);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("no dead route name survives as a literal in the layer", () => {
|
|
63
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
64
|
+
const files = ["components", "pages", "layouts", "composables", "middleware", "plugins"]
|
|
65
|
+
.flatMap((d) => sourceFiles(join(root, d)))
|
|
66
|
+
.filter((f) => !f.endsWith(".test.ts"));
|
|
67
|
+
|
|
68
|
+
assert.ok(files.length > 100, `expected to scan the layer, scanned ${files.length} files`);
|
|
69
|
+
|
|
70
|
+
const offenders: string[] = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const source = readFileSync(file, "utf8");
|
|
73
|
+
for (const dead of DEAD_NAMES) {
|
|
74
|
+
// Only a `name:` BINDING counts. The same string also appears as a bare
|
|
75
|
+
// argument in a `pickRouteName(...)` candidate list, which is the fix, not
|
|
76
|
+
// the fault. Plain string matching on purpose - a backslash class inside a
|
|
77
|
+
// template literal silently loses its escape and matches nothing.
|
|
78
|
+
const bound = [`"${dead}"`, `'${dead}'`].some(
|
|
79
|
+
(quoted) => source.includes(`name: ${quoted}`) || source.includes(`name:${quoted}`)
|
|
80
|
+
);
|
|
81
|
+
if (bound) offenders.push(`${file.slice(root.length)} -> ${dead}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
assert.deepEqual(offenders, [], `dead route names still bound:\n${offenders.join("\n")}`);
|
|
85
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the route name the RUNNING app actually has.
|
|
3
|
+
*
|
|
4
|
+
* This layer is extended by apps whose pages sit at different depths, so the
|
|
5
|
+
* same screen has a different Nuxt route name in each of them - the service
|
|
6
|
+
* apps produce `org-site-keys-visitor-pass-add` from
|
|
7
|
+
* `pages/[org]/[site]/keys/visitor/pass/add.vue`, while a flatter app would
|
|
8
|
+
* produce `keys-visitor-pass-add`. A literal name hard-coded in the layer is
|
|
9
|
+
* therefore correct in some consumers and throws in the rest, because
|
|
10
|
+
* `router.push({ name })` on an unknown name is a hard error.
|
|
11
|
+
*
|
|
12
|
+
* Pass the candidates most-specific-first. Returns the first one this app has,
|
|
13
|
+
* or `null` when it has none - and `null` means "this app has no such page",
|
|
14
|
+
* which is the caller's cue to hide the control rather than navigate somewhere
|
|
15
|
+
* wrong.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: vue-router's own `hasRoute`, no name table to keep in sync.
|
|
18
|
+
*/
|
|
19
|
+
export function pickRouteName(
|
|
20
|
+
router: { hasRoute: (name: string) => boolean },
|
|
21
|
+
...candidates: string[]
|
|
22
|
+
): string | null {
|
|
23
|
+
return candidates.find((name) => router.hasRoute(name)) ?? null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { statusTone, statusToneClass } from "./status.ts";
|
|
5
|
+
|
|
6
|
+
test("the handoff's mapping, word for word", () => {
|
|
7
|
+
for (const s of ["Completed", "Paid", "Available", "Open"]) {
|
|
8
|
+
assert.equal(statusTone(s), "ok", s);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
assert.equal(statusTone("Pending"), "warn");
|
|
12
|
+
|
|
13
|
+
for (const s of ["In Use", "Closed", "Checkout"]) {
|
|
14
|
+
assert.equal(statusTone(s), "err", s);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
assert.equal(statusTone("Role"), "info");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** The same status arrives spelled three ways from three endpoints. */
|
|
21
|
+
test("casing, spacing and underscores do not change a status's colour", () => {
|
|
22
|
+
for (const s of ["in use", "IN USE", "In_Use", "in-use", " In Use "]) {
|
|
23
|
+
assert.equal(statusTone(s), "err", s);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The important one. A status the design never named must NOT be guessed into
|
|
29
|
+
* a colour - a wrong green on a failed job reads as success. Neutral is
|
|
30
|
+
* visible, honest, and gets it noticed.
|
|
31
|
+
*/
|
|
32
|
+
test("an unmapped status comes out neutral rather than guessed", () => {
|
|
33
|
+
for (const s of ["Cancelled", "Overdue", "Draft", "", null, undefined]) {
|
|
34
|
+
assert.equal(statusTone(s), "neutral", String(s));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* PHASE 4. Each of these words is on a module screen today with a colour a
|
|
40
|
+
* per-screen `getStatusColor` gave it. The list has to keep giving it the SAME
|
|
41
|
+
* tone, or adopting the shared chip would quietly repaint a status.
|
|
42
|
+
*/
|
|
43
|
+
test("the product's own status words keep the tone the screens already gave them", () => {
|
|
44
|
+
for (const s of ["Accepted", "Approved", "Resolved", "Active"]) {
|
|
45
|
+
assert.equal(statusTone(s), "ok", s);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const s of ["Rejected", "Deleted", "Suspended"]) {
|
|
49
|
+
assert.equal(statusTone(s), "err", s);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const s of ["In Progress", "In-Progress", "To-Do", "Awaiting Approval", "Replaced", "Returned"]) {
|
|
53
|
+
assert.equal(statusTone(s), "warn", s);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const s of ["Ongoing", "For Review", "Assigned"]) {
|
|
57
|
+
assert.equal(statusTone(s), "info", s);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const s of ["Inactive", "Expired"]) {
|
|
61
|
+
assert.equal(statusTone(s), "neutral", s);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* HID enrolment. These two are the whole point of the Status column on
|
|
67
|
+
* `HidUserEnrollment` - if they were left unlisted they would BOTH be neutral
|
|
68
|
+
* and the column would stop distinguishing anything, so they are pinned.
|
|
69
|
+
*/
|
|
70
|
+
test("Mapped and Unmapped keep the tones the enrolment screen already gave them", () => {
|
|
71
|
+
assert.equal(statusTone("Mapped"), "ok");
|
|
72
|
+
assert.equal(statusTone("Unmapped"), "warn");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* HID intercom SIP state. "Disabled" and "Not connected" must stay NEUTRAL -
|
|
77
|
+
* painting an intentionally-off intercom red would read as a fault.
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* HID access authorization, landing on `staging` in b0667a7. Pinned before that
|
|
81
|
+
* merge so the words cannot arrive and quietly render neutral - the difference
|
|
82
|
+
* between a granted and a denied door is the whole point of the column.
|
|
83
|
+
*/
|
|
84
|
+
test("HID authorization states are known to the shared map before b0667a7 merges", () => {
|
|
85
|
+
assert.equal(statusTone("Authorized"), "ok");
|
|
86
|
+
assert.equal(statusTone("Not Authorized"), "err");
|
|
87
|
+
assert.equal(statusTone("Unknown"), "warn");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("intercom SIP states keep the tones the intercom screen already gave them", () => {
|
|
91
|
+
assert.equal(statusTone("Connected"), "ok");
|
|
92
|
+
assert.equal(statusTone("Connecting"), "warn");
|
|
93
|
+
assert.equal(statusTone("Not connected"), "neutral");
|
|
94
|
+
assert.equal(statusTone("Disabled"), "neutral");
|
|
95
|
+
assert.equal(statusTone("Failed"), "err");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The one place the handoff and the old per-screen colour disagree: `Open` was
|
|
100
|
+
* grey on the cleaning schedules and the design names it `ok`. Pinned so the
|
|
101
|
+
* disagreement is a decision on the record rather than a surprise.
|
|
102
|
+
*/
|
|
103
|
+
test("Open follows the design, not the old grey", () => {
|
|
104
|
+
assert.equal(statusTone("open"), "ok");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("the class name matches what tokens.css actually defines", () => {
|
|
108
|
+
assert.equal(statusToneClass("Paid"), "tone-ok");
|
|
109
|
+
assert.equal(statusToneClass("Whatever"), "tone-neutral");
|
|
110
|
+
});
|