@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,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
|
+
}
|
|
@@ -56,8 +56,50 @@ export const INVENTORY_PRESETS: TPermissionPreset[] = [
|
|
|
56
56
|
},
|
|
57
57
|
];
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Security Ops in plain words: the two jobs the module actually has.
|
|
61
|
+
*
|
|
62
|
+
* WHY A PRESET AND NOT A NEW PERMISSION. Neither of these is a new right: both
|
|
63
|
+
* sets are made of strings that already exist. The preset only spares somebody
|
|
64
|
+
* hunting a permission tree, and that matters here because the alternative
|
|
65
|
+
* people reach for is a wildcard role, which is how a service provider's
|
|
66
|
+
* administrator ends up supervising a security site.
|
|
67
|
+
*
|
|
68
|
+
* GEOFENCING IS NOT IN EITHER SET, and cannot be: `applyPermissionPreset` is
|
|
69
|
+
* scoped to one resource, and `manage-geofence` belongs to `site-settings`.
|
|
70
|
+
* That matters because the owner's definition of a head of security
|
|
71
|
+
* (2026-10-13) is this permission AND geofencing, and the server reads exactly
|
|
72
|
+
* that pair to decide who is told when an action is raised or sent for
|
|
73
|
+
* verification. So the preset's description has to send the administrator to
|
|
74
|
+
* tick the other box, rather than quietly leaving the job half done.
|
|
75
|
+
*
|
|
76
|
+
* `start-site-visit` is deliberately NOT in the head of security set: it is the
|
|
77
|
+
* right to go and do a visit, and somebody who never walks a site should not
|
|
78
|
+
* have to hold it to be told what is happening on one. Holding it as well does
|
|
79
|
+
* not disqualify them.
|
|
80
|
+
*/
|
|
81
|
+
export const SECURITY_OPS_PRESETS: TPermissionPreset[] = [
|
|
82
|
+
{
|
|
83
|
+
key: "officer",
|
|
84
|
+
label: "Security Ops officer",
|
|
85
|
+
description:
|
|
86
|
+
"Goes to the site, works through the visit checklists and checks out. Sees their own work.",
|
|
87
|
+
actions: ["start-site-visit"],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: "head",
|
|
91
|
+
label: "Head of security",
|
|
92
|
+
description:
|
|
93
|
+
"Sees every officer's visits and actions. Also tick Manage geofencing under Site settings: that pair is what decides who is told when an action is raised or sent for verification.",
|
|
94
|
+
actions: ["see-all-site-visits"],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/** Presets per module key. */
|
|
99
|
+
export const PERMISSION_PRESETS: Record<string, TPermissionPreset[]> = {
|
|
100
|
+
inventory: INVENTORY_PRESETS,
|
|
101
|
+
"security-ops": SECURITY_OPS_PRESETS,
|
|
102
|
+
};
|
|
61
103
|
|
|
62
104
|
/**
|
|
63
105
|
* The permission list with `resource`'s grants replaced by `preset`'s actions --
|
|
@@ -143,3 +185,67 @@ export function inventoryControls(a?: Partial<TInventoryAccess> | null) {
|
|
|
143
185
|
unitPrice: c.canManage || c.canApprove || c.canHandle || c.canReports,
|
|
144
186
|
};
|
|
145
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whole-role starting points (TLR A7), offered in the create-role dialog when
|
|
191
|
+
* the catalogue has every module they use. Keys are the security catalogue's
|
|
192
|
+
* own (`composables/useSecurityPermission.ts`). Applying one REPLACES the
|
|
193
|
+
* ticks in the dialog and saves nothing; the admin adjusts and saves.
|
|
194
|
+
*/
|
|
195
|
+
export type TRolePreset = {
|
|
196
|
+
key: string;
|
|
197
|
+
label: string;
|
|
198
|
+
description: string;
|
|
199
|
+
permissions: string[];
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The Guardhouse officer's grants, by module, in the same nested form as the
|
|
204
|
+
* security catalogue that offers them (`useSecurityPermission.ts`). A grant
|
|
205
|
+
* list, not a check: `constants/permissions.ts` (what this package CHECKS)
|
|
206
|
+
* deliberately has no Passes & Keys entry, so the role editors there gain no
|
|
207
|
+
* extra checkbox.
|
|
208
|
+
*/
|
|
209
|
+
const GUARDHOUSE_OFFICER: Record<string, string[]> = {
|
|
210
|
+
// check-out is a visitor update on the server
|
|
211
|
+
"visitor-mgmt": ["add-visitor", "see-all-visitor", "see-visitor-details", "update-visitor"],
|
|
212
|
+
"daily-occurrence-books": ["see-all-daily-occurrence", "see-daily-occurrence-details"],
|
|
213
|
+
"daily-occurrence-entries": ["add-daily-occurrence-entries", "see-daily-occurrence-entries-details"],
|
|
214
|
+
"incident-reports": ["add-incident-report", "see-incident-reports", "see-incident-report-details"],
|
|
215
|
+
feedbacks: ["add-feedback", "see-all-feedback", "see-feedback-details"],
|
|
216
|
+
// issuing and taking back a key is a pass/key update on the server
|
|
217
|
+
"passes-and-keys-mgmt": ["see-all-passes-and-keys", "update-passes-and-keys"],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const grants = (byModule: Record<string, string[]>) =>
|
|
221
|
+
Object.entries(byModule).flatMap(([resource, actions]) => actions.map((action) => [resource, action].join(":")));
|
|
222
|
+
|
|
223
|
+
export const ROLE_PRESETS: TRolePreset[] = [
|
|
224
|
+
{
|
|
225
|
+
key: "guardhouse-officer",
|
|
226
|
+
label: "Guardhouse officer",
|
|
227
|
+
description:
|
|
228
|
+
"Register, see and check out visitors; fill in the daily occurrence book; raise and view incident reports and feedback; issue and take back passes and keys. No delete rights.",
|
|
229
|
+
permissions: grants(GUARDHOUSE_OFFICER),
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The dialog state for `preset` against the offered catalogue, or null when
|
|
235
|
+
* the catalogue lacks any of it. Never returns an empty list: an empty
|
|
236
|
+
* permission list means "everything" on the server.
|
|
237
|
+
*/
|
|
238
|
+
export function applyRolePreset(
|
|
239
|
+
catalogue: Record<string, Record<string, unknown> | undefined>,
|
|
240
|
+
preset: TRolePreset,
|
|
241
|
+
): { selected: string[]; enabled: string[] } | null {
|
|
242
|
+
const offered = preset.permissions.every((p) => {
|
|
243
|
+
const [resource, action] = p.split(":");
|
|
244
|
+
return Boolean(catalogue?.[resource]?.[action]);
|
|
245
|
+
});
|
|
246
|
+
if (!offered || !preset.permissions.length) return null;
|
|
247
|
+
return {
|
|
248
|
+
selected: [...preset.permissions],
|
|
249
|
+
enabled: [...new Set(preset.permissions.map((p) => p.split(":")[0]))],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key presses (cabinets) and the Passes & keys site switches (TLR B8 / D2).
|
|
3
|
+
* Stored on the site as `metadata.keySettings`; read with the same defaults
|
|
4
|
+
* as core's `getKeySettings`, so a site that never saved anything reads as
|
|
5
|
+
* today: no presses, no daily list, check-out never blocked by the server.
|
|
6
|
+
*/
|
|
7
|
+
export type TKeyPress = { id: string; name: string };
|
|
8
|
+
|
|
9
|
+
export type TKeySettings = {
|
|
10
|
+
cabinets: TKeyPress[];
|
|
11
|
+
dailyUnreturnedAlert: boolean;
|
|
12
|
+
alertTime: string;
|
|
13
|
+
blockCheckoutWithKeys: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function readKeySettings(site: any): TKeySettings {
|
|
17
|
+
const k = site?.metadata?.keySettings ?? {};
|
|
18
|
+
return {
|
|
19
|
+
cabinets: Array.isArray(k.cabinets)
|
|
20
|
+
? k.cabinets.filter((c: any) => c && typeof c.id === "string" && typeof c.name === "string")
|
|
21
|
+
: [],
|
|
22
|
+
dailyUnreturnedAlert: k.dailyUnreturnedAlert === true,
|
|
23
|
+
alertTime: typeof k.alertTime === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(k.alertTime) ? k.alertTime : "18:00",
|
|
24
|
+
blockCheckoutWithKeys: k.blockCheckoutWithKeys === true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The press's name, or "" when the key has none (or one since removed). */
|
|
29
|
+
export function keyPressName(cabinets: TKeyPress[], id?: string | null): string {
|
|
30
|
+
if (!id) return "";
|
|
31
|
+
return cabinets.find((c) => c.id === id)?.name ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The list as saved: names trimmed, empty rows dropped, and an id for each new
|
|
36
|
+
* row made from its name (lowercase letters, digits and dashes, unique). An
|
|
37
|
+
* existing row keeps its id, so renaming a press keeps every key in it.
|
|
38
|
+
*/
|
|
39
|
+
export function cleanKeyPresses(rows: Array<{ id?: string; name?: string }>): TKeyPress[] {
|
|
40
|
+
const out: TKeyPress[] = [];
|
|
41
|
+
const taken = new Set(rows.map((r) => r.id).filter(Boolean) as string[]);
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
const name = String(row.name ?? "").trim().slice(0, 60);
|
|
44
|
+
if (!name) continue;
|
|
45
|
+
let id = row.id;
|
|
46
|
+
if (!id) {
|
|
47
|
+
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 34) || "press";
|
|
48
|
+
id = base;
|
|
49
|
+
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
|
50
|
+
taken.add(id);
|
|
51
|
+
}
|
|
52
|
+
out.push({ id, name });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV download of a list screen (TLR B6): the rows the list itself can read,
|
|
3
|
+
* through the same list API and filters, paged through to the end. Browser-safe.
|
|
4
|
+
*/
|
|
5
|
+
import { toCsv } from "./attendance-proxy.ts";
|
|
6
|
+
|
|
7
|
+
export type TExportColumn = { title: string; value: (row: any) => unknown };
|
|
8
|
+
|
|
9
|
+
/** Largest page the list APIs accept (core feedback / work-order: max 100). */
|
|
10
|
+
export const EXPORT_PAGE_SIZE = 100;
|
|
11
|
+
/** ponytail: hard cap, so one click never pulls an estate's whole history; raise or add a server export if hit. */
|
|
12
|
+
export const EXPORT_MAX_ROWS = 5000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every row from `fetchPage(1..n)`, stopping at the last page, an empty page,
|
|
16
|
+
* or `maxRows`. `fetchPage` returns the list API's `{ items, pages }`.
|
|
17
|
+
*/
|
|
18
|
+
export async function collectAllPages(
|
|
19
|
+
fetchPage: (page: number, limit: number) => Promise<{ items?: any[]; pages?: number } | null | undefined>,
|
|
20
|
+
maxRows = EXPORT_MAX_ROWS,
|
|
21
|
+
limit = EXPORT_PAGE_SIZE,
|
|
22
|
+
): Promise<{ rows: any[]; truncated: boolean }> {
|
|
23
|
+
const rows: any[] = [];
|
|
24
|
+
for (let page = 1; rows.length < maxRows; page++) {
|
|
25
|
+
const res = await fetchPage(page, limit);
|
|
26
|
+
const items = Array.isArray(res?.items) ? res!.items : [];
|
|
27
|
+
rows.push(...items);
|
|
28
|
+
const last = Number(res?.pages) || 0;
|
|
29
|
+
if (!items.length || page >= last) return { rows: rows.slice(0, maxRows), truncated: rows.length > maxRows };
|
|
30
|
+
}
|
|
31
|
+
return { rows: rows.slice(0, maxRows), truncated: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listCsv(columns: TExportColumn[], rows: any[]): string {
|
|
35
|
+
return toCsv(
|
|
36
|
+
columns.map((c) => c.title),
|
|
37
|
+
rows.map((row) => columns.map((c) => c.value(row))),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -204,6 +204,16 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
|
|
|
204
204
|
robotAlert: [{ apps: ["security", "property-management", "hygiene"], page: () => "robot-management/alerts" }],
|
|
205
205
|
// hygiene has its Service Providers page too since 2026-09-28
|
|
206
206
|
serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
|
|
207
|
+
/**
|
|
208
|
+
* Security Ops: an action was raised, sent for verification, signed off,
|
|
209
|
+
* reopened, or went overdue. Lands on the Ops Review page, which is where the
|
|
210
|
+
* register is read.
|
|
211
|
+
*
|
|
212
|
+
* WITHOUT THIS ENTRY THE ROW IS A DEAD END. `notificationTarget` answers null
|
|
213
|
+
* for a kind it has no page for, so every overdue escalation ever written has
|
|
214
|
+
* opened nothing at all. Adding it repairs those too, not only the new alerts.
|
|
215
|
+
*/
|
|
216
|
+
"security-ops": [{ apps: ["security"], page: () => "security-ops" }],
|
|
207
217
|
};
|
|
208
218
|
SITE_PAGES.myVisitors = SITE_PAGES.visitors;
|
|
209
219
|
|
package/utils/site-access.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type SiteAccessRole = { _id: string; name: string; type?: string; scope?:
|
|
|
8
8
|
export type SiteAccessRow = {
|
|
9
9
|
site: string;
|
|
10
10
|
name: string;
|
|
11
|
-
membership: { role: string; roleName?: string } | null;
|
|
11
|
+
membership: { role: string; roleName?: string; securityOps?: boolean } | null;
|
|
12
12
|
roles: SiteAccessRole[];
|
|
13
13
|
};
|
|
14
14
|
export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
|
|
@@ -31,6 +31,18 @@ export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
|
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Is this person already marked as Security Ops?
|
|
36
|
+
*
|
|
37
|
+
* ONE ANSWER FOR THE WHOLE PERSON, because that is the question the switch
|
|
38
|
+
* asks: an officer is an officer everywhere they are sent. Any marked site
|
|
39
|
+
* means yes, so a person whose rows disagree (only reachable by an older client
|
|
40
|
+
* or a hand edit) reads as marked, and saving settles every row to one value.
|
|
41
|
+
*/
|
|
42
|
+
export function initialSecurityOps(rows: SiteAccessRow[]): boolean {
|
|
43
|
+
return (rows ?? []).some((r) => Boolean(r.membership?.securityOps));
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/** One site's Role list: "Organisation roles" first, then "<Site> roles". */
|
|
35
47
|
export function roleItems(choice: Pick<SiteChoice, "name" | "roles">): RoleItem[] {
|
|
36
48
|
const org = choice.roles.filter(isOrgRole);
|