@7365admin1/layer-common 4.118.10 → 4.120.0

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 (45) hide show
  1. package/CHANGELOG.md +25 -4
  2. package/components/AccessCardDetailsDialog.vue +3 -2
  3. package/components/AddPassKeyToVisitor.vue +5 -0
  4. package/components/BuildingUnitFormEdit.vue +3 -1
  5. package/components/DocumentManagement.vue +56 -3
  6. package/components/FeedbackHandlingSettings.vue +385 -0
  7. package/components/HandoverChecklistRecords.vue +223 -0
  8. package/components/HandoverChecklistSetup.vue +224 -0
  9. package/components/IncidentReportEmailsSettings.vue +88 -0
  10. package/components/KeyPressFilter.vue +43 -0
  11. package/components/KeyPressName.vue +14 -0
  12. package/components/KeyPressSelect.vue +31 -0
  13. package/components/KeySettingsPanel.vue +156 -0
  14. package/components/ListCsvExport.vue +71 -0
  15. package/components/MaStaffRegistrationSettings.vue +61 -0
  16. package/components/MemberInformation.vue +2 -2
  17. package/components/PassInformation.vue +5 -0
  18. package/components/RolePermissionFormCreate.vue +33 -0
  19. package/components/SearchVehicleNumberUser.vue +3 -0
  20. package/components/SiteDataRetention.vue +286 -0
  21. package/components/SiteSettings.vue +48 -0
  22. package/components/SiteSettingsVisitorRegistration.vue +118 -0
  23. package/components/VehicleForm.vue +2 -2
  24. package/components/VisitorForm.vue +41 -6
  25. package/components/VisitorManagement.vue +3 -1
  26. package/components/VisitorSearchResultSummary.vue +2 -1
  27. package/composables/useCommonPermission.ts +10 -0
  28. package/composables/useDataRetention.ts +43 -0
  29. package/composables/useDocument.ts +13 -0
  30. package/composables/useFeedback.ts +12 -0
  31. package/composables/useHandoverChecklist.ts +111 -0
  32. package/composables/useKeyPresses.ts +57 -0
  33. package/composables/usePassKey.ts +4 -0
  34. package/composables/useSecurityPermission.ts +38 -0
  35. package/composables/useSettingsPermission.ts +16 -1
  36. package/composables/useSiteFeatureSettings.ts +37 -0
  37. package/composables/useVisitor.ts +22 -0
  38. package/constants/permissions.ts +3 -0
  39. package/package.json +2 -2
  40. package/types/key-log.d.ts +2 -0
  41. package/types/passKey.d.ts +2 -0
  42. package/utils/feedback-handling-settings.ts +139 -0
  43. package/utils/inventory-presets.ts +64 -0
  44. package/utils/key-presses.ts +55 -0
  45. package/utils/list-export.ts +39 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * PDPA data retention per site (`/api/data-retention`, API-core). The setting
