@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.
- package/CHANGELOG.md +28 -4
- package/components/AccessCardDetailsDialog.vue +3 -2
- package/components/AddPassKeyToVisitor.vue +5 -0
- package/components/BuildingUnitFormEdit.vue +3 -1
- package/components/DataRetentionBanner.vue +73 -0
- 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/MemberSiteAccess.vue +51 -2
- package/components/PassInformation.vue +5 -0
- package/components/RolePermissionFormCreate.vue +33 -0
- package/components/SearchVehicleNumberUser.vue +3 -0
- package/components/SiteDataRetention.vue +153 -0
- package/components/SiteSettings.vue +48 -0
- package/components/SiteSettingsVisitorRegistration.vue +131 -0
- package/components/VehicleForm.vue +2 -2
- package/components/VisitorForm.vue +45 -6
- package/components/VisitorManagement.vue +3 -1
- package/components/VisitorSearchResultSummary.vue +2 -1
- package/composables/useDataRetention.ts +49 -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/useMember.ts +6 -0
- package/composables/usePassKey.ts +4 -0
- package/composables/useSiteFeatureSettings.ts +37 -0
- package/composables/useVisitor.ts +25 -0
- package/constants/permissions.ts +12 -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 +108 -2
- package/utils/key-presses.ts +55 -0
- package/utils/list-export.ts +39 -0
- package/utils/notification-target.ts +10 -0
- 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 "Managing agent staff" 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({
|
|
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 & 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,
|