@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.
- package/CHANGELOG.md +25 -4
- package/components/AccessCardDetailsDialog.vue +3 -2
- package/components/AddPassKeyToVisitor.vue +5 -0
- package/components/BuildingUnitFormEdit.vue +3 -1
- package/components/DocumentManagement.vue +56 -3
- package/components/FeedbackHandlingSettings.vue +385 -0
- package/components/HandoverChecklistRecords.vue +223 -0
- package/components/HandoverChecklistSetup.vue +224 -0
- package/components/IncidentReportEmailsSettings.vue +88 -0
- package/components/KeyPressFilter.vue +43 -0
- package/components/KeyPressName.vue +14 -0
- package/components/KeyPressSelect.vue +31 -0
- package/components/KeySettingsPanel.vue +156 -0
- package/components/ListCsvExport.vue +71 -0
- package/components/MaStaffRegistrationSettings.vue +61 -0
- package/components/MemberInformation.vue +2 -2
- package/components/PassInformation.vue +5 -0
- package/components/RolePermissionFormCreate.vue +33 -0
- package/components/SearchVehicleNumberUser.vue +3 -0
- package/components/SiteDataRetention.vue +286 -0
- package/components/SiteSettings.vue +48 -0
- package/components/SiteSettingsVisitorRegistration.vue +118 -0
- package/components/VehicleForm.vue +2 -2
- package/components/VisitorForm.vue +41 -6
- package/components/VisitorManagement.vue +3 -1
- package/components/VisitorSearchResultSummary.vue +2 -1
- package/composables/useCommonPermission.ts +10 -0
- package/composables/useDataRetention.ts +43 -0
- package/composables/useDocument.ts +13 -0
- package/composables/useFeedback.ts +12 -0
- package/composables/useHandoverChecklist.ts +111 -0
- package/composables/useKeyPresses.ts +57 -0
- package/composables/usePassKey.ts +4 -0
- package/composables/useSecurityPermission.ts +38 -0
- package/composables/useSettingsPermission.ts +16 -1
- package/composables/useSiteFeatureSettings.ts +37 -0
- package/composables/useVisitor.ts +22 -0
- package/constants/permissions.ts +3 -0
- package/package.json +2 -2
- package/types/key-log.d.ts +2 -0
- package/types/passKey.d.ts +2 -0
- package/utils/feedback-handling-settings.ts +139 -0
- package/utils/inventory-presets.ts +64 -0
- package/utils/key-presses.ts +55 -0
- 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 & 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="[
|
|
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="
|
|
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
|
-
<
|
|
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:
|