@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,286 @@
1
+ <template>
2
+ <!--
3
+ Data retention (PDPA), per site (TLR, 2026-10-13). Off by default: a site
4
+ that never switched it on keeps everything, as today. When on, the site's
5
+ admins are warned (in the app and by email) `noticeDays` before old records
6
+ are removed, and every removal leaves a record with a downloadable
7
+ certificate. Who may change it is decided by the server (the site's owning
8
+ organisation, or Seven365).
9
+ -->
10
+ <div class="sdr" data-test="sdr">
11
+ <p class="sdr-help">
12
+ Remove old records automatically after a set time, to meet your data protection (PDPA) policy. Off means
13
+ everything is kept.
14
+ </p>
15
+
16
+ <v-switch
17
+ :model-value="enabled"
18
+ color="primary"
19
+ inset
20
+ hide-details
21
+ label="Remove old records automatically"
22
+ :disabled="!canEdit || saving"
23
+ data-test="sdr-enabled"
24
+ @update:model-value="onToggle"
25
+ />
26
+ <div v-if="!canEdit" class="sdr-sub" data-test="sdr-owner-only">
27
+ Only the client that owns this site can change data retention.
28
+ </div>
29
+ <div class="sdr-sub">Before anything is removed, your site admins get a notice with a link to download the records.</div>
30
+
31
+ <template v-if="enabled">
32
+ <div class="sdr-label">How long to keep each kind of record</div>
33
+ <div class="sdr-sub">
34
+ Leave blank to keep that kind of record forever. Only finished records are removed: closed feedback and work
35
+ orders, visitors who have checked out, decided incident reports and past days' occurrence books.
36
+ </div>
37
+ <div v-for="t in RETENTION_TYPES" :key="t.value" class="sdr-row">
38
+ <span class="sdr-row__name">{{ t.label }}</span>
39
+ <v-text-field
40
+ v-model="months[t.value]"
41
+ type="number"
42
+ min="1"
43
+ max="120"
44
+ suffix="months"
45
+ density="compact"
46
+ variant="outlined"
47
+ hide-details="auto"
48
+ placeholder="Keep"
49
+ class="sdr-row__field"
50
+ :disabled="!canEdit || saving"
51
+ :rules="[monthRule]"
52
+ :aria-label="`Keep ${t.label} for how many months`"
53
+ :data-test="`sdr-months-${t.value}`"
54
+ />
55
+ </div>
56
+
57
+ <div class="sdr-label">Notice before removal</div>
58
+ <v-text-field
59
+ v-model="noticeDays"
60
+ type="number"
61
+ min="7"
62
+ max="90"
63
+ suffix="days"
64
+ density="compact"
65
+ variant="outlined"
66
+ hide-details="auto"
67
+ class="sdr-row__field"
68
+ :disabled="!canEdit || saving"
69
+ :rules="[noticeRule]"
70
+ aria-label="Days of notice before removal"
71
+ data-test="sdr-notice"
72
+ />
73
+ <div class="sdr-sub">Between 7 and 90 days. Records are removed only after this notice.</div>
74
+ </template>
75
+
76
+ <div class="sdr-actions">
77
+ <v-btn class="screen-btn-ghost" variant="outlined" :loading="previewing" :disabled="!enabled || !rules.length" data-test="sdr-preview" @click="runPreview">
78
+ Preview
79
+ </v-btn>
80
+ <v-btn class="screen-btn-primary" variant="flat" color="primary-button" :loading="saving" :disabled="!metadata || !canEdit || !valid || !dirty" data-test="sdr-save" @click="save">
81
+ Save
82
+ </v-btn>
83
+ </div>
84
+
85
+ <div v-if="preview" class="sdr-box" data-test="sdr-preview-result">
86
+ <div class="sdr-box__title">If this ran today, it would remove:</div>
87
+ <ul class="sdr-list">
88
+ <li v-for="i in preview.items" :key="i.dataType">{{ i.count }} {{ i.label.toLowerCase() }} created before {{ day(i.cutoff) }}</li>
89
+ </ul>
90
+ <div class="sdr-sub">Nothing is removed by Preview. The first removal comes only after the notice above.</div>
91
+ </div>
92
+ <div v-if="error" class="sdr-error" role="alert">{{ error }}</div>
93
+
94
+ <div class="sdr-label mt-6">Data removal records</div>
95
+ <div class="sdr-sub">Each removal, with a certificate you can give your DPO (data protection lead).</div>
96
+ <div v-if="recordsLoading" class="sdr-sub">Loading...</div>
97
+ <div v-else-if="!records.length" class="sdr-sub" data-test="sdr-no-records">No records have been removed at this site.</div>
98
+ <div v-for="r in records" :key="r._id" class="sdr-record" data-test="sdr-record">
99
+ <div>
100
+ <strong>{{ statusText(r) }}</strong>
101
+ <div class="sdr-sub">
102
+ {{ r.items.filter((i: any) => i.count).map((i: any) => `${i.count} ${i.label.toLowerCase()}`).join(", ") || "No records" }}
103
+ · Record {{ r._id }}
104
+ </div>
105
+ </div>
106
+ <v-btn
107
+ v-if="r.status === 'done'"
108
+ variant="text"
109
+ prepend-icon="mdi-file-download-outline"
110
+ :loading="downloading === r._id"
111
+ data-test="sdr-certificate"
112
+ @click="certificate(r._id)"
113
+ >
114
+ Download certificate
115
+ </v-btn>
116
+ </div>
117
+ </div>
118
+
119
+ <ConfirmDialog v-model="confirmOn">
120
+ <template #title>Remove old records automatically?</template>
121
+ <template #description>
122
+ Records older than the times you set will be permanently deleted at this site. Your site admins will be told
123
+ {{ noticeNumber }} days before each removal, with a link to download them first. Deleted records cannot be
124
+ brought back.
125
+ </template>
126
+ <template #footer>
127
+ <v-btn class="screen-btn-ghost" variant="outlined" @click="confirmOn = false">Cancel</v-btn>
128
+ <v-btn class="screen-btn-primary" variant="flat" color="primary-button" data-test="sdr-confirm" @click="turnOn">
129
+ Turn on
130
+ </v-btn>
131
+ </template>
132
+ </ConfirmDialog>
133
+ </template>
134
+
135
+ <script lang="ts" setup>
136
+ import { RETENTION_TYPES, type TRetentionRule } from "../composables/useDataRetention";
137
+
138
+ const props = defineProps({
139
+ siteId: { type: String, required: true },
140
+ /** The site's stored metadata (`{}` once read for a site with none; null = not read yet, Save stays off). */
141
+ metadata: { type: Object as PropType<Record<string, any> | null>, default: null },
142
+ /** Only the site's owning organisation (or Seven365) may change it; the server checks too. */
143
+ canEdit: { type: Boolean, default: false },
144
+ });
145
+ const emit = defineEmits<{ (e: "saved"): void; (e: "error", message: string): void }>();
146
+
147
+ const { saveSiteFeature } = useSiteFeatureSettings();
148
+ const { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate } = useDataRetention();
149
+
150
+ const enabled = ref(false);
151
+ const months = reactive<Record<string, string | number>>({});
152
+ const noticeDays = ref<string | number>(14);
153
+ const saving = ref(false);
154
+ const previewing = ref(false);
155
+ const preview = ref<Record<string, any> | null>(null);
156
+ const error = ref("");
157
+ const confirmOn = ref(false);
158
+ const records = ref<Record<string, any>[]>([]);
159
+ const recordsLoading = ref(false);
160
+ const downloading = ref("");
161
+ const stored = ref("");
162
+
163
+ function load(meta: Record<string, any> | null) {
164
+ const d = meta?.dataRetention ?? {};
165
+ enabled.value = d.enabled === true;
166
+ for (const t of RETENTION_TYPES) months[t.value] = "";
167
+ for (const r of Array.isArray(d.rules) ? d.rules : []) months[r.dataType] = r.keepMonths;
168
+ noticeDays.value = d.noticeDays ?? 14;
169
+ stored.value = JSON.stringify(value());
170
+ }
171
+
172
+ const toInt = (v: unknown) => (v === "" || v === null || v === undefined ? null : Number(v));
173
+ const monthRule = (v: unknown) => {
174
+ const n = toInt(v);
175
+ return n === null || (Number.isInteger(n) && n >= 1 && n <= 120) || "1 to 120 months, or blank";
176
+ };
177
+ const noticeRule = (v: unknown) => {
178
+ const n = toInt(v);
179
+ return (n !== null && Number.isInteger(n) && n >= 7 && n <= 90) || "7 to 90 days";
180
+ };
181
+ const noticeNumber = computed(() => toInt(noticeDays.value) ?? 14);
182
+
183
+ const rules = computed<TRetentionRule[]>(() =>
184
+ RETENTION_TYPES.map((t) => ({ dataType: t.value, keepMonths: toInt(months[t.value]) as number })).filter((r) => r.keepMonths !== null),
185
+ );
186
+ const valid = computed(
187
+ () => RETENTION_TYPES.every((t) => monthRule(months[t.value]) === true) && (!enabled.value || noticeRule(noticeDays.value) === true),
188
+ );
189
+ function value() {
190
+ return enabled.value
191
+ ? { enabled: true, rules: rules.value, noticeDays: toInt(noticeDays.value) ?? 14 }
192
+ : { enabled: false, rules: rules.value, ...(toInt(noticeDays.value) ? { noticeDays: toInt(noticeDays.value) } : {}) };
193
+ }
194
+ const dirty = computed(() => JSON.stringify(value()) !== stored.value);
195
+
196
+ watch(() => props.metadata, (m) => load(m), { immediate: true });
197
+
198
+ function turnOn() {
199
+ enabled.value = true;
200
+ confirmOn.value = false;
201
+ }
202
+
203
+ function onToggle(v: boolean | null) {
204
+ preview.value = null;
205
+ if (v) confirmOn.value = true;
206
+ else enabled.value = false;
207
+ }
208
+
209
+ const message = (e: any) => e?.data?.message || e?.message || "Something went wrong. Please try again.";
210
+ const day = (d: string) => new Date(d).toLocaleDateString("en-SG", { day: "numeric", month: "short", year: "numeric" });
211
+
212
+ async function save() {
213
+ if (!props.metadata) return;
214
+ saving.value = true;
215
+ error.value = "";
216
+ try {
217
+ await saveSiteFeature(props.siteId, "dataRetention", value());
218
+ stored.value = JSON.stringify(value());
219
+ emit("saved");
220
+ } catch (e) {
221
+ error.value = message(e);
222
+ emit("error", error.value);
223
+ } finally {
224
+ saving.value = false;
225
+ }
226
+ }
227
+
228
+ async function runPreview() {
229
+ previewing.value = true;
230
+ error.value = "";
231
+ try {
232
+ preview.value = await previewDataRetention(props.siteId, { rules: rules.value });
233
+ } catch (e) {
234
+ error.value = message(e);
235
+ } finally {
236
+ previewing.value = false;
237
+ }
238
+ }
239
+
240
+ function statusText(r: Record<string, any>) {
241
+ if (r.status === "pending") return `Removal planned for ${day(r.dueAt)}`;
242
+ if (r.status === "running") return "Removal in progress";
243
+ if (r.status === "cancelled") return `Planned removal withdrawn${r.cancelledAt ? ` on ${day(r.cancelledAt)}` : ""}`;
244
+ if (r.status === "failed") return "Removal did not finish - Seven365 has been told";
245
+ return `Old records removed on ${day(r.ranAt)}`;
246
+ }
247
+
248
+ async function loadRecords() {
249
+ recordsLoading.value = true;
250
+ try {
251
+ records.value = (await listDataPurgeRecords(props.siteId))?.items ?? [];
252
+ } catch {
253
+ records.value = [];
254
+ } finally {
255
+ recordsLoading.value = false;
256
+ }
257
+ }
258
+
259
+ async function certificate(id: string) {
260
+ downloading.value = id;
261
+ try {
262
+ await downloadDataPurgeCertificate(props.siteId, id);
263
+ } catch (e) {
264
+ error.value = message(e);
265
+ } finally {
266
+ downloading.value = "";
267
+ }
268
+ }
269
+
270
+ onMounted(loadRecords);
271
+ </script>
272
+
273
+ <style scoped>
274
+ .sdr-help { margin-bottom: 8px; }
275
+ .sdr-sub { font-size: 13px; opacity: 0.75; margin-bottom: 8px; }
276
+ .sdr-label { font-weight: 600; margin-top: 16px; }
277
+ .sdr-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 6px 0; }
278
+ .sdr-row__name { flex: 1 1 160px; min-width: 140px; }
279
+ .sdr-row__field { flex: 0 1 180px; max-width: 180px; min-width: 140px; }
280
+ .sdr-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
281
+ .sdr-box { border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 8px; padding: 12px; margin-top: 12px; }
282
+ .sdr-box__title { font-weight: 600; margin-bottom: 4px; }
283
+ .sdr-list { padding-left: 18px; margin-bottom: 6px; }
284
+ .sdr-error { color: rgb(var(--v-theme-error)); margin-top: 8px; }
285
+ .sdr-record { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.08); word-break: break-word; }
286
+ </style>
@@ -219,6 +219,51 @@
219
219
  </v-expansion-panel-text>
