@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
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
</div>
|
|
49
49
|
</v-list>
|
|
50
50
|
</v-menu>
|
|
51
|
+
<p v-if="visitorFieldSwitches.nric === 'last4'" class="text-caption text-medium-emphasis mt-1"
|
|
52
|
+
data-test="visitor-nric-last4-hint">
|
|
53
|
+
{{ NRIC_LAST4_HINT }}
|
|
54
|
+
</p>
|
|
51
55
|
</v-col>
|
|
52
56
|
</v-row>
|
|
53
57
|
</v-col>
|
|
@@ -93,7 +97,7 @@
|
|
|
93
97
|
content-class="visitor-form__menu">
|
|
94
98
|
<template #activator="{ props }">
|
|
95
99
|
<InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
|
|
96
|
-
:rules="[
|
|
100
|
+
:rules="[vehicleRule]"
|
|
97
101
|
@update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
|
|
98
102
|
@blur="isPlateNumberFieldFocused = false" />
|
|
99
103
|
</template>
|
|
@@ -121,7 +125,7 @@
|
|
|
121
125
|
<InputLabel class="text-capitalize" title="Contractor Type (Required)" required />
|
|
122
126
|
<v-combobox v-model="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
|
|
123
127
|
autocomplete="off" :hide-no-data="false" @update:focused="handleFocusedContractorType"
|
|
124
|
-
:items="
|
|
128
|
+
:items="contractorTypeItems" :rules="[requiredRule]" item-value="value"
|
|
125
129
|
@update:model-value="handleSelectContractorType" variant="outlined" density="comfortable" persistent-hint
|
|
126
130
|
small-chips>
|
|
127
131
|
<template v-slot:no-data>
|
|
@@ -311,7 +315,7 @@
|
|
|
311
315
|
<div class="screen-modal__body nric-options-body">
|
|
312
316
|
<div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
|
|
313
317
|
<span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
|
|
314
|
-
<span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ selectedNricSearchResult.nric || "N/A" }}</span>
|
|
318
|
+
<span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ maskNRIC(selectedNricSearchResult.nric) || "N/A" }}</span>
|
|
315
319
|
</div>
|
|
316
320
|
|
|
317
321
|
<template v-if="(!visitor.contact || currentAutofillSource === 'contact') && selectedNricContactOptions.length >= 1">
|
|
@@ -355,7 +359,7 @@
|
|
|
355
359
|
</p>
|
|
356
360
|
|
|
357
361
|
<div class="checkout-dialog__subject">
|
|
358
|
-
<div v-if="!skipNonCheckedOutNric">NRIC: {{ visitor.nric || "N/A" }}</div>
|
|
362
|
+
<div v-if="!skipNonCheckedOutNric">NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}</div>
|
|
359
363
|
<div>Phone Number: {{ visitor.contact || "N/A" }}</div>
|
|
360
364
|
</div>
|
|
361
365
|
|
|
@@ -422,7 +426,7 @@
|
|
|
422
426
|
<div class="visitor-form__footer" v-if="!skipNonCheckedOutNric">
|
|
423
427
|
<v-btn class="screen-btn-primary" variant="flat" :loading="loading.searchingNonCheckedOut"
|
|
424
428
|
@click="handleSkipNricNonCheckedOut">
|
|
425
|
-
Skip NRIC: {{ visitor.nric || "N/A" }}
|
|
429
|
+
Skip NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}
|
|
426
430
|
</v-btn>
|
|
427
431
|
</div>
|
|
428
432
|
</v-card>
|
|
@@ -613,7 +617,7 @@ import useUtils from "../composables/useUtils";
|
|
|
613
617
|
import useSiteSettings from "../composables/useSiteSettings";
|
|
614
618
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
615
619
|
import useBuilding from "../composables/useBuilding";
|
|
616
|
-
import useVisitor from "../composables/useVisitor";
|
|
620
|
+
import useVisitor, { getVisitorFieldSwitches, NRIC_LAST4_HINT, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
|
|
617
621
|
import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
|
|
618
622
|
import useAccessManagement from "../composables/useAccessManagement";
|
|
619
623
|
import usePeople from "../composables/usePeople";
|
|
@@ -845,10 +849,18 @@ const formFieldsExcluded: Partial<Record<TVisitorType, string[]>> = {
|
|
|
845
849
|
guest: ["purpose"],
|
|
846
850
|
};
|
|
847
851
|
|
|
852
|
+
// The site's registration switches (Site Settings -> Visitor registration).
|
|
853
|
+
// A site that never saved them reads all "default" = the per-type rules below.
|
|
854
|
+
const visitorFieldSwitches = computed(() =>
|
|
855
|
+
getVisitorFieldSwitches(siteData.value)
|
|
856
|
+
);
|
|
857
|
+
|
|
848
858
|
const isFormField = (
|
|
849
859
|
fieldKey: keyof TVisitorPayload | "delivery-company"
|
|
850
860
|
) => {
|
|
851
861
|
if (formFieldsExcluded[prop.type]?.includes(fieldKey)) return false;
|
|
862
|
+
if (fieldKey === "nric" && visitorFieldSwitches.value.nric === "hidden") return false;
|
|
863
|
+
if (fieldKey === "contact" && visitorFieldSwitches.value.contact === "hidden") return false;
|
|
852
864
|
return !!typeFieldMap[prop.type]?.includes(fieldKey);
|
|
853
865
|
};
|
|
854
866
|
|
|
@@ -892,13 +904,22 @@ const requireNRIC = computed(() => {
|
|
|
892
904
|
});
|
|
893
905
|
|
|
894
906
|
const requireContact = computed(() => {
|
|
907
|
+
if (visitorFieldSwitches.value.contact === "required") return true;
|
|
895
908
|
return ["contractor", "delivery"].includes(prop.type);
|
|
896
909
|
});
|
|
897
910
|
|
|
911
|
+
// "Always required" only reaches types whose form has a plate field (not walk-in).
|
|
898
912
|
const isVehicleRequired = computed(() => {
|
|
913
|
+
if (visitorFieldSwitches.value.vehicle === "required") return true;
|
|
899
914
|
return ["guest", "pick-up", "drop-off"].includes(prop.type);
|
|
900
915
|
});
|
|
901
916
|
|
|
917
|
+
const vehicleRule = (v: string) => {
|
|
918
|
+
if (visitorFieldSwitches.value.vehicle === "required")
|
|
919
|
+
return !!String(v ?? "").trim() || VEHICLE_REQUIRED_MESSAGE;
|
|
920
|
+
return isVehicleRequired.value ? requiredRule(v) : true;
|
|
921
|
+
};
|
|
922
|
+
|
|
902
923
|
const requireCompany = computed(() => {
|
|
903
924
|
return prop.type === "contractor";
|
|
904
925
|
});
|
|
@@ -1012,6 +1033,10 @@ const formTitle = computed(() => {
|
|
|
1012
1033
|
|
|
1013
1034
|
function handleSelectContractorType(obj: { title: string; value: string }) {
|
|
1014
1035
|
visitor.contractorType = obj.value ?? "";
|
|
1036
|
+
// Managing agent staff (site switch): fill in the MA's company if empty.
|
|
1037
|
+
if (obj?.value === MA_STAFF_CONTRACTOR_TYPE && !visitor.company) {
|
|
1038
|
+
visitor.company = maStaffRegistration.value.company;
|
|
1039
|
+
}
|
|
1015
1040
|
}
|
|
1016
1041
|
|
|
1017
1042
|
function handleFocusedContractorType() {
|
|
@@ -1511,6 +1536,20 @@ const {
|
|
|
1511
1536
|
getSiteById(prop.site)
|
|
1512
1537
|
);
|
|
1513
1538
|
|
|
1539
|
+
// "Managing agent staff" is offered only where the site switched it on
|
|
1540
|
+
// (`metadata.maStaffRegistration`); it is saved as a contractor, so older app
|
|
1541
|
+
// builds still list it. Off = the list is exactly as before.
|
|
1542
|
+
const MA_STAFF_CONTRACTOR_TYPE = "managing-agent-staff";
|
|
1543
|
+
const maStaffRegistration = computed(() => {
|
|
1544
|
+
const m = (siteData.value as any)?.metadata?.maStaffRegistration;
|
|
1545
|
+
return { enabled: m?.enabled === true, company: typeof m?.company === "string" ? m.company : "" };
|
|
1546
|
+
});
|
|
1547
|
+
const contractorTypeItems = computed(() =>
|
|
1548
|
+
maStaffRegistration.value.enabled
|
|
1549
|
+
? [...contractorTypes, { title: "Managing agent staff", value: MA_STAFF_CONTRACTOR_TYPE }]
|
|
1550
|
+
: contractorTypes
|
|
1551
|
+
);
|
|
1552
|
+
|
|
1514
1553
|
// Has to sit BELOW `siteData`, not beside `hidQrConfiguredReaderId` up at the HID block.
|
|
1515
1554
|
// `hidQrConfiguredReaderId` reads `hidQrCodePassConfig`, which reads `siteData`, and Vue
|
|
1516
1555
|
// runs a watcher's source getter the moment `watch()` is called - so declared
|
|
@@ -162,7 +162,9 @@
|
|
|
162
162
|
<template v-slot:item.type-company="{ item }">
|
|
163
163
|
<span class="d-flex align-center ga-2">
|
|
164
164
|
<v-icon icon="mdi-user" size="15" />
|
|
165
|
-
<
|
|
165
|
+
<v-chip v-if="item.type === 'contractor' && item.contractorType === 'managing-agent-staff'" size="x-small"
|
|
166
|
+
color="primary" variant="tonal">MA staff</v-chip>
|
|
167
|
+
<span v-else-if="item.type === 'contractor'" class="text-capitalize">{{
|
|
166
168
|
formatCamelCaseToWords(item.contractorType)
|
|
167
169
|
}}</span>
|
|
168
170
|
<span v-else class="text-capitalize">{{ formatType(item) }}</span>
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
</div>
|
|
27
27
|
<div class="visitor-summary__cell">
|
|
28
28
|
<div class="visitor-summary__label">NRIC</div>
|
|
29
|
-
<div class="visitor-summary__value">{{ item?.nric || "N/A" }}</div>
|
|
29
|
+
<div class="visitor-summary__value">{{ maskNRIC(item?.nric) || "N/A" }}</div>
|
|
30
30
|
</div>
|
|
31
31
|
</div>
|
|
32
32
|
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
</template>
|
|
50
50
|
|
|
51
51
|
<script setup lang="ts">
|
|
52
|
+
const { maskNRIC } = useUtils();
|
|
52
53
|
const props = defineProps<{
|
|
53
54
|
item: any;
|
|
54
55
|
}>();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDPA data retention per site (`/api/data-retention`, API-core). The setting
|
|
3
|
+
* itself is one site switch (`metadata.dataRetention`), managed by the Seven365
|
|
4
|
+
* Super Admin in Seven365 Admin since 2026-10-14; the apps only read it.
|
|
5
|
+
* Documents are not part of data retention in this version.
|
|
6
|
+
*/
|
|
7
|
+
export type TRetentionRule = { dataType: string; keepMonths: number };
|
|
8
|
+
|
|
9
|
+
export const RETENTION_TYPES = [
|
|
10
|
+
{ value: "visitors", label: "Visitor records" },
|
|
11
|
+
{ value: "feedback", label: "Feedback" },
|
|
12
|
+
{ value: "work-orders", label: "Work orders" },
|
|
13
|
+
{ value: "incident-reports", label: "Incident reports" },
|
|
14
|
+
{ value: "occurrence-books", label: "Occurrence books" },
|
|
15
|
+
{ value: "attendance", label: "Attendance" },
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export default function useDataRetention() {
|
|
19
|
+
const api = () => useNuxtApp().$api;
|
|
20
|
+
|
|
21
|
+
/** Read-only counts of what the given (or saved) rules would remove today. */
|
|
22
|
+
function previewDataRetention(site: string, body: { rules?: TRetentionRule[] } = {}) {
|
|
23
|
+
return api()<Record<string, any>>("/api/data-retention/preview", { method: "POST", body: { site, ...body } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function listDataPurgeRecords(site: string) {
|
|
27
|
+
return api()<Record<string, any>>("/api/data-retention/records", { method: "GET", query: { site } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function downloadDataPurgeCertificate(site: string, id: string) {
|
|
31
|
+
const res = await api()<Blob>(`/api/data-retention/records/${id}/certificate`, { method: "GET", query: { site } });
|
|
32
|
+
if (!res) throw new Error("Could not download the certificate.");
|
|
33
|
+
const url = window.URL.createObjectURL(res);
|
|
34
|
+
const a = document.createElement("a");
|
|
35
|
+
a.href = url;
|
|
36
|
+
a.download = `data-removal-record-${id}.pdf`;
|
|
37
|
+
document.body.appendChild(a);
|
|
38
|
+
a.click();
|
|
39
|
+
a.remove();
|
|
40
|
+
window.URL.revokeObjectURL(url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A scheduled removal at the site, for the people who work it (`{ banner: null }` when none). */
|
|
44
|
+
function getDataRetentionBanner(site: string) {
|
|
45
|
+
return api()<{ banner: Record<string, any> | null }>("/api/data-retention/banner", { method: "GET", query: { site } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate, getDataRetentionBanner };
|
|
49
|
+
}
|
|
@@ -7,6 +7,7 @@ export default function useDocument() {
|
|
|
7
7
|
site = "",
|
|
8
8
|
parentId = "",
|
|
9
9
|
expiry = "all",
|
|
10
|
+
archived = false,
|
|
10
11
|
}: {
|
|
11
12
|
page?: number;
|
|
12
13
|
search?: string;
|
|
@@ -19,6 +20,8 @@ export default function useDocument() {
|
|
|
19
20
|
* Document (Expired). Defaults to "all" so existing callers are unaffected.
|
|
20
21
|
*/
|
|
21
22
|
expiry?: string;
|
|
23
|
+
/** The Archived tab (core document archive). Left out = the normal lists. */
|
|
24
|
+
archived?: boolean;
|
|
22
25
|
} = {}) {
|
|
23
26
|
const query: Record<string, any> = {
|
|
24
27
|
page,
|
|
@@ -28,6 +31,7 @@ export default function useDocument() {
|
|
|
28
31
|
site,
|
|
29
32
|
parentId,
|
|
30
33
|
expiry,
|
|
34
|
+
...(archived ? { archived: true } : {}),
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
return useNuxtApp().$api<Record<string, any>>(`/api/documents`, {
|
|
@@ -60,6 +64,14 @@ export default function useDocument() {
|
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/** Archive keeps the document but hides it from the main list; restore brings it back. */
|
|
68
|
+
function setArchived(id: string, archived: boolean) {
|
|
69
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
70
|
+
`/api/documents/${id}/${archived ? "archive" : "unarchive"}`,
|
|
71
|
+
{ method: "PUT" }
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function getById(id: string) {
|
|
64
76
|
return useNuxtApp().$api<Record<string, any>>(`/api/documents/${id}`, {
|
|
65
77
|
method: "GET",
|
|
@@ -108,6 +120,7 @@ export default function useDocument() {
|
|
|
108
120
|
add,
|
|
109
121
|
deleteById,
|
|
110
122
|
updateById,
|
|
123
|
+
setArchived,
|
|
111
124
|
getDocumentsBySiteId,
|
|
112
125
|
create,
|
|
113
126
|
createFolder,
|
|
@@ -162,6 +162,17 @@ export default function useFeedback() {
|
|
|
162
162
|
);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** A linked copy of this feedback for another provider at the same site. */
|
|
166
|
+
function sendToProvider(id: string, provider: string) {
|
|
167
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
168
|
+
"/api/feedbacks2/send-to-provider",
|
|
169
|
+
{
|
|
170
|
+
method: "POST",
|
|
171
|
+
body: { _id: id, provider },
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
165
176
|
function addFeedBackWorkOrder(payload: any) {
|
|
166
177
|
return useNuxtApp().$api<Record<string, any>>(
|
|
167
178
|
"/api/feedbacks2/create-work-order",
|
|
@@ -204,5 +215,6 @@ export default function useFeedback() {
|
|
|
204
215
|
updateStatusComplete,
|
|
205
216
|
addFeedBackWorkOrder,
|
|
206
217
|
updateStatusFeedback,
|
|
218
|
+
sendToProvider,
|
|
207
219
|
};
|
|
208
220
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room handover / takeover checklists (API-core `/api/handover-checklists`).
|
|
3
|
+
* The managing agent sets up a checklist per room; guards fill one in at each
|
|
4
|
+
* handover (before use) and takeover (after use). No facility booking needed.
|
|
5
|
+
*/
|
|
6
|
+
export type THandoverItem = {
|
|
7
|
+
label: string;
|
|
8
|
+
quantity?: number | null;
|
|
9
|
+
tagNo?: string | null;
|
|
10
|
+
referencePhotoId?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type THandoverTemplate = {
|
|
14
|
+
_id?: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string | null;
|
|
17
|
+
items: THandoverItem[];
|
|
18
|
+
updatedAt?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type THandoverRecordItem = THandoverItem & {
|
|
22
|
+
result: "in-order" | "not-in-order";
|
|
23
|
+
remarks?: string | null;
|
|
24
|
+
damagePhotoIds?: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type THandoverRecord = {
|
|
28
|
+
_id: string;
|
|
29
|
+
templateId: string;
|
|
30
|
+
title: string;
|
|
31
|
+
stage: "handover" | "takeover";
|
|
32
|
+
items?: THandoverRecordItem[];
|
|
33
|
+
notInOrderCount?: number;
|
|
34
|
+
photoIds?: string[];
|
|
35
|
+
remarks?: string | null;
|
|
36
|
+
officerName?: string | null;
|
|
37
|
+
completedAt: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const HANDOVER_STAGE_LABELS: Record<string, string> = {
|
|
41
|
+
handover: "Handover (before use)",
|
|
42
|
+
takeover: "Takeover (after use)",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ready-made checklists an MA can start from (renovation, move in / out). They
|
|
47
|
+
* only open the editor pre-filled; nothing is saved until the MA clicks Save.
|
|
48
|
+
*/
|
|
49
|
+
export const HANDOVER_STARTERS: Array<{ name: string; template: THandoverTemplate }> = [
|
|
50
|
+
{
|
|
51
|
+
name: "Lift & lobby protection (renovation)",
|
|
52
|
+
template: {
|
|
53
|
+
title: "Lift & lobby protection (renovation)",
|
|
54
|
+
description: "Check before renovation works start and again after they finish. Take photos of any damage.",
|
|
55
|
+
items: [
|
|
56
|
+
{ label: "Lift protection in place (wall panels and floor)" },
|
|
57
|
+
{ label: "Lobby floor protection in place" },
|
|
58
|
+
{ label: "Lift interior condition (walls, doors, floor, buttons)" },
|
|
59
|
+
{ label: "Lobby walls, doors and fittings condition" },
|
|
60
|
+
{ label: "Common corridor clean, no debris" },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "Move in / move out",
|
|
66
|
+
template: {
|
|
67
|
+
title: "Move in / move out",
|
|
68
|
+
description: "Check before the move starts and again after it ends. Take photos of any damage.",
|
|
69
|
+
items: [
|
|
70
|
+
{ label: "Lift protection in place" },
|
|
71
|
+
{ label: "Lobby floor protection in place" },
|
|
72
|
+
{ label: "Lift interior condition (walls, doors, floor, buttons)" },
|
|
73
|
+
{ label: "Lobby and corridor condition" },
|
|
74
|
+
{ label: "Loading bay / driveway clear and undamaged" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export default function useHandoverChecklist() {
|
|
81
|
+
const base = "/api/handover-checklists";
|
|
82
|
+
|
|
83
|
+
function getTemplates(site: string) {
|
|
84
|
+
return useNuxtApp().$api<THandoverTemplate[]>(`${base}/templates/site/${site}`, { method: "GET" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addTemplate(site: string, body: THandoverTemplate) {
|
|
88
|
+
return useNuxtApp().$api<{ _id: string }>(`${base}/templates/site/${site}`, { method: "POST", body });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function updateTemplate(id: string, body: THandoverTemplate) {
|
|
92
|
+
return useNuxtApp().$api<Record<string, any>>(`${base}/templates/${id}`, { method: "PUT", body });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function deleteTemplate(id: string) {
|
|
96
|
+
return useNuxtApp().$api<Record<string, any>>(`${base}/templates/${id}`, { method: "DELETE" });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getRecords(site: string, query: { page?: number; limit?: number; templateId?: string } = {}) {
|
|
100
|
+
return useNuxtApp().$api<{ items: THandoverRecord[]; total: number; page: number; limit: number }>(
|
|
101
|
+
`${base}/site/${site}`,
|
|
102
|
+
{ method: "GET", query },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getRecord(id: string) {
|
|
107
|
+
return useNuxtApp().$api<THandoverRecord>(`${base}/${id}`, { method: "GET" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { getTemplates, addTemplate, updateTemplate, deleteTemplate, getRecords, getRecord };
|
|
111
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readKeySettings, keyPressName, type TKeyPress } from "../utils/key-presses";
|
|
2
|
+
import useSite from "./useSite";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One read per site per page load, shared by every caller (a list draws one
|
|
6
|
+
* press name per row). ponytail: never expires; a press added in another tab
|
|
7
|
+
* shows after a reload.
|
|
8
|
+
*/
|
|
9
|
+
const presses = new Map<string, Promise<TKeyPress[]>>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The site's key presses (`metadata.keySettings.cabinets`). Empty for a site
|
|
13
|
+
* that has none, which is every site until it adds one - every screen that
|
|
14
|
+
* uses this then looks exactly as before.
|
|
15
|
+
*/
|
|
16
|
+
export default function useKeyPresses(site: MaybeRefOrGetter<string>) {
|
|
17
|
+
const cabinets = ref<TKeyPress[]>([]);
|
|
18
|
+
const { getSiteById } = useSite();
|
|
19
|
+
|
|
20
|
+
async function load() {
|
|
21
|
+
const id = toValue(site);
|
|
22
|
+
// No site, or a site whose list is now empty: no press names left over.
|
|
23
|
+
if (!id) {
|
|
24
|
+
cabinets.value = [];
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!presses.has(id)) {
|
|
28
|
+
presses.set(
|
|
29
|
+
id,
|
|
30
|
+
getSiteById(id)
|
|
31
|
+
.then((site) => readKeySettings(site).cabinets)
|
|
32
|
+
.catch(() => {
|
|
33
|
+
presses.delete(id);
|
|
34
|
+
return [];
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const list = await presses.get(id)!;
|
|
39
|
+
// A slower answer for a site the caller has since left is dropped.
|
|
40
|
+
if (toValue(site) === id) cabinets.value = Array.isArray(list) ? list : [];
|
|
41
|
+
}
|
|
42
|
+
watch(() => toValue(site), load, { immediate: true });
|
|
43
|
+
|
|
44
|
+
const nameOf = (id?: string | null) => keyPressName(cabinets.value, id);
|
|
45
|
+
/** For a key autocomplete's `item-props`: the press as the item's second line. */
|
|
46
|
+
const keyItemProps = (item: any) => {
|
|
47
|
+
const name = nameOf(item?.cabinet);
|
|
48
|
+
return name ? { subtitle: name } : {};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** After the site's presses are saved: read them again. */
|
|
52
|
+
function forget(id: string = toValue(site)) {
|
|
53
|
+
presses.delete(id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { cabinets, nameOf, keyItemProps, load, forget };
|
|
57
|
+
}
|
package/composables/useMember.ts
CHANGED
|
@@ -201,6 +201,12 @@ export default function useMember() {
|
|
|
201
201
|
org: string;
|
|
202
202
|
user: string;
|
|
203
203
|
sites: Array<{ site: string; role: string }>;
|
|
204
|
+
/**
|
|
205
|
+
* Is this person one of the Security Ops officers who visits sites? One
|
|
206
|
+
* value for the whole person, written onto every site they end up with.
|
|
207
|
+
* Omit it and the server leaves the mark exactly as it is.
|
|
208
|
+
*/
|
|
209
|
+
securityOps?: boolean;
|
|
204
210
|
removeOrgAccess?: boolean;
|
|
205
211
|
}) {
|
|
206
212
|
return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
|
|
@@ -125,6 +125,7 @@ export default function usePassKey() {
|
|
|
125
125
|
statuses = [],
|
|
126
126
|
sortBy,
|
|
127
127
|
prefixAndName,
|
|
128
|
+
cabinets,
|
|
128
129
|
}: {
|
|
129
130
|
_ids?: any;
|
|
130
131
|
page?: number;
|
|
@@ -137,6 +138,8 @@ export default function usePassKey() {
|
|
|
137
138
|
statuses?: string[] | null;
|
|
138
139
|
sortBy?: object;
|
|
139
140
|
prefixAndName?: string;
|
|
141
|
+
/** Key press ids; "" = keys in no press. Left out = no press filter. */
|
|
142
|
+
cabinets?: string[];
|
|
140
143
|
}) {
|
|
141
144
|
return useNuxtApp().$api("/api/keys/search/v1", {
|
|
142
145
|
method: "POST",
|
|
@@ -152,6 +155,7 @@ export default function usePassKey() {
|
|
|
152
155
|
statuses,
|
|
153
156
|
sortBy,
|
|
154
157
|
prefixAndName,
|
|
158
|
+
...(cabinets?.length ? { cabinets } : {}),
|
|
155
159
|
},
|
|
156
160
|
});
|
|
157
161
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site switches (`site.metadata.<key>`, core `site-feature-settings.util.ts`).
|
|
3
|
+
*
|
|
4
|
+
* Every settings panel that writes one saves ONLY its own switch through
|
|
5
|
+
* `PATCH /api/sites/:id/feature-settings` `{ field: "metadata.<key>", value }`.
|
|
6
|
+
* Never the whole `metadata`: a panel that sends back what it read could wipe
|
|
7
|
+
* a switch another panel (or another person) saved in the meantime. The
|
|
8
|
+
* server validates the value and refuses data retention and feedback handling
|
|
9
|
+
* changes from anyone but the site's owner (or Seven365).
|
|
10
|
+
*/
|
|
11
|
+
export const SITE_FEATURE_KEYS = [
|
|
12
|
+
"visitorFields",
|
|
13
|
+
"keySettings",
|
|
14
|
+
"incidentReportEmails",
|
|
15
|
+
"feedbackSettings",
|
|
16
|
+
"dataRetention",
|
|
17
|
+
"maStaffRegistration",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type TSiteFeatureKey = (typeof SITE_FEATURE_KEYS)[number];
|
|
21
|
+
|
|
22
|
+
/** The request body for one switch. */
|
|
23
|
+
export function siteFeatureSettingBody(key: TSiteFeatureKey, value: unknown) {
|
|
24
|
+
if (!SITE_FEATURE_KEYS.includes(key)) throw new Error(`Unknown site setting: ${key}`);
|
|
25
|
+
return { field: `metadata.${key}`, value };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function useSiteFeatureSettings() {
|
|
29
|
+
async function saveSiteFeature(siteId: string, key: TSiteFeatureKey, value: unknown) {
|
|
30
|
+
return await useNuxtApp().$api<Record<string, any>>(`/api/sites/${siteId}/feature-settings`, {
|
|
31
|
+
method: "PATCH",
|
|
32
|
+
body: siteFeatureSettingBody(key, value),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { saveSiteFeature };
|
|
37
|
+
}
|
|
@@ -273,3 +273,28 @@ export default function () {
|
|
|
273
273
|
visitorDataFromScannedQRCodeCheckIn,
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The site's guardhouse registration switches (`site.metadata.visitorFields`).
|
|
279
|
+
* Anything a site never saved reads as "default" = today's form.
|
|
280
|
+
*/
|
|
281
|
+
export type TVisitorFieldSwitches = {
|
|
282
|
+
nric: "default" | "hidden" | "last4";
|
|
283
|
+
contact: "default" | "hidden" | "required";
|
|
284
|
+
vehicle: "default" | "required";
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export function getVisitorFieldSwitches(site: any): TVisitorFieldSwitches {
|
|
288
|
+
const v = site?.metadata?.visitorFields ?? {};
|
|
289
|
+
return {
|
|
290
|
+
nric: v.nric === "hidden" || v.nric === "last4" ? v.nric : "default",
|
|
291
|
+
contact: v.contact === "hidden" || v.contact === "required" ? v.contact : "default",
|
|
292
|
+
vehicle: v.vehicle === "required" ? "required" : "default",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Shown under the NRIC field on a "keep only the last 4" site. */
|
|
297
|
+
export const NRIC_LAST4_HINT = "Only the last 4 characters will be kept.";
|
|
298
|
+
|
|
299
|
+
/** Same words as the server's refusal, so the guard reads one message. */
|
|
300
|
+
export const VEHICLE_REQUIRED_MESSAGE = "This site requires a vehicle number for every visitor.";
|
package/constants/permissions.ts
CHANGED
|
@@ -342,6 +342,18 @@ export const PERMISSIONS = {
|
|
|
342
342
|
// Owner answer Q10, 2026-09-11. Mirrors `start-virtual-patrol-route`.
|
|
343
343
|
"start-nfc-patrol",
|
|
344
344
|
],
|
|
345
|
+
// Security Ops. Both strings are enforced by the server (API-core
|
|
346
|
+
// `SITE_VISIT_PERMISSIONS`) and read by the security mobile app's own
|
|
347
|
+
// catalogue, so the spelling is maintained by hand in three places and this
|
|
348
|
+
// is the one that makes a typo visible.
|
|
349
|
+
//
|
|
350
|
+
// `see-all-site-visits` also decides who is told about an action: a head of
|
|
351
|
+
// security holds it together with `site-settings:manage-geofence`. A wildcard
|
|
352
|
+
// deliberately does NOT qualify for that, because a wildcard says somebody
|
|
353
|
+
// administers this site, not that they run security at it - measured
|
|
354
|
+
// 2026-10-13, every wildcard holder at a live site was an org owner, a test
|
|
355
|
+
// account or a service provider's administrator.
|
|
356
|
+
"security-ops": ["start-site-visit", "see-all-site-visits"],
|
|
345
357
|
// Owner answer Q9, 2026-09-11: seeing the CCTV wall. Distinct from
|
|
346
358
|
// `site-settings:manage-cctv-camera`, which is camera setup.
|
|
347
359
|
"cctv-mgmt": ["see-all-cctv", "view-recordings"],
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.121.0",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"build": "nuxt build .playground",
|
|
38
38
|
"generate": "nuxt generate .playground",
|
|
39
39
|
"preview": "nuxt preview .playground",
|
|
40
|
-
"test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useSite.ts --format=esm --outfile=test/.build/useSite.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && esbuild utils/rmf-editor.ts --bundle --platform=node --format=esm --outfile=test/.build/rmf-editor.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
40
|
+
"test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useSite.ts --format=esm --outfile=test/.build/useSite.mjs --log-level=error && esbuild composables/useSiteFeatureSettings.ts --format=esm --outfile=test/.build/useSiteFeatureSettings.mjs --log-level=error && esbuild composables/useVisitor.ts --format=esm --outfile=test/.build/useVisitor.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && esbuild utils/rmf-editor.ts --bundle --platform=node --format=esm --outfile=test/.build/rmf-editor.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
|
|
41
41
|
"test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
|
|
42
42
|
"release": "yarn run build && changeset publish"
|
|
43
43
|
},
|
package/types/key-log.d.ts
CHANGED