@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
@@ -0,0 +1,61 @@
1
+ <template>
2
+ <div data-test="ma-staff-registration">
3
+ <v-switch v-model="enabled" :disabled="readOnly" label="Offer &quot;Managing agent staff&quot; at the guardhouse"
4
+ color="primary" hide-details />
5
+ <p class="text-caption text-medium-emphasis mt-1 mb-3">
6
+ The guardhouse can register your staff drawing keys as a contractor of type "Managing agent staff", with your company
7
+ name filled in.
8
+ </p>
9
+ <v-text-field v-if="enabled" v-model.trim="company" :readonly="readOnly" label="Company name to fill in"
10
+ placeholder="e.g. JLL" density="comfortable" variant="outlined" maxlength="120" />
11
+ <v-btn v-if="!readOnly" class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
12
+ :loading="saving" :disabled="!loaded" @click="save" />
13
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
19
+
20
+ /**
21
+ * Site setting `metadata.maStaffRegistration` (core site-feature-settings).
22
+ * Off = today: the contractor type list is unchanged.
23
+ */
24
+ const props = defineProps({
25
+ siteId: { type: String, required: true },
26
+ saved: { type: Object as PropType<{ enabled?: boolean; company?: string } | null>, default: null },
27
+ readOnly: { type: Boolean, default: false },
28
+ /** The site has been read; Save stays off until then. */
29
+ loaded: { type: Boolean, default: false },
30
+ });
31
+ const emit = defineEmits(["saved"]);
32
+
33
+ const enabled = ref(false);
34
+ const company = ref("");
35
+ watch(
36
+ () => props.saved,
37
+ (v) => {
38
+ enabled.value = v?.enabled === true;
39
+ company.value = v?.company ?? "";
40
+ },
41
+ { immediate: true },
42
+ );
43
+
44
+ const { saveSiteFeature } = useSiteFeatureSettings();
45
+ const saving = ref(false);
46
+ const toast = reactive({ show: false, message: "", color: "" });
47
+
48
+ async function save() {
49
+ if (!props.loaded) return;
50
+ saving.value = true;
51
+ try {
52
+ await saveSiteFeature(props.siteId, "maStaffRegistration", { enabled: enabled.value, company: company.value });
53
+ Object.assign(toast, { show: true, message: "Saved.", color: "success" });
54
+ emit("saved");
55
+ } catch (e: any) {
56
+ Object.assign(toast, { show: true, message: e?.data?.message || "Could not save.", color: "error" });
57
+ } finally {
58
+ saving.value = false;
59
+ }
60
+ }
61
+ </script>
@@ -114,7 +114,7 @@
114
114
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
115
115
  <span>{{ memberSelectedSearchResult.name || "Unknown" }}</span>
116
116
  <span class="text-no-wrap">
117
- NRIC: {{ memberSelectedSearchResult.nric || "N/A" }}
117
+ NRIC: {{ maskNRIC(memberSelectedSearchResult.nric) || "N/A" }}
118
118
  </span>
119
119
  </div>
120
120
 
@@ -214,7 +214,7 @@ const props = defineProps({
214
214
  },
215
215
  });
216
216
 
217
- const { requiredRule, debounce } = useUtils();
217
+ const { requiredRule, debounce, maskNRIC } = useUtils();
218
218
  const { getPassKeysByPageSearch } = usePassKey();
