@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
@@ -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>
@@ -5,7 +5,7 @@
5
5
  :options="options"
6
6
  :show-time-filter="false"
7
7
  @update:model-value="$emit('update:filters', $event)"
8
- @export="(type) => $emit('export', type)"
8
+ @export="(type) => $emit('export', type, csvSections())"
9
9
  />
10
10
  <div class="report-export">
11
11
  <v-divider :thickness="1" class="border-opacity-100" />
@@ -13,7 +13,7 @@
13
13
  <template v-if="report">
14
14
  <ReportLocationHeader :location="report.location" />
15
15
  <v-divider :thickness="1" class="border-opacity-100 mb-6" />
16
- <SummaryReportTable :title="report.title" :rows="report.rows" />
16
+ <SummaryReportTable :title="report.title" :rows="report.rows" @select="openDetails" />
17
17
  </template>
18
18
 
19
19
 
@@ -31,6 +31,8 @@
31
31
  />
32
32
 
33
33
  <ReportEmptyState v-else-if="!loading" />
34
+
35
+ <PatrolRouteDetailsDialog v-model="detailsOpen" :row="selectedRow" />
34
36
  </div>
35
37
  </div>
36
38
  </template>
@@ -39,8 +41,12 @@
39
41
  import type {
40
42
  NFCPatrolReportFilterOptions,
41
43
  NFCPatrolReportFilters,
44
+ NFCPatrolReportSummaryRow,
42
45
  } from "../../../types/nfc-patrol-report";
43
46
  import { useNFCPatrolDailyReport } from "../../../composables/useNFCPatrolDailyReport";
47
+ import { dailyReportCsv } from "../../../utils/nfc-patrol-report";
48
+ import type { CsvSection } from "../../../utils/report-export";
49
+ import PatrolRouteDetailsDialog from "./PatrolRouteDetailsDialog.vue";
44
50
  import ReportEmptyState from "./ReportEmptyState.vue";
45
51
  import ReportFilters from "./ReportFilters.vue";
46
52
  import ReportLocationHeader from "./ReportLocationHeader.vue";
