@7365admin1/layer-common 4.119.0 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -4
  2. package/components/AccessCardDetailsDialog.vue +3 -2
  3. package/components/AddPassKeyToVisitor.vue +5 -0
  4. package/components/BuildingUnitFormEdit.vue +3 -1
  5. package/components/DocumentManagement.vue +56 -3
  6. package/components/FeedbackHandlingSettings.vue +385 -0
  7. package/components/HandoverChecklistRecords.vue +223 -0
  8. package/components/HandoverChecklistSetup.vue +224 -0
  9. package/components/IncidentReportEmailsSettings.vue +88 -0
  10. package/components/KeyPressFilter.vue +43 -0
  11. package/components/KeyPressName.vue +14 -0
  12. package/components/KeyPressSelect.vue +31 -0
  13. package/components/KeySettingsPanel.vue +156 -0
  14. package/components/ListCsvExport.vue +71 -0
  15. package/components/MaStaffRegistrationSettings.vue +61 -0
  16. package/components/MemberInformation.vue +2 -2
  17. package/components/PassInformation.vue +5 -0
  18. package/components/RolePermissionFormCreate.vue +33 -0
  19. package/components/SearchVehicleNumberUser.vue +3 -0
  20. package/components/SiteDataRetention.vue +286 -0
  21. package/components/SiteSettings.vue +48 -0
  22. package/components/SiteSettingsVisitorRegistration.vue +118 -0
  23. package/components/VehicleForm.vue +2 -2
  24. package/components/VisitorForm.vue +41 -6
  25. package/components/VisitorManagement.vue +3 -1
  26. package/components/VisitorSearchResultSummary.vue +2 -1
  27. package/composables/useDataRetention.ts +43 -0
  28. package/composables/useDocument.ts +13 -0
  29. package/composables/useFeedback.ts +12 -0
  30. package/composables/useHandoverChecklist.ts +111 -0
  31. package/composables/useKeyPresses.ts +57 -0
  32. package/composables/usePassKey.ts +4 -0
  33. package/composables/useSiteFeatureSettings.ts +37 -0
  34. package/composables/useVisitor.ts +22 -0
  35. package/package.json +2 -2
  36. package/types/key-log.d.ts +2 -0
  37. package/types/passKey.d.ts +2 -0
  38. package/utils/feedback-handling-settings.ts +139 -0
  39. package/utils/inventory-presets.ts +64 -0
  40. package/utils/key-presses.ts +55 -0
  41. package/utils/list-export.ts +39 -0