219
219
  const {
220
220
  findPersonByNRICMultipleResult,
@@ -34,6 +34,33 @@
34
34
  <p v-if="!choices.length" class="text-body-2">This organisation has no sites yet.</p>
35
35
 
36
36
  <div v-else>
37
+ <!--
38
+ WHO GOES OUT TO THE SITES. One switch for the whole person, not one
39
+ per row: an officer is an officer everywhere they are sent, and the
40
+ server writes the same value onto every site ticked below.
41
+
42
+ It grants NOTHING. What a member may do is still their role, which
43
+ is why this sits apart from the Role column rather than inside it.
44
+ -->
45
+ <v-switch
46
+ v-model="securityOps"
47
+ color="primary"
48
+ density="compact"
49
+ hide-details
50
+ inset
51
+ class="mb-2"
52
+ data-test="site-access-security-ops"
53
+ >
54
+ <template #label>
55
+ <div>
56
+ <div class="text-body-2">This person is part of Security Ops</div>
57
+ <div class="text-caption text-medium-emphasis">
58
+ They visit the sites ticked below and fill in the checklists.
59
+ </div>
60
+ </div>
61
+ </template>
62
+ </v-switch>
63
+
37
64
  <div class="d-flex align-center ga-2 mb-2">
38
65
  <v-select
39
66
  v-model="sameRole"
@@ -53,6 +80,7 @@
53
80
 
54
81
  <div v-for="(c, i) in choices" :key="c.site" class="d-flex align-center ga-2 py-1" data-test="site-access-row">
55
82
  <v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
83
+ <v-chip v-if="securityOps && choices[i].on" size="x-small" color="primary" variant="tonal">Security Ops</v-chip>
56
84
  <v-select
57
85
  v-if="c.roles.length"
58
86
  v-model="choices[i].role"
@@ -72,6 +100,10 @@
72
100
  </p>
73
101
  </div>
74
102
 
103
+ <p v-if="choices.length" class="text-caption mt-2" data-test="site-access-security-ops-note">
104
+ {{ securityOpsNote }}
105
+ </p>
106
+
75
107
  <p v-if="missing.length" class="text-error text-caption mt-2">Choose a role for: {{ missing.join(", ") }}</p>
76
108
  <p v-if="saveError" class="text-error mt-2">{{ saveError }}</p>
77
109
  </template>
@@ -87,7 +119,7 @@
87
119
 
88
120
  <script setup lang="ts">
89
121
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
90
- import { applySameRole, initialChoices, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
122
+ import { applySameRole, initialChoices, initialSecurityOps, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
91
123
 
92
124
  const props = defineProps({
93
125
  org: { type: String, required: true },
@@ -106,6 +138,7 @@ const choices = ref<SiteChoice[]>([]);
106
138
  const orgWide = ref<Array<{ roleName?: string }>>([]);
107
139
  const removeOrgAccess = ref(false);
108
140
  const sameRole = ref<string | null>(null);
141
+ const securityOps = ref(false);
109
142
 
110
143
  const sharedItems = computed(() => sharedRoleItems(choices.value));
111
144
  const anyOn = computed(() => choices.value.some((c) => c.on));
@@ -119,6 +152,15 @@ function createOrgRole() {
119
152
  }
120
153
  const missing = computed(() => toPayload(choices.value).missing);
121
154
 
155
+ /** Say back what the switch and the ticks add up to, in the order they read. */
156
+ const securityOpsNote = computed(() => {
157
+ if (!securityOps.value) return "Not part of Security Ops.";
158
+ const names = choices.value.filter((c) => c.on).map((c) => c.name);
159
+ if (!names.length) return "Part of Security Ops, but no site is ticked yet.";
160
+ if (names.length === 1) return `Part of Security Ops at ${names[0]}.`;
161
+ return `Part of Security Ops at ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}.`;
162
+ });
163
+
122
164
  function applySame() {
123
165
  if (sameRole.value) choices.value = applySameRole(choices.value, sameRole.value);
124
166
  }
@@ -130,6 +172,7 @@ async function load() {
130
172
  const res = await getSiteAccess(props.org, props.user);
131
173
  choices.value = initialChoices(res?.sites ?? []);
132
174
  orgWide.value = res?.orgWide ?? [];
175
+ securityOps.value = initialSecurityOps(res?.sites ?? []);
133
176
  } catch (e: any) {
134
177
  loadError.value = e?.data?.message || "Site access could not be loaded. Only the organisation's owner or an admin can change it.";
135
178
  }
@@ -141,7 +184,13 @@ async function save() {
141
184
  saveError.value = "";
142
185
  try {
143
186
  const { sites } = toPayload(choices.value);
144
- await saveSiteAccess({ org: props.org, user: props.user, sites, removeOrgAccess: removeOrgAccess.value });
187
+ await saveSiteAccess({
188
+ org: props.org,
189
+ user: props.user,
190
+ sites,
191
+ securityOps: securityOps.value,
192
+ removeOrgAccess: removeOrgAccess.value,
193
+ });
145
194
  emit("saved", sites.length ? `Site access saved: ${sites.length} site(s).` : "Site access saved: no sites.");
146
195
  } catch (e: any) {
147
196
  saveError.value = e?.data?.message || "Site access could not be saved.";
@@ -48,6 +48,7 @@
48
48
  :rules="props.passRules"
49
49
  item-title="prefixAndName"
50
50
  item-value="_id"
51
+ :item-props="keyItemProps"
51
52
  label="Keys (Optional)"
52
53
  multiple
53
54
  variant="outlined"
@@ -97,6 +98,7 @@
97
98
  import type { PropType } from "vue";
98
99
  import type { ValidationRule } from "vuetify/lib/types.mjs";
99
100
  import usePassKey from "../composables/usePassKey";
101
+ import useKeyPresses from "../composables/useKeyPresses";
100
102
  import { errorConverter } from "../utils/data";
101
103
  import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
102
104
 
@@ -125,6 +127,9 @@ const props = withDefaults(defineProps<PassInformationProps>(), {
125
127
  });
126
128
 
127
129
  const emit = defineEmits(["update:pass", "update:keys"]);
130
+ // The key's press (if the site has any) under each key in the picker. Read
131
+ // only when the key picker is shown.
132
+ const { keyItemProps } = useKeyPresses(() => (props.hideKeys ? "" : props.site));
128
133
  const pass = ref<TPassKeyPayload[]>(props.pass);
129
134
  const keys = ref<TPassKeyPayload[]>(props.keys);
130
135
  const selectedPass = ref<string>("");
@@ -79,6 +79,27 @@
79
79
  />
80
80
  </v-col>
81
81
 
82
+ <!--
83
+ Whole-role starting points (e.g. Guardhouse officer). Replaces the
84
+ ticks below; nothing is saved until Create. Only offered when this
85
+ catalogue has every permission the preset uses.
86
+ -->
87
+ <v-col v-if="rolePresets.length" cols="12" class="mt-3" data-test="role-presets">
88
+ <div class="text-caption font-weight-bold mb-1">Start from a ready-made role</div>
89
+ <div class="d-flex flex-wrap ga-2">
90
+ <v-tooltip v-for="p in rolePresets" :key="p.key" :text="p.description" location="top">
91
+ <template #activator="{ props: tip }">
92
+ <v-chip v-bind="tip" size="small" variant="outlined" class="text-none" @click="applyRoleTemplate(p)">
93
+ {{ p.label }}
94
+ </v-chip>
95
+ </template>
96
+ </v-tooltip>
97
+ </div>
98
+ <div class="text-caption text-medium-emphasis mt-1">
99
+ Replaces the ticks below. Adjust them if needed, then create the role.
100
+ </div>
101
+ </v-col>
102
+
82
103
  <v-col cols="12" class="mt-3">
83
104
  <div class="role-create__list">
84
105
  <div
@@ -262,6 +283,7 @@ import useRole from "../composables/useRole";
262
283
  import useUtils from "../composables/useUtils";
263
284
  import useSite from "../composables/useSite";
264
285
  import { readApiError } from "../utils/subscription-form";
286
+ import { applyRolePreset, ROLE_PRESETS, type TRolePreset } from "../utils/inventory-presets";
265
287
 
266
288
  /** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
267
289
  const SAVE_FALLBACK =
@@ -406,6 +428,17 @@ function toggleCategory(resource: string, value: boolean | null) {
406
428
  }
407
429
  }
408
430
 
431
+ /** Whole-role presets this catalogue can express in full. */
432
+ const rolePresets = computed(() =>
433
+ ROLE_PRESETS.filter((p) => applyRolePreset(safePermissions.value as any, p)),
434
+ );
435
+
436
+ function applyRoleTemplate(preset: TRolePreset) {
437
+ const state = applyRolePreset(safePermissions.value as any, preset);
438
+ // never an empty list: empty means "everything" on the server
439
+ if (state?.selected.length) permState.value = state;
440
+ }
441
+
409
442
  /** A module preset replaced that module's ticks (see RolePermissionPresets). */
410
443
  function applyPreset(selected: string[]) {
411
444
  permState.value = { selected, enabled: permState.value.enabled };
@@ -19,6 +19,8 @@
19
19
  </span>
20
20
  </template>
21
21
 
22
+ <template #item.nric="{ value }">{{ maskNRIC(value) || "-" }}</template>
23
+
22
24
  <template #item.actions="{ item }">
23
25
  <AppButton variant="ghost" @click="handleSelectUser(item)">Select</AppButton>
24
26
  </template>
@@ -37,6 +39,7 @@
37
39
  </template>
38
40
 
39
41
  <script setup lang="ts">
42
+ const { maskNRIC } = useUtils();
40
43
 
41
44
  const prop = defineProps({
42
45
  vehicleNumber: {
@@ -0,0 +1,153 @@
1
+ <template>
2
+ <!--
3
+ Data retention (PDPA), per site. Since 2026-10-14 (owner) it is managed by
4
+ the Seven365 Super Admin in Seven365 Admin; here it is a read-only summary
5
+ for the site's managers: the rules, the dates, and every removal with its
6
+ certificate. A site that never had it switched on keeps everything, as today.
7
+ -->
8
+ <div class="sdr" data-test="sdr">
9
+ <div class="sdr-managed" data-test="sdr-managed">
10
+ Managed by Seven365 Admin. To change how long this site keeps its records, contact Seven365.
11
+ </div>
12
+
13
+ <div v-if="!metadata" class="sdr-sub">Loading...</div>
14
+ <template v-else-if="!setting.enabled">
15
+ <div class="sdr-label">Off</div>
16
+ <div class="sdr-sub" data-test="sdr-off">Every record at this site is kept. Nothing is deleted automatically.</div>
17
+ </template>
18
+ <template v-else>
19
+ <div class="sdr-label">On</div>
20
+ <div class="sdr-sub" data-test="sdr-deletion">
21
+ {{
22
+ setting.deletionApproved
23
+ ? "Old records are permanently deleted on the dates below."
24
+ : "Requested - waiting for Seven365 approval. Nothing is warned about or deleted until Seven365 approves it."
25
+ }}
26
+ </div>
27
+
28
+ <div class="sdr-label">How long each kind of record is kept</div>
29
+ <div v-for="t in RETENTION_TYPES" :key="t.value" class="sdr-row" :data-test="`sdr-rule-${t.value}`">
30
+ <span class="sdr-row__name">{{ t.label }}</span>
31
+ <span>{{ keep(t.value) }}</span>
32
+ </div>
33
+ <div class="sdr-sub mt-2">
34
+ Only finished records are deleted: closed feedback and work orders, visitors who have checked out, decided
35
+ incident reports and past days' occurrence books.
36
+ </div>
37
+
38
+ <div class="sdr-label">Dates</div>
39
+ <div class="sdr-sub">Your site admins are told {{ setting.noticeDays ?? 14 }} days before each deletion, and reminded the day before.</div>
40
+ <div v-if="setting.startFrom" class="sdr-sub" data-test="sdr-start">Nothing is deleted before {{ day(setting.startFrom) }}.</div>
41
+ <div v-if="pending" class="sdr-sub" data-test="sdr-next">
42
+ <strong>{{ setting.deletionApproved ? "Next deletion" : "Warning sent for" }}: {{ day(pending.dueAt) }}</strong>
43
+ </div>
44
+ </template>
45
+
46
+ <div v-if="error" class="sdr-error" role="alert">{{ error }}</div>
47
+
48
+ <div class="sdr-label mt-6">Data removal records</div>
49
+ <div class="sdr-sub">Each removal, with a certificate you can give your DPO (data protection lead).</div>
50
+ <div v-if="recordsLoading" class="sdr-sub">Loading...</div>
51
+ <div v-else-if="!records.length" class="sdr-sub" data-test="sdr-no-records">No records have been removed at this site.</div>
52
+ <div v-for="r in records" :key="r._id" class="sdr-record" data-test="sdr-record">
53
+ <div>
54
+ <strong>{{ statusText(r) }}</strong>
55
+ <div class="sdr-sub">
56
+ {{ r.items.filter((i: any) => i.count).map((i: any) => `${i.count} ${i.label.toLowerCase()}`).join(", ") || "No records" }}
57
+ · Record {{ r._id }}
58
+ </div>
59
+ </div>
60
+ <v-btn
61
+ v-if="r.status === 'done'"
62
+ variant="text"
63
+ prepend-icon="mdi-file-download-outline"
64
+ :loading="downloading === r._id"
65
+ data-test="sdr-certificate"
66
+ @click="certificate(r._id)"
67
+ >
68
+ Download certificate
69
+ </v-btn>
70
+ </div>
71
+ </div>
72
+ </template>
73
+
74
+ <script lang="ts" setup>
75
+ import { RETENTION_TYPES } from "../composables/useDataRetention";
76
+
77
+ const props = defineProps({
78
+ siteId: { type: String, required: true },
79
+ /** The site's stored metadata (`{}` once read for a site with none; null = not read yet). */
80
+ metadata: { type: Object as PropType<Record<string, any> | null>, default: null },
81
+ /** No longer used: the summary is read-only for everyone. Kept so a host that still passes it renders as before. */
82
+ canEdit: { type: Boolean, default: false },
83
+ });
84
+
85
+ const { listDataPurgeRecords, downloadDataPurgeCertificate } = useDataRetention();
86
+
87
+ const records = ref<Record<string, any>[]>([]);
88
+ const recordsLoading = ref(false);
89
+ const downloading = ref("");
90
+ const error = ref("");
91
+
92
+ const setting = computed<Record<string, any>>(() => {
93
+ const d = props.metadata?.dataRetention;
94
+ return d && d.enabled === true ? d : { enabled: false };
95
+ });
96
+ const pending = computed(() => records.value.find((r) => r.status === "pending") ?? null);
97
+
98
+ function keep(type: string) {
99
+ const r = (setting.value.rules ?? []).find((x: any) => x.dataType === type);
100
+ return r ? `${r.keepMonths} month${r.keepMonths === 1 ? "" : "s"}` : "Kept";
101
+ }
102
+
103
+ const message = (e: any) => e?.data?.message || e?.message || "Something went wrong. Please try again.";
104
+ const day = (d: string) =>
105
+ new Date(/^\d{4}-\d{2}-\d{2}$/.test(d) ? `${d}T00:00:00+08:00` : d).toLocaleDateString("en-SG", {
106
+ day: "numeric",
107
+ month: "short",
108
+ year: "numeric",
109
+ timeZone: "Asia/Singapore",
110
+ });
111
+
112
+ function statusText(r: Record<string, any>) {
113
+ if (r.status === "pending") return `Removal planned for ${day(r.dueAt)}`;
114
+ if (r.status === "running") return "Removal in progress";
115
+ if (r.status === "cancelled") return `Planned removal withdrawn${r.cancelledAt ? ` on ${day(r.cancelledAt)}` : ""}`;
116
+ if (r.status === "failed") return "Removal did not finish - Seven365 has been told";
117
+ return `Old records removed on ${day(r.ranAt)}`;
118
+ }
119
+
120
+ async function loadRecords() {
121
+ recordsLoading.value = true;
122
+ try {
123
+ records.value = (await listDataPurgeRecords(props.siteId))?.items ?? [];
124
+ } catch {
125
+ records.value = [];
126
+ } finally {
127
+ recordsLoading.value = false;
128
+ }
129
+ }
130
+
131
+ async function certificate(id: string) {
132
+ downloading.value = id;
133
+ try {
134
+ await downloadDataPurgeCertificate(props.siteId, id);
135
+ } catch (e) {
136
+ error.value = message(e);
137
+ } finally {
138
+ downloading.value = "";
139
+ }
140
+ }
141
+
142
+ onMounted(loadRecords);
143
+ </script>
144
+
145
+ <style scoped>
146
+ .sdr-managed { font-weight: 600; margin-bottom: 8px; }
147
+ .sdr-sub { font-size: 13px; opacity: 0.75; margin-bottom: 8px; }
148
+ .sdr-label { font-weight: 600; margin-top: 16px; }
149
+ .sdr-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 4px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.06); }
150
+ .sdr-row__name { flex: 1 1 160px; min-width: 140px; }
151
+ .sdr-error { color: rgb(var(--v-theme-error)); margin-top: 8px; }
152
+ .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; }
153
+ </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,131 @@
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-select
8
+ v-model="nric"
9
+ :items="nricItems"
10
+ label="NRIC / FIN"
11
+ density="comfortable"
12
+ variant="outlined"
13
+ hide-details
14
+ :disabled="saving || !canManage"
15
+ data-test="visitor-fields-nric"
16
+ />
17
+ <p class="text-caption text-medium-emphasis mt-1 mb-4" data-test="visitor-fields-nric-help">
18
+ {{ nricHelp[nric] }} Existing records are not changed.
19
+ </p>
20
+
21
+ <v-select
22
+ v-model="contact"
23
+ :items="contactItems"
24
+ label="Contact number"
25
+ density="comfortable"
26
+ variant="outlined"
27
+ hide-details
28
+ :disabled="saving || !canManage"
29
+ data-test="visitor-fields-contact"
30
+ />
31
+ <p class="text-caption text-medium-emphasis mt-1 mb-4">
32
+ "Always required" means a visitor cannot be saved without a contact number.
33
+ </p>
34
+
35
+ <v-select
36
+ v-model="vehicle"
37
+ :items="vehicleItems"
38
+ label="Vehicle number"
39
+ density="comfortable"
40
+ variant="outlined"
41
+ hide-details
42
+ :disabled="saving || !canManage"
43
+ data-test="visitor-fields-vehicle"
44
+ />
45
+ <p class="text-caption text-medium-emphasis mt-1 mb-4">
46
+ "Always required" means a visitor cannot be saved without a plate number. Walk-in visitors are not asked.
47
+ </p>
48
+
49
+ <v-btn
50
+ v-if="canManage"
51
+ class="screen-btn-primary text-none"
52
+ variant="flat"
53
+ :loading="saving"
54
+ :disabled="!siteData"
55
+ data-test="visitor-fields-save"
56
+ @click="save"
57
+ >
58
+ Save
59
+ </v-btn>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import type { PropType } from "vue";
65
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
66
+ import { getVisitorFieldSwitches } from "../composables/useVisitor";
67
+
68
+ const props = defineProps({
69
+ siteId: { type: String, required: true },
70
+ siteData: { type: Object as PropType<Record<string, any> | null>, default: null },
71
+ canManage: { type: Boolean, default: false },
72
+ });
73
+ const emit = defineEmits<{ (e: "saved"): void; (e: "message", text: string, color: string): void }>();
74
+
75
+ const nricItems = [
76
+ { title: "Ask and keep in full (as usual)", value: "default" },
77
+ { title: "Ask, but keep only the last 4 characters", value: "last4" },
78
+ { title: "Don't ask", value: "hidden" },
79
+ ];
80
+ const nricHelp: Record<string, string> = {
81
+ default: "The full ID number is saved, and staff can find a returning visitor by typing it.",
82
+ last4:
83
+ "The full number is still typed at registration, but only the last 4 characters are saved (e.g. *****567A), for data protection. Returning visitors are then found by those last 4 characters, so check the name before using a suggestion.",
84
+ hidden: "Choose this if your site does not collect ID numbers.",
85
+ };
86
+ const contactItems = [
87
+ { title: "Ask as usual", value: "default" },
88
+ { title: "Don't ask", value: "hidden" },
89
+ { title: "Always required", value: "required" },
90
+ ];
91
+ const vehicleItems = [
92
+ { title: "As usual", value: "default" },
93
+ { title: "Always required", value: "required" },
94
+ ];
95
+
96
+ const nric = ref<"default" | "hidden" | "last4">("default");
97
+ const contact = ref("default");
98
+ const vehicle = ref("default");
99
+ const saving = ref(false);
100
+
101
+ watch(
102
+ () => props.siteData,
103
+ (site) => {
104
+ const v = getVisitorFieldSwitches(site);
105
+ nric.value = v.nric;
106
+ contact.value = v.contact;
107
+ vehicle.value = v.vehicle;
108
+ },
109
+ { immediate: true }
110
+ );
111
+
112
+ const { saveSiteFeature } = useSiteFeatureSettings();
113
+
114
+ async function save() {
115
+ if (!props.siteData) return;
116
+ saving.value = true;
117
+ try {
118
+ await saveSiteFeature(props.siteId, "visitorFields", {
119
+ nric: nric.value,
120
+ contact: contact.value,
121
+ vehicle: vehicle.value,
122
+ });
123
+ emit("message", "Visitor registration settings saved", "success");
124
+ emit("saved");
125
+ } catch (e: any) {
126
+ emit("message", e?.data?.message || "Could not save the visitor registration settings", "error");
127
+ } finally {
128
+ saving.value = false;
129
+ }
130
+ }
131
+ </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,