220
220
  </v-expansion-panel>
221
221
 
222
+ <!-- The per-site client switches below are drawn only by the hosts that set
223
+ `showSiteSwitches` (Property Management and Security); the other
224
+ service-provider apps show no new panel. -->
225
+ <v-expansion-panel v-if="showSiteSwitches && canManageSiteSettings">
226
+ <v-expansion-panel-title>
227
+ <v-icon class="mr-2">mdi-card-account-details-outline</v-icon>
228
+ Visitor registration
229
+ </v-expansion-panel-title>
230
+ <v-expansion-panel-text>
231
+ <SiteSettingsVisitorRegistration :site-id="siteId" :site-data="siteData"
232
+ :can-manage="canManageSiteSettings" @saved="refreshSiteData" @message="show" />
233
+ </v-expansion-panel-text>
234
+ </v-expansion-panel>
235
+
236
+ <!-- Passes & keys (TLR B8 / D2): key presses, daily unreturned list,
237
+ check-out block. All off until a site saves them. Gated like Site
238
+ Information; saving needs site-settings rights (the server checks
239
+ it too). -->
240
+ <v-expansion-panel v-if="showSiteSwitches && canManageSiteInformation">
241
+ <v-expansion-panel-title>
242
+ <v-icon class="mr-2">mdi-key-chain</v-icon>
243
+ Passes &amp; keys
244
+ </v-expansion-panel-title>
245
+ <v-expansion-panel-text>
246
+ <KeySettingsPanel
247
+ :site="siteId"
248
+ :site-data="siteData"
249
+ :read-only="!canManageSiteSettings"
250
+ @message="show"
251
+ @saved="refreshSiteData"
252
+ />
253
+ </v-expansion-panel-text>
254
+ </v-expansion-panel>
255
+
256
+ <v-expansion-panel v-if="showSiteSwitches && canManageAnySiteSetting">
257
+ <v-expansion-panel-title>
258
+ <v-icon class="mr-2">mdi-email-alert-outline</v-icon>
259
+ Incident report emails
260
+ </v-expansion-panel-title>
261
+ <v-expansion-panel-text>
262
+ <IncidentReportEmailsSettings :site-id="siteId" :saved="(siteData as any)?.metadata?.incidentReportEmails"
263
+ :loaded="!!siteData" :read-only="!canManageSiteSettings" @saved="refreshSiteData" />
264
+ </v-expansion-panel-text>
265
+ </v-expansion-panel>
266
+
222
267
  <slot
