@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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDPA data retention per site (`/api/data-retention`, API-core). The setting
|
|
3
|
+
* itself is one site switch (`metadata.dataRetention`, useSiteFeatureSettings).
|
|
4
|
+
* Documents are not part of data retention in this version.
|
|
5
|
+
*/
|
|
6
|
+
export type TRetentionRule = { dataType: string; keepMonths: number };
|
|
7
|
+
|
|
8
|
+
export const RETENTION_TYPES = [
|
|
9
|
+
{ value: "visitors", label: "Visitor records" },
|
|
10
|
+
{ value: "feedback", label: "Feedback" },
|
|
11
|
+
{ value: "work-orders", label: "Work orders" },
|
|
12
|
+
{ value: "incident-reports", label: "Incident reports" },
|
|
13
|
+
{ value: "occurrence-books", label: "Occurrence books" },
|
|
14
|
+
{ value: "attendance", label: "Attendance" },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export default function useDataRetention() {
|
|
18
|
+
const api = () => useNuxtApp().$api;
|
|
19
|
+
|
|
20
|
+
/** Read-only counts of what the given (or saved) rules would remove today. */
|
|
21
|
+
function previewDataRetention(site: string, body: { rules?: TRetentionRule[] } = {}) {
|
|
22
|
+
return api()<Record<string, any>>("/api/data-retention/preview", { method: "POST", body: { site, ...body } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function listDataPurgeRecords(site: string) {
|
|
26
|
+
return api()<Record<string, any>>("/api/data-retention/records", { method: "GET", query: { site } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function downloadDataPurgeCertificate(site: string, id: string) {
|
|
30
|
+
const res = await api()<Blob>(`/api/data-retention/records/${id}/certificate`, { method: "GET", query: { site } });
|
|
31
|
+
if (!res) throw new Error("Could not download the certificate.");
|
|
32
|
+
const url = window.URL.createObjectURL(res);
|
|
33
|
+
const a = document.createElement("a");
|
|
34
|
+
a.href = url;
|
|
35
|
+
a.download = `data-removal-record-${id}.pdf`;
|
|
36
|
+
document.body.appendChild(a);
|
|
37
|
+
a.click();
|
|
38
|
+
a.remove();
|
|
39
|
+
window.URL.revokeObjectURL(url);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate };
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -735,6 +735,38 @@ export default function useSecurityPermission() {
|
|
|
735
735
|
description: "Allows the user to delete routes.",
|
|
736
736
|
},
|
|
737
737
|
},
|
|
738
|
+
/**
|
|
739
|
+
* NEW. Security Ops - the site visit and its checklists.
|
|
740
|
+
*
|
|
741
|
+
* NO ROLE HOLDS THESE YET. The module is new, so every one of these strings
|
|
742
|
+
* has a count of zero today. API-core's `siteVisit` gate is therefore
|
|
743
|
+
* COUNTED, not enforcing (`SITE_VISIT_PERMISSION_MODE`, default `log`): it
|
|
744
|
+
* records who WOULD be refused and refuses nobody, the same way the NFC
|
|
745
|
+
* patrol and incident report gates shipped. Provision roles against that
|
|
746
|
+
* count before anyone sets it to `enforce`.
|
|
747
|
+
*
|
|
748
|
+
* THREE PLACES, BY HAND. These spellings have to match API-core
|
|
749
|
+
* (`SITE_VISIT_PERMISSIONS` in `site-authz.util.ts`) and the RN app
|
|
750
|
+
* (`permission.catalog.ts`), which does NOT import this file - it keeps a
|
|
751
|
+
* literal copy and compares strings exactly. `roles-picker-covers-mobile-gates`
|
|
752
|
+
* in web-app-security is the test that holds the three together.
|
|
753
|
+
*
|
|
754
|
+
* The geofence right is NOT here: it is a site setting, so it lives in
|
|
755
|
+
* `useCommonPermission.ts` as `site-settings:manage-geofence` beside the
|
|
756
|
+
* other panels.
|
|
757
|
+
*/
|
|
758
|
+
"security-ops": {
|
|
759
|
+
"start-site-visit": {
|
|
760
|
+
check: true,
|
|
761
|
+
description:
|
|
762
|
+
"Allows the user to check in at a site, fill in the visit checklists and check out.",
|
|
763
|
+
},
|
|
764
|
+
"see-all-site-visits": {
|
|
765
|
+
check: true,
|
|
766
|
+
description:
|
|
767
|
+
"Allows the user to view site visits across the sites they can reach, including other executives' visits.",
|
|
768
|
+
},
|
|
769
|
+
},
|
|
738
770
|
// NEW. Seeing the CCTV wall, as distinct from setting cameras up
|
|
739
771
|
// (`site-settings:manage-cctv-camera`).
|
|
740
772
|
"cctv-mgmt": {
|
|
@@ -768,6 +800,12 @@ export default function useSecurityPermission() {
|
|
|
768
800
|
"manage-nfc-management": siteSettingsPermissions["manage-nfc-management"],
|
|
769
801
|
"manage-nfc-patrol-settings":
|
|
770
802
|
siteSettingsPermissions["manage-nfc-patrol-settings"],
|
|
803
|
+
// Security Ops geofencing. This block CHERRY-PICKS from
|
|
804
|
+
// `siteSettingsPermissions` rather than spreading it, so a new entry in
|
|
805
|
+
// the common catalogue does not reach the security role editor on its own
|
|
806
|
+
// - without this line no administrator on a security site could grant the
|
|
807
|
+
// right, and the Geofencing panel would be invisible to everyone.
|
|
808
|
+
"manage-geofence": siteSettingsPermissions["manage-geofence"],
|
|
771
809
|
"manage-red-dot-settings": {
|
|
772
810
|
check: true,
|
|
773
811
|
description:
|
|
@@ -129,6 +129,20 @@ export function useSettingsPermission() {
|
|
|
129
129
|
"update-vehicle": { check: true, description: "Allows the user to update vehicle details." },
|
|
130
130
|
},
|
|
131
131
|
};
|
|
132
|
+
/**
|
|
133
|
+
* The Geofencing panel - Security Ops site visits.
|
|
134
|
+
*
|
|
135
|
+
* MUST ALSO BE IN `canManageSiteSettings` BELOW. That computed is an OR of
|
|
136
|
+
* every panel gate and it is what decides whether the Settings page renders at
|
|
137
|
+
* all, so a role holding ONLY this right would otherwise be given a page it
|
|
138
|
+
* cannot open. Adding a panel is always two lines, never one.
|
|
139
|
+
*/
|
|
140
|
+
const canManageGeofence = computed(() => {
|
|
141
|
+
if (!userAppRole.value) return false;
|
|
142
|
+
if (userAppRole.value.permissions.includes("*")) return true;
|
|
143
|
+
return hasPermission(userAppRole.value, permissions, "site-settings", "manage-geofence");
|
|
144
|
+
});
|
|
145
|
+
|
|
132
146
|
const canManageVehicleQr = computed(() => {
|
|
133
147
|
if (!userAppRole.value) return false;
|
|
134
148
|
if (userAppRole.value.permissions.includes("*")) return true;
|
|
@@ -136,7 +150,7 @@ export function useSettingsPermission() {
|
|
|
136
150
|
});
|
|
137
151
|
|
|
138
152
|
const canManageSiteSettings = computed(() => {
|
|
139
|
-
return canManageSiteInformation.value || canManageANPRCamera.value || canManageCCTVCamera.value || canManageDeliveryCompanies.value || canManageResidentVisitors.value || canManageWorkOrderSettings.value || canManageBillingAndSOAConfiguration.value || canManageNFCManagement.value || canManageNFCPatrolSettings.value || canManageEntryPass.value || canManageRedDotSettings.value
|
|
153
|
+
return canManageSiteInformation.value || canManageANPRCamera.value || canManageCCTVCamera.value || canManageDeliveryCompanies.value || canManageResidentVisitors.value || canManageWorkOrderSettings.value || canManageBillingAndSOAConfiguration.value || canManageNFCManagement.value || canManageNFCPatrolSettings.value || canManageEntryPass.value || canManageRedDotSettings.value || canManageGeofence.value
|
|
140
154
|
})
|
|
141
155
|
|
|
142
156
|
return {
|
|
@@ -151,6 +165,7 @@ export function useSettingsPermission() {
|
|
|
151
165
|
canManageNFCPatrolSettings,
|
|
152
166
|
canManageEntryPass,
|
|
153
167
|
canManageRedDotSettings,
|
|
168
|
+
canManageGeofence,
|
|
154
169
|
canManageVehicleQr,
|
|
155
170
|
canManageSiteSettings
|
|
156
171
|
|
|
@@ -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,25 @@ 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";
|
|
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" ? "hidden" : "default",
|
|
291
|
+
contact: v.contact === "hidden" || v.contact === "required" ? v.contact : "default",
|
|
292
|
+
vehicle: v.vehicle === "required" ? "required" : "default",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Same words as the server's refusal, so the guard reads one message. */
|
|
297
|
+
export const VEHICLE_REQUIRED_MESSAGE = "This site requires a vehicle number for every visitor.";
|
package/constants/permissions.ts
CHANGED
|
@@ -150,6 +150,9 @@ export const PERMISSIONS = {
|
|
|
150
150
|
"manage-nfc-patrol-settings",
|
|
151
151
|
"manage-entry-pass",
|
|
152
152
|
"manage-red-dot-settings",
|
|
153
|
+
// Security Ops: the site boundary and the guardhouse check-in code. The
|
|
154
|
+
// server reads this one too - API-core `GEOFENCE_MANAGE_PERMISSIONS`.
|
|
155
|
+
"manage-geofence",
|
|
153
156
|
],
|
|
154
157
|
|
|
155
158
|
// ---- module apps (cleaning, landscape, M&E, pest, pool) ----
|
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.120.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
package/types/passKey.d.ts
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Settings -> "Feedback handling" (TLR, 2026-10-13): the form behind
|
|
3
|
+
* `FeedbackHandlingSettings.vue`, stored on the site as
|
|
4
|
+
* `metadata.feedbackSettings` and validated by core
|
|
5
|
+
* (`site-feature-settings.util.ts`).
|
|
6
|
+
*
|
|
7
|
+
* A site that never saved this reads as: Mon-Fri 09:00-18:00, holidays not
|
|
8
|
+
* excluded, no routes, escalation off, nothing auto-closed - which is exactly
|
|
9
|
+
* today's behaviour, because the server does nothing without a route, an
|
|
10
|
+
* enabled escalation or a listed category.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type TFeedbackWindowTarget = "ma" | "provider";
|
|
14
|
+
|
|
15
|
+
export type TFeedbackRouteForm = {
|
|
16
|
+
category: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
officeHours: TFeedbackWindowTarget;
|
|
19
|
+
afterHours: TFeedbackWindowTarget;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** `id` is the level's stable key (set by the server on its first save; "" for a new step). */
|
|
23
|
+
export type TEscalationLevelForm = { id: string; afterHours: number | string; users: string[]; emails: string[] };
|
|
24
|
+
|
|
25
|
+
export type TFeedbackHandlingForm = {
|
|
26
|
+
workingDays: number[];
|
|
27
|
+
officeStart: string;
|
|
28
|
+
officeEnd: string;
|
|
29
|
+
excludePublicHolidays: boolean;
|
|
30
|
+
routes: TFeedbackRouteForm[];
|
|
31
|
+
escalationEnabled: boolean;
|
|
32
|
+
workingTimeOnly: boolean;
|
|
33
|
+
levels: TEscalationLevelForm[];
|
|
34
|
+
autoCloseCategories: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const MAX_ESCALATION_LEVELS = 5;
|
|
38
|
+
|
|
39
|
+
/** Mon first, as people read a week. `value` is the stored 0 = Sunday. */
|
|
40
|
+
export const WEEK_DAYS = [
|
|
41
|
+
{ title: "Mon", value: 1 },
|
|
42
|
+
{ title: "Tue", value: 2 },
|
|
43
|
+
{ title: "Wed", value: 3 },
|
|
44
|
+
{ title: "Thu", value: 4 },
|
|
45
|
+
{ title: "Fri", value: 5 },
|
|
46
|
+
{ title: "Sat", value: 6 },
|
|
47
|
+
{ title: "Sun", value: 0 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
51
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
52
|
+
const OBJECT_ID = /^[a-f0-9]{24}$/i;
|
|
53
|
+
|
|
54
|
+
const list = (v: unknown) => (Array.isArray(v) ? v : []);
|
|
55
|
+
|
|
56
|
+
/** The stored value (or nothing) as the form. */
|
|
57
|
+
export function feedbackHandlingForm(stored: any): TFeedbackHandlingForm {
|
|
58
|
+
const s = stored && typeof stored === "object" ? stored : {};
|
|
59
|
+
const days = list(s.workingDays).filter((d: unknown) => Number.isInteger(d) && (d as number) >= 0 && (d as number) <= 6);
|
|
60
|
+
return {
|
|
61
|
+
workingDays: days.length ? days : [1, 2, 3, 4, 5],
|
|
62
|
+
officeStart: HHMM.test(s.officeStart) ? s.officeStart : "09:00",
|
|
63
|
+
officeEnd: HHMM.test(s.officeEnd) ? s.officeEnd : "18:00",
|
|
64
|
+
excludePublicHolidays: s.excludePublicHolidays === true,
|
|
65
|
+
routes: list(s.routes).map((r: any) => ({
|
|
66
|
+
category: String(r?.category ?? ""),
|
|
67
|
+
provider: r?.provider ? String(r.provider) : "",
|
|
68
|
+
officeHours: r?.officeHours === "provider" ? "provider" : "ma",
|
|
69
|
+
afterHours: r?.afterHours === "provider" ? "provider" : "ma",
|
|
70
|
+
})),
|
|
71
|
+
escalationEnabled: s.escalation?.enabled === true,
|
|
72
|
+
workingTimeOnly: s.escalation?.workingTimeOnly === true,
|
|
73
|
+
levels: list(s.escalation?.levels).map((l: any) => ({
|
|
74
|
+
id: typeof l?.id === "string" ? l.id : "",
|
|
75
|
+
afterHours: Number(l?.afterHours) || 1,
|
|
76
|
+
users: list(l?.users).map(String),
|
|
77
|
+
emails: list(l?.emails).map(String),
|
|
78
|
+
})),
|
|
79
|
+
autoCloseCategories: list(s.autoCloseCategories).map(String),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Plain-English problems that stop a save; empty = fine. */
|
|
84
|
+
export function feedbackHandlingErrors(form: TFeedbackHandlingForm): string[] {
|
|
85
|
+
const errors: string[] = [];
|
|
86
|
+
if (!form.workingDays.length) errors.push("Pick at least one office day.");
|
|
87
|
+
if (!HHMM.test(form.officeStart) || !HHMM.test(form.officeEnd) || form.officeEnd <= form.officeStart)
|
|
88
|
+
errors.push("Office hours must end after they start.");
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
form.routes.forEach((r, i) => {
|
|
91
|
+
const row = `Row ${i + 1}`;
|
|
92
|
+
if (!r.category.trim()) errors.push(`${row}: choose a category.`);
|
|
93
|
+
else if (seen.has(r.category.trim().toLowerCase())) errors.push(`${row}: ${r.category} is listed twice.`);
|
|
94
|
+
seen.add(r.category.trim().toLowerCase());
|
|
95
|
+
if ((r.officeHours === "provider" || r.afterHours === "provider") && !OBJECT_ID.test(r.provider))
|
|
96
|
+
errors.push(`${row}: choose which provider receives it.`);
|
|
97
|
+
});
|
|
98
|
+
if (form.escalationEnabled) {
|
|
99
|
+
if (!form.levels.length) errors.push("Add at least one escalation step, or turn escalation off.");
|
|
100
|
+
if (form.levels.length > MAX_ESCALATION_LEVELS) errors.push(`At most ${MAX_ESCALATION_LEVELS} escalation steps.`);
|
|
101
|
+
form.levels.forEach((l, i) => {
|
|
102
|
+
const step = `Step ${i + 1}`;
|
|
103
|
+
const hours = Number(l.afterHours);
|
|
104
|
+
if (!Number.isFinite(hours) || hours < 1 || hours > 1440) errors.push(`${step}: hours must be between 1 and 1440.`);
|
|
105
|
+
if (!l.users.length && !l.emails.length) errors.push(`${step}: choose somebody to notify.`);
|
|
106
|
+
for (const e of l.emails) if (!EMAIL.test(e.trim())) errors.push(`${step}: "${e}" is not an email address.`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return errors;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The form as the stored `metadata.feedbackSettings` value. */
|
|
113
|
+
export function feedbackSettingsFromForm(form: TFeedbackHandlingForm) {
|
|
114
|
+
return {
|
|
115
|
+
workingDays: [...form.workingDays].sort((a, b) => a - b),
|
|
116
|
+
officeStart: form.officeStart,
|
|
117
|
+
officeEnd: form.officeEnd,
|
|
118
|
+
excludePublicHolidays: form.excludePublicHolidays,
|
|
119
|
+
routes: form.routes.map((r) => ({
|
|
120
|
+
category: r.category.trim(),
|
|
121
|
+
officeHours: r.officeHours,
|
|
122
|
+
afterHours: r.afterHours,
|
|
123
|
+
provider: r.officeHours === "provider" || r.afterHours === "provider" ? r.provider : null,
|
|
124
|
+
})),
|
|
125
|
+
escalation: {
|
|
126
|
+
enabled: form.escalationEnabled,
|
|
127
|
+
workingTimeOnly: form.workingTimeOnly,
|
|
128
|
+
// A step keeps its id so a re-ordered or edited step is not sent again;
|
|
129
|
+
// a new step has none and the server gives it one.
|
|
130
|
+
levels: form.levels.map((l) => ({
|
|
131
|
+
...(l.id ? { id: l.id } : {}),
|
|
132
|
+
afterHours: Number(l.afterHours),
|
|
133
|
+
users: [...new Set(l.users)],
|
|
134
|
+
emails: [...new Set(l.emails.map((e) => e.trim().toLowerCase()))],
|
|
135
|
+
})),
|
|
136
|
+
},
|
|
137
|
+
autoCloseCategories: [...new Set(form.autoCloseCategories)],
|
|
138
|
+
};
|
|
139
|
+
}
|