@@ -55,7 +61,8 @@ const props = defineProps<{
55
61
 
56
62
  defineEmits<{
57
63
  "update:filters": [value: NFCPatrolReportFilters];
58
- export: [type: "pdf" | "csv"];
64
+ /** `csv` is the report's tables, built from its data rather than read off the screen. */
65
+ export: [type: "pdf" | "csv", csv: CsvSection[]];
59
66
  }>();
60
67
 
61
68
  const { report, loading, notFound, failed, fetchReport } = useNFCPatrolDailyReport(
@@ -63,6 +70,20 @@ const { report, loading, notFound, failed, fetchReport } = useNFCPatrolDailyRepo
63
70
  props.site,
64
71
  );
65
72
 
73
+ const { getFileUrl } = useFile();
74
+
75
+ function csvSections() {
76
+ return report.value ? dailyReportCsv(report.value, getFileUrl) : [];
77
+ }
78
+
79
+ const detailsOpen = ref(false);
80
+ const selectedRow = ref<NFCPatrolReportSummaryRow | null>(null);
81
+
82
+ function openDetails(row: NFCPatrolReportSummaryRow) {
83
+ selectedRow.value = row;
84
+ detailsOpen.value = true;
85
+ }
86
+
66
87
  watch(
67
88
  () => [props.filters.route, props.filters.date, props.active],
68
89
  () => {
@@ -6,7 +6,7 @@
6
6
  :show-time-filter="false"
7
7
  :show-date-filter="false"
8
8
  @update:model-value="$emit('update:filters', $event)"
9
- @export="(type) => $emit('export', type)"
9
+ @export="(type) => $emit('export', type, csvSections())"
10
10
  />
11
11
  <div class="report-export">
12
12
  <v-divider :thickness="1" class="border-opacity-100" />
@@ -18,6 +18,7 @@
18
18
  <MonthlyReportTable
19
19
  :title="report.title"
20
20
  :rows="report.rows"
21
+ @select="openMonth"
21
22
  />
22
23
  </template>
23
24
 
@@ -33,6 +34,13 @@
33
34
  title="No Monthly Report"
34
35
  message="No patrol log found for this route"
35
36
  />
37
+
38
+ <PatrolMonthDialog
39
+ v-model="monthOpen"
40
+ :title="monthTitle"
41
+ :route-name="monthRows[0]?.detail?.routeSummary.routeName ?? ''"
42
+ :rows="monthRows"
43
+ />
36
44
  </div>
37
45
  </div>
38
46
  </template>
@@ -41,9 +49,13 @@
41
49
  import type {
42
50
  NFCPatrolReportFilterOptions,
43
51
  NFCPatrolReportFilters,
52
+ NFCPatrolReportSummaryRow,
44
53
  } from "../../../types/nfc-patrol-report";
45
54
 
46
55
  import { useNFCPatrolMonthlyReport } from "../../../composables/useNFCPatrolMonthlyReport";
56
+ import { monthlyReportCsv } from "../../../utils/nfc-patrol-report";
57
+ import PatrolMonthDialog from "./PatrolMonthDialog.vue";
58
+ import type { CsvSection } from "../../../utils/report-export";
47
59
 
48
60
  import ReportEmptyState from "./ReportEmptyState.vue";
49
61
  import ReportFilters from "./ReportFilters.vue";
@@ -59,12 +71,29 @@ const props = defineProps<{
59
71
 
60
72
  defineEmits<{
61
73
  "update:filters": [value: NFCPatrolReportFilters];
62
- export: [type: "pdf" | "csv"];
74
+ /** `csv` is the report's tables, built from its data rather than read off the screen. */
75
+ export: [type: "pdf" | "csv", csv: CsvSection[]];
63
76
  }>();
64
77
 
65
- const { report, loading, notFound, failed, fetchReport } =
78
+ const { report, loading, notFound, failed, fetchReport, monthRuns } =
66
79
  useNFCPatrolMonthlyReport(props.filters, props.site);
67
80
 
81
+ // A month's row opens the list of that month's patrols.
82
+ const monthOpen = ref(false);
83
+ const monthTitle = ref("");
84
+ const monthRows = shallowRef<NFCPatrolReportSummaryRow[]>([]);
85
+
86
+ function openMonth(monthIndex: number) {
87
+ // The yearly report always covers the current year (useNFCPatrolMonthlyReport).
88
+ monthTitle.value = `${report.value?.rows[monthIndex]?.month ?? ""} ${new Date().getFullYear()}`;
89
+ monthRows.value = monthRuns(monthIndex);
90
+ monthOpen.value = true;
91
+ }
92
+
93
+ function csvSections() {
94
+ return report.value ? monthlyReportCsv(report.value) : [];
95
+ }
96
+
68
97
  watch(
69
98
  () => [props.filters.route, props.active],
70
99
  () => {
@@ -1,3 +1,8 @@
1
+ <!--
2
+ The year, month by month. A month that had patrols opens its list of patrol
3
+ runs (`PatrolMonthDialog`), and each run there opens its checkpoints and
4
+ skip photos.
5
+ -->
1
6
  <template>
2
7
  <div class="mt-6">
3
8
  <div class="screen-card-title mb-4">
@@ -17,8 +22,13 @@
17
22
 
18
23
  <tbody>
19
24
  <tr
20
- v-for="row in rows"
25
+ v-for="(row, index) in rows"
21
26
  :key="row.month"
27
+ :class="{ 'monthly-table__row--link': row.runs }"
28
+ :tabindex="row.runs ? 0 : undefined"
29
+ :title="row.runs ? `View the patrols of ${row.month}` : undefined"
30
+ @click="row.runs && emit('select', index)"
31
+ @keydown.enter="row.runs && emit('select', index)"
22
32
  >
23
33
  <td>{{ row.month }}</td>
24
34
 
@@ -52,6 +62,11 @@ defineProps<{
52
62
  title: string;
53
63
  rows: NFCPatrolMonthlyReportRow[];
54
64
  }>();
65
+
66
+ const emit = defineEmits<{
67
+ /** The month (0-11) whose patrols to list. */
68
+ select: [monthIndex: number];
69
+ }>();
55
70
  </script>
56
71
 
57
72
  <style scoped>
@@ -68,4 +83,17 @@ thead th {
68
83
  tbody td {
69
84
  height: 56px;
70
85
  }
86
+
87
+ .monthly-table__row--link {
88
+ cursor: pointer;
89
+ }
90
+
91
+ .monthly-table__row--link:hover > td {
92
+ background: var(--hover);
93
+ }
94
+
95
+ .monthly-table__row--link:focus-visible {
96
+ outline: 2px solid var(--accent);
97
+ outline-offset: -2px;
98
+ }
71
99
  </style>
@@ -10,6 +10,11 @@
10
10
  tokens by way of `utils/theme.ts`, and `utils/status.ts` does not name
11
11
  "Skipped", so routing this through `StatusChip` would turn a red skipped
12
12
  checkpoint neutral. That is a design decision, not a conversion.
13
+
14
+ A skipped checkpoint's photo shows in its own column (`SkipPhotoThumb`); the
15
+ thumbnail opens the photo, the rest of the row still opens the remarks. The
16
+ PDF drops that column and draws the photo with its remarks in a row of its
17
+ own right under the checkpoint (`SkipPhotoDetails`), kept on the same page.
13
18
  -->
14
19
  <template>
15
20
  <div class="table-card table-card__shrink">
@@ -26,13 +31,14 @@
26
31
  <th>Signature</th>
27
32
  <th>Start - End Time</th>
28
33
  <th>Status</th>
34
+ <th data-pdf-hide>Skip Photo</th>
29
35
  </tr>
30
36
  </thead>
31
37
  <tbody>
38
+ <template v-for="(activity, index) in activities" :key="`${activity.id}-${index}`">
32
39
  <tr
33
- v-for="activity in activities"
34
- :key="activity.id"
35
40
  :class="{ 'cursor-pointer': activity.status === 'Skipped' }"
41
+ :data-pdf-keep-with-next="activity.status === 'Skipped' || undefined"
36
42
  @click="handleActivityClick(activity)"
37
43
  >
38
44
  <td>{{ activity.checkpoint }}</td>
@@ -44,6 +50,7 @@
44
50
  :alt="`${activity.guardName || worker.one} signature`"
45
51
  class="guard-signature"
46
52
  contain
53
+ eager
47
54
  />
48
55
  <span v-else>-</span>
49
56
  </td>
@@ -62,7 +69,16 @@
62
69
  </v-icon>
63
70
  </v-chip>
64
71
  </td>
72
+ <td data-pdf-hide>
73
+ <SkipPhotoThumb :activity="activity" />
74
+ </td>
75
+ </tr>
76
+ <tr v-if="activity.status === 'Skipped'" data-pdf-only>
77
+ <td colspan="5" class="activity-table__skip">
78
+ <SkipPhotoDetails :activities="[activity]" inline />
79
+ </td>
65
80
  </tr>
81
+ </template>
66
82
  </tbody>
67
83
  </v-table>
68
84
  </div>
@@ -75,6 +91,8 @@ import type {
75
91
  NFCPatrolActivity,
76
92
  NFCPatrolActivityStatus,
77
93
  } from "../../../types/nfc-patrol-report";
94
+ import SkipPhotoDetails from "./SkipPhotoDetails.vue";
95
+ import SkipPhotoThumb from "./SkipPhotoThumb.vue";
78
96
 
79
97
  const worker = useWorkerTerm();
80
98
  defineProps<{
@@ -104,6 +122,10 @@ function handleActivityClick(activity: NFCPatrolActivity) {
104
122
  border-bottom: 1px solid var(--border);
105
123
  }
106
124
 
125
+ .activity-table__skip {
126
+ padding-top: 0 !important;
127
+ }
128
+
107
129
  .cursor-pointer {
108
130
  cursor: pointer;
109
131
  }
@@ -0,0 +1,195 @@
1
+ <!--
2
+ Every patrol run of one month of the yearly report, opened from its month's
3
+ row: by day, then by scheduled start. A run opens the same Patrol Route
4
+ Details dialog as the daily summary - checkpoints, remarks, skip photos.
5
+
6
+ The month's totals sit in the title row so the list can be checked against
7
+ the yearly table it came from.
8
+
9
+ Ten runs a page, paged the way the app's tables are ("1-10 of 124" and the
10
+ two arrows). The month is already loaded, so paging asks the server nothing.
11
+ -->
12
+ <template>
13
+ <v-dialog
14
+ :model-value="modelValue"
15
+ max-width="960"
16
+ @update:model-value="emit('update:modelValue', $event)"
17
+ >
18
+ <v-card class="screen-modal">
19
+ <div class="month-dialog__head">
20
+ <div>
21
+ <v-card-title class="pa-0">{{ title }}</v-card-title>
22
+ <p class="month-dialog__sub">
23
+ {{ routeName }} · {{ rows.length }} {{ rows.length === 1 ? "patrol" : "patrols" }} ·
24
+ {{ checked }} checked · {{ missed }} missed
25
+ </p>
26
+ </div>
27
+ <v-btn
28
+ class="screen-modal__close"
29
+ icon="mdi-close"
30
+ variant="flat"
31
+ aria-label="Close"
32
+ @click="close"
33
+ />
34
+ </div>
35
+
36
+ <div class="screen-modal__body px-6 pb-6">
37
+ <div class="table-card table-card__shrink">
38
+ <div class="month-dialog__pager">
39
+ <span class="table-card__range">{{ pageRange }}</span>
40
+ <v-btn
41
+ icon="mdi-chevron-left"
42
+ variant="text"
43
+ density="comfortable"
44
+ aria-label="Previous page"
45
+ :disabled="page <= 1"
46
+ @click="page--"
47
+ />
48
+ <v-btn
49
+ icon="mdi-chevron-right"
50
+ variant="text"
51
+ density="comfortable"
52
+ aria-label="Next page"
53
+ :disabled="page >= pages"
54
+ @click="page++"
55
+ />
56
+ </div>
57
+ <v-table>
58
+ <thead>
59
+ <tr>
60
+ <th>Date</th>
61
+ <th>Security Check Schedule</th>
62
+ <th>Security Personnel</th>
63
+ <th>Checked</th>
64
+ <th>Missed</th>
65
+ <th>Status</th>
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ <tr
70
+ v-for="row in pageRows"
71
+ :key="row.id"
72
+ class="month-dialog__row"
73
+ tabindex="0"
74
+ title="View patrol route details"
75
+ @click="open(row)"
76
+ @keydown.enter="open(row)"
77
+ >
78
+ <td class="text-no-wrap">{{ formatReportDate(row.date ?? "") }}</td>
79
+ <td>
80
+ <v-icon size="small" class="mr-1">mdi-clock-outline</v-icon>
81
+ {{ row.securityCheckSchedule }}
82
+ </td>
83
+ <td>{{ row.guardName || "-" }}</td>
84
+ <td>{{ row.checked }}</td>
85
+ <td>{{ row.missed }}</td>
86
+ <td>
87
+ <span :class="row.status === 'Completed' ? 'text-success' : 'text-error'">
88
+ {{ row.status }}
89
+ </span>
90
+ </td>
91
+ </tr>
92
+ </tbody>
93
+ </v-table>
94
+ </div>
95
+ </div>
96
+
97
+ <div class="screen-modal__footer">
98
+ <v-btn variant="outlined" class="screen-btn-ghost" @click="close">Close</v-btn>
99
+ </div>
100
+
101
+ <PatrolRouteDetailsDialog v-model="detailsOpen" :row="selected" />
102
+ </v-card>
103
+ </v-dialog>
104
+ </template>
105
+
106
+ <script setup lang="ts">
107
+ import type { NFCPatrolReportSummaryRow } from "../../../types/nfc-patrol-report";
108
+ import { formatReportDate } from "../../../utils/report-export";
109
+ import PatrolRouteDetailsDialog from "./PatrolRouteDetailsDialog.vue";
110
+
111
+ const props = defineProps<{
112
+ modelValue: boolean;
113
+ /** "October 2026" */
114
+ title: string;
115
+ routeName: string;
116
+ rows: NFCPatrolReportSummaryRow[];
117
+ }>();
118
+
119
+ const emit = defineEmits<{
120
+ "update:modelValue": [value: boolean];
121
+ }>();
122
+
123
+ const PAGE_SIZE = 10;
124
+ const page = ref(1);
125
+ const pages = computed(() => Math.max(1, Math.ceil(props.rows.length / PAGE_SIZE)));
126
+ const pageRows = computed(() => props.rows.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE));
127
+ const pageRange = computed(() => {
128
+ const total = props.rows.length;
129
+ if (!total) return "0 of 0";
130
+ const from = (page.value - 1) * PAGE_SIZE + 1;
131
+ return `${from}-${Math.min(total, page.value * PAGE_SIZE)} of ${total}`;
132
+ });
133
+
134
+ // Another month opens on its first page.
135
+ watch(
136
+ () => props.rows,
137
+ () => {
138
+ page.value = 1;
139
+ },
140
+ );
141
+
142
+ const checked = computed(() => props.rows.reduce((sum, row) => sum + row.checked, 0));
143
+ const missed = computed(() => props.rows.reduce((sum, row) => sum + row.missed, 0));
144
+
145
+ const detailsOpen = ref(false);
146
+ const selected = ref<NFCPatrolReportSummaryRow | null>(null);
147
+
148
+ function open(row: NFCPatrolReportSummaryRow) {
149
+ selected.value = row;
150
+ detailsOpen.value = true;
151
+ }
152
+
153
+ function close() {
154
+ detailsOpen.value = false;
155
+ emit("update:modelValue", false);
156
+ }
157
+ </script>
158
+
159
+ <style scoped>
160
+ .month-dialog__head {
161
+ display: flex;
162
+ align-items: flex-start;
163
+ justify-content: space-between;
164
+ gap: 12px;
165
+ padding: 20px 24px;
166
+ }
167
+
168
+ .month-dialog__sub {
169
+ margin: 4px 0 0;
170
+ font-size: var(--fs-cell);
171
+ color: var(--text2);
172
+ }
173
+
174
+ .month-dialog__pager {
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: flex-end;
178
+ gap: 2px;
179
+ padding: 6px 12px;
180
+ border-bottom: 1px solid var(--border);
181
+ }
182
+
183
+ .month-dialog__pager .table-card__range {
184
+ margin-right: 6px;
185
+ }
186
+
187
+ .month-dialog__row {
188
+ cursor: pointer;
189
+ }
190
+
191
+ .month-dialog__row:focus-visible {
192
+ outline: 2px solid var(--accent);
193
+ outline-offset: -2px;
194
+ }
195
+ </style>
@@ -4,7 +4,7 @@
4
4
  :model-value="filters"
5
5
  :options="options"
6
6
  @update:model-value="$emit('update:filters', $event)"
7
- @export="(type) => $emit('export', type)"
7
+ @export="(type) => $emit('export', type, csvSections())"
8
8
  />
9
9
  <div class="report-export">
10
10
  <v-divider :thickness="1" class="border-opacity-100" />
@@ -47,6 +47,8 @@ import type {
47
47
  NFCPatrolReportFilters,
48
48
  } from "../../../types/nfc-patrol-report";
49
49
  import { useNFCPatrolReport } from "../../../composables/useNFCPatrolReport";
50
+ import { patrolReportCsv } from "../../../utils/nfc-patrol-report";
51
+ import type { CsvSection } from "../../../utils/report-export";
50
52
  import PatrolActivityTable from "./PatrolActivityTable.vue";
51
53
  import PatrolRouteSummary from "./PatrolRouteSummary.vue";
52
54
  import RemarksDialog from "./RemarksDialog.vue";
@@ -63,7 +65,8 @@ const props = defineProps<{
63
65
 
64
66
  defineEmits<{
65
67
  "update:filters": [value: NFCPatrolReportFilters];
66
- export: [type: "pdf" | "csv"];
68
+ /** `csv` is the report's tables, built from its data rather than read off the screen. */
69
+ export: [type: "pdf" | "csv", csv: CsvSection[]];
67
70
  }>();
68
71
 
69
72
  const {
@@ -77,6 +80,12 @@ const {
77
80
  openRemarksDialog,
78
81
  } = useNFCPatrolReport(props.filters, props.site);
79
82
 
83
+ const { getFileUrl } = useFile();
84
+
85
+ function csvSections() {
86
+ return report.value ? patrolReportCsv(report.value, getFileUrl) : [];
87
+ }
88
+
80
89
  watch(
81
90
  () => [props.filters.route, props.filters.timeRange, props.filters.date, props.active],
82
91
  () => {