223
268
  name="panels"
224
269
  :can-manage="canManageSiteSettings"
@@ -246,6 +291,8 @@ const props = defineProps({
246
291
  showCctvCamera: { type: Boolean, default: false },
247
292
  showDeliveryCompanies: { type: Boolean, default: false },
248
293
  showResidentVisitors: { type: Boolean, default: false },
294
+ /** Visitor registration, Passes & keys and Incident report emails: PM and Security only. */
295
+ showSiteSwitches: { type: Boolean, default: false },
249
296
  });
250
297
 
251
298
  const openPanels = ref([]); // default open first
@@ -255,6 +302,7 @@ const { canManageSiteInformation,
255
302
  canManageDeliveryCompanies,
256
303
  canManageResidentVisitors,
257
304
  canManageVehicleQr,
305
+ canManageSiteSettings: canManageAnySiteSetting,
258
306
  } = useSettingsPermission();
259
307
 
260
308
  const { getSiteById, updateSitebyId, updateSiteInformation } =
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <!--
3
+ Guardhouse registration switches (`site.metadata.visitorFields`). A site
4
+ that never saved them shows "as usual" everywhere and behaves as today.
5
+ -->
6
+ <div data-test="visitor-registration-settings">
7
+ <v-switch
8
+ v-model="askNric"
9
+ label="Ask for NRIC / FIN"
10
+ color="primary"
11
+ hide-details
12
+ :disabled="saving || !canManage"
13
+ data-test="visitor-fields-nric"
14
+ />
15
+ <p class="text-caption text-medium-emphasis mb-4">
16
+ Turn off if your site does not collect ID numbers. Existing records are not changed.
17
+ </p>
18
+
19
+ <v-select
20
+ v-model="contact"
21
+ :items="contactItems"
22
+ label="Contact number"
23
+ density="comfortable"
24
+ variant="outlined"
25
+ hide-details
26
+ :disabled="saving || !canManage"
27
+ data-test="visitor-fields-contact"
28
+ />
29
+ <p class="text-caption text-medium-emphasis mt-1 mb-4">
30
+ "Always required" means a visitor cannot be saved without a contact number.
31
+ </p>
32
+
33
+ <v-select
34
+ v-model="vehicle"
35
+ :items="vehicleItems"
36
+ label="Vehicle number"
37
+ density="comfortable"
38
+ variant="outlined"
39
+ hide-details
40
+ :disabled="saving || !canManage"
41
+ data-test="visitor-fields-vehicle"
42
+ />
43
+ <p class="text-caption text-medium-emphasis mt-1 mb-4">
44
+ "Always required" means a visitor cannot be saved without a plate number. Walk-in visitors are not asked.
45
+ </p>
46
+
47
+ <v-btn
48
+ v-if="canManage"
49
+ class="screen-btn-primary text-none"
50
+ variant="flat"
51
+ :loading="saving"
52
+ :disabled="!siteData"
53
+ data-test="visitor-fields-save"
54
+ @click="save"
55
+ >
56
+ Save
57
+ </v-btn>
58
+ </div>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import type { PropType } from "vue";
63
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
64
+ import { getVisitorFieldSwitches } from "../composables/useVisitor";
65
+
66
+ const props = defineProps({
67
+ siteId: { type: String, required: true },
68
+ siteData: { type: Object as PropType<Record<string, any> | null>, default: null },
69
+ canManage: { type: Boolean, default: false },
70
+ });
71
+ const emit = defineEmits<{ (e: "saved"): void; (e: "message", text: string, color: string): void }>();
72
+
73
+ const contactItems = [
74
+ { title: "Ask as usual", value: "default" },
75
+ { title: "Don't ask", value: "hidden" },
76
+ { title: "Always required", value: "required" },
77
+ ];
78
+ const vehicleItems = [
79
+ { title: "As usual", value: "default" },
80
+ { title: "Always required", value: "required" },
81
+ ];
82
+
83
+ const askNric = ref(true);
84
+ const contact = ref("default");
85
+ const vehicle = ref("default");
86
+ const saving = ref(false);
87
+
88
+ watch(
89
+ () => props.siteData,
90
+ (site) => {
91
+ const v = getVisitorFieldSwitches(site);
92
+ askNric.value = v.nric !== "hidden";
93
+ contact.value = v.contact;
94
+ vehicle.value = v.vehicle;
95
+ },
96
+ { immediate: true }
97
+ );
98
+
99
+ const { saveSiteFeature } = useSiteFeatureSettings();
100
+
101
+ async function save() {
102
+ if (!props.siteData) return;
103
+ saving.value = true;
104
+ try {
105
+ await saveSiteFeature(props.siteId, "visitorFields", {
106
+ nric: askNric.value ? "default" : "hidden",
107
+ contact: contact.value,
108
+ vehicle: vehicle.value,
109
+ });
110
+ emit("message", "Visitor registration settings saved", "success");
111
+ emit("saved");
112
+ } catch (e: any) {
113
+ emit("message", e?.data?.message || "Could not save the visitor registration settings", "error");
114
+ } finally {
115
+ saving.value = false;
116
+ }
117
+ }
118
+ </script>
@@ -476,7 +476,7 @@
476
476
  </v-list-item-title>
