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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/assets/css/primitives.css +1113 -0
  2. package/assets/css/screens.css +1097 -0
  3. package/assets/css/shell.css +258 -0
  4. package/assets/css/tokens.css +236 -0
  5. package/components/AcceptDialog.vue +17 -18
  6. package/components/AccessCard/AvailableStats.vue +24 -16
  7. package/components/AccessCardAddForm.vue +42 -42
  8. package/components/AccessCardAssignToUnitForm.vue +13 -21
  9. package/components/AccessCardDeleteDialog.vue +41 -39
  10. package/components/AccessCardDetailsDialog.vue +125 -79
  11. package/components/AccessCardPreviewDialog.vue +58 -84
  12. package/components/AccessCardQrTagging.vue +60 -63
  13. package/components/AccessCardReplaceForm.vue +27 -23
  14. package/components/AccessManagement.vue +86 -85
  15. package/components/AddEqupmentForm.vue +18 -22
  16. package/components/AddPassKeyToVisitor.vue +95 -73
  17. package/components/AppButton.vue +62 -0
  18. package/components/AppCard.vue +20 -0
  19. package/components/AppDateField.vue +49 -0
  20. package/components/AppField.vue +76 -0
  21. package/components/AppKpiTile.vue +65 -0
  22. package/components/AppSegmented.vue +38 -0
  23. package/components/AppSelect.vue +247 -0
  24. package/components/AreaChecklistHistoryLogs.vue +51 -44
  25. package/components/AreaChecklistHistoryMain.vue +14 -7
  26. package/components/AreaFormDialog.vue +71 -59
  27. package/components/AreaMain.vue +73 -107
  28. package/components/AttachmentsViewer.vue +84 -22
  29. package/components/AttendanceCheckInOutDialog.vue +85 -88
  30. package/components/AttendanceDetailsDialog.vue +57 -63
  31. package/components/AttendanceMain.vue +4 -3
  32. package/components/AttendanceMapSearchDialog.vue +42 -44
  33. package/components/AttendanceSettingsDialog.vue +79 -63
  34. package/components/Avatar/Main.vue +54 -2
  35. package/components/BillingMain.vue +26 -30
  36. package/components/BtnUploadFile.vue +53 -11
  37. package/components/BuildingForm.vue +34 -27
  38. package/components/BuildingManagement/buildings.vue +39 -39
  39. package/components/BuildingManagement/units.vue +26 -42
  40. package/components/BuildingUnitFormAdd.vue +20 -23
  41. package/components/BuildingUnitFormEdit.vue +8 -4
  42. package/components/BulletinBoardForm.vue +19 -24
  43. package/components/BulletinBoardManagement.vue +49 -57
  44. package/components/BulletinBoardView.vue +9 -6
  45. package/components/BulletinExpirationChip.vue +13 -4
  46. package/components/Button/Close.vue +2 -2
  47. package/components/CameraForm.vue +21 -22
  48. package/components/CameraMain.vue +50 -49
  49. package/components/Card/DeleteConfirmation.vue +24 -22
  50. package/components/Card/MemberInfoSummary.vue +28 -3
  51. package/components/Card/Toggle.vue +15 -3
  52. package/components/CardToolbar.vue +37 -0
  53. package/components/Carousel.vue +56 -36
  54. package/components/Chat/Bubbles.vue +43 -14
  55. package/components/Chat/Information.vue +49 -32
  56. package/components/Chat/ListCard.vue +38 -9
  57. package/components/Chat/Message.vue +41 -15
  58. package/components/Chat/Navigation.vue +27 -15
  59. package/components/Chat/SkeletonLoader.vue +9 -2
  60. package/components/ChecklistItemRow.vue +25 -1
  61. package/components/CleaningScheduleMain.vue +44 -49
  62. package/components/ClientDetailForm.vue +81 -56
  63. package/components/ClientMain.vue +92 -82
  64. package/components/ConfirmDialog.vue +14 -8
  65. package/components/Container/Standard.vue +9 -7
  66. package/components/CreateSubsciptionPlan.vue +148 -89
  67. package/components/DashboardEmptyState.vue +48 -3
  68. package/components/DashboardMain.vue +924 -676
  69. package/components/DashboardPanel.vue +84 -21
  70. package/components/DeliveryCompany.vue +34 -20
  71. package/components/Dialog/DeleteConfirmation.vue +20 -21
  72. package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
  73. package/components/Dialog/ReusablePrompt.vue +30 -29
  74. package/components/Dialog/UpdateMoreAction.vue +62 -59
  75. package/components/DocumentForm.vue +14 -21
  76. package/components/DocumentManagement.vue +162 -108
  77. package/components/DocumentViewer.vue +29 -12
  78. package/components/DrawImage.vue +29 -21
  79. package/components/EntryPass/QrTemplatePreview.vue +27 -16
  80. package/components/EntryPassInformation.vue +40 -10
  81. package/components/EntryPassMain.vue +49 -27
  82. package/components/EquipmentItemMain.vue +84 -97
  83. package/components/EquipmentManagementMain.vue +81 -103
  84. package/components/Facility/BookingSetup.vue +43 -52
  85. package/components/Feedback/Form.vue +26 -12
  86. package/components/FeedbackMain.vue +35 -49
  87. package/components/FileInput.vue +75 -58
  88. package/components/FileInputWithList.vue +59 -12
  89. package/components/FormDialog.vue +60 -21
  90. package/components/HidAccessLogDashboard.vue +512 -490
  91. package/components/HidCameraCaptureDialog.vue +216 -0
  92. package/components/HidEnabledGate.vue +67 -9
  93. package/components/HidIntercomManagement.vue +1322 -653
  94. package/components/HidQrCodeConfiguration.vue +1030 -258
  95. package/components/HidReaderForm.vue +142 -59
  96. package/components/HidReaderManagement.vue +211 -323
  97. package/components/HidServiceSettingsPanel.vue +44 -13
  98. package/components/HidUserEnrollment.vue +1185 -412
  99. package/components/HygieneUpdateMoreAction.vue +44 -44
  100. package/components/IncidentReport/Authorities.vue +30 -18
  101. package/components/IncidentReport/IncidentInformation.vue +10 -10
  102. package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
  103. package/components/IncidentReport/affectedEntities.vue +41 -20
  104. package/components/Input/Date.vue +3 -0
  105. package/components/Input/DatePicker.vue +1 -2
  106. package/components/Input/DateTimePicker.vue +1 -1
  107. package/components/Input/File.vue +55 -10
  108. package/components/Input/FileV2.vue +39 -6
  109. package/components/Input/InputPhoneNumberV2.vue +2 -1
  110. package/components/Input/ListGroupSelection.vue +56 -8
  111. package/components/Input/NRICNumber.vue +1 -0
  112. package/components/Input/NewDate.vue +1 -0
  113. package/components/Input/Number.vue +1 -0
  114. package/components/Input/Password.vue +1 -0
  115. package/components/Input/PhoneNumber.vue +21 -1
  116. package/components/Input/VehicleNumber.vue +1 -0
  117. package/components/InputLabel.vue +2 -2
  118. package/components/InvitationClientForm.vue +21 -19
  119. package/components/InvitationForm.vue +15 -21
  120. package/components/InvitationMain.vue +46 -61
  121. package/components/Layout/Header.vue +263 -36
  122. package/components/Layout/NavigationDrawer.vue +300 -30
  123. package/components/ListItem.vue +33 -3
  124. package/components/LocalPagination.vue +27 -3
  125. package/components/ManageChecklistMain.vue +136 -86
  126. package/components/MemberInformation.vue +32 -16
  127. package/components/MemberMain.vue +113 -93
  128. package/components/MyAttendanceMain.vue +3 -5
  129. package/components/NavigationItem.vue +83 -3
  130. package/components/Nfc/NFCPatrolReportMain.vue +47 -33
  131. package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
  132. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  133. package/components/Nfc/NFCPatrolSettings.vue +14 -4
  134. package/components/Nfc/NFCTagForm.vue +22 -8
  135. package/components/Nfc/NFCTagMain.vue +41 -43
  136. package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
  137. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
  138. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
  139. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
  140. package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
  141. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
  142. package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
  143. package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
  144. package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
  145. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
  146. package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
  147. package/components/NotificationSettings.vue +373 -0
  148. package/components/NumberSettingField.vue +9 -2
  149. package/components/OnlineFormConfigurationForm.vue +725 -237
  150. package/components/OnlineFormFill.vue +213 -106
  151. package/components/OnlineFormRenderPreview.vue +25 -30
  152. package/components/OnlineFormsConfiguration.vue +113 -109
  153. package/components/OrgViewDialog.vue +34 -16
  154. package/components/OvernightParkingAvailability.vue +49 -26
  155. package/components/OvernightParkingManagement.vue +78 -18
  156. package/components/PageHeader.vue +43 -0
  157. package/components/PassInformation.vue +109 -53
  158. package/components/PasswordConfirmation.vue +44 -26
  159. package/components/PeopleForm.vue +41 -20
  160. package/components/PhotoUpload.vue +110 -48
  161. package/components/PlaceholderComponent.vue +35 -19
  162. package/components/PlanActionsMenu.vue +12 -2
  163. package/components/PlanStatusBadge.vue +7 -8
  164. package/components/PlateNumberDisplay.vue +9 -6
  165. package/components/QrTemplate/PrintDialog.vue +66 -43
  166. package/components/RolePermissionFormCreate.vue +104 -79
  167. package/components/RolePermissionFormPreviewUpdate.vue +111 -109
  168. package/components/RolePermissionMain.vue +183 -81
  169. package/components/ScanVisitorQRCode.vue +99 -35
  170. package/components/ScheduleAreaMain.vue +34 -59
  171. package/components/ScheduleTaskForm.vue +61 -42
  172. package/components/ScheduleTaskMain.vue +56 -69
  173. package/components/SearchVehicleNumberUser.vue +39 -37
  174. package/components/ServiceProviderFormCreate.vue +29 -20
  175. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  176. package/components/ServiceProviderMain.vue +601 -432
  177. package/components/Signature.vue +40 -34
  178. package/components/SignaturePad.vue +5 -29
  179. package/components/SiteSettings.vue +41 -11
  180. package/components/SiteSettingsWorkOrder.vue +61 -30
  181. package/components/SlideCardGroup.vue +35 -11
  182. package/components/Snackbar.vue +36 -3
  183. package/components/SpecificAttr.vue +18 -8
  184. package/components/StatusChip.vue +156 -0
  185. package/components/StockCard.vue +17 -12
  186. package/components/SubscriptionPlanTable.vue +207 -140
  187. package/components/SwitchContext.vue +201 -9
  188. package/components/TableHygiene.vue +36 -46
  189. package/components/TableList.vue +10 -10
  190. package/components/TableListSecondary.vue +105 -54
  191. package/components/TableMain.vue +251 -78
  192. package/components/TableV3.vue +61 -70
  193. package/components/TiptapEditor.vue +33 -40
  194. package/components/TiptapEditorQr.vue +5 -2
  195. package/components/Tooltip/Info.vue +69 -24
  196. package/components/UnitMain.vue +40 -60
  197. package/components/UnitPersonCard.vue +42 -13
  198. package/components/VehicleAddSelection.vue +48 -32
  199. package/components/VehicleForm.vue +112 -129
  200. package/components/VehicleManagement.vue +26 -12
  201. package/components/VehicleUpdateMoreAction.vue +59 -46
  202. package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
  203. package/components/VisitorForm.vue +774 -210
  204. package/components/VisitorFormSelection.vue +40 -37
  205. package/components/VisitorManagement.vue +239 -82
  206. package/components/VisitorPassKeyQRScanner.vue +65 -18
  207. package/components/VisitorSearchResultSummary.vue +60 -16
  208. package/components/VisitorSocketPopUp.vue +22 -14
  209. package/components/VisitorsReportPreview.vue +44 -29
  210. package/components/WorkOrder/Create.vue +26 -17
  211. package/components/WorkOrder/ListView.vue +38 -7
  212. package/components/WorkOrder/Main.vue +62 -59
  213. package/composables/useFeedback.ts +3 -3
  214. package/composables/useHidAmico.ts +269 -0
  215. package/composables/useHidNavigation.ts +2 -9
  216. package/composables/useMember.ts +3 -3
  217. package/composables/useNFCPatrolDailyReport.ts +13 -0
  218. package/composables/useNFCPatrolMonthlyReport.ts +22 -56
  219. package/composables/useNFCPatrolReport.ts +13 -0
  220. package/composables/useNotificationPreference.ts +87 -0
  221. package/composables/useOnlineFormPages.ts +2 -0
  222. package/composables/useRole.ts +22 -0
  223. package/composables/useServiceProvider.ts +3 -3
  224. package/composables/useServiceProviderInvitation.ts +145 -0
  225. package/composables/useSipWebPhone.ts +336 -0
  226. package/composables/useSubscriptionPlan.ts +62 -0
  227. package/composables/useThemePreference.ts +63 -0
  228. package/composables/useVisitor.ts +6 -1
  229. package/composables/useWebUsb.ts +127 -37
  230. package/composables/useWorkOrder.ts +17 -3
  231. package/error.vue +30 -6
  232. package/nuxt.config.ts +36 -0
  233. package/package.json +25 -4
  234. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
  235. package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
  236. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  237. package/pages/notification-settings.vue +19 -0
  238. package/pages/payment-method-linked.vue +34 -10
  239. package/pages/require-customer.vue +48 -10
  240. package/pages/require-organization-membership.vue +32 -8
  241. package/pages/unauthorized.vue +29 -4
  242. package/plugins/secure-member.client.ts +21 -56
  243. package/plugins/vuetify.ts +69 -2
  244. package/types/nfc-patrol-report.ts +3 -2
  245. package/types/site.d.ts +71 -0
  246. package/types/subscription.ts +18 -11
  247. package/utils/breadcrumb.test.ts +108 -0
  248. package/utils/breadcrumb.ts +82 -0
  249. package/utils/dashboard.test.ts +118 -0
  250. package/utils/dashboard.ts +53 -0
  251. package/utils/data.test.ts +95 -0
  252. package/utils/data.ts +40 -2
  253. package/utils/hid-discover.test.ts +102 -0
  254. package/utils/list-page.test.ts +60 -0
  255. package/utils/list-page.ts +38 -0
  256. package/utils/materialSymbols.ts +363 -0
  257. package/utils/nav-sections.test.ts +190 -0
  258. package/utils/nav-sections.ts +151 -0
  259. package/utils/nfc-patrol-report.test.ts +130 -0
  260. package/utils/nfc-patrol-report.ts +101 -0
  261. package/utils/role.test.ts +69 -0
  262. package/utils/role.ts +10 -0
  263. package/utils/route-name.test.ts +85 -0
  264. package/utils/route-name.ts +24 -0
  265. package/utils/status.test.ts +110 -0
  266. package/utils/status.ts +135 -0
  267. package/utils/table-card.test.ts +84 -0
  268. package/utils/theme-aa-ledger.ts +148 -0
  269. package/utils/theme.test.ts +485 -7
  270. package/utils/theme.ts +295 -110
  271. package/.changeset/README.md +0 -8
  272. package/.changeset/config.json +0 -11
  273. package/.editorconfig +0 -12
  274. package/.github/workflows/main.yml +0 -17
  275. package/.github/workflows/publish-staging.yml +0 -47
  276. package/.github/workflows/publish.yml +0 -39
  277. package/PUBLISHING.md +0 -269
  278. package/components/HidIdentityMapping.vue +0 -975
  279. package/components/ScheduleTaskAreaFormDialog.vue +0 -144
  280. package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
  281. package/components/ScheduleTastTicketMain.vue +0 -182
  282. package/components/TableWithButton.vue +0 -94
  283. package/test/visitor-socket.test.mjs +0 -36
@@ -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
+ });