@7365admin1/layer-common 4.119.0 → 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 (41) hide show
  1. package/CHANGELOG.md +19 -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/useDataRetention.ts +43 -0
  28. package/composables/useDocument.ts +13 -0
  29. package/composables/useFeedback.ts +12 -0
  30. package/composables/useHandoverChecklist.ts +111 -0
  31. package/composables/useKeyPresses.ts +57 -0
  32. package/composables/usePassKey.ts +4 -0
  33. package/composables/useSiteFeatureSettings.ts +37 -0
  34. package/composables/useVisitor.ts +22 -0
  35. package/package.json +2 -2
  36. package/types/key-log.d.ts +2 -0
  37. package/types/passKey.d.ts +2 -0
  38. package/utils/feedback-handling-settings.ts +139 -0
  39. package/utils/inventory-presets.ts +64 -0
  40. package/utils/key-presses.ts +55 -0
  41. 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
  }
@@ -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.";
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.119.0",
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
+ }
@@ -143,3 +143,67 @@ export function inventoryControls(a?: Partial<TInventoryAccess> | null) {
143
143
  unitPrice: c.canManage || c.canApprove || c.canHandle || c.canReports,
144
144
  };
145
145
  }
146
+
147
+ /**
148
+ * Whole-role starting points (TLR A7), offered in the create-role dialog when
149
+ * the catalogue has every module they use. Keys are the security catalogue's
150
+ * own (`composables/useSecurityPermission.ts`). Applying one REPLACES the
151
+ * ticks in the dialog and saves nothing; the admin adjusts and saves.
152
+ */
153
+ export type TRolePreset = {
154
+ key: string;
155
+ label: string;
156
+ description: string;
157
+ permissions: string[];
158
+ };
159
+
160
+ /**
161
+ * The Guardhouse officer's grants, by module, in the same nested form as the
162
+ * security catalogue that offers them (`useSecurityPermission.ts`). A grant
163
+ * list, not a check: `constants/permissions.ts` (what this package CHECKS)
164
+ * deliberately has no Passes & Keys entry, so the role editors there gain no
165
+ * extra checkbox.
166
+ */
167
+ const GUARDHOUSE_OFFICER: Record<string, string[]> = {
168
+ // check-out is a visitor update on the server
169
+ "visitor-mgmt": ["add-visitor", "see-all-visitor", "see-visitor-details", "update-visitor"],
170
+ "daily-occurrence-books": ["see-all-daily-occurrence", "see-daily-occurrence-details"],
171
+ "daily-occurrence-entries": ["add-daily-occurrence-entries", "see-daily-occurrence-entries-details"],
172
+ "incident-reports": ["add-incident-report", "see-incident-reports", "see-incident-report-details"],
173
+ feedbacks: ["add-feedback", "see-all-feedback", "see-feedback-details"],
174
+ // issuing and taking back a key is a pass/key update on the server
175
+ "passes-and-keys-mgmt": ["see-all-passes-and-keys", "update-passes-and-keys"],
176
+ };
177
+
178
+ const grants = (byModule: Record<string, string[]>) =>
179
+ Object.entries(byModule).flatMap(([resource, actions]) => actions.map((action) => [resource, action].join(":")));
180
+
181
+ export const ROLE_PRESETS: TRolePreset[] = [
182
+ {
183
+ key: "guardhouse-officer",
184
+ label: "Guardhouse officer",
185
+ description:
186
+ "Register, see and check out visitors; fill in the daily occurrence book; raise and view incident reports and feedback; issue and take back passes and keys. No delete rights.",
187
+ permissions: grants(GUARDHOUSE_OFFICER),
188
+ },
189
+ ];
190
+
191
+ /**
192
+ * The dialog state for `preset` against the offered catalogue, or null when
193
+ * the catalogue lacks any of it. Never returns an empty list: an empty
194
+ * permission list means "everything" on the server.
195
+ */
196
+ export function applyRolePreset(
197
+ catalogue: Record<string, Record<string, unknown> | undefined>,
198
+ preset: TRolePreset,
199
+ ): { selected: string[]; enabled: string[] } | null {
200
+ const offered = preset.permissions.every((p) => {
201
+ const [resource, action] = p.split(":");
202
+ return Boolean(catalogue?.[resource]?.[action]);
203
+ });
204
+ if (!offered || !preset.permissions.length) return null;
205
+ return {
206
+ selected: [...preset.permissions],
207
+ enabled: [...new Set(preset.permissions.map((p) => p.split(":")[0]))],
208
+ };
209
+ }