477
477
 
478
478
  <v-list-item-subtitle>
479
- NRIC: {{ v.nric || "-" }}
479
+ NRIC: {{ maskNRIC(v.nric) || "-" }}
480
480
  </v-list-item-subtitle>
481
481
 
482
482
  <div class="mt-1 d-flex flex-wrap ga-1">
@@ -581,7 +581,7 @@ const prop = defineProps({
581
581
  },
582
582
  });
583
583
 
584
- const { requiredRule, debounce } = useUtils();
584
+ const { requiredRule, debounce, maskNRIC } = useUtils();
585
585
  const {
586
586
  addVehicle,
587
587
  getCustomSeasonPassTypes,
@@ -93,7 +93,7 @@
93
93
  content-class="visitor-form__menu">
94
94
  <template #activator="{ props }">
95
95
  <InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
96
- :rules="[isVehicleRequired ? requiredRule : () => true]"
96
+ :rules="[vehicleRule]"
97
97
  @update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
98
98
  @blur="isPlateNumberFieldFocused = false" />
99
99
  </template>
@@ -121,7 +121,7 @@
121
121
  <InputLabel class="text-capitalize" title="Contractor Type (Required)" required />
122
122
  <v-combobox v-model="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
123
123
  autocomplete="off" :hide-no-data="false" @update:focused="handleFocusedContractorType"
124
- :items="contractorTypes" :rules="[requiredRule]" item-value="value"
124
+ :items="contractorTypeItems" :rules="[requiredRule]" item-value="value"
125
125
  @update:model-value="handleSelectContractorType" variant="outlined" density="comfortable" persistent-hint
126
126
  small-chips>
127
127
  <template v-slot:no-data>
@@ -311,7 +311,7 @@
311
311
  <div class="screen-modal__body nric-options-body">
312
312
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
313
313
  <span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
314
- <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ selectedNricSearchResult.nric || "N/A" }}</span>
314
+ <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ maskNRIC(selectedNricSearchResult.nric) || "N/A" }}</span>
315
315
  </div>
