@7365admin1/layer-common 4.119.0 → 4.121.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 (47) hide show
  1. package/CHANGELOG.md +28 -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/DataRetentionBanner.vue +73 -0
  6. package/components/DocumentManagement.vue +56 -3
  7. package/components/FeedbackHandlingSettings.vue +385 -0
  8. package/components/HandoverChecklistRecords.vue +223 -0
  9. package/components/HandoverChecklistSetup.vue +224 -0
  10. package/components/IncidentReportEmailsSettings.vue +88 -0
  11. package/components/KeyPressFilter.vue +43 -0
  12. package/components/KeyPressName.vue +14 -0
  13. package/components/KeyPressSelect.vue +31 -0
  14. package/components/KeySettingsPanel.vue +156 -0
  15. package/components/ListCsvExport.vue +71 -0
  16. package/components/MaStaffRegistrationSettings.vue +61 -0
  17. package/components/MemberInformation.vue +2 -2
  18. package/components/MemberSiteAccess.vue +51 -2
  19. package/components/PassInformation.vue +5 -0
  20. package/components/RolePermissionFormCreate.vue +33 -0
  21. package/components/SearchVehicleNumberUser.vue +3 -0
  22. package/components/SiteDataRetention.vue +153 -0
  23. package/components/SiteSettings.vue +48 -0
  24. package/components/SiteSettingsVisitorRegistration.vue +131 -0
  25. package/components/VehicleForm.vue +2 -2
  26. package/components/VisitorForm.vue +45 -6
  27. package/components/VisitorManagement.vue +3 -1
  28. package/components/VisitorSearchResultSummary.vue +2 -1
  29. package/composables/useDataRetention.ts +49 -0
  30. package/composables/useDocument.ts +13 -0
  31. package/composables/useFeedback.ts +12 -0
  32. package/composables/useHandoverChecklist.ts +111 -0
  33. package/composables/useKeyPresses.ts +57 -0
  34. package/composables/useMember.ts +6 -0
  35. package/composables/usePassKey.ts +4 -0
  36. package/composables/useSiteFeatureSettings.ts +37 -0
  37. package/composables/useVisitor.ts +25 -0
  38. package/constants/permissions.ts +12 -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 +108 -2
  44. package/utils/key-presses.ts +55 -0
  45. package/utils/list-export.ts +39 -0
  46. package/utils/notification-target.ts +10 -0
  47. package/utils/site-access.ts +13 -1
@@ -48,6 +48,10 @@
48
48
  </div>
49
49
  </v-list>
50
50
  </v-menu>
51
+ <p v-if="visitorFieldSwitches.nric === 'last4'" class="text-caption text-medium-emphasis mt-1"
52
+ data-test="visitor-nric-last4-hint">
53
+ {{ NRIC_LAST4_HINT }}
54
+ </p>
51
55
  </v-col>
52
56
  </v-row>
53
57
  </v-col>
@@ -93,7 +97,7 @@
93
97
  content-class="visitor-form__menu">
94
98
  <template #activator="{ props }">
95
99
  <InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
96
- :rules="[isVehicleRequired ? requiredRule : () => true]"
100
+ :rules="[vehicleRule]"
97
101
  @update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
98
102
  @blur="isPlateNumberFieldFocused = false" />
99
103
  </template>
@@ -121,7 +125,7 @@
121
125
  <InputLabel class="text-capitalize" title="Contractor Type (Required)" required />
122
126
  <v-combobox v-model="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
123
127
  autocomplete="off" :hide-no-data="false" @update:focused="handleFocusedContractorType"
124
- :items="contractorTypes" :rules="[requiredRule]" item-value="value"
128
+ :items="contractorTypeItems" :rules="[requiredRule]" item-value="value"
125
129
  @update:model-value="handleSelectContractorType" variant="outlined" density="comfortable" persistent-hint
126
130
  small-chips>
127
131
  <template v-slot:no-data>
@@ -311,7 +315,7 @@
311
315
  <div class="screen-modal__body nric-options-body">
312
316
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
313
317
  <span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