3
+ * itself is one site switch (`metadata.dataRetention`, useSiteFeatureSettings).
4
+ * Documents are not part of data retention in this version.
5
+ */
6
+ export type TRetentionRule = { dataType: string; keepMonths: number };
7
+
8
+ export const RETENTION_TYPES = [
9
+ { value: "visitors", label: "Visitor records" },
10
+ { value: "feedback", label: "Feedback" },
11
+ { value: "work-orders", label: "Work orders" },
12
+ { value: "incident-reports", label: "Incident reports" },
13
+ { value: "occurrence-books", label: "Occurrence books" },
14
+ { value: "attendance", label: "Attendance" },
15
+ ] as const;
16
+
17
+ export default function useDataRetention() {
18
+ const api = () => useNuxtApp().$api;
19
+
20
+ /** Read-only counts of what the given (or saved) rules would remove today. */
21
+ function previewDataRetention(site: string, body: { rules?: TRetentionRule[] } = {}) {
22
+ return api()<Record<string, any>>("/api/data-retention/preview", { method: "POST", body: { site, ...body } });
23
+ }
24
+
25
+ function listDataPurgeRecords(site: string) {
26
+ return api()<Record<string, any>>("/api/data-retention/records", { method: "GET", query: { site } });
27
+ }
28
+
29
+ async function downloadDataPurgeCertificate(site: string, id: string) {
30
+ const res = await api()<Blob>(`/api/data-retention/records/${id}/certificate`, { method: "GET", query: { site } });
31
+ if (!res) throw new Error("Could not download the certificate.");
32
+ const url = window.URL.createObjectURL(res);
33
+ const a = document.createElement("a");
34
+ a.href = url;
35
+ a.download = `data-removal-record-${id}.pdf`;
36
+ document.body.appendChild(a);
37
+ a.click();
38
+ a.remove();
39
+ window.URL.revokeObjectURL(url);
40
+ }
41
+
42
+ return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate };
43
+ }
@@ -7,6 +7,7 @@ export default function useDocument() {
7
7
  site = "",
8
8
  parentId = "",
9
9
  expiry = "all",
10
+ archived = false,
10
11
  }: {
11
12
  page?: number;
12
13
  search?: string;
@@ -19,6 +20,8 @@ export default function useDocument() {
19
20
  * Document (Expired). Defaults to "all" so existing callers are unaffected.
20
21
  */
21
22
  expiry?: string;
23
+ /** The Archived tab (core document archive). Left out = the normal lists. */
24
+ archived?: boolean;
22
25
  } = {}) {
23
26
  const query: Record<string, any> = {
24
27
  page,
@@ -28,6 +31,7 @@ export default function useDocument() {
28
31
  site,
29
32
  parentId,
30
33
  expiry,
34
+ ...(archived ? { archived: true } : {}),
31
35
  };
32
36
 
33
37
  return useNuxtApp().$api<Record<string, any>>(`/api/documents`, {
@@ -60,6 +64,14 @@ export default function useDocument() {
60
64
  });
61
65
  }
62
66
 
67
+ /** Archive keeps the document but hides it from the main list; restore brings it back. */
68
+ function setArchived(id: string, archived: boolean) {
69
+ return useNuxtApp().$api<Record<string, any>>(
70
+ `/api/documents/${id}/${archived ? "archive" : "unarchive"}`,
71
+ { method: "PUT" }
72
+ );
73
+ }
74
+
63
75
  function getById(id: string) {
64
76
  return useNuxtApp().$api<Record<string, any>>(`/api/documents/${id}`, {
65
77
  method: "GET",
@@ -108,6 +120,7 @@ export default function useDocument() {
108
120
  add,
109
121
  deleteById,
110
122
  updateById,
123
+ setArchived,
111
124
  getDocumentsBySiteId,
112
125
  create,
113
126
  createFolder,
@@ -162,6 +162,17 @@ export default function useFeedback() {
162
162
  );
163
163
  }
164
164
 
165
+ /** A linked copy of this feedback for another provider at the same site. */
166
+ function sendToProvider(id: string, provider: string) {
167
+ return useNuxtApp().$api<Record<string, any>>(
168
+ "/api/feedbacks2/send-to-provider",
169
+ {
170
+ method: "POST",
171
+ body: { _id: id, provider },
172
+ }
173
+ );
174
+ }
175
+
165
176
  function addFeedBackWorkOrder(payload: any) {
166
177
  return useNuxtApp().$api<Record<string, any>>(
167
178
  "/api/feedbacks2/create-work-order",
@@ -204,5 +215,6 @@ export default function useFeedback() {
204
215
  updateStatusComplete,
205
216
  addFeedBackWorkOrder,
206
217
  updateStatusFeedback,
218
+ sendToProvider,
207
219
  };
208
220
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Room handover / takeover checklists (API-core `/api/handover-checklists`).
3
+ * The managing agent sets up a checklist per room; guards fill one in at each
4
+ * handover (before use) and takeover (after use). No facility booking needed.
5
+ */
6
+ export type THandoverItem = {
7
+ label: string;
8
+ quantity?: number | null;
9
+ tagNo?: string | null;
10
+ referencePhotoId?: string | null;
11
+ };
12
+
13
+ export type THandoverTemplate = {
14
+ _id?: string;
15
+ title: string;
16
+ description?: string | null;
17
+ items: THandoverItem[];
18
+ updatedAt?: string;
19
+ };
20
+
21
+ export type THandoverRecordItem = THandoverItem & {
22
+ result: "in-order" | "not-in-order";
23
+ remarks?: string | null;
24
+ damagePhotoIds?: string[];
25
+ };
26
+
27
+ export type THandoverRecord = {
28
+ _id: string;
29
+ templateId: string;
30
+ title: string;
31
+ stage: "handover" | "takeover";
32
+ items?: THandoverRecordItem[];
33
+ notInOrderCount?: number;
34
+ photoIds?: string[];
35
+ remarks?: string | null;
36
+ officerName?: string | null;
37
+ completedAt: string;
38
+ };
39
+
40
+ export const HANDOVER_STAGE_LABELS: Record<string, string> = {
41
+ handover: "Handover (before use)",
42
+ takeover: "Takeover (after use)",
43
+ };
44
+
45
+ /**
46
+ * Ready-made checklists an MA can start from (renovation, move in / out). They
47
+ * only open the editor pre-filled; nothing is saved until the MA clicks Save.
48
+ */
49
+ export const HANDOVER_STARTERS: Array<{ name: string; template: THandoverTemplate }> = [
50
+ {
51
+ name: "Lift & lobby protection (renovation)",
52
+ template: {
53
+ title: "Lift & lobby protection (renovation)",
54
+ description: "Check before renovation works start and again after they finish. Take photos of any damage.",
55
+ items: [
56
+ { label: "Lift protection in place (wall panels and floor)" },
57
+ { label: "Lobby floor protection in place" },
58
+ { label: "Lift interior condition (walls, doors, floor, buttons)" },
59
+ { label: "Lobby walls, doors and fittings condition" },
60
+ { label: "Common corridor clean, no debris" },
61
+ ],
62
+ },
63
+ },
64
+ {
65
+ name: "Move in / move out",
66
+ template: {
67
+ title: "Move in / move out",
68
+ description: "Check before the move starts and again after it ends. Take photos of any damage.",
69
+ items: [
70
+ { label: "Lift protection in place" },
71
+ { label: "Lobby floor protection in place" },
72
+ { label: "Lift interior condition (walls, doors, floor, buttons)" },
73
+ { label: "Lobby and corridor condition" },
74
+ { label: "Loading bay / driveway clear and undamaged" },
75
+ ],
76
+ },
77
+ },
78
+ ];
79
+
80
+ export default function useHandoverChecklist() {
81
+ const base = "/api/handover-checklists";
82
+
83
+ function getTemplates(site: string) {
84
+ return useNuxtApp().$api<THandoverTemplate[]>(`${base}/templates/site/${site}`, { method: "GET" });
85
+ }
86
+
87
+ function addTemplate(site: string, body: THandoverTemplate) {
88
+ return useNuxtApp().$api<{ _id: string }>(`${base}/templates/site/${site}`, { method: "POST", body });
89
+ }
90
+
91
+ function updateTemplate(id: string, body: THandoverTemplate) {
92
+ return useNuxtApp().$api<Record<string, any>>(`${base}/templates/${id}`, { method: "PUT", body });
93
+ }
94
+
95
+ function deleteTemplate(id: string) {
96
+ return useNuxtApp().$api<Record<string, any>>(`${base}/templates/${id}`, { method: "DELETE" });
97
+ }
98
+
99
+ function getRecords(site: string, query: { page?: number; limit?: number; templateId?: string } = {}) {
100
+ return useNuxtApp().$api<{ items: THandoverRecord[]; total: number; page: number; limit: number }>(
101
+ `${base}/site/${site}`,
102
+ { method: "GET", query },
103
+ );
104
+ }
105
+
106
+ function getRecord(id: string) {
107
+ return useNuxtApp().$api<THandoverRecord>(`${base}/${id}`, { method: "GET" });
108
+ }
109
+
110
+ return { getTemplates, addTemplate, updateTemplate, deleteTemplate, getRecords, getRecord };
111
+ }
@@ -0,0 +1,57 @@
1
+ import { readKeySettings, keyPressName, type TKeyPress } from "../utils/key-presses";
2
+ import useSite from "./useSite";
3
+
4
+ /**
5
+ * One read per site per page load, shared by every caller (a list draws one
6
+ * press name per row). ponytail: never expires; a press added in another tab
7
+ * shows after a reload.
8
+ */
9
+ const presses = new Map<string, Promise<TKeyPress[]>>();
10
+
11
+ /**
12
+ * The site's key presses (`metadata.keySettings.cabinets`). Empty for a site
13
+ * that has none, which is every site until it adds one - every screen that
14
+ * uses this then looks exactly as before.
15
+ */
16
+ export default function useKeyPresses(site: MaybeRefOrGetter<string>) {
17
+ const cabinets = ref<TKeyPress[]>([]);
18
+ const { getSiteById } = useSite();
19
+
20
+ async function load() {
21
+ const id = toValue(site);
22
+ // No site, or a site whose list is now empty: no press names left over.
23
+ if (!id) {
24
+ cabinets.value = [];
25
+ return;
26
+ }
27
+ if (!presses.has(id)) {
28
+ presses.set(
29
+ id,
30
+ getSiteById(id)
31
+ .then((site) => readKeySettings(site).cabinets)
32
+ .catch(() => {
33
+ presses.delete(id);
34
+ return [];
35
+ }),
36
+ );
37
+ }
38
+ const list = await presses.get(id)!;
39
+ // A slower answer for a site the caller has since left is dropped.
40
+ if (toValue(site) === id) cabinets.value = Array.isArray(list) ? list : [];
41
+ }
42
+ watch(() => toValue(site), load, { immediate: true });
43
+
44
+ const nameOf = (id?: string | null) => keyPressName(cabinets.value, id);
45
+ /** For a key autocomplete's `item-props`: the press as the item's second line. */
46
+ const keyItemProps = (item: any) => {
47
+ const name = nameOf(item?.cabinet);
48
+ return name ? { subtitle: name } : {};
49
+ };
50
+
51
+ /** After the site's presses are saved: read them again. */
52
+ function forget(id: string = toValue(site)) {
53
+ presses.delete(id);
54
+ }
55
+
56
+ return { cabinets, nameOf, keyItemProps, load, forget };
57
+ }
@@ -125,6 +125,7 @@ export default function usePassKey() {
125
125
  statuses = [],
126
126
  sortBy,
127
127
  prefixAndName,
128
+ cabinets,
128
129
  }: {
129
130
  _ids?: any;
130
131
  page?: number;
@@ -137,6 +138,8 @@ export default function usePassKey() {
137
138
  statuses?: string[] | null;
138
139
  sortBy?: object;
139
140
  prefixAndName?: string;
141
+ /** Key press ids; "" = keys in no press. Left out = no press filter. */
142
+ cabinets?: string[];
140
143
  }) {
141
144
  return useNuxtApp().$api("/api/keys/search/v1", {
142
145
  method: "POST",
@@ -152,6 +155,7 @@ export default function usePassKey() {
152
155
  statuses,
153
156
  sortBy,
154
157
  prefixAndName,
158
+ ...(cabinets?.length ? { cabinets } : {}),
155
159
  },
156
160
  });
157
161
  }
@@ -735,6 +735,38 @@ export default function useSecurityPermission() {
735
735
  description: "Allows the user to delete routes.",
736
736
  },
737
737
  },
738
+ /**
739
+ * NEW. Security Ops - the site visit and its checklists.
740
+ *
741
+ * NO ROLE HOLDS THESE YET. The module is new, so every one of these strings
742
+ * has a count of zero today. API-core's `siteVisit` gate is therefore
743
+ * COUNTED, not enforcing (`SITE_VISIT_PERMISSION_MODE`, default `log`): it
744
+ * records who WOULD be refused and refuses nobody, the same way the NFC
745
+ * patrol and incident report gates shipped. Provision roles against that
746
+ * count before anyone sets it to `enforce`.
747
+ *
748
+ * THREE PLACES, BY HAND. These spellings have to match API-core
749
+ * (`SITE_VISIT_PERMISSIONS` in `site-authz.util.ts`) and the RN app
750
+ * (`permission.catalog.ts`), which does NOT import this file - it keeps a
751
+ * literal copy and compares strings exactly. `roles-picker-covers-mobile-gates`
752
+ * in web-app-security is the test that holds the three together.
753
+ *
754
+ * The geofence right is NOT here: it is a site setting, so it lives in
755
+ * `useCommonPermission.ts` as `site-settings:manage-geofence` beside the
756
+ * other panels.
757
+ */
758
+ "security-ops": {
759
+ "start-site-visit": {
760
+ check: true,
761
+ description:
762
+ "Allows the user to check in at a site, fill in the visit checklists and check out.",
763
+ },
764
+ "see-all-site-visits": {
765
+ check: true,
766
+ description:
767
+ "Allows the user to view site visits across the sites they can reach, including other executives' visits.",
768
+ },
769
+ },
738
770
  // NEW. Seeing the CCTV wall, as distinct from setting cameras up
739
771
  // (`site-settings:manage-cctv-camera`).
740
772
  "cctv-mgmt": {
@@ -768,6 +800,12 @@ export default function useSecurityPermission() {
768
800
  "manage-nfc-management": siteSettingsPermissions["manage-nfc-management"],
769
801
  "manage-nfc-patrol-settings":
770
802
  siteSettingsPermissions["manage-nfc-patrol-settings"],
803
+ // Security Ops geofencing. This block CHERRY-PICKS from
804
+ // `siteSettingsPermissions` rather than spreading it, so a new entry in
805
+ // the common catalogue does not reach the security role editor on its own
806
+ // - without this line no administrator on a security site could grant the
807
+ // right, and the Geofencing panel would be invisible to everyone.
808
+ "manage-geofence": siteSettingsPermissions["manage-geofence"],
771
809
  "manage-red-dot-settings": {
772
810
  check: true,
773
811
  description:
@@ -129,6 +129,20 @@ export function useSettingsPermission() {
129
129
  "update-vehicle": { check: true, description: "Allows the user to update vehicle details." },
130
130
  },
131
131
  };
132
+ /**
133
+ * The Geofencing panel - Security Ops site visits.
134
+ *
135
+ * MUST ALSO BE IN `canManageSiteSettings` BELOW. That computed is an OR of
136
+ * every panel gate and it is what decides whether the Settings page renders at
137
+ * all, so a role holding ONLY this right would otherwise be given a page it
138
+ * cannot open. Adding a panel is always two lines, never one.
139
+ */
140
+ const canManageGeofence = computed(() => {
141
+ if (!userAppRole.value) return false;
142
+ if (userAppRole.value.permissions.includes("*")) return true;
143
+ return hasPermission(userAppRole.value, permissions, "site-settings", "manage-geofence");
144
+ });
145
+
132
146
  const canManageVehicleQr = computed(() => {
133
147
  if (!userAppRole.value) return false;
134
148
  if (userAppRole.value.permissions.includes("*")) return true;
@@ -136,7 +150,7 @@ export function useSettingsPermission() {
136
150
  });
137
151
 
138
152
  const canManageSiteSettings = computed(() => {
139
- return canManageSiteInformation.value || canManageANPRCamera.value || canManageCCTVCamera.value || canManageDeliveryCompanies.value || canManageResidentVisitors.value || canManageWorkOrderSettings.value || canManageBillingAndSOAConfiguration.value || canManageNFCManagement.value || canManageNFCPatrolSettings.value || canManageEntryPass.value || canManageRedDotSettings.value
153
+ return canManageSiteInformation.value || canManageANPRCamera.value || canManageCCTVCamera.value || canManageDeliveryCompanies.value || canManageResidentVisitors.value || canManageWorkOrderSettings.value || canManageBillingAndSOAConfiguration.value || canManageNFCManagement.value || canManageNFCPatrolSettings.value || canManageEntryPass.value || canManageRedDotSettings.value || canManageGeofence.value
140
154
  })
141
155
 
142
156
  return {
@@ -151,6 +165,7 @@ export function useSettingsPermission() {
151
165
  canManageNFCPatrolSettings,
152
166
  canManageEntryPass,
153
167
  canManageRedDotSettings,
168
+ canManageGeofence,
154
169
  canManageVehicleQr,
155
170
  canManageSiteSettings
156
171
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Per-site switches (`site.metadata.<key>`, core `site-feature-settings.util.ts`).
3
+ *
4
+ * Every settings panel that writes one saves ONLY its own switch through
5
+ * `PATCH /api/sites/:id/feature-settings` `{ field: "metadata.<key>", value }`.
6
+ * Never the whole `metadata`: a panel that sends back what it read could wipe
7
+ * a switch another panel (or another person) saved in the meantime. The
8
+ * server validates the value and refuses data retention and feedback handling
9
+ * changes from anyone but the site's owner (or Seven365).
10
+ */
11
+ export const SITE_FEATURE_KEYS = [
12
+ "visitorFields",
13
+ "keySettings",
14
+ "incidentReportEmails",
15
+ "feedbackSettings",
16
+ "dataRetention",
17
+ "maStaffRegistration",
18
+ ] as const;
19
+
20
+ export type TSiteFeatureKey = (typeof SITE_FEATURE_KEYS)[number];
21
+
22
+ /** The request body for one switch. */
23
+ export function siteFeatureSettingBody(key: TSiteFeatureKey, value: unknown) {
24
+ if (!SITE_FEATURE_KEYS.includes(key)) throw new Error(`Unknown site setting: ${key}`);
25
+ return { field: `metadata.${key}`, value };
26
+ }
27
+
28
+ export default function useSiteFeatureSettings() {
29
+ async function saveSiteFeature(siteId: string, key: TSiteFeatureKey, value: unknown) {
30
+ return await useNuxtApp().$api<Record<string, any>>(`/api/sites/${siteId}/feature-settings`, {
31
+ method: "PATCH",
32
+ body: siteFeatureSettingBody(key, value),
33
+ });
34
+ }
35
+
36
+ return { saveSiteFeature };
37
+ }
@@ -273,3 +273,25 @@ export default function () {
273
273
  visitorDataFromScannedQRCodeCheckIn,
274
274
  };
275
275
  }
276
+
277
+ /**
278
+ * The site's guardhouse registration switches (`site.metadata.visitorFields`).
279
+ * Anything a site never saved reads as "default" = today's form.
280
+ */
281
+ export type TVisitorFieldSwitches = {
282
+ nric: "default" | "hidden";
283
+ contact: "default" | "hidden" | "required";
284
+ vehicle: "default" | "required";
285
+ };
286
+
287
+ export function getVisitorFieldSwitches(site: any): TVisitorFieldSwitches {
288
+ const v = site?.metadata?.visitorFields ?? {};
289
+ return {
290
+ nric: v.nric === "hidden" ? "hidden" : "default",
291
+ contact: v.contact === "hidden" || v.contact === "required" ? v.contact : "default",
292
+ vehicle: v.vehicle === "required" ? "required" : "default",
293
+ };
294
+ }
295
+
296
+ /** Same words as the server's refusal, so the guard reads one message. */
297
+ export const VEHICLE_REQUIRED_MESSAGE = "This site requires a vehicle number for every visitor.";
@@ -150,6 +150,9 @@ export const PERMISSIONS = {
150
150
  "manage-nfc-patrol-settings",
151
151
  "manage-entry-pass",
152
152
  "manage-red-dot-settings",
153
+ // Security Ops: the site boundary and the guardhouse check-in code. The
154
+ // server reads this one too - API-core `GEOFENCE_MANAGE_PERMISSIONS`.
155
+ "manage-geofence",
153
156
  ],
154
157
 
155
158
  // ---- module apps (cleaning, landscape, M&E, pest, pool) ----
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.118.10",
5
+ "version": "4.120.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -37,7 +37,7 @@
37
37
  "build": "nuxt build .playground",
38
38
  "generate": "nuxt generate .playground",
39
39
  "preview": "nuxt preview .playground",
40
- "test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useSite.ts --format=esm --outfile=test/.build/useSite.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && esbuild utils/rmf-editor.ts --bundle --platform=node --format=esm --outfile=test/.build/rmf-editor.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
40
+ "test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useSite.ts --format=esm --outfile=test/.build/useSite.mjs --log-level=error && esbuild composables/useSiteFeatureSettings.ts --format=esm --outfile=test/.build/useSiteFeatureSettings.mjs --log-level=error && esbuild composables/useVisitor.ts --format=esm --outfile=test/.build/useVisitor.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && esbuild utils/rmf-editor.ts --bundle --platform=node --format=esm --outfile=test/.build/rmf-editor.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
41
41
  "test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
42
42
  "release": "yarn run build && changeset publish"
43
43
  },
@@ -19,6 +19,8 @@ declare type TKeyLog = {
19
19
  keyNo?: number;
20
20
  userType?: string;
21
21
  updatedBy?: string;
22
+ /** Key press id from the site's `metadata.keySettings.cabinets`. */
23
+ cabinet?: string;
22
24
  };
23
25
 
24
26
  declare type TKeyLogResponse = {
@@ -8,6 +8,8 @@ declare type TPassKey = {
8
8
  createdAt?: Date;
9
9
  site: string;
10
10
  status?: string;
11
+ /** Key press id (keys only), from the site's key presses. */
12
+ cabinet?: string;
11
13
  };
12
14
 
13
15
  declare type TPassKeyResponse = {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Site Settings -> "Feedback handling" (TLR, 2026-10-13): the form behind
3
+ * `FeedbackHandlingSettings.vue`, stored on the site as
4
+ * `metadata.feedbackSettings` and validated by core
5
+ * (`site-feature-settings.util.ts`).
6
+ *
7
+ * A site that never saved this reads as: Mon-Fri 09:00-18:00, holidays not
8
+ * excluded, no routes, escalation off, nothing auto-closed - which is exactly
9
+ * today's behaviour, because the server does nothing without a route, an
10
+ * enabled escalation or a listed category.
11
+ */
12
+
13
+ export type TFeedbackWindowTarget = "ma" | "provider";
14
+
15
+ export type TFeedbackRouteForm = {
16
+ category: string;
17
+ provider: string;
18
+ officeHours: TFeedbackWindowTarget;
19
+ afterHours: TFeedbackWindowTarget;
20
+ };
21
+
22
+ /** `id` is the level's stable key (set by the server on its first save; "" for a new step). */
23
+ export type TEscalationLevelForm = { id: string; afterHours: number | string; users: string[]; emails: string[] };
24
+
25
+ export type TFeedbackHandlingForm = {
26
+ workingDays: number[];
27
+ officeStart: string;
28
+ officeEnd: string;
29
+ excludePublicHolidays: boolean;
30
+ routes: TFeedbackRouteForm[];
31
+ escalationEnabled: boolean;
32
+ workingTimeOnly: boolean;
33
+ levels: TEscalationLevelForm[];
34
+ autoCloseCategories: string[];
35
+ };
36
+
37
+ export const MAX_ESCALATION_LEVELS = 5;
38
+
39
+ /** Mon first, as people read a week. `value` is the stored 0 = Sunday. */
40
+ export const WEEK_DAYS = [
41
+ { title: "Mon", value: 1 },
42
+ { title: "Tue", value: 2 },
43
+ { title: "Wed", value: 3 },
44
+ { title: "Thu", value: 4 },
45
+ { title: "Fri", value: 5 },
46
+ { title: "Sat", value: 6 },
47
+ { title: "Sun", value: 0 },
48
+ ];
49
+
50
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
51
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
52
+ const OBJECT_ID = /^[a-f0-9]{24}$/i;
53
+
54
+ const list = (v: unknown) => (Array.isArray(v) ? v : []);
55
+
56
+ /** The stored value (or nothing) as the form. */
57
+ export function feedbackHandlingForm(stored: any): TFeedbackHandlingForm {
58
+ const s = stored && typeof stored === "object" ? stored : {};
59
+ const days = list(s.workingDays).filter((d: unknown) => Number.isInteger(d) && (d as number) >= 0 && (d as number) <= 6);
60
+ return {
61
+ workingDays: days.length ? days : [1, 2, 3, 4, 5],
62
+ officeStart: HHMM.test(s.officeStart) ? s.officeStart : "09:00",
63
+ officeEnd: HHMM.test(s.officeEnd) ? s.officeEnd : "18:00",
64
+ excludePublicHolidays: s.excludePublicHolidays === true,
65
+ routes: list(s.routes).map((r: any) => ({
66
+ category: String(r?.category ?? ""),
67
+ provider: r?.provider ? String(r.provider) : "",
68
+ officeHours: r?.officeHours === "provider" ? "provider" : "ma",
69
+ afterHours: r?.afterHours === "provider" ? "provider" : "ma",
70
+ })),
71
+ escalationEnabled: s.escalation?.enabled === true,
72
+ workingTimeOnly: s.escalation?.workingTimeOnly === true,
73
+ levels: list(s.escalation?.levels).map((l: any) => ({
74
+ id: typeof l?.id === "string" ? l.id : "",
75
+ afterHours: Number(l?.afterHours) || 1,
76
+ users: list(l?.users).map(String),
77
+ emails: list(l?.emails).map(String),
78
+ })),
79
+ autoCloseCategories: list(s.autoCloseCategories).map(String),
80
+ };
81
+ }
82
+
83
+ /** Plain-English problems that stop a save; empty = fine. */
84
+ export function feedbackHandlingErrors(form: TFeedbackHandlingForm): string[] {
85
+ const errors: string[] = [];
86
+ if (!form.workingDays.length) errors.push("Pick at least one office day.");
87
+ if (!HHMM.test(form.officeStart) || !HHMM.test(form.officeEnd) || form.officeEnd <= form.officeStart)
88
+ errors.push("Office hours must end after they start.");
89
+ const seen = new Set<string>();
90
+ form.routes.forEach((r, i) => {
91
+ const row = `Row ${i + 1}`;
92
+ if (!r.category.trim()) errors.push(`${row}: choose a category.`);
93
+ else if (seen.has(r.category.trim().toLowerCase())) errors.push(`${row}: ${r.category} is listed twice.`);
94
+ seen.add(r.category.trim().toLowerCase());
95
+ if ((r.officeHours === "provider" || r.afterHours === "provider") && !OBJECT_ID.test(r.provider))
96
+ errors.push(`${row}: choose which provider receives it.`);
97
+ });
98
+ if (form.escalationEnabled) {
99
+ if (!form.levels.length) errors.push("Add at least one escalation step, or turn escalation off.");
100
+ if (form.levels.length > MAX_ESCALATION_LEVELS) errors.push(`At most ${MAX_ESCALATION_LEVELS} escalation steps.`);
101
+ form.levels.forEach((l, i) => {
102
+ const step = `Step ${i + 1}`;
103
+ const hours = Number(l.afterHours);
104
+ if (!Number.isFinite(hours) || hours < 1 || hours > 1440) errors.push(`${step}: hours must be between 1 and 1440.`);
105
+ if (!l.users.length && !l.emails.length) errors.push(`${step}: choose somebody to notify.`);
106
+ for (const e of l.emails) if (!EMAIL.test(e.trim())) errors.push(`${step}: "${e}" is not an email address.`);
107
+ });
108
+ }
109
+ return errors;
110
+ }
111
+
112
+ /** The form as the stored `metadata.feedbackSettings` value. */
113
+ export function feedbackSettingsFromForm(form: TFeedbackHandlingForm) {
114
+ return {
115
+ workingDays: [...form.workingDays].sort((a, b) => a - b),
116
+ officeStart: form.officeStart,
117
+ officeEnd: form.officeEnd,
118
+ excludePublicHolidays: form.excludePublicHolidays,
119
+ routes: form.routes.map((r) => ({
120
+ category: r.category.trim(),
121
+ officeHours: r.officeHours,
122
+ afterHours: r.afterHours,
123
+ provider: r.officeHours === "provider" || r.afterHours === "provider" ? r.provider : null,
124
+ })),
125
+ escalation: {
126
+ enabled: form.escalationEnabled,
127
+ workingTimeOnly: form.workingTimeOnly,
128
+ // A step keeps its id so a re-ordered or edited step is not sent again;
129
+ // a new step has none and the server gives it one.
130
+ levels: form.levels.map((l) => ({
131
+ ...(l.id ? { id: l.id } : {}),
132
+ afterHours: Number(l.afterHours),
133
+ users: [...new Set(l.users)],
134
+ emails: [...new Set(l.emails.map((e) => e.trim().toLowerCase()))],
135
+ })),
136
+ },
137
+ autoCloseCategories: [...new Set(form.autoCloseCategories)],
138
+ };
139
+ }