@@ -0,0 +1,223 @@
1
+ <template>
2
+ <div data-test="handover-records">
3
+ <div class="d-flex flex-wrap ga-2 mb-3">
4
+ <v-select v-model="templateId" :items="templateOptions" label="Room / inspection" density="comfortable"
5
+ variant="outlined" hide-details clearable class="handover-filter" />
6
+ </div>
7
+
8
+ <v-data-table-server :headers="headers" :items="rows" :items-length="total" :loading="loading"
9
+ :items-per-page="limit" :page="page" density="comfortable" class="cursor-pointer"
10
+ @update:page="(p: number) => { page = p; load(); }"
11
+ @update:items-per-page="(n: number) => { limit = n; page = 1; load(); }" @click:row="(_: any, r: any) => open(r.item)">
12
+ <template #item.completedAt="{ item }">{{ when(item.completedAt) }}</template>
13
+ <template #item.stage="{ item }">{{ HANDOVER_STAGE_LABELS[item.stage] ?? item.stage }}</template>
14
+ <template #item.notInOrderCount="{ item }">
15
+ <v-chip v-if="item.notInOrderCount" color="error" size="small" variant="tonal">
16
+ {{ item.notInOrderCount }} not in order
17
+ </v-chip>
18
+ <v-chip v-else color="success" size="small" variant="tonal">All in order</v-chip>
19
+ </template>
20
+ <template #no-data>No handover or takeover checks yet.</template>
21
+ </v-data-table-server>
22
+
23
+ <v-dialog v-model="viewer.show" max-width="820" scrollable>
24
+ <v-card>
25
+ <v-card-title class="d-flex align-center">
26
+ <span class="flex-grow-1">{{ viewer.record?.title }}</span>
27
+ <v-btn variant="text" prepend-icon="mdi-file-pdf-box" text="Download PDF" :loading="exporting"
28
+ :disabled="!viewer.record" @click="downloadPdf" />
29
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="viewer.show = false" />
30
+ </v-card-title>
31
+ <v-card-text>
32
+ <v-progress-linear v-if="viewer.loading" indeterminate />
33
+ <div v-else-if="viewer.record" ref="report" class="report-export">
34
+ <p class="mb-3">
35
+ <strong>{{ HANDOVER_STAGE_LABELS[viewer.record.stage] ?? viewer.record.stage }}</strong>
36
+ &middot; {{ when(viewer.record.completedAt) }} &middot; Checked by: {{ viewer.record.officerName || "N/A" }}
37
+ </p>
38
+ <v-table density="compact">
39
+ <thead>
40
+ <tr>
41
+ <th>Item</th>
42
+ <th>Qty</th>
43
+ <th>Serial / tag no.</th>
44
+ <th>Condition</th>
45
+ <th>Remarks</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <tr v-for="(item, i) in viewer.record.items ?? []" :key="i">
50
+ <td>
51
+ <div>{{ item.label }}</div>
52
+ <div class="d-flex flex-wrap ga-1 my-1">
53
+ <a v-if="item.referencePhotoId" :href="getFileUrl(item.referencePhotoId)" target="_blank"
54
+ rel="noopener" title="Reference photo">
55
+ <img :src="getFileUrl(item.referencePhotoId)" alt="Reference" class="handover-thumb" crossorigin="anonymous" />
56
+ </a>
57
+ </div>
58
+ </td>
59
+ <td>{{ item.quantity ?? "" }}</td>
60
+ <td>{{ item.tagNo || "" }}</td>
61
+ <td>
62
+ <span :class="item.result === 'in-order' ? 'text-success' : 'text-error'" class="font-weight-bold">
63
+ {{ item.result === "in-order" ? "In order" : "Not in order" }}
64
+ </span>
65
+ </td>
66
+ <td>
67
+ <div>{{ item.remarks || "" }}</div>
68
+ <div class="d-flex flex-wrap ga-1 my-1">
69
+ <a v-for="p in item.damagePhotoIds ?? []" :key="p" :href="getFileUrl(p)" target="_blank"
70
+ rel="noopener" title="Damage photo">
71
+ <img :src="getFileUrl(p)" alt="Damage" class="handover-thumb" crossorigin="anonymous" />
72
+ </a>
73
+ </div>
74
+ </td>
75
+ </tr>
76
+ </tbody>
77
+ </v-table>
78
+ <div v-if="viewer.record.remarks" class="mt-4">
79
+ <div class="text-subtitle-2">Remarks</div>
80
+ <p class="text-body-2">{{ viewer.record.remarks }}</p>
81
+ </div>
82
+ <div v-if="viewer.record.photoIds?.length" class="mt-4">
83
+ <div class="text-subtitle-2 mb-1">Photos</div>
84
+ <div class="d-flex flex-wrap ga-2">
85
+ <a v-for="p in viewer.record.photoIds" :key="p" :href="getFileUrl(p)" target="_blank" rel="noopener">
86
+ <img :src="getFileUrl(p)" alt="Room photo" class="handover-photo" crossorigin="anonymous" />
87
+ </a>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </v-card-text>
92
+ </v-card>
93
+ </v-dialog>
94
+
95
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
96
+ </div>
97
+ </template>
98
+
99
+ <script setup lang="ts">
100
+ import useFile from "../composables/useFile";
101
+ import useReportPdf from "../composables/useReportPdf";
102
+ import useHandoverChecklist, {
103
+ HANDOVER_STAGE_LABELS,
104
+ type THandoverRecord,
105
+ type THandoverTemplate,
106
+ } from "../composables/useHandoverChecklist";
107
+
108
+ const props = defineProps({
109
+ site: { type: String, required: true },
110
+ siteName: { type: String, default: "" },
111
+ });
112
+
113
+ const { getTemplates, getRecords, getRecord } = useHandoverChecklist();
114
+ const { getFileUrl } = useFile();
115
+ const { exportReportPdf } = useReportPdf();
116
+
117
+ const headers = [
118
+ { title: "Date / time", key: "completedAt", sortable: false },
119
+ { title: "Room / inspection", key: "title", sortable: false },
120
+ { title: "Type", key: "stage", sortable: false },
121
+ { title: "Checked by", key: "officerName", sortable: false },
122
+ { title: "Result", key: "notInOrderCount", sortable: false },
123
+ ];
124
+
125
+ const templates = ref<THandoverTemplate[]>([]);
126
+ const templateOptions = computed(() => templates.value.map((t) => ({ title: t.title, value: t._id })));
127
+ const templateId = ref<string | null>(null);
128
+ const rows = ref<THandoverRecord[]>([]);
129
+ const total = ref(0);
130
+ const page = ref(1);
131
+ const limit = ref(20);
132
+ const loading = ref(false);
133
+ const exporting = ref(false);
134
+ const report = ref<HTMLElement | null>(null);
135
+ const viewer = reactive({ show: false, loading: false, record: null as THandoverRecord | null });
136
+ const toast = reactive({ show: false, message: "", color: "" });
137
+
138
+ function when(d?: string) {
139
+ return d ? new Date(d).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : "";
140
+ }
141
+
142
+ function fail(e: any, message: string) {
143
+ Object.assign(toast, { show: true, message: e?.data?.message || message, color: "error" });
144
+ }
145
+
146
+ async function load() {
147
+ loading.value = true;
148
+ try {
149
+ const res = await getRecords(props.site, { page: page.value, limit: limit.value, templateId: templateId.value || "" });
150
+ rows.value = res?.items ?? [];
151
+ total.value = res?.total ?? 0;
152
+ } catch (e) {
153
+ fail(e, "Could not load the records.");
154
+ } finally {
155
+ loading.value = false;
156
+ }
157
+ }
158
+
159
+ async function open(row: THandoverRecord) {
160
+ Object.assign(viewer, { show: true, loading: true, record: null });
161
+ try {
162
+ viewer.record = await getRecord(row._id);
163
+ } catch (e) {
164
+ fail(e, "Could not open the record.");
165
+ } finally {
166
+ viewer.loading = false;
167
+ }
168
+ }
169
+
170
+ async function downloadPdf() {
171
+ const r = viewer.record;
172
+ if (!r || !report.value) return;
173
+ exporting.value = true;
174
+ try {
175
+ await exportReportPdf(report.value, {
176
+ title: `${HANDOVER_STAGE_LABELS[r.stage] ?? "Handover"} checklist`,
177
+ site: props.siteName,
178
+ details: [
179
+ { label: "Room", value: r.title },
180
+ { label: "Date", value: when(r.completedAt) },
181
+ { label: "Checked by", value: r.officerName || "N/A" },
182
+ ],
183
+ fileName: `handover-${r.title}-${(r.completedAt || "").slice(0, 10)}.pdf`.replace(/[^\w.-]+/g, "-"),
184
+ });
185
+ } catch (e) {
186
+ fail(e, "Could not make the PDF.");
187
+ } finally {
188
+ exporting.value = false;
189
+ }
190
+ }
191
+
192
+ watch(templateId, () => {
193
+ page.value = 1;
194
+ load();
195
+ });
196
+
197
+ watch(
198
+ () => props.site,
199
+ async () => {
200
+ load();
201
+ templates.value = await getTemplates(props.site).catch(() => []);
202
+ },
203
+ { immediate: true },
204
+ );
205
+ </script>
206
+
207
+ <style scoped>
208
+ .handover-filter {
209
+ max-width: 320px;
210
+ }
211
+ .handover-thumb {
212
+ border-radius: 4px;
213
+ height: 48px;
214
+ object-fit: cover;
215
+ width: 48px;
216
+ }
217
+ .handover-photo {
218
+ border-radius: 6px;
219
+ height: 120px;
220
+ object-fit: cover;
221
+ width: 160px;
222
+ }
223
+ </style>
@@ -0,0 +1,224 @@
1
+ <template>
2
+ <div data-test="handover-setup">
3
+ <div class="d-flex flex-wrap align-center ga-2 mb-4">
4
+ <p class="text-body-2 text-medium-emphasis mb-0 flex-grow-1">
5
+ One checklist per room or inspection. The site team uses it to hand the room over (before use) and take it back (after
6
+ use).
7
+ </p>
8
+ <template v-if="canEdit">
9
+ <v-menu>
10
+ <template #activator="{ props: menu }">
11
+ <v-btn v-bind="menu" variant="outlined" class="screen-btn-ghost" prepend-icon="mdi-file-document-outline"
12
+ text="Use a ready-made template" />
13
+ </template>
14
+ <v-list density="compact">
15
+ <v-list-item v-for="s in HANDOVER_STARTERS" :key="s.name" :title="s.name" @click="openEditor(s.template)" />
16
+ </v-list>
17
+ </v-menu>
18
+ <v-btn class="screen-btn-primary" color="primary-button" variant="flat" prepend-icon="mdi-plus"
19
+ text="Add checklist" @click="openEditor()" />
20
+ </template>
21
+ </div>
22
+
23
+ <v-progress-linear v-if="loading" indeterminate class="mb-2" />
24
+ <p v-else-if="!templates.length" class="text-body-2 text-medium-emphasis">
25
+ No checklists yet. The site team will not see room handover until you add one.
26
+ </p>
27
+
28
+ <AppCard v-for="t in templates" :key="t._id" class="mb-3 pa-4">
29
+ <div class="d-flex align-start ga-2">
30
+ <div class="flex-grow-1">
31
+ <div class="text-subtitle-1 font-weight-bold">{{ t.title }}</div>
32
+ <div v-if="t.description" class="text-body-2 text-medium-emphasis">{{ t.description }}</div>
33
+ <div class="text-caption text-medium-emphasis">{{ t.items.length }} item{{ t.items.length === 1 ? "" : "s" }}</div>
34
+ </div>
35
+ <template v-if="canEdit">
36
+ <v-btn icon="mdi-pencil" variant="text" size="small" aria-label="Edit" @click="openEditor(t)" />
37
+ <v-btn icon="mdi-delete" variant="text" size="small" aria-label="Remove" @click="remove(t)" />
38
+ </template>
39
+ </div>
40
+ </AppCard>
41
+
42
+ <v-dialog v-model="editor.show" max-width="760" scrollable>
43
+ <v-card>
44
+ <v-card-title>{{ editor.id ? "Edit checklist" : "New checklist" }}</v-card-title>
45
+ <v-card-text>
46
+ <v-form ref="form">
47
+ <v-text-field v-model.trim="editor.title" label="Room or inspection name" placeholder="e.g. Function room 1"
48
+ :rules="[required]" density="comfortable" variant="outlined" />
49
+ <v-textarea v-model.trim="editor.description" label="Instructions for the guard (optional)" rows="2"
50
+ auto-grow density="comfortable" variant="outlined" />
51
+
52
+ <div class="text-subtitle-2 mb-2">Items to check</div>
53
+ <div v-for="(item, i) in editor.items" :key="i" class="handover-item mb-3 pa-3">
54
+ <v-row dense>
55
+ <v-col cols="12" sm="6">
56
+ <v-text-field v-model.trim="item.label" label="Description" :rules="[required]" density="compact"
57
+ variant="outlined" hide-details="auto" />
58
+ </v-col>
59
+ <v-col cols="4" sm="2">
60
+ <v-text-field v-model.number="item.quantity" label="Qty" type="number" min="0" density="compact"
61
+ variant="outlined" hide-details />
62
+ </v-col>
63
+ <v-col cols="8" sm="4">
64
+ <v-text-field v-model.trim="item.tagNo" label="Serial / tag no." placeholder="e.g. Chair 1-8"
65
+ density="compact" variant="outlined" hide-details />
66
+ </v-col>
67
+ </v-row>
68
+ <div class="d-flex align-center ga-2 mt-2">
69
+ <v-img v-if="item.referencePhotoId" :src="getFileUrl(item.referencePhotoId)" width="56" height="56"
70
+ max-width="56" cover class="rounded" />
71
+ <v-btn size="small" variant="text" prepend-icon="mdi-camera" :loading="uploading === i"
72
+ :text="item.referencePhotoId ? 'Change reference photo' : 'Add reference photo'"
73
+ @click="pickPhoto(i)" />
74
+ <v-btn v-if="item.referencePhotoId" size="small" variant="text" text="Remove photo"
75
+ @click="item.referencePhotoId = null" />
76
+ <v-spacer />
77
+ <v-btn icon="mdi-close" size="small" variant="text" aria-label="Remove item"
78
+ :disabled="editor.items.length === 1" @click="editor.items.splice(i, 1)" />
79
+ </div>
80
+ </div>
81
+ <v-btn variant="text" prepend-icon="mdi-plus" text="Add item" @click="editor.items.push(blankItem())" />
82
+ </v-form>
83
+ <input ref="fileInput" type="file" accept="image/*" hidden @change="uploadPhoto" />
84
+ </v-card-text>
85
+ <v-card-actions>
86
+ <v-spacer />
87
+ <v-btn text="Cancel" @click="editor.show = false" />
88
+ <v-btn class="screen-btn-primary" color="primary-button" variant="flat" text="Save" :loading="saving"
89
+ @click="save" />
90
+ </v-card-actions>
91
+ </v-card>
92
+ </v-dialog>
93
+
94
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
95
+ </div>
96
+ </template>
97
+
98
+ <script setup lang="ts">
99
+ import useFile from "../composables/useFile";
100
+ import useHandoverChecklist, {
101
+ HANDOVER_STARTERS,
102
+ type THandoverItem,
103
+ type THandoverTemplate,
104
+ } from "../composables/useHandoverChecklist";
105
+
106
+ const props = defineProps({
107
+ site: { type: String, required: true },
108
+ canEdit: { type: Boolean, default: false },
109
+ });
110
+
111
+ const { getTemplates, addTemplate, updateTemplate, deleteTemplate } = useHandoverChecklist();
112
+ const { addFile, getFileUrl } = useFile();
113
+
114
+ const templates = ref<THandoverTemplate[]>([]);
115
+ const loading = ref(false);
116
+ const saving = ref(false);
117
+ const uploading = ref<number | null>(null);
118
+ const form = ref<any>(null);
119
+ const fileInput = ref<HTMLInputElement | null>(null);
120
+ const toast = reactive({ show: false, message: "", color: "" });
121
+ const required = (v: string) => !!v || "Required";
122
+ const blankItem = (): THandoverItem => ({ label: "", quantity: null, tagNo: "", referencePhotoId: null });
123
+
124
+ const editor = reactive({
125
+ show: false,
126
+ id: "" as string | undefined,
127
+ title: "",
128
+ description: "",
129
+ items: [] as THandoverItem[],
130
+ });
131
+
132
+ function notify(message: string, color = "success") {
133
+ Object.assign(toast, { show: true, message, color });
134
+ }
135
+
136
+ async function load() {
137
+ loading.value = true;
138
+ try {
139
+ templates.value = (await getTemplates(props.site)) ?? [];
140
+ } catch (e: any) {
141
+ notify(e?.data?.message || "Could not load the checklists.", "error");
142
+ } finally {
143
+ loading.value = false;
144
+ }
145
+ }
146
+
147
+ /** A stored checklist to edit, a starter to copy, or nothing for a blank one. */
148
+ function openEditor(t?: THandoverTemplate) {
149
+ editor.id = t?._id;
150
+ editor.title = t?.title ?? "";
151
+ editor.description = t?.description ?? "";
152
+ editor.items = t?.items?.length ? t.items.map((i) => ({ ...blankItem(), ...i })) : [blankItem()];
153
+ editor.show = true;
154
+ }
155
+
156
+ let photoIndex = -1;
157
+ function pickPhoto(i: number) {
158
+ photoIndex = i;
159
+ fileInput.value?.click();
160
+ }
161
+
162
+ async function uploadPhoto(e: Event) {
163
+ const input = e.target as HTMLInputElement;
164
+ const file = input.files?.[0];
165
+ input.value = "";
166
+ if (!file || photoIndex < 0) return;
167
+ uploading.value = photoIndex;
168
+ try {
169
+ const res = await addFile(file);
170
+ editor.items[photoIndex].referencePhotoId = res?.id ?? null;
171
+ } catch (err: any) {
172
+ notify(err?.data?.message || "Could not upload the photo.", "error");
173
+ } finally {
174
+ uploading.value = null;
175
+ }
176
+ }
177
+
178
+ async function save() {
179
+ const { valid } = await form.value.validate();
180
+ if (!valid) return;
181
+ const body: THandoverTemplate = {
182
+ title: editor.title,
183
+ description: editor.description || null,
184
+ items: editor.items.map((i) => ({
185
+ label: i.label,
186
+ quantity: typeof i.quantity === "number" && !Number.isNaN(i.quantity) ? i.quantity : null,
187
+ tagNo: i.tagNo || null,
188
+ referencePhotoId: i.referencePhotoId || null,
189
+ })),
190
+ };
191
+ saving.value = true;
192
+ try {
193
+ if (editor.id) await updateTemplate(editor.id, body);
194
+ else await addTemplate(props.site, body);
195
+ editor.show = false;
196
+ notify("Checklist saved.");
197
+ await load();
198
+ } catch (err: any) {
199
+ notify(err?.data?.message || "Could not save the checklist.", "error");
200
+ } finally {
201
+ saving.value = false;
202
+ }
203
+ }
204
+
205
+ async function remove(t: THandoverTemplate) {
206
+ if (!t._id || !window.confirm(`Remove "${t.title}"? Records already submitted are kept.`)) return;
207
+ try {
208
+ await deleteTemplate(t._id);
209
+ notify("Checklist removed.");
210
+ await load();
211
+ } catch (err: any) {
212
+ notify(err?.data?.message || "Could not remove the checklist.", "error");
213
+ }
214
+ }
215
+
216
+ watch(() => props.site, load, { immediate: true });
217
+ </script>
218
+
219
+ <style scoped>
220
+ .handover-item {
221
+ border: 1px solid var(--border, #e0e0e0);
222
+ border-radius: 8px;
223
+ }
224
+ </style>
@@ -0,0 +1,88 @@
1
+ <template>
2
+ <div data-test="incident-report-emails">
3
+ <p class="text-body-2 mb-3">For people who do not use the app, e.g. your HQ or insurer.</p>
4
+ <v-combobox
5
+ v-model="emails"
6
+ label="Also email every incident report to"
7
+ placeholder="Type an email address and press Enter"
8
+ multiple
9
+ chips
10
+ closable-chips
11
+ hide-no-data
12
+ :readonly="readOnly"
13
+ :rules="[validRule]"
14
+ :hint="`Up to ${MAX} addresses.`"
15
+ persistent-hint
16
+ density="comfortable"
17
+ variant="outlined"
18
+ />
19
+ <v-btn
20
+ v-if="!readOnly"
21
+ class="screen-btn-primary mt-4"
22
+ color="primary-button"
23
+ variant="flat"
24
+ text="Save"
25
+ :loading="saving"
26
+ :disabled="!loaded || validRule(emails) !== true"
27
+ @click="save"
28
+ />
29
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
35
+
36
+ /**
37
+ * Site setting `metadata.incidentReportEmails` (core site-feature-settings).
38
+ * Empty = today: only app users who switched on incident report emails get one.
39
+ */
40
+ const props = defineProps({
41
+ siteId: { type: String, required: true },
42
+ saved: { type: Array as PropType<string[]>, default: () => [] },
43
+ readOnly: { type: Boolean, default: false },
44
+ /** The site has been read; Save stays off until then. */
45
+ loaded: { type: Boolean, default: false },
46
+ });
47
+ const emit = defineEmits(["saved"]);
48
+
49
+ const MAX = 10;
50
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
51
+
52
+ const emails = ref<string[]>([]);
53
+ watch(
54
+ () => props.saved,
55
+ (v) => (emails.value = Array.isArray(v) ? [...v] : []),
56
+ { immediate: true },
57
+ );
58
+
59
+ function validRule(list: string[]) {
60
+ const clean = (list ?? []).map((e) => String(e).trim());
61
+ if (clean.length > MAX) return `Up to ${MAX} addresses.`;
62
+ const bad = clean.find((e) => !EMAIL.test(e));
63
+ return bad ? `"${bad}" is not a valid email address.` : true;
64
+ }
65
+
66
+ const { saveSiteFeature } = useSiteFeatureSettings();
67
+ const saving = ref(false);
68
+ const toast = reactive({ show: false, message: "", color: "" });
69
+
70
+ async function save() {
71
+ if (!props.loaded) return;
72
+ saving.value = true;
73
+ try {
74
+ const value = [...new Set(emails.value.map((e) => String(e).trim().toLowerCase()))];
75
+ await saveSiteFeature(props.siteId, "incidentReportEmails", value);
76
+ Object.assign(toast, { show: true, message: "Incident report emails saved.", color: "success" });
77
+ emit("saved");
78
+ } catch (e: any) {
79
+ Object.assign(toast, {
80
+ show: true,
81
+ message: e?.data?.message || "Could not save the incident report emails.",
82
+ color: "error",
83
+ });
84
+ } finally {
85
+ saving.value = false;
86
+ }
87
+ }
88
+ </script>
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <!-- Shown only when the site has key presses. Tells the list whether it
3
+ has any (`available`), so the list can add its Key press column. -->
4
+ <v-select
5
+ v-if="cabinets.length"
6
+ :model-value="modelValue"
7
+ :items="items"
8
+ item-title="name"
9
+ item-value="id"
10
+ label="Filter by key press"
11
+ multiple
12
+ clearable
13
+ hide-details
14
+ density="compact"
15
+ menu-icon="mdi-chevron-down"
16
+ style="max-width: 220px; min-width: 150px"
17
+ data-test="key-press-filter"
18
+ @update:model-value="(v: string[] | null) => emit('update:modelValue', v ?? [])"
19
+ />
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ import type { PropType } from "vue";
24
+ import useKeyPresses from "../composables/useKeyPresses";
25
+
26
+ const props = defineProps({
27
+ site: { type: String, required: true },
28
+ modelValue: { type: Array as PropType<string[]>, default: () => [] },
29
+ });
30
+ const emit = defineEmits<{
31
+ (e: "update:modelValue", value: string[]): void;
32
+ (e: "update:available", value: boolean): void;
33
+ }>();
34
+
35
+ const { cabinets } = useKeyPresses(() => props.site);
36
+ // "" = keys kept in no named press.
37
+ const items = computed(() => [...cabinets.value, { id: "", name: "No key press" }]);
38
+ watch(
39
+ () => cabinets.value.length > 0,
40
+ (has) => emit("update:available", has),
41
+ { immediate: true },
42
+ );
43
+ </script>
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <span>{{ nameOf(id) }}</span>
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import useKeyPresses from "../composables/useKeyPresses";
7
+
8
+ // The key's press name in a list cell. One site read per page, shared.
9
+ const props = defineProps({
10
+ site: { type: String, required: true },
11
+ id: { type: String, default: "" },
12
+ });
13
+ const { nameOf } = useKeyPresses(() => props.site);
14
+ </script>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <!-- Shown only when the site has key presses; otherwise nothing changes. -->
3
+ <div v-if="cabinets.length">
4
+ <div class="text-subtitle-1 text-medium-emphasis">Key press</div>
5
+ <v-select
6
+ :model-value="modelValue || null"
7
+ :items="cabinets"
8
+ item-title="name"
9
+ item-value="id"
10
+ placeholder="Choose a key press"
11
+ density="comfortable"
12
+ hint="Where this key is kept."
13
+ persistent-hint
14
+ clearable
15
+ data-test="key-press-select"
16
+ @update:model-value="(v: string | null) => emit('update:modelValue', v ?? '')"
17
+ />
18
+ </div>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import useKeyPresses from "../composables/useKeyPresses";
23
+
24
+ const props = defineProps({
25
+ site: { type: String, required: true },
26
+ modelValue: { type: String, default: "" },
27
+ });
28
+ const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>();
29
+
30
+ const { cabinets } = useKeyPresses(() => props.site);
31
+ </script>