@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +28 -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/DataRetentionBanner.vue +73 -0
  6. package/components/DocumentManagement.vue +56 -3
  7. package/components/FeedbackHandlingSettings.vue +385 -0
  8. package/components/HandoverChecklistRecords.vue +223 -0
  9. package/components/HandoverChecklistSetup.vue +224 -0
  10. package/components/IncidentReportEmailsSettings.vue +88 -0
  11. package/components/KeyPressFilter.vue +43 -0
  12. package/components/KeyPressName.vue +14 -0
  13. package/components/KeyPressSelect.vue +31 -0
  14. package/components/KeySettingsPanel.vue +156 -0
  15. package/components/ListCsvExport.vue +71 -0
  16. package/components/MaStaffRegistrationSettings.vue +61 -0
  17. package/components/MemberInformation.vue +2 -2
  18. package/components/MemberSiteAccess.vue +51 -2
  19. package/components/PassInformation.vue +5 -0
  20. package/components/RolePermissionFormCreate.vue +33 -0
  21. package/components/SearchVehicleNumberUser.vue +3 -0
  22. package/components/SiteDataRetention.vue +153 -0
  23. package/components/SiteSettings.vue +48 -0
  24. package/components/SiteSettingsVisitorRegistration.vue +131 -0
  25. package/components/VehicleForm.vue +2 -2
  26. package/components/VisitorForm.vue +45 -6
  27. package/components/VisitorManagement.vue +3 -1
  28. package/components/VisitorSearchResultSummary.vue +2 -1
  29. package/composables/useDataRetention.ts +49 -0
  30. package/composables/useDocument.ts +13 -0
  31. package/composables/useFeedback.ts +12 -0
  32. package/composables/useHandoverChecklist.ts +111 -0
  33. package/composables/useKeyPresses.ts +57 -0
  34. package/composables/useMember.ts +6 -0
  35. package/composables/usePassKey.ts +4 -0
  36. package/composables/useSiteFeatureSettings.ts +37 -0
  37. package/composables/useVisitor.ts +25 -0
  38. package/constants/permissions.ts +12 -0
  39. package/package.json +2 -2
  40. package/types/key-log.d.ts +2 -0
  41. package/types/passKey.d.ts +2 -0
  42. package/utils/feedback-handling-settings.ts +139 -0
  43. package/utils/inventory-presets.ts +108 -2
  44. package/utils/key-presses.ts +55 -0
  45. package/utils/list-export.ts +39 -0
  46. package/utils/notification-target.ts +10 -0
  47. package/utils/site-access.ts +13 -1
