@7365admin1/layer-common 4.107.0 → 4.109.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 +20 -0
- package/assets/css/screens.css +11 -0
- package/components/BulletinBoardForm.vue +112 -0
- package/components/BulletinBoardView.vue +2 -0
- package/components/BulletinEventRsvp.vue +126 -0
- package/components/Nfc/NFCPatrolReportMain.vue +61 -72
- package/components/Nfc/NFCPatrolRouteMain.vue +21 -31
- package/components/Nfc/NFCRouteScheduleTimes.vue +83 -0
- package/components/Nfc/PatrolReport/DailyReportTab.vue +24 -3
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +32 -3
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +29 -1
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +24 -2
- package/components/Nfc/PatrolReport/PatrolMonthDialog.vue +195 -0
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +11 -2
- package/components/Nfc/PatrolReport/PatrolRouteDetailsDialog.vue +214 -0
- package/components/Nfc/PatrolReport/ReportLocationHeader.vue +4 -1
- package/components/Nfc/PatrolReport/SkipPhotoDetails.vue +137 -0
- package/components/Nfc/PatrolReport/SkipPhotoThumb.vue +132 -0
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +42 -1
- package/components/OnlineFormFill.vue +11 -0
- package/components/SiteSettings.vue +4 -0
- package/components/VehicleManagement.vue +4 -1
- package/composables/useNFCPatrolDailyReport.ts +4 -18
- package/composables/useNFCPatrolMonthlyReport.ts +34 -5
- package/composables/useNFCPatrolReport.ts +5 -74
- package/composables/useReportPdf.ts +220 -0
- package/package.json +1 -1
- package/types/bulletin-board.d.ts +11 -1
- package/types/nfc-patrol-report.ts +14 -0
- package/utils/nfc-patrol-report.ts +239 -1
- package/utils/nfc-patrol-schedule.ts +73 -0
- package/utils/online-form-submission.ts +48 -0
- package/utils/report-export.ts +120 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.109.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e45a6e7: Bulletin Board: an announcement can be an event (start, end, venue) with optional resident RSVP (close time, places); the estate team sees who is coming and can download the list. Clearer copy: bulletin file size, Vehicle Mgmt works without LPR cameras, Site Documents are visible to residents.
|
|
8
|
+
- 9a29b8e: NFC patrol report: clicking a row of the daily Patrol Route Summary opens a Patrol Route Details dialog - the guard and signature, schedule against actual, and every checkpoint with its times and status. Skipped checkpoints show the photo taken at the skip (`skippedPhotoFileId`) in a new Skip Photo column, both in that dialog and in the Patrol tab's Patrol Activity table; the thumbnail opens the photo full size with the guard's remarks (new `SkipPhotoThumb`). The log-to-run mapping moved to `mapLogToPatrolRun()` in `utils/nfc-patrol-report.ts` so the Patrol tab and the dialog share it.
|
|
9
|
+
|
|
10
|
+
The report PDF is laid out as a document rather than a screenshot cut into pages (new `useReportPdf`, page planning in `utils/report-export.ts`): A4 with margins, a header on every page (report, site and address, route, date, time) and a footer (when it was generated, page X of Y); pages break only between rows, photos and sections, and a table that carries on repeats its header row; always the light theme, at one width whatever the screen; file named after the report, route and date. Skip photos sit with what they belong to in the PDF (new `SkipPhotoDetails`, PDF only): on the Patrol report each skipped checkpoint has its photo, time and remarks right under its row, kept on the same page; on the Daily report each patrol run has its skipped checkpoints under its row. A row or block marked `data-pdf-only` is hidden on screen and drawn in the PDF, `data-pdf-hide` the other way round.
|
|
11
|
+
|
|
12
|
+
The report CSV is built from the report's data instead of read off the first table on screen (`buildReportCsv` in `utils/report-export.ts`, `patrolReportCsv` / `dailyReportCsv` / `monthlyReportCsv` in `utils/nfc-patrol-report.ts`; each tab's `export` event now carries its tables): the report, site, route and date on the first lines, then every table under its title. The Patrol CSV now has every checkpoint, with each skip's remarks and photo link on its row (it used to carry only schedule against actual); the Daily CSV adds a Skipped Checkpoints table, one row per skip naming its run. UTF-8 with a byte order mark so Excel reads it, cells a spreadsheet would run as a formula kept as text, and named like the PDF. Signatures load eagerly so ones below the fold are no longer missing from the PDF.
|
|
13
|
+
|
|
14
|
+
Monthly report: a month that had patrols opens the list of that month's patrol runs (new `PatrolMonthDialog`) - by day then scheduled start, with the month's totals in its title, ten runs a page ("1-10 of 124" and the arrows, like the app's tables) - and each run opens the same Patrol Route Details dialog, checkpoints and skip photos included. Built from the year of logs the report already loads (`mapLogsToMonthRuns`, sharing `monthIndexOf` with the yearly table so a month's list adds up to its row), so opening a month asks the server nothing. The summary-row mapping moved from the daily composable to `mapLogToSummaryRow()` and now carries the run's `date`, which the details dialog shows.
|
|
15
|
+
- 8d3d430: NFC Routes: the Security Check Schedule reads at a glance. Each check window is a pill that never breaks across lines ("09:01 – 14:40"), earliest first instead of in the order the times were typed, with "+1" on a window that ends the next day (new `NFCRouteScheduleTimes`, `routeWindows` in `utils/nfc-patrol-schedule.ts`). The table shows the first three and folds the rest into "+N more" (all of them on hover), so a busy route keeps a normal row; the Route Details dialog shows them all under "Security Check Schedule · 9 checks a day". Route days read Monday first with runs as ranges - "Mon – Sat", "Every day" - instead of "Mon, Wed, Fri, Tue, Thu, Sat" (`formatRouteDays`).
|
|
16
|
+
|
|
17
|
+
## 4.108.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- 6b9abd5: Online forms: an application whose form template was deleted can still be reviewed - new `definitionFromSubmission()` builds a read-only form from the submitted answers (keys turned into readable labels), and `OnlineFormFill` takes a `notice` shown above the form.
|
|
22
|
+
|
|
3
23
|
## 4.107.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/assets/css/screens.css
CHANGED
|
@@ -1246,3 +1246,14 @@
|
|
|
1246
1246
|
letter-spacing: 0;
|
|
1247
1247
|
color: var(--muted);
|
|
1248
1248
|
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Report PDF only. A report's PDF is drawn from a copy of its `.report-export`
|
|
1252
|
+
* area (`useReportPdf`), which shows `data-pdf-only` and drops `data-pdf-hide`.
|
|
1253
|
+
* On screen the first never shows - e.g. the skip photos under each skipped
|
|
1254
|
+
* checkpoint, which the tables already carry as thumbnails. Scoped to a
|
|
1255
|
+
* report's export area, so the attribute means nothing anywhere else.
|
|
1256
|
+
*/
|
|
1257
|
+
.report-export [data-pdf-only] {
|
|
1258
|
+
display: none !important;
|
|
1259
|
+
}
|
|
@@ -103,10 +103,75 @@
|
|
|
103
103
|
/>
|
|
104
104
|
</v-col>
|
|
105
105
|
|
|
106
|
+
<!-- Event with RSVP (owner 2026-10-07, 1PAM: "Mooncake Festival - time, RSVP, RSVP closes"). -->
|
|
107
|
+
<v-col cols="12" class="mt-2">
|
|
108
|
+
<v-checkbox
|
|
109
|
+
v-model="eventForm.enabled"
|
|
110
|
+
hide-details
|
|
111
|
+
label="This announcement is an event people can attend"
|
|
112
|
+
data-test="bulletin-event-toggle"
|
|
113
|
+
/>
|
|
114
|
+
<p class="text-caption text-medium-emphasis ml-10 mb-2">
|
|
115
|
+
For example a festival, a briefing or a gathering. The date, time and place are shown on the
|
|
116
|
+
announcement in the residents' and service providers' apps.
|
|
117
|
+
</p>
|
|
118
|
+
</v-col>
|
|
119
|
+
<template v-if="eventForm.enabled">
|
|
120
|
+
<v-col cols="12" md="6" class="pr-md-2">
|
|
121
|
+
<InputLabel title="Event starts" required />
|
|
122
|
+
<InputDateTimePicker v-model:utc="eventForm.startAt" :rules="[eventStartRule]" />
|
|
123
|
+
</v-col>
|
|
124
|
+
<v-col cols="12" md="6" class="pl-md-2">
|
|
125
|
+
<InputLabel title="Event ends (optional)" />
|
|
126
|
+
<InputDateTimePicker v-model:utc="eventForm.endAt" :rules="[eventEndRule]" />
|
|
127
|
+
</v-col>
|
|
128
|
+
<v-col cols="12">
|
|
129
|
+
<InputLabel title="Venue (optional)" />
|
|
130
|
+
<v-text-field v-model.trim="eventForm.venue" density="compact" placeholder="e.g. Function Room, Block 2" maxlength="200" />
|
|
131
|
+
</v-col>
|
|
132
|
+
<v-col cols="12">
|
|
133
|
+
<v-checkbox
|
|
134
|
+
v-model="eventForm.rsvp"
|
|
135
|
+
hide-details
|
|
136
|
+
label="Ask residents to RSVP (Going / Not going)"
|
|
137
|
+
data-test="bulletin-rsvp-toggle"
|
|
138
|
+
/>
|
|
139
|
+
<p class="text-caption text-medium-emphasis ml-10 mb-2">
|
|
140
|
+
Residents answer in the Service365 app and say how many people are coming. You see every answer, with
|
|
141
|
+
the unit, when you open this announcement.
|
|
142
|
+
</p>
|
|
143
|
+
<p v-if="eventForm.rsvp && !bulletinForm.recipients.includes('resident')" class="text-caption text-warning ml-10 mb-2">
|
|
144
|
+
Add "Resident" to the recipients so residents can see and answer it.
|
|
145
|
+
</p>
|
|
146
|
+
</v-col>
|
|
147
|
+
<template v-if="eventForm.rsvp">
|
|
148
|
+
<v-col cols="12" md="6" class="pr-md-2">
|
|
149
|
+
<InputLabel title="RSVP closes (optional)" />
|
|
150
|
+
<InputDateTimePicker v-model:utc="eventForm.rsvpClosesAt" :rules="[rsvpCloseRule]" />
|
|
151
|
+
<p class="text-caption text-medium-emphasis">Leave empty to take answers until the event starts.</p>
|
|
152
|
+
</v-col>
|
|
153
|
+
<v-col cols="12" md="6" class="pl-md-2">
|
|
154
|
+
<InputLabel title="Places available (optional)" />
|
|
155
|
+
<v-text-field
|
|
156
|
+
v-model.number="eventForm.capacity"
|
|
157
|
+
type="number"
|
|
158
|
+
min="1"
|
|
159
|
+
density="compact"
|
|
160
|
+
:rules="[capacityRule]"
|
|
161
|
+
hint="Total people across all units. Leave empty for no limit."
|
|
162
|
+
persistent-hint
|
|
163
|
+
/>
|
|
164
|
+
</v-col>
|
|
165
|
+
</template>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
106
168
|
<v-col cols="12">
|
|
107
169
|
<v-col cols="12" class="font-weight-bold text-h6"
|
|
108
170
|
>Attachments</v-col
|
|
109
171
|
>
|
|
172
|
+
<v-col cols="12" class="text-caption text-medium-emphasis py-0">
|
|
173
|
+
Photos, videos and documents, up to 25 MB each.
|
|
174
|
+
</v-col>
|
|
110
175
|
<v-col cols="12">
|
|
111
176
|
<!-- Was `color="primary-button"` on an OUTLINED button, i.e. a
|
|
112
177
|
FILL token used as ink - the exact case `utils/theme.ts`
|
|
@@ -228,6 +293,42 @@ const bulletinForm = reactive<TCreateAnnouncementPayload>({
|
|
|
228
293
|
|
|
229
294
|
const emit = defineEmits(["close", "done"]);
|
|
230
295
|
|
|
296
|
+
const eventForm = reactive({
|
|
297
|
+
enabled: false,
|
|
298
|
+
startAt: "",
|
|
299
|
+
endAt: "",
|
|
300
|
+
venue: "",
|
|
301
|
+
rsvp: false,
|
|
302
|
+
rsvpClosesAt: "",
|
|
303
|
+
capacity: null as number | null,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
function eventStartRule(v: string) {
|
|
307
|
+
return !eventForm.enabled || !!v || "Please choose when the event starts";
|
|
308
|
+
}
|
|
309
|
+
function eventEndRule(v: string) {
|
|
310
|
+
return !v || !eventForm.startAt || new Date(v) > new Date(eventForm.startAt) || "The event must end after it starts";
|
|
311
|
+
}
|
|
312
|
+
function rsvpCloseRule(v: string) {
|
|
313
|
+
return !v || !eventForm.startAt || new Date(v) <= new Date(eventForm.startAt) || "RSVP must close before the event starts";
|
|
314
|
+
}
|
|
315
|
+
function capacityRule(v: any) {
|
|
316
|
+
return v === null || v === "" || v === undefined || (Number.isInteger(Number(v)) && Number(v) >= 1) || "Enter a whole number of 1 or more";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** What is sent: no event = `null`, so turning the event off on edit clears it. */
|
|
320
|
+
function eventPayload() {
|
|
321
|
+
if (!eventForm.enabled) return null;
|
|
322
|
+
return {
|
|
323
|
+
startAt: eventForm.startAt,
|
|
324
|
+
endAt: eventForm.endAt || null,
|
|
325
|
+
venue: eventForm.venue || "",
|
|
326
|
+
rsvp: eventForm.rsvp,
|
|
327
|
+
rsvpClosesAt: eventForm.rsvp ? eventForm.rsvpClosesAt || null : null,
|
|
328
|
+
capacity: eventForm.rsvp && eventForm.capacity ? Number(eventForm.capacity) : null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
231
332
|
const validForm = ref(false);
|
|
232
333
|
const formRef = ref<HTMLFormElement>();
|
|
233
334
|
const formKey = ref(0);
|
|
@@ -289,6 +390,16 @@ watch(
|
|
|
289
390
|
bulletinForm.endDate = newData?.endDate;
|
|
290
391
|
bulletinForm.file = newData?.file;
|
|
291
392
|
bulletinForm.recipients = newData?.recipients;
|
|
393
|
+
const ev = newData?.event;
|
|
394
|
+
Object.assign(eventForm, {
|
|
395
|
+
enabled: !!ev?.startAt,
|
|
396
|
+
startAt: ev?.startAt ?? "",
|
|
397
|
+
endAt: ev?.endAt ?? "",
|
|
398
|
+
venue: ev?.venue ?? "",
|
|
399
|
+
rsvp: !!ev?.rsvp,
|
|
400
|
+
rsvpClosesAt: ev?.rsvpClosesAt ?? "",
|
|
401
|
+
capacity: ev?.capacity ?? null,
|
|
402
|
+
});
|
|
292
403
|
|
|
293
404
|
// mapped rawFileArray
|
|
294
405
|
|
|
@@ -393,6 +504,7 @@ async function onSubmit() {
|
|
|
393
504
|
const { site, ...rest } = bulletinForm;
|
|
394
505
|
payload = rest;
|
|
395
506
|
}
|
|
507
|
+
(payload as any).event = eventPayload();
|
|
396
508
|
|
|
397
509
|
try {
|
|
398
510
|
loading.processing = true;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The event part of an announcement (owner 2026-10-07, 1PAM "Mooncake
|
|
4
|
+
Festival"): when, where, and - for the estate team - who is coming.
|
|
5
|
+
Residents answer in the Service365 app; this only reads the answers.
|
|
6
|
+
-->
|
|
7
|
+
<v-col v-if="event?.startAt" cols="12" class="mb-5" data-test="bulletin-event">
|
|
8
|
+
<v-row no-gutters>
|
|
9
|
+
<v-col class="font-weight-bold">Event:</v-col>
|
|
10
|
+
<v-col cols="9" class="px-10">
|
|
11
|
+
<div>{{ when }}</div>
|
|
12
|
+
<div v-if="event.venue" class="text-medium-emphasis">{{ event.venue }}</div>
|
|
13
|
+
|
|
14
|
+
<template v-if="event.rsvp">
|
|
15
|
+
<div class="text-caption text-medium-emphasis mt-1">
|
|
16
|
+
RSVP {{ closed ? "closed" : "open" }} {{ closed ? "on" : "until" }} {{ fmt(event.rsvpClosesAt || event.startAt) }}{{
|
|
17
|
+
event.capacity ? ` · ${event.capacity} places` : " · no limit on places"
|
|
18
|
+
}}
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<template v-if="canSeeAnswers">
|
|
22
|
+
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mt-3">{{ error }}</v-alert>
|
|
23
|
+
<div v-else-if="loading" class="text-caption mt-3">Loading answers...</div>
|
|
24
|
+
<template v-else-if="summary">
|
|
25
|
+
<div class="d-flex flex-wrap ga-2 mt-3" data-test="bulletin-rsvp-summary">
|
|
26
|
+
<v-chip size="small" color="success" variant="tonal">
|
|
27
|
+
Going: {{ summary.goingPeople }} {{ summary.goingPeople === 1 ? "person" : "people" }} ({{ summary.goingHouseholds }}
|
|
28
|
+
{{ summary.goingHouseholds === 1 ? "unit" : "units" }})
|
|
29
|
+
</v-chip>
|
|
30
|
+
<v-chip size="small" variant="tonal">Not going: {{ summary.notGoing }}</v-chip>
|
|
31
|
+
<v-chip v-if="summary.capacity" size="small" variant="tonal">
|
|
32
|
+
Places left: {{ Math.max(summary.capacity - summary.goingPeople, 0) }}
|
|
33
|
+
</v-chip>
|
|
34
|
+
<v-btn
|
|
35
|
+
v-if="items.length"
|
|
36
|
+
size="small"
|
|
37
|
+
variant="outlined"
|
|
38
|
+
class="text-none screen-btn-ghost"
|
|
39
|
+
prepend-icon="mdi-download"
|
|
40
|
+
data-test="bulletin-rsvp-export"
|
|
41
|
+
@click="exportCsv"
|
|
42
|
+
>
|
|
43
|
+
Download list (CSV)
|
|
44
|
+
</v-btn>
|
|
45
|
+
</div>
|
|
46
|
+
<p v-if="!items.length" class="text-caption text-medium-emphasis mt-2">
|
|
47
|
+
No answers yet. Residents answer in the Service365 app.
|
|
48
|
+
</p>
|
|
49
|
+
<v-table v-else density="compact" class="mt-2 border rounded">
|
|
50
|
+
<thead>
|
|
51
|
+
<tr><th>Unit</th><th>Name</th><th>Answer</th><th>People</th><th>Answered</th></tr>
|
|
52
|
+
</thead>
|
|
53
|
+
<tbody>
|
|
54
|
+
<tr v-for="r in items" :key="r._id">
|
|
55
|
+
<td>{{ r.unit || "-" }}</td>
|
|
56
|
+
<td>{{ r.name || r.email || "-" }}</td>
|
|
57
|
+
<td>{{ r.response === "going" ? "Going" : "Not going" }}</td>
|
|
58
|
+
<td>{{ r.response === "going" ? r.attendees : "-" }}</td>
|
|
59
|
+
<td>{{ fmt(r.updatedAt) }}</td>
|
|
60
|
+
</tr>
|
|
61
|
+
</tbody>
|
|
62
|
+
</v-table>
|
|
63
|
+
</template>
|
|
64
|
+
</template>
|
|
65
|
+
</template>
|
|
66
|
+
</v-col>
|
|
67
|
+
</v-row>
|
|
68
|
+
</v-col>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
const props = defineProps({
|
|
73
|
+
announcement: { type: Object as PropType<Record<string, any> | null>, default: null },
|
|
74
|
+
/** The estate team (bulletin edit right) sees who answered. */
|
|
75
|
+
canSeeAnswers: { type: Boolean, default: false },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const event = computed(() => props.announcement?.event ?? null);
|
|
79
|
+
const items = ref<Record<string, any>[]>([]);
|
|
80
|
+
const summary = ref<Record<string, any> | null>(null);
|
|
81
|
+
const loading = ref(false);
|
|
82
|
+
const error = ref("");
|
|
83
|
+
|
|
84
|
+
const fmt = (v: any) =>
|
|
85
|
+
v ? new Date(v).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" }) : "";
|
|
86
|
+
const when = computed(() => {
|
|
87
|
+
const e = event.value;
|
|
88
|
+
if (!e?.startAt) return "";
|
|
89
|
+
return e.endAt ? `${fmt(e.startAt)} - ${fmt(e.endAt)}` : fmt(e.startAt);
|
|
90
|
+
});
|
|
91
|
+
const closed = computed(() => {
|
|
92
|
+
const at = event.value?.rsvpClosesAt || event.value?.startAt;
|
|
93
|
+
return !!at && new Date(at) < new Date();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async function load() {
|
|
97
|
+
items.value = [];
|
|
98
|
+
summary.value = null;
|
|
99
|
+
error.value = "";
|
|
100
|
+
if (!event.value?.rsvp || !props.canSeeAnswers || !props.announcement?._id) return;
|
|
101
|
+
loading.value = true;
|
|
102
|
+
try {
|
|
103
|
+
const res: any = await useNuxtApp().$api(`/api/bulletin-boards/id/${props.announcement._id}/rsvps`);
|
|
104
|
+
items.value = res?.items ?? [];
|
|
105
|
+
summary.value = res?.summary ?? null;
|
|
106
|
+
} catch (e: any) {
|
|
107
|
+
error.value = e?.data?.message || "The answers could not be loaded. Please try again.";
|
|
108
|
+
} finally {
|
|
109
|
+
loading.value = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
watch(() => [props.announcement?._id, props.canSeeAnswers], load, { immediate: true });
|
|
113
|
+
|
|
114
|
+
function exportCsv() {
|
|
115
|
+
const q = (v: any) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
|
116
|
+
const rows = [["Unit", "Name", "Email", "Answer", "People", "Answered"]].concat(
|
|
117
|
+
items.value.map((r) => [r.unit, r.name, r.email, r.response === "going" ? "Going" : "Not going", r.response === "going" ? r.attendees : 0, fmt(r.updatedAt)]),
|
|
118
|
+
);
|
|
119
|
+
const blob = new Blob([rows.map((r) => r.map(q).join(",")).join("\n")], { type: "text/csv" });
|
|
120
|
+
const a = document.createElement("a");
|
|
121
|
+
a.href = URL.createObjectURL(blob);
|
|
122
|
+
a.download = `rsvp-${String(props.announcement?.title || "event").replace(/[^A-Za-z0-9]+/g, "-")}.csv`;
|
|
123
|
+
a.click();
|
|
124
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
125
|
+
}
|
|
126
|
+
</script>
|
|
@@ -86,15 +86,20 @@
|
|
|
86
86
|
</template>
|
|
87
87
|
|
|
88
88
|
<script setup lang="ts">
|
|
89
|
-
import html2canvas from "html2canvas";
|
|
90
|
-
import jsPDF from "jspdf";
|
|
91
|
-
|
|
92
89
|
import type {
|
|
93
90
|
NFCPatrolReportFilters,
|
|
94
91
|
NFCPatrolReportTab,
|
|
95
92
|
} from "../../types/nfc-patrol-report";
|
|
96
93
|
|
|
97
94
|
import { useNFCPatrolReportFilters } from "../../composables/useNFCPatrolReportFilters";
|
|
95
|
+
import useReportPdf, { type ReportPdfMeta } from "../../composables/useReportPdf";
|
|
96
|
+
import {
|
|
97
|
+
buildReportCsv,
|
|
98
|
+
formatGeneratedAt,
|
|
99
|
+
formatReportDate,
|
|
100
|
+
reportFileName,
|
|
101
|
+
type CsvSection,
|
|
102
|
+
} from "../../utils/report-export";
|
|
98
103
|
|
|
99
104
|
import PatrolReportTab from "./PatrolReport/PatrolReportTab.vue";
|
|
100
105
|
import DailyReportTab from "./PatrolReport/DailyReportTab.vue";
|
|
@@ -134,7 +139,42 @@ function updateFilters(value: NFCPatrolReportFilters) {
|
|
|
134
139
|
filters.date = value.date;
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
|
|
142
|
+
const { exportReportPdf } = useReportPdf();
|
|
143
|
+
|
|
144
|
+
/** An export's heading and file name: which report, where, and for what. */
|
|
145
|
+
function reportMeta(report: HTMLElement, extension: "pdf" | "csv"): ReportPdfMeta {
|
|
146
|
+
const tab = reportTabs.find(({ value }) => value === activeTab.value);
|
|
147
|
+
const route = options.value.routes.find(({ value }) => value === filters.route)?.title;
|
|
148
|
+
const text = (selector: string) => report.querySelector(selector)?.textContent?.trim() || undefined;
|
|
149
|
+
|
|
150
|
+
// The yearly table always covers the current year (useNFCPatrolMonthlyReport).
|
|
151
|
+
const period =
|
|
152
|
+
activeTab.value === "monthly"
|
|
153
|
+
? { label: "Year", value: String(new Date().getFullYear()) }
|
|
154
|
+
: { label: "Date", value: formatReportDate(filters.date) };
|
|
155
|
+
const details = [{ label: "Route", value: route ?? "-" }, period];
|
|
156
|
+
if (activeTab.value === "patrol" && filters.timeRange) {
|
|
157
|
+
details.push({ label: "Time", value: filters.timeRange });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
title: tab?.label ?? "Patrol Report",
|
|
162
|
+
site: text(".location-header__name"),
|
|
163
|
+
address: text(".location-header__address"),
|
|
164
|
+
details,
|
|
165
|
+
fileName: reportFileName(
|
|
166
|
+
[
|
|
167
|
+
tab?.label,
|
|
168
|
+
route,
|
|
169
|
+
activeTab.value === "monthly" ? period.value : filters.date,
|
|
170
|
+
activeTab.value === "patrol" ? filters.timeRange.split(" - ")[0]?.replace(":", "") : undefined,
|
|
171
|
+
],
|
|
172
|
+
extension,
|
|
173
|
+
),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function exportReport(type: "pdf" | "csv", sections: CsvSection[] = []) {
|
|
138
178
|
const report = document.querySelector(
|
|
139
179
|
".report-export",
|
|
140
180
|
) as HTMLElement | null;
|
|
@@ -142,82 +182,31 @@ async function exportReport(type: "pdf" | "csv") {
|
|
|
142
182
|
if (!report) return;
|
|
143
183
|
|
|
144
184
|
if (type === "pdf") {
|
|
145
|
-
|
|
146
|
-
scale: 2,
|
|
147
|
-
useCORS: true,
|
|
148
|
-
backgroundColor: "#fff",
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
const pdf = new jsPDF("p", "mm", "a4");
|
|
152
|
-
|
|
153
|
-
const pdfWidth = pdf.internal.pageSize.getWidth();
|
|
154
|
-
const pdfHeight = pdf.internal.pageSize.getHeight();
|
|
155
|
-
|
|
156
|
-
const imgWidth = pdfWidth;
|
|
157
|
-
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
|
158
|
-
|
|
159
|
-
const imgData = canvas.toDataURL("image/png");
|
|
160
|
-
|
|
161
|
-
let heightLeft = imgHeight;
|
|
162
|
-
let position = 0;
|
|
163
|
-
|
|
164
|
-
pdf.addImage(imgData, "PNG", 0, position, imgWidth, imgHeight);
|
|
165
|
-
|
|
166
|
-
heightLeft -= pdfHeight;
|
|
167
|
-
|
|
168
|
-
while (heightLeft > 0) {
|
|
169
|
-
position -= pdfHeight;
|
|
170
|
-
|
|
171
|
-
pdf.addPage();
|
|
172
|
-
pdf.addImage(imgData, "PNG", 0, position, imgWidth, imgHeight);
|
|
173
|
-
|
|
174
|
-
heightLeft -= pdfHeight;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
pdf.save(`${activeTab.value}-report.pdf`);
|
|
185
|
+
await exportReportPdf(report, reportMeta(report, "pdf"));
|
|
178
186
|
return;
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
// Built from the report's data by its tab, not read off the screen: every
|
|
190
|
+
// table, with each skip's remarks and photo link on its checkpoint's row.
|
|
191
|
+
if (!sections.length) return;
|
|
192
|
+
|
|
193
|
+
const meta = reportMeta(report, "csv");
|
|
194
|
+
const csv = buildReportCsv({
|
|
195
|
+
title: meta.title,
|
|
196
|
+
details: [
|
|
197
|
+
{ label: "Site", value: meta.site },
|
|
198
|
+
{ label: "Address", value: meta.address },
|
|
199
|
+
...meta.details,
|
|
200
|
+
{ label: "Generated", value: formatGeneratedAt() },
|
|
201
|
+
],
|
|
202
|
+
sections,
|
|
195
203
|
});
|
|
196
204
|
|
|
197
205
|
const link = document.createElement("a");
|
|
198
|
-
|
|
199
|
-
link.
|
|
200
|
-
link.download = `${activeTab.value}-report.csv`;
|
|
206
|
+
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" }));
|
|
207
|
+
link.download = meta.fileName;
|
|
201
208
|
link.click();
|
|
202
209
|
|
|
203
210
|
URL.revokeObjectURL(link.href);
|
|
204
211
|
}
|
|
205
|
-
|
|
206
|
-
function getCsvCellValue(cell: Element) {
|
|
207
|
-
const image = cell.querySelector("img");
|
|
208
|
-
|
|
209
|
-
if (image) {
|
|
210
|
-
const imageUrl = image.currentSrc || image.src;
|
|
211
|
-
|
|
212
|
-
if (imageUrl) {
|
|
213
|
-
return imageUrl;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return cell.textContent?.trim() ?? "";
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function escapeCsvCell(value: string) {
|
|
221
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
222
|
-
}
|
|
223
212
|
</script>
|
|
@@ -54,11 +54,10 @@
|
|
|
54
54
|
</div>
|
|
55
55
|
</template>
|
|
56
56
|
|
|
57
|
+
<!-- Earliest first, one unbreakable pill per window; past three the
|
|
58
|
+
rest fold into "+N more" so a busy route keeps a normal row. -->
|
|
57
59
|
<template #item.schedules="{ value }">
|
|
58
|
-
<
|
|
59
|
-
{{ value.map((s: any) => `${s.start}-${s.end}`).join(", ") }}
|
|
60
|
-
</span>
|
|
61
|
-
<span v-else>--</span>
|
|
60
|
+
<NFCRouteScheduleTimes :windows="value || []" :limit="3" />
|
|
62
61
|
</template>
|
|
63
62
|
|
|
64
63
|
<template #item.repeatDays="{ value }">
|
|
@@ -152,10 +151,14 @@
|
|
|
152
151
|
class="mb-2"
|
|
153
152
|
v-if="selectedRoute.schedules && selectedRoute.schedules.length"
|
|
154
153
|
>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
<div class="mb-2">
|
|
155
|
+
Security Check Schedule
|
|
156
|
+
<span class="route-preview__count">
|
|
157
|
+
· {{ selectedRoute.schedules.length }}
|
|
158
|
+
{{ selectedRoute.schedules.length === 1 ? "check" : "checks" }} a day
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
<NFCRouteScheduleTimes :windows="selectedRoute.schedules" />
|
|
159
162
|
</v-col>
|
|
160
163
|
|
|
161
164
|
<v-col
|
|
@@ -291,6 +294,8 @@
|
|
|
291
294
|
|
|
292
295
|
<script setup lang="ts">
|
|
293
296
|
import useNFCPatrolRoute from "../../composables/useNFCPatrolRoute";
|
|
297
|
+
import { formatRouteDays, routeWindows } from "../../utils/nfc-patrol-schedule";
|
|
298
|
+
import NFCRouteScheduleTimes from "./NFCRouteScheduleTimes.vue";
|
|
294
299
|
|
|
295
300
|
const props = defineProps({
|
|
296
301
|
site: {
|
|
@@ -343,21 +348,6 @@ const searchInput = ref("");
|
|
|
343
348
|
const { getAll: getAllRoutes, getById: getRouteById, deleteById: deleteRouteById } =
|
|
344
349
|
useNFCPatrolRoute();
|
|
345
350
|
|
|
346
|
-
function calculateEndTime(startTime: string, toleranceMinutes: number): string {
|
|
347
|
-
if (!startTime) return '';
|
|
348
|
-
|
|
349
|
-
const [hours, minutes] = startTime.split(':').map(Number);
|
|
350
|
-
if (isNaN(hours) || isNaN(minutes)) return startTime;
|
|
351
|
-
|
|
352
|
-
const totalMinutes = hours * 60 + minutes + toleranceMinutes;
|
|
353
|
-
const wrappedMinutes = ((totalMinutes % 1440) + 1440) % 1440;
|
|
354
|
-
|
|
355
|
-
const endHours = String(Math.floor(wrappedMinutes / 60)).padStart(2, '0');
|
|
356
|
-
const endMins = String(wrappedMinutes % 60).padStart(2, '0');
|
|
357
|
-
|
|
358
|
-
return `${endHours}:${endMins}`;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
351
|
// Use a reactive trigger for manual refresh
|
|
362
352
|
const refreshTrigger = ref(0);
|
|
363
353
|
|
|
@@ -389,14 +379,9 @@ watchEffect(() => {
|
|
|
389
379
|
// Display fields for the table
|
|
390
380
|
routeName: item.name,
|
|
391
381
|
numberOfCheckpoints: item.checkPointNumber,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
})) || [],
|
|
396
|
-
repeatDays: item.days?.map((d: number) => {
|
|
397
|
-
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
398
|
-
return dayNames[d];
|
|
399
|
-
}).join(', ') || '--',
|
|
382
|
+
// Each start time's window, earliest first; days Monday first, runs as ranges.
|
|
383
|
+
schedules: routeWindows(item.startTimes, item.tolerance),
|
|
384
|
+
repeatDays: formatRouteDays(item.days) || '--',
|
|
400
385
|
checkpoints: item.checkPoints?.map((cp: any) => ({
|
|
401
386
|
nfcTagId: cp.nfcTag_id,
|
|
402
387
|
})) || []
|
|
@@ -529,6 +514,11 @@ async function submitDelete() {
|
|
|
529
514
|
}
|
|
530
515
|
}
|
|
531
516
|
</script>
|
|
517
|
+
<style scoped>
|
|
518
|
+
.route-preview__count {
|
|
519
|
+
color: var(--text2);
|
|
520
|
+
}
|
|
521
|
+
</style>
|
|
532
522
|
<!--
|
|
533
523
|
The screen's own table CSS is gone with the `v-data-table` it styled: it
|
|
534
524
|
hardcoded an `rgb(250,250,250)` header fill and black-alpha borders, which
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A route's check windows as pills, earliest first: "09:01 – 14:40", never
|
|
3
|
+
broken across lines, with a "+1" on a window that ends the next day.
|
|
4
|
+
|
|
5
|
+
It replaces one comma-joined string in the order the times were typed,
|
|
6
|
+
which wrapped mid-time ("10:01-" / "18:40") and grew a table row as tall as
|
|
7
|
+
its list. Given `limit`, the first windows show and the rest fold into a
|
|
8
|
+
"+5 more" pill whose tooltip lists them.
|
|
9
|
+
-->
|
|
10
|
+
<template>
|
|
11
|
+
<div v-if="windows.length" class="route-times">
|
|
12
|
+
<span
|
|
13
|
+
v-for="(entry, index) in shown"
|
|
14
|
+
:key="`${entry.start}-${index}`"
|
|
15
|
+
class="route-times__pill"
|
|
16
|
+
:title="routeWindowLabel(entry)"
|
|
17
|
+
>
|
|
18
|
+
{{ entry.end ? `${entry.start} – ${entry.end}` : entry.start }}<span
|
|
19
|
+
v-if="entry.nextDay"
|
|
20
|
+
class="route-times__next"
|
|
21
|
+
aria-label="next day"
|
|
22
|
+
>+1</span>
|
|
23
|
+
</span>
|
|
24
|
+
<span
|
|
25
|
+
v-if="hidden.length"
|
|
26
|
+
class="route-times__pill route-times__pill--more"
|
|
27
|
+
:title="hidden.map(routeWindowLabel).join('\n')"
|
|
28
|
+
>
|
|
29
|
+
+{{ hidden.length }} more
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
<span v-else>--</span>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { routeWindowLabel, type RouteWindow } from "../../utils/nfc-patrol-schedule";
|
|
37
|
+
|
|
38
|
+
const props = defineProps<{
|
|
39
|
+
windows: RouteWindow[];
|
|
40
|
+
/** How many to show before folding the rest into "+N more"; all when not given. */
|
|
41
|
+
limit?: number;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const shown = computed(() => (props.limit ? props.windows.slice(0, props.limit) : props.windows));
|
|
45
|
+
const hidden = computed(() => (props.limit ? props.windows.slice(props.limit) : []));
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style scoped>
|
|
49
|
+
.route-times {
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-wrap: wrap;
|
|
52
|
+
gap: 6px;
|
|
53
|
+
padding: 2px 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.route-times__pill {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: baseline;
|
|
59
|
+
gap: 3px;
|
|
60
|
+
padding: 3px 10px;
|
|
61
|
+
border: 1px solid var(--border);
|
|
62
|
+
border-radius: var(--r-pill);
|
|
63
|
+
background: var(--thead);
|
|
64
|
+
font-family: var(--font-sans);
|
|
65
|
+
font-size: var(--fs-chip-lg);
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
color: var(--text);
|
|
68
|
+
font-variant-numeric: tabular-nums;
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.route-times__next {
|
|
73
|
+
font-size: 10px;
|
|
74
|
+
font-weight: 700;
|
|
75
|
+
color: var(--warn);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.route-times__pill--more {
|
|
79
|
+
background: transparent;
|
|
80
|
+
color: var(--text2);
|
|
81
|
+
cursor: help;
|
|
82
|
+
}
|
|
83
|
+
</style>
|