316
316
 
317
317
  <template v-if="(!visitor.contact || currentAutofillSource === 'contact') && selectedNricContactOptions.length >= 1">
@@ -355,7 +355,7 @@
355
355
  </p>
356
356
 
357
357
  <div class="checkout-dialog__subject">
358
- <div v-if="!skipNonCheckedOutNric">NRIC: {{ visitor.nric || "N/A" }}</div>
358
+ <div v-if="!skipNonCheckedOutNric">NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}</div>
359
359
  <div>Phone Number: {{ visitor.contact || "N/A" }}</div>
360
360
  </div>
361
361
 
@@ -422,7 +422,7 @@
422
422
  <div class="visitor-form__footer" v-if="!skipNonCheckedOutNric">
423
423
  <v-btn class="screen-btn-primary" variant="flat" :loading="loading.searchingNonCheckedOut"
424
424
  @click="handleSkipNricNonCheckedOut">
425
- Skip NRIC: {{ visitor.nric || "N/A" }}
425
+ Skip NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}
426
426
  </v-btn>
427
427
  </div>
428
428
  </v-card>
@@ -613,7 +613,7 @@ import useUtils from "../composables/useUtils";
613
613
  import useSiteSettings from "../composables/useSiteSettings";
614
614
  import useLocalAuth from "../composables/useLocalAuth";