@@ -0,0 +1,385 @@
1
+ <!--
2
+ FEEDBACK HANDLING - per-site office hours, who receives new feedback, the
3
+ escalation steps and the categories that close themselves (TLR, 2026-10-13).
4
+
5
+ Saved as the one switch `site.metadata.feedbackSettings`
6
+ (`useSiteFeatureSettings`). Only the site's owning organisation (or Seven365)
7
+ may change it - the server checks. Until a site saves this card nothing
8
+ changes for it: no routes, no escalation, no auto-close (see
9
+ `utils/feedback-handling-settings.ts`).
10
+ -->
11
+ <template>
12
+ <div class="feedback-handling">
13
+ <v-skeleton-loader v-if="loading" type="article" />
14
+
15
+ <template v-else>
16
+ <div v-if="!canEdit" class="screen-hint fh-note mb-4" data-test="fh-owner-only">
17
+ Only the client that owns this site can change feedback handling.
18
+ </div>
19
+ <!-- Office hours -->
20
+ <section class="mb-6">
21
+ <div class="fh-label">Office hours</div>
22
+ <div class="screen-hint fh-note mt-1 mb-3">
23
+ Used to decide who receives new feedback and, if you choose, how escalation time is counted.
24
+ </div>
25
+
26
+ <v-chip-group v-model="form.workingDays" multiple column selected-class="text-primary" :disabled="!canEdit">
27
+ <v-chip v-for="day in WEEK_DAYS" :key="day.value" :value="day.value" filter variant="outlined" size="small">
28
+ {{ day.title }}
29
+ </v-chip>
30
+ </v-chip-group>
31
+
32
+ <v-row dense class="mt-2">
33
+ <v-col cols="6" sm="3">
34
+ <v-text-field v-model="form.officeStart" type="time" label="Opens" density="comfortable" hide-details :disabled="!canEdit" />
35
+ </v-col>
36
+ <v-col cols="6" sm="3">
37
+ <v-text-field v-model="form.officeEnd" type="time" label="Closes" density="comfortable" hide-details :disabled="!canEdit" />
38
+ </v-col>
39
+ </v-row>
40
+
41
+ <v-switch
42
+ v-model="form.excludePublicHolidays"
43
+ class="app-switch mt-2"
44
+ hide-details
45
+ :disabled="!canEdit"
46
+ label="Treat public holidays as non-working days"
47
+ />
48
+ <div class="screen-hint fh-note">Uses the Singapore public holiday list kept up to date by Seven365.</div>
49
+ </section>
50
+
51
+ <v-divider class="mb-6" />
52
+
53
+ <!-- Routing -->
54
+ <section class="mb-6">
55
+ <div class="fh-label">Who receives new feedback</div>
56
+ <div class="screen-hint fh-note mt-1 mb-3">
57
+ The managing agent always sees every feedback. Choosing the provider also sends it straight to them.
58
+ Categories not listed here work as they do today.
59
+ </div>
60
+
61
+ <div v-for="(route, i) in form.routes" :key="i" class="fh-row pa-3 mb-2">
62
+ <v-row dense align="center">
63
+ <v-col cols="12" md="3">
64
+ <v-select
65
+ v-model="route.category"
66
+ :items="categoryItems(route.category)"
67
+ label="Category"
68
+ density="comfortable"
69
+ hide-details
70
+ :disabled="!canEdit"
71
+ />
72
+ </v-col>
73
+ <v-col cols="12" md="3">
74
+ <v-select
75
+ v-model="route.provider"
76
+ :items="providerItems"
77
+ label="Provider"
78
+ density="comfortable"
79
+ hide-details
80
+ clearable
81
+ :no-data-text="'No active providers at this site'"
82
+ :disabled="!canEdit"
83
+ />
84
+ </v-col>
85
+ <v-col cols="12" sm="6" md="3">
86
+ <v-select
87
+ v-model="route.officeHours"
88
+ :items="targetItems"
89
+ label="During office hours"
90
+ density="comfortable"
91
+ hide-details
92
+ :disabled="!canEdit"
93
+ />
94
+ </v-col>
95
+ <v-col cols="10" sm="5" md="2">
96
+ <v-select
97
+ v-model="route.afterHours"
98
+ :items="targetItems"
99
+ label="Weekends & public holidays"
100
+ density="comfortable"
101
+ hide-details
102
+ :disabled="!canEdit"
103
+ />
104
+ </v-col>
105
+ <v-col cols="2" sm="1" class="d-flex justify-end">
106
+ <v-btn
107
+ v-if="canEdit"
108
+ icon="mdi-delete-outline"
109
+ variant="text"
110
+ size="small"
111
+ aria-label="Remove this category"
112
+ @click="form.routes.splice(i, 1)"
113
+ />
114
+ </v-col>
115
+ </v-row>
116
+ </div>
117
+
118
+ <AppButton
119
+ v-if="canEdit"
120
+ variant="ghost"
121
+ icon="mdi-plus"
122
+ :disabled="!categoryItems('').length"
123
+ @click="form.routes.push({ category: '', provider: '', officeHours: 'ma', afterHours: 'ma' })"
124
+ >
125
+ Add category
126
+ </AppButton>
127
+ </section>
128
+
129
+ <v-divider class="mb-6" />
130
+
131
+ <!-- Escalation -->
132
+ <section class="mb-6">
133
+ <v-switch
134
+ v-model="form.escalationEnabled"
135
+ class="app-switch"
136
+ hide-details
137
+ :disabled="!canEdit"
138
+ label="Escalate unresolved feedback"
139
+ />
140
+ <div class="screen-hint fh-note mb-3">
141
+ Tell more people when a feedback is still not completed after the time you set.
142
+ </div>
143
+
144
+ <template v-if="form.escalationEnabled">
145
+ <div v-for="(level, i) in form.levels" :key="i" class="fh-row pa-3 mb-2">
146
+ <div class="fh-step mb-2">Step {{ i + 1 }}</div>
147
+ <v-row dense align="center">
148
+ <v-col cols="12" sm="4" md="3">
149
+ <v-text-field
150
+ v-model.number="level.afterHours"
151
+ type="number"
152
+ min="1"
153
+ max="1440"
154
+ label="If not completed after (hours)"
155
+ density="comfortable"
156
+ hide-details
157
+ :disabled="!canEdit"
158
+ />
159
+ </v-col>
160
+ <v-col cols="12" sm="8" md="4">
161
+ <v-autocomplete
162
+ v-model="level.users"
163
+ :items="peopleItems"
164
+ label="Notify these people"
165
+ multiple
166
+ chips
167
+ closable-chips
168
+ density="comfortable"
169
+ hide-details
170
+ :disabled="!canEdit"
171
+ />
172
+ </v-col>
173
+ <v-col cols="10" md="4">
174
+ <v-combobox
175
+ v-model="level.emails"
176
+ label="And these email addresses"
177
+ multiple
178
+ chips
179
+ closable-chips
180
+ density="comfortable"
181
+ hide-details
182
+ :disabled="!canEdit"
183
+ />
184
+ </v-col>
185
+ <v-col cols="2" md="1" class="d-flex justify-end">
186
+ <v-btn
187
+ v-if="canEdit"
188
+ icon="mdi-delete-outline"
189
+ variant="text"
190
+ size="small"
191
+ aria-label="Remove this step"
192
+ @click="form.levels.splice(i, 1)"
193
+ />
194
+ </v-col>
195
+ </v-row>
196
+ </div>
197
+
198
+ <AppButton
199
+ v-if="canEdit && form.levels.length < MAX_ESCALATION_LEVELS"
200
+ variant="ghost"
201
+ icon="mdi-plus"
202
+ @click="form.levels.push({ id: '', afterHours: 24, users: [], emails: [] })"
203
+ >
204
+ Add step
205
+ </AppButton>
206
+
207
+ <v-switch
208
+ v-model="form.workingTimeOnly"
209
+ class="app-switch mt-3"
210
+ hide-details
211
+ :disabled="!canEdit"
212
+ label="Count office hours only"
213
+ />
214
+ <div class="screen-hint fh-note">
215
+ Time outside office hours (and public holidays, if treated as non-working) does not count.
216
+ </div>
217
+ </template>
218
+ </section>
219
+
220
+ <v-divider class="mb-6" />
221
+
222
+ <!-- Auto-close -->
223
+ <section class="mb-6">
224
+ <div class="fh-label">Close automatically when the provider marks done</div>
225
+ <div class="screen-hint fh-note mt-1 mb-3">
226
+ Use only for simple jobs like cleaning. The managing agent can still re-open.
227
+ </div>
228
+ <v-select
229
+ v-model="form.autoCloseCategories"
230
+ :items="subjectTitles"
231
+ label="Categories"
232
+ multiple
233
+ chips
234
+ closable-chips
235
+ density="comfortable"
236
+ hide-details
237
+ :disabled="!canEdit"
238
+ />
239
+ </section>
240
+
241
+ <div v-if="errors.length && tried" class="text-error text-caption mb-3">
242
+ <div v-for="e in errors" :key="e">{{ e }}</div>
243
+ </div>
244
+
245
+ <div v-if="canEdit" class="d-flex justify-end">
246
+ <AppButton :disabled="saving || !loaded" @click="save">{{ saving ? "Saving" : "Save feedback handling" }}</AppButton>
247
+ </div>
248
+ </template>
249
+
250
+ <Snackbar v-model="snackbar" :text="message" :color="messageColor" />
251
+ </div>
252
+ </template>
253
+
254
+ <script setup lang="ts">
255
+ import {
256
+ MAX_ESCALATION_LEVELS,
257
+ WEEK_DAYS,
258
+ feedbackHandlingErrors,
259
+ feedbackHandlingForm,
260
+ feedbackSettingsFromForm,
261
+ type TFeedbackHandlingForm,
262
+ } from "../utils/feedback-handling-settings";
263
+
264
+ const props = withDefaults(
265
+ defineProps<{
266
+ siteId: string;
267
+ orgId?: string;
268
+ canEdit?: boolean;
269
+ }>(),
270
+ { orgId: "", canEdit: false },
271
+ );
272
+
273
+ const emit = defineEmits<{ saved: [] }>();
274
+
275
+ const { getSiteById } = useSiteSettings();
276
+ const { saveSiteFeature } = useSiteFeatureSettings();
277
+ const { getAll: getProviders } = useServiceProvider();
278
+ const { getAll: getMembers } = useMember();
279
+ const { subjects } = useLocal();
280
+
281
+ const loading = ref(true);
282
+ /** The stored settings were read; Save stays off until then (a failed read must not save defaults). */
283
+ const loaded = ref(false);
284
+ const saving = ref(false);
285
+ const tried = ref(false);
286
+ const form = ref<TFeedbackHandlingForm>(feedbackHandlingForm(null));
287
+ const providers = ref<Array<{ title: string; value: string }>>([]);
288
+ const people = ref<Array<{ title: string; value: string }>>([]);
289
+
290
+ const snackbar = ref(false);
291
+ const message = ref("");
292
+ const messageColor = ref("");
293
+
294
+ const subjectTitles = subjects.map((s) => s.value);
295
+ const targetItems = [
296
+ { title: "Managing agent", value: "ma" },
297
+ { title: "Provider", value: "provider" },
298
+ ];
299
+ const providerItems = computed(() => providers.value);
300
+ const peopleItems = computed(() => people.value);
301
+ const errors = computed(() => feedbackHandlingErrors(form.value));
302
+
303
+ /** The subject list minus categories already routed (a row keeps its own). */
304
+ function categoryItems(current: string) {
305
+ const used = new Set(form.value.routes.map((r) => r.category).filter((c) => c && c !== current));
306
+ return subjectTitles.filter((s) => !used.has(s));
307
+ }
308
+
309
+ function notify(text: string, color: string) {
310
+ message.value = text;
311
+ messageColor.value = color;
312
+ snackbar.value = true;
313
+ }
314
+
315
+ async function load() {
316
+ loading.value = true;
317
+ try {
318
+ const site: any = await getSiteById(props.siteId);
319
+ form.value = feedbackHandlingForm(site?.metadata?.feedbackSettings ?? site?.data?.metadata?.feedbackSettings);
320
+ loaded.value = !!site;
321
+ const [p, m] = await Promise.allSettled([
322
+ getProviders({ siteId: props.siteId, limit: 100, status: "active" }),
323
+ getMembers({ siteId: props.siteId, org: props.orgId, limit: 100 }),
324
+ ]);
325
+ if (p.status === "fulfilled") {
326
+ providers.value = (p.value?.items ?? []).map((i: any) => ({
327
+ title: i.name || i.orgName || "Provider",
328
+ value: String(i._id),
329
+ }));
330
+ }
331
+ if (m.status === "fulfilled") {
332
+ const seen = new Set<string>();
333
+ people.value = (m.value?.items ?? [])
334
+ .map((i: any) => ({ title: i.name || i.email || "Member", value: String(i.user?._id ?? i.user ?? "") }))
335
+ .filter((i: { value: string }) => /^[a-f0-9]{24}$/i.test(i.value) && !seen.has(i.value) && !!seen.add(i.value));
336
+ }
337
+ } catch (error) {
338
+ notify(errorConverter(error), "error");
339
+ } finally {
340
+ loading.value = false;
341
+ }
342
+ }
343
+
344
+ async function save() {
345
+ tried.value = true;
346
+ if (errors.value.length || !loaded.value) return;
347
+ saving.value = true;
348
+ try {
349
+ await saveSiteFeature(props.siteId, "feedbackSettings", feedbackSettingsFromForm(form.value));
350
+ tried.value = false;
351
+ notify("Feedback handling saved", "success");
352
+ emit("saved");
353
+ } catch (error) {
354
+ notify(errorConverter(error), "error");
355
+ } finally {
356
+ saving.value = false;
357
+ }
358
+ }
359
+
360
+ onMounted(load);
361
+ </script>
362
+
363
+ <style scoped>
364
+ .fh-label {
365
+ font-family: var(--font-sans);
366
+ font-size: 14px;
367
+ font-weight: 800;
368
+ color: var(--text);
369
+ }
370
+
371
+ .fh-note {
372
+ font-size: 13px;
373
+ font-weight: 600;
374
+ }
375
+
376
+ .fh-step {
377
+ font-size: 13px;
378
+ font-weight: 700;
379
+ }
380
+
381
+ .fh-row {
382
+ border: 1px solid var(--border, rgba(0, 0, 0, 0.12));
383
+ border-radius: 8px;
384
+ }
385
+ </style>
@@ -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>