314
- <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ selectedNricSearchResult.nric || "N/A" }}</span>
318
+ <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ maskNRIC(selectedNricSearchResult.nric) || "N/A" }}</span>
315
319
  </div>
316
320
 
317
321
  <template v-if="(!visitor.contact || currentAutofillSource === 'contact') && selectedNricContactOptions.length >= 1">
@@ -355,7 +359,7 @@
355
359
  </p>
356
360
 
357
361
  <div class="checkout-dialog__subject">
358
- <div v-if="!skipNonCheckedOutNric">NRIC: {{ visitor.nric || "N/A" }}</div>
362
+ <div v-if="!skipNonCheckedOutNric">NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}</div>
359
363
  <div>Phone Number: {{ visitor.contact || "N/A" }}</div>
360
364
  </div>
361
365
 
@@ -422,7 +426,7 @@
422
426
  <div class="visitor-form__footer" v-if="!skipNonCheckedOutNric">
423
427
  <v-btn class="screen-btn-primary" variant="flat" :loading="loading.searchingNonCheckedOut"
424
428
  @click="handleSkipNricNonCheckedOut">
425
- Skip NRIC: {{ visitor.nric || "N/A" }}
429
+ Skip NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}
426
430
  </v-btn>
427
431
  </div>
428
432
  </v-card>
@@ -613,7 +617,7 @@ import useUtils from "../composables/useUtils";
613
617
  import useSiteSettings from "../composables/useSiteSettings";
614
618
  import useLocalAuth from "../composables/useLocalAuth";
615
619
  import useBuilding from "../composables/useBuilding";