615
615
  import useBuilding from "../composables/useBuilding";
616
- import useVisitor from "../composables/useVisitor";
616
+ import useVisitor, { getVisitorFieldSwitches, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
617
617
  import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
618
618
  import useAccessManagement from "../composables/useAccessManagement";
619
619
  import usePeople from "../composables/usePeople";
@@ -845,10 +845,18 @@ const formFieldsExcluded: Partial<Record<TVisitorType, string[]>> = {
845
845
  guest: ["purpose"],
846
846
  };
847
847
 
848
+ // The site's registration switches (Site Settings -> Visitor registration).
849
+ // A site that never saved them reads all "default" = the per-type rules below.
850
+ const visitorFieldSwitches = computed(() =>
851
+ getVisitorFieldSwitches(siteData.value)
852
+ );
853
+
848
854
  const isFormField = (
849
855
  fieldKey: keyof TVisitorPayload | "delivery-company"
850
856
  ) => {
851
857
  if (formFieldsExcluded[prop.type]?.includes(fieldKey)) return false;
858
+ if (fieldKey === "nric" && visitorFieldSwitches.value.nric === "hidden") return false;
859
+ if (fieldKey === "contact" && visitorFieldSwitches.value.contact === "hidden") return false;
852
860
  return !!typeFieldMap[prop.type]?.includes(fieldKey);
853
861
  };
854
862
 
@@ -892,13 +900,22 @@ const requireNRIC = computed(() => {
892
900
  });
893
901
 
894
902
  const requireContact = computed(() => {
903
+ if (visitorFieldSwitches.value.contact === "required") return true;
895
904
  return ["contractor", "delivery"].includes(prop.type);
896
905
  });
897
906
 
907
+ // "Always required" only reaches types whose form has a plate field (not walk-in).
898
908
  const isVehicleRequired = computed(() => {
909
+ if (visitorFieldSwitches.value.vehicle === "required") return true;
899
910
  return ["guest", "pick-up", "drop-off"].includes(prop.type);
900
911
  });
901
912
 
913
+ const vehicleRule = (v: string) => {
914
+ if (visitorFieldSwitches.value.vehicle === "required")
915
+ return !!String(v ?? "").trim() || VEHICLE_REQUIRED_MESSAGE;
916
+ return isVehicleRequired.value ? requiredRule(v) : true;
917
+ };
918
+
902
919
  const requireCompany = computed(() => {
903
920
  return prop.type === "contractor";
904
921
  });
@@ -1012,6 +1029,10 @@ const formTitle = computed(() => {
1012
1029
 
1013
1030
  function handleSelectContractorType(obj: { title: string; value: string }) {
1014
1031
  visitor.contractorType = obj.value ?? "";
1032
+ // Managing agent staff (site switch): fill in the MA's company if empty.
1033
+ if (obj?.value === MA_STAFF_CONTRACTOR_TYPE && !visitor.company) {
1034
+ visitor.company = maStaffRegistration.value.company;
1035
+ }
1015
1036
  }
1016
1037
 
1017
1038
  function handleFocusedContractorType() {
@@ -1511,6 +1532,20 @@ const {
1511
1532
  getSiteById(prop.site)
1512
1533
  );
1513
1534
 
1535
+ // "Managing agent staff" is offered only where the site switched it on
1536
+ // (`metadata.maStaffRegistration`); it is saved as a contractor, so older app
1537
+ // builds still list it. Off = the list is exactly as before.
1538
+ const MA_STAFF_CONTRACTOR_TYPE = "managing-agent-staff";
1539
+ const maStaffRegistration = computed(() => {
1540
+ const m = (siteData.value as any)?.metadata?.maStaffRegistration;
1541
+ return { enabled: m?.enabled === true, company: typeof m?.company === "string" ? m.company : "" };
1542
+ });
1543
+ const contractorTypeItems = computed(() =>
1544
+ maStaffRegistration.value.enabled
1545
+ ? [...contractorTypes, { title: "Managing agent staff", value: MA_STAFF_CONTRACTOR_TYPE }]
1546
+ : contractorTypes
1547
+ );
1548
+
1514
1549
  // Has to sit BELOW `siteData`, not beside `hidQrConfiguredReaderId` up at the HID block.
1515
1550
  // `hidQrConfiguredReaderId` reads `hidQrCodePassConfig`, which reads `siteData`, and Vue
1516
1551
  // 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
  }>();
@@ -280,6 +280,16 @@ export function useCommonPermissions() {
280
280
  check: true,
281
281
  description: "Allows the user to manage work order settings.",
282
282
  },
283
+ // Security Ops site visits. The boundary is one half; the guardhouse
284
+ // code an Ops Executive scans to open a visit is the other, and it is the
285
+ // proof the whole module rests on - so this is the right that decides who
286
+ // may re-register it. In the COMMON catalogue because site settings are
287
+ // shared across verticals, not only security.
288
+ "manage-geofence": {
289
+ check: true,
290
+ description:
291
+ "Allows the user to set a site's boundary and register the guardhouse check-in code used for Security Ops site visits.",
292
+ },
283
293
  "manage-billing-and-soa-configuration": {
284
294
  check: true,
285
295
  description: