@7365admin1/layer-common 4.108.0 → 4.109.1

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 (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/assets/css/screens.css +11 -0
  3. package/components/BulletinBoardForm.vue +112 -0
  4. package/components/BulletinBoardView.vue +2 -0
  5. package/components/BulletinEventRsvp.vue +126 -0
  6. package/components/DashboardMain.vue +7 -7
  7. package/components/Nfc/NFCPatrolReportMain.vue +61 -72
  8. package/components/Nfc/NFCPatrolRouteMain.vue +21 -31
  9. package/components/Nfc/NFCRouteScheduleTimes.vue +83 -0
  10. package/components/Nfc/PatrolReport/DailyReportTab.vue +24 -3
  11. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +32 -3
  12. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +29 -1
  13. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +24 -2
  14. package/components/Nfc/PatrolReport/PatrolMonthDialog.vue +195 -0
  15. package/components/Nfc/PatrolReport/PatrolReportTab.vue +11 -2
  16. package/components/Nfc/PatrolReport/PatrolRouteDetailsDialog.vue +214 -0
  17. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +4 -1
  18. package/components/Nfc/PatrolReport/SkipPhotoDetails.vue +137 -0
  19. package/components/Nfc/PatrolReport/SkipPhotoThumb.vue +132 -0
  20. package/components/Nfc/PatrolReport/SummaryReportTable.vue +42 -1
  21. package/components/SiteSettings.vue +4 -0
  22. package/components/VehicleManagement.vue +4 -1
  23. package/composables/useNFCPatrolDailyReport.ts +4 -18
  24. package/composables/useNFCPatrolMonthlyReport.ts +34 -5
  25. package/composables/useNFCPatrolReport.ts +5 -74
  26. package/composables/useReportPdf.ts +220 -0
  27. package/package.json +1 -1
  28. package/types/bulletin-board.d.ts +11 -1
  29. package/types/nfc-patrol-report.ts +14 -0
  30. package/utils/nav-sections.ts +1 -0
  31. package/utils/nfc-patrol-report.ts +239 -1
  32. package/utils/nfc-patrol-schedule.ts +73 -0
  33. 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.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 518f053: Task Calendar: the dashboard panel "Upcoming Events" is now "Task Calendar" (property apps); the sidebar groups the renamed menu item under PROPERTY.
8
+
9
+ ## 4.109.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+ - 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.
15
+
16
+ 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.
17
+
18
+ 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.
19
+
20
+ 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.
21
+ - 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`).
22
+
3
23
  ## 4.108.0
4
24
 
5
25
  ### Minor Changes
@@ -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;
@@ -33,6 +33,8 @@
33
33
  </v-col>
34
34
  </template>
35
35
 
36
+ <BulletinEventRsvp :announcement="prop.activeAnnouncement" :can-see-answers="canUpdate" />
37
+
36
38
  <v-col
37
39
  cols="12"
38
40
  v-if="prop.activeAnnouncement?.file?.length > 0"
@@ -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>
@@ -676,7 +676,7 @@
676
676
  <v-col v-if="isWidgetVisible('upcomingEvents')" cols="12" lg="6">
677
677
  <div class="dashboard-card dashboard-panel figma-panel">
678
678
  <div class="figma-panel-header">
679
- <h2>Upcoming Events</h2>
679
+ <h2>Task Calendar</h2>
680
680
  <button
681
681
  class="figma-panel-action"
682
682
  type="button"
@@ -2167,8 +2167,8 @@ const customizeWidgets = computed(() => {
2167
2167
  },
2168
2168
  {
2169
2169
  key: "upcomingEvents",
2170
- title: "Upcoming Events",
2171
- description: "Quick view of scheduled events happening soon.",
2170
+ title: "Task Calendar",
2171
+ description: "Upcoming tasks and dates on your management team's calendar.",
2172
2172
  },
2173
2173
  {
2174
2174
  key: "todayReminders",
@@ -2928,9 +2928,9 @@ const printTableSections = computed<PrintTableSection[]>(() => {
2928
2928
  sections.push(
2929
2929
  {
2930
2930
  key: "upcomingEvents",
2931
- title: "Upcoming Events",
2931
+ title: "Task Calendar",
2932
2932
  columns: [
2933
- { key: "title", label: "Event Title", width: "40%", strong: true },
2933
+ { key: "title", label: "Title", width: "40%", strong: true },
2934
2934
  { key: "subtitle", label: "Description / Subtitle", width: "60%" },
2935
2935
  ],
2936
2936
  rows: upcomingEvents.value,
@@ -3873,9 +3873,9 @@ async function exportDashboard(format: "PDF" | "Word") {
3873
3873
  }
3874
3874
 
3875
3875
  if (isPropertyMode.value) {
3876
- wordHtml += `<div class="section-title">Upcoming Events</div>`;
3876
+ wordHtml += `<div class="section-title">Task Calendar</div>`;
3877
3877
  wordHtml += `<table class="data-table">`;
3878
- wordHtml += `<thead><tr><th>Event Title</th><th>Description / Subtitle</th></tr></thead><tbody>`;
3878
+ wordHtml += `<thead><tr><th>Title</th><th>Description / Subtitle</th></tr></thead><tbody>`;
3879
3879
  if (upcomingEvents.value.length > 0) {
3880
3880
  upcomingEvents.value.forEach((item) => {
3881
3881
  wordHtml += `<tr>
@@ -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
- async function exportReport(type: "pdf" | "csv") {
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
- const canvas = await html2canvas(report, {
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
- const table = report.querySelector("table");
182
-
183
- if (!table) return;
184
-
185
- const csv = Array.from(table.querySelectorAll("tr"))
186
- .map((row) =>
187
- Array.from(row.querySelectorAll("th,td"))
188
- .map((cell) => escapeCsvCell(getCsvCellValue(cell)))
189
- .join(","),
190
- )
191
- .join("\n");
192
-
193
- const blob = new Blob([csv], {
194
- type: "text/csv;charset=utf-8;",
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.href = URL.createObjectURL(blob);
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
- <span v-if="Array.isArray(value) && value.length > 0">
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
- Security Check Schedule:
156
- <span class="font-weight-bold">
157
- {{ selectedRoute.schedules.map(s => `${s.start}-${s.end}`).join(", ") }}
158
- </span>
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
- schedules: item.startTimes?.map((time: string) => ({
393
- start: time,
394
- end: calculateEndTime(time, item.tolerance || 0)
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