616
- import useVisitor from "../composables/useVisitor";
620
+ import useVisitor, { getVisitorFieldSwitches, NRIC_LAST4_HINT, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
617
621
  import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
618
622
  import useAccessManagement from "../composables/useAccessManagement";
619
623
  import usePeople from "../composables/usePeople";
@@ -845,10 +849,18 @@ const formFieldsExcluded: Partial<Record<TVisitorType, string[]>> = {
845
849
  guest: ["purpose"],
846
850
  };
847
851
 
852
+ // The site's registration switches (Site Settings -> Visitor registration).
853
+ // A site that never saved them reads all "default" = the per-type rules below.
854
+ const visitorFieldSwitches = computed(() =>
855
+ getVisitorFieldSwitches(siteData.value)
856
+ );
857
+
848
858
  const isFormField = (
849
859
  fieldKey: keyof TVisitorPayload | "delivery-company"
850
860
  ) => {
851
861
  if (formFieldsExcluded[prop.type]?.includes(fieldKey)) return false;
862
+ if (fieldKey === "nric" && visitorFieldSwitches.value.nric === "hidden") return false;
863
+ if (fieldKey === "contact" && visitorFieldSwitches.value.contact === "hidden") return false;
852
864
  return !!typeFieldMap[prop.type]?.includes(fieldKey);
853
865
  };
854
866
 
@@ -892,13 +904,22 @@ const requireNRIC = computed(() => {
892
904
  });
893
905
 
894
906
  const requireContact = computed(() => {
907
+ if (visitorFieldSwitches.value.contact === "required") return true;
895
908
  return ["contractor", "delivery"].includes(prop.type);
896
909
  });
897
910
 
911
+ // "Always required" only reaches types whose form has a plate field (not walk-in).
898
912
  const isVehicleRequired = computed(() => {
913
+ if (visitorFieldSwitches.value.vehicle === "required") return true;
899
914
  return ["guest", "pick-up", "drop-off"].includes(prop.type);
900
915
  });
901
916
 
917
+ const vehicleRule = (v: string) => {
918
+ if (visitorFieldSwitches.value.vehicle === "required")
919
+ return !!String(v ?? "").trim() || VEHICLE_REQUIRED_MESSAGE;
920
+ return isVehicleRequired.value ? requiredRule(v) : true;
921
+ };
922
+
902
923
  const requireCompany = computed(() => {
903
924
  return prop.type === "contractor";
904
925
  });
@@ -1012,6 +1033,10 @@ const formTitle = computed(() => {
1012
1033
 
1013
1034
  function handleSelectContractorType(obj: { title: string; value: string }) {
1014
1035
  visitor.contractorType = obj.value ?? "";
1036
+ // Managing agent staff (site switch): fill in the MA's company if empty.
1037
+ if (obj?.value === MA_STAFF_CONTRACTOR_TYPE && !visitor.company) {
1038
+ visitor.company = maStaffRegistration.value.company;
1039
+ }
1015
1040
  }
1016
1041
 
1017
1042
  function handleFocusedContractorType() {
@@ -1511,6 +1536,20 @@ const {
1511
1536
  getSiteById(prop.site)
1512
1537
  );
1513
1538
 
1539
+ // "Managing agent staff" is offered only where the site switched it on
1540
+ // (`metadata.maStaffRegistration`); it is saved as a contractor, so older app
1541
+ // builds still list it. Off = the list is exactly as before.
1542
+ const MA_STAFF_CONTRACTOR_TYPE = "managing-agent-staff";
1543
+ const maStaffRegistration = computed(() => {
1544
+ const m = (siteData.value as any)?.metadata?.maStaffRegistration;
1545
+ return { enabled: m?.enabled === true, company: typeof m?.company === "string" ? m.company : "" };
1546
+ });
1547
+ const contractorTypeItems = computed(() =>
1548
+ maStaffRegistration.value.enabled
1549
+ ? [...contractorTypes, { title: "Managing agent staff", value: MA_STAFF_CONTRACTOR_TYPE }]
1550
+ : contractorTypes
1551
+ );
1552
+
1514
1553
  // Has to sit BELOW `siteData`, not beside `hidQrConfiguredReaderId` up at the HID block.
1515
1554
  // `hidQrConfiguredReaderId` reads `hidQrCodePassConfig`, which reads `siteData`, and Vue
1516
1555
  // runs a watcher's source getter the moment `watch()` is called - so declared
@@ -162,7 +162,9 @@
162
162
  <template v-slot:item.type-company="{ item }">
163
163
  <span class="d-flex align-center ga-2">
164
164
  <v-icon icon="mdi-user" size="15" />
165
- <span v-if="item.type === 'contractor'" class="text-capitalize">{{
165
+ <v-chip v-if="item.type === 'contractor' && item.contractorType === 'managing-agent-staff'" size="x-small"
166
+ color="primary" variant="tonal">MA staff</v-chip>
167
+ <span v-else-if="item.type === 'contractor'" class="text-capitalize">{{
166
168
  formatCamelCaseToWords(item.contractorType)
167
169
  }}</span>
168
170
  <span v-else class="text-capitalize">{{ formatType(item) }}</span>
@@ -26,7 +26,7 @@
26
26
  </div>
27
27
  <div class="visitor-summary__cell">
28
28
  <div class="visitor-summary__label">NRIC</div>
29
- <div class="visitor-summary__value">{{ item?.nric || "N/A" }}</div>
29
+ <div class="visitor-summary__value">{{ maskNRIC(item?.nric) || "N/A" }}</div>
30
30
  </div>
31
31
  </div>
32
32
 
@@ -49,6 +49,7 @@
49
49
  </template>
50
50
 
51
51
  <script setup lang="ts">
52
+ const { maskNRIC } = useUtils();
52
53
  const props = defineProps<{
53
54
  item: any;
54
55
  }>();
@@ -0,0 +1,49 @@
1
+ /**
2
+ * PDPA data retention per site (`/api/data-retention`, API-core). The setting
3
+ * itself is one site switch (`metadata.dataRetention`), managed by the Seven365
4
+ * Super Admin in Seven365 Admin since 2026-10-14; the apps only read it.
5
+ * Documents are not part of data retention in this version.
6
+ */
7
+ export type TRetentionRule = { dataType: string; keepMonths: number };
8
+
9
+ export const RETENTION_TYPES = [
10
+ { value: "visitors", label: "Visitor records" },
11
+ { value: "feedback", label: "Feedback" },
12
+ { value: "work-orders", label: "Work orders" },
13
+ { value: "incident-reports", label: "Incident reports" },
14
+ { value: "occurrence-books", label: "Occurrence books" },
15
+ { value: "attendance", label: "Attendance" },
16
+ ] as const;
17
+
18
+ export default function useDataRetention() {
19
+ const api = () => useNuxtApp().$api;
20
+
21
+ /** Read-only counts of what the given (or saved) rules would remove today. */
22
+ function previewDataRetention(site: string, body: { rules?: TRetentionRule[] } = {}) {
23
+ return api()<Record<string, any>>("/api/data-retention/preview", { method: "POST", body: { site, ...body } });
24
+ }
25
+
26
+ function listDataPurgeRecords(site: string) {
27
+ return api()<Record<string, any>>("/api/data-retention/records", { method: "GET", query: { site } });
28
+ }
29
+
30
+ async function downloadDataPurgeCertificate(site: string, id: string) {
31
+ const res = await api()<Blob>(`/api/data-retention/records/${id}/certificate`, { method: "GET", query: { site } });
32
+ if (!res) throw new Error("Could not download the certificate.");
33
+ const url = window.URL.createObjectURL(res);
34
+ const a = document.createElement("a");
35
+ a.href = url;
36
+ a.download = `data-removal-record-${id}.pdf`;
37
+ document.body.appendChild(a);
38
+ a.click();
39
+ a.remove();
40
+ window.URL.revokeObjectURL(url);
41
+ }
42
+
43
+ /** A scheduled removal at the site, for the people who work it (`{ banner: null }` when none). */
44
+ function getDataRetentionBanner(site: string) {
45
+ return api()<{ banner: Record<string, any> | null }>("/api/data-retention/banner", { method: "GET", query: { site } });
46
+ }
47
+
48
+ return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate, getDataRetentionBanner };
49
+ }
@@ -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
+ }
@@ -201,6 +201,12 @@ export default function useMember() {
201
201
  org: string;
202
202
  user: string;
203
203
  sites: Array<{ site: string; role: string }>;
204
+ /**
205
+ * Is this person one of the Security Ops officers who visits sites? One
206
+ * value for the whole person, written onto every site they end up with.
207
+ * Omit it and the server leaves the mark exactly as it is.
208
+ */
209
+ securityOps?: boolean;
204
210
  removeOrgAccess?: boolean;
205
211
  }) {
206
212
  return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
@@ -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,28 @@ 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" | "last4";
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" || v.nric === "last4" ? v.nric : "default",
291
+ contact: v.contact === "hidden" || v.contact === "required" ? v.contact : "default",
292
+ vehicle: v.vehicle === "required" ? "required" : "default",
293
+ };
294
+ }
295
+
296
+ /** Shown under the NRIC field on a "keep only the last 4" site. */
297
+ export const NRIC_LAST4_HINT = "Only the last 4 characters will be kept.";
298
+
299
+ /** Same words as the server's refusal, so the guard reads one message. */
300
+ export const VEHICLE_REQUIRED_MESSAGE = "This site requires a vehicle number for every visitor.";
@@ -342,6 +342,18 @@ export const PERMISSIONS = {
342
342
  // Owner answer Q10, 2026-09-11. Mirrors `start-virtual-patrol-route`.
343
343
  "start-nfc-patrol",
344
344
  ],
345
+ // Security Ops. Both strings are enforced by the server (API-core
346
+ // `SITE_VISIT_PERMISSIONS`) and read by the security mobile app's own
347
+ // catalogue, so the spelling is maintained by hand in three places and this
348
+ // is the one that makes a typo visible.
349
+ //
350
+ // `see-all-site-visits` also decides who is told about an action: a head of
351
+ // security holds it together with `site-settings:manage-geofence`. A wildcard
352
+ // deliberately does NOT qualify for that, because a wildcard says somebody
353
+ // administers this site, not that they run security at it - measured
354
+ // 2026-10-13, every wildcard holder at a live site was an org owner, a test
355
+ // account or a service provider's administrator.
356
+ "security-ops": ["start-site-visit", "see-all-site-visits"],
345
357
  // Owner answer Q9, 2026-09-11: seeing the CCTV wall. Distinct from
346
358
  // `site-settings:manage-cctv-camera`, which is camera setup.
347
359
  "cctv-mgmt": ["see-all-cctv", "view-recordings"],
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.121.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 = {