@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.
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/Nfc/NFCPatrolReportMain.vue +61 -72
  7. package/components/Nfc/NFCPatrolRouteMain.vue +21 -31
  8. package/components/Nfc/NFCRouteScheduleTimes.vue +83 -0
  9. package/components/Nfc/PatrolReport/DailyReportTab.vue +24 -3
  10. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +32 -3
  11. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +29 -1
  12. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +24 -2
  13. package/components/Nfc/PatrolReport/PatrolMonthDialog.vue +195 -0
  14. package/components/Nfc/PatrolReport/PatrolReportTab.vue +11 -2
  15. package/components/Nfc/PatrolReport/PatrolRouteDetailsDialog.vue +214 -0
  16. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +4 -1
  17. package/components/Nfc/PatrolReport/SkipPhotoDetails.vue +137 -0
  18. package/components/Nfc/PatrolReport/SkipPhotoThumb.vue +132 -0
  19. package/components/Nfc/PatrolReport/SummaryReportTable.vue +42 -1
  20. package/components/OnlineFormFill.vue +11 -0
  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/nfc-patrol-report.ts +239 -1
  31. package/utils/nfc-patrol-schedule.ts +73 -0
  32. package/utils/online-form-submission.ts +48 -0
  33. package/utils/report-export.ts +120 -0
@@ -8,6 +8,22 @@
8
8
  * client. The page walk and the month mapping live here so they have tests.
9
9
  */
10
10
 
11
+ import type {
12
+ NFCPatrolActivity,
13
+ NFCPatrolMonthlyReport,
14
+ NFCPatrolReport,
15
+ NFCPatrolReportSummaryRow,
16
+ NFCPatrolRunDetail,
17
+ NFCPatrolSummaryReport,
18
+ } from "../types/nfc-patrol-report.ts";
19
+ import type { CsvSection } from "./report-export.ts";
20
+ import {
21
+ actualPatrolSpan,
22
+ checkpointClock,
23
+ clockTime,
24
+ DEFAULT_PATROL_TIMEZONE,
25
+ } from "./nfc-patrol-time.ts";
26
+
11
27
  /** One page of the patrol-log endpoint's reply. */
12
28
  export type TPatrolLogPage = {
13
29
  items?: Record<string, any>[];
@@ -62,6 +78,11 @@ export async function collectAllPages(
62
78
  return items;
63
79
  }
64
80
 
81
+ /** The month (0-11) a patrol log counts under; NaN for a date that is not one. */
82
+ export function monthIndexOf(log: Record<string, any>) {
83
+ return new Date(log?.date).getMonth();
84
+ }
85
+
65
86
  /**
66
87
  * One row per calendar month. A month with no checkpoint due keeps its zero
67
88
  * counts - zero checkpoints really were checked - but reports `null` for both
@@ -80,7 +101,7 @@ export function mapLogsToMonthlyRows(
80
101
  }));
81
102
 
82
103
  for (const log of logs) {
83
- const monthIndex = new Date(log?.date).getMonth();
104
+ const monthIndex = monthIndexOf(log);
84
105
  if (Number.isNaN(monthIndex)) continue;
85
106
 
86
107
  for (const checkpoint of log?.checkPoints ?? []) {
@@ -99,3 +120,220 @@ export function mapLogsToMonthlyRows(
99
120
 
100
121
  return rows;
101
122
  }
123
+
124
+ function formatDuration(totalMinutes: number) {
125
+ if (totalMinutes < 60) return `${totalMinutes} mins`;
126
+
127
+ const hrs = Math.floor(totalMinutes / 60);
128
+ const mins = totalMinutes % 60;
129
+
130
+ if (mins === 0) return `${hrs} hrs`;
131
+ return `${hrs} hrs ${mins} mins`;
132
+ }
133
+
134
+ function diffMinutes(startIso?: string | Date, endIso?: string | Date) {
135
+ if (!startIso || !endIso) return "-";
136
+ const totalMinutes = Math.max(
137
+ 0,
138
+ Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000),
139
+ );
140
+ return formatDuration(totalMinutes);
141
+ }
142
+
143
+ /**
144
+ * One patrol run as a report shows it: the schedule-vs-actual pair and a row
145
+ * per checkpoint. The Patrol tab draws one run; the daily summary keeps one per
146
+ * row for its route details dialog.
147
+ */
148
+ export function mapLogToPatrolRun(
149
+ log: Record<string, any>,
150
+ siteTimezone?: string,
151
+ ): NFCPatrolRunDetail {
152
+ const checkpoints: Record<string, any>[] = log.checkPoints ?? [];
153
+ const timezone = log.timezone || siteTimezone || DEFAULT_PATROL_TIMEZONE;
154
+ const actual = actualPatrolSpan(log);
155
+
156
+ const activities: NFCPatrolActivity[] = checkpoints.map((cp) => {
157
+ const clock = checkpointClock(log, cp, siteTimezone);
158
+ return {
159
+ id: cp.nfcTag_id,
160
+ checkpoint: cp.nfcTag_name,
161
+ guardName: log.sign?.guardName,
162
+ guardSignatureFileId: log.sign?.guardSignatureFileId,
163
+ startTime: clock.start,
164
+ endTime: clock.end,
165
+ status: cp.status,
166
+ remarks: cp.skippedRemarks && cp.skippedRemarks !== "N/A" ? cp.skippedRemarks : undefined,
167
+ skippedPhotoFileId: cp.skippedPhotoFileId ? String(cp.skippedPhotoFileId) : undefined,
168
+ };
169
+ });
170
+
171
+ return {
172
+ routeSummary: {
173
+ routeName: log.route?.name,
174
+ tolerance: Number(log.route?.tolerance) || 0,
175
+ schedule: {
176
+ start: log.route?.startTime,
177
+ end: log.route?.endTime,
178
+ totalTime: diffMinutes(log.startDateTime, log.endDateTime),
179
+ },
180
+ actual: {
181
+ start: clockTime(actual.start, timezone),
182
+ end: clockTime(actual.end, timezone),
183
+ totalTime: diffMinutes(actual.start, actual.end),
184
+ },
185
+ },
186
+ activities,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * One patrol run as a summary row: who patrolled, what was checked and
192
+ * missed, and the run itself for the route details dialog. `index` numbers
193
+ * the row ("1. WH01 Patrol Route").
194
+ */
195
+ export function mapLogToSummaryRow(
196
+ log: Record<string, any>,
197
+ index: number,
198
+ siteTimezone?: string,
199
+ ): NFCPatrolReportSummaryRow {
200
+ const checkpoints: Record<string, any>[] = log.checkPoints ?? [];
201
+ const checked = checkpoints.filter((cp) => cp.status === "Completed").length;
202
+ const missed = checkpoints.filter((cp) => cp.status === "Skipped").length;
203
+
204
+ return {
205
+ id: log._id,
206
+ date: log.date,
207
+ routeName: `${index + 1}. ${log.route?.name ?? "-"}`,
208
+ securityCheckSchedule: log.route?.startTime ?? "-",
209
+ guardName: log.sign?.guardName,
210
+ guardSignatureFileId: log.sign?.guardSignatureFileId,
211
+ checked,
212
+ missed,
213
+ status: missed === 0 ? "Completed" : "Incomplete",
214
+ detail: mapLogToPatrolRun(log, siteTimezone),
215
+ };
216
+ }
217
+
218
+ /**
219
+ * The patrol runs of one month of the yearly report, in the order they ran -
220
+ * by day, then by scheduled start. Counted under the same month as the yearly
221
+ * table counts them (`monthIndexOf`), so a month's list adds up to its row.
222
+ */
223
+ export function mapLogsToMonthRuns(
224
+ logs: Record<string, any>[],
225
+ monthIndex: number,
226
+ siteTimezone?: string,
227
+ ): NFCPatrolReportSummaryRow[] {
228
+ return logs
229
+ .filter((log) => monthIndexOf(log) === monthIndex)
230
+ .sort(
231
+ (a, b) =>
232
+ String(a.date).localeCompare(String(b.date)) ||
233
+ String(a.route?.startTime ?? "").localeCompare(String(b.route?.startTime ?? "")),
234
+ )
235
+ .map((log, index) => mapLogToSummaryRow(log, index, siteTimezone));
236
+ }
237
+
238
+ /** A file's URL, or "" when there is none. */
239
+ type FileUrl = (id: string) => string;
240
+ const urlOf = (fileUrl: FileUrl, id?: string) => (id ? fileUrl(id) : "");
241
+ const isSkipped = (activity: NFCPatrolActivity) => activity.status === "Skipped";
242
+
243
+ /**
244
+ * The Patrol report's CSV: schedule against actual, then every checkpoint -
245
+ * a skipped one with its remarks and the link to its photo on its own row.
246
+ */
247
+ export function patrolReportCsv(report: NFCPatrolReport, fileUrl: FileUrl): CsvSection[] {
248
+ const sections: CsvSection[] = [];
249
+ const summary = report.routeSummary;
250
+
251
+ if (summary) {
252
+ sections.push({
253
+ title: `Patrol Route Summary: ${summary.routeName} (tolerance ${summary.tolerance} mins)`,
254
+ header: ["Time", "Start", "End", "Total time"],
255
+ rows: [
256
+ ["Schedule", summary.schedule.start, summary.schedule.end, summary.schedule.totalTime],
257
+ ["Actual", summary.actual.start, summary.actual.end, summary.actual.totalTime],
258
+ ],
259
+ });
260
+ }
261
+
262
+ sections.push({
263
+ title: "Patrol Activity",
264
+ header: ["Checkpoint", "Security Personnel", "Signature", "Start", "End", "Status", "Skip Remarks", "Skip Photo"],
265
+ rows: report.activities.map((activity) => [
266
+ activity.checkpoint,
267
+ activity.guardName ?? "",
268
+ urlOf(fileUrl, activity.guardSignatureFileId),
269
+ activity.startTime,
270
+ activity.endTime,
271
+ activity.status,
272
+ isSkipped(activity) ? activity.remarks ?? "" : "",
273
+ urlOf(fileUrl, activity.skippedPhotoFileId),
274
+ ]),
275
+ });
276
+
277
+ return sections;
278
+ }
279
+
280
+ /**
281
+ * The Daily report's CSV: one row per patrol run, then one row per skipped
282
+ * checkpoint naming the run it belongs to - a day of many runs stays sorted
283
+ * by run, and filters by run, in a spreadsheet.
284
+ */
285
+ export function dailyReportCsv(report: NFCPatrolSummaryReport, fileUrl: FileUrl): CsvSection[] {
286
+ const sections: CsvSection[] = [
287
+ {
288
+ title: report.title,
289
+ header: ["Patrol Route Name", "Security Check Schedule", "Security Personnel", "Signature", "Checked", "Missed", "Status"],
290
+ rows: report.rows.map((row) => [
291
+ row.routeName,
292
+ row.securityCheckSchedule,
293
+ row.guardName ?? "",
294
+ urlOf(fileUrl, row.guardSignatureFileId),
295
+ String(row.checked),
296
+ String(row.missed),
297
+ row.status,
298
+ ]),
299
+ },
300
+ ];
301
+
302
+ const skips = report.rows.flatMap((row) =>
303
+ (row.detail?.activities ?? []).filter(isSkipped).map((activity) => [
304
+ row.routeName,
305
+ row.securityCheckSchedule,
306
+ activity.checkpoint,
307
+ activity.endTime,
308
+ activity.remarks ?? "",
309
+ urlOf(fileUrl, activity.skippedPhotoFileId),
310
+ ]),
311
+ );
312
+ if (skips.length) {
313
+ sections.push({
314
+ title: "Skipped Checkpoints",
315
+ header: ["Patrol Route Name", "Security Check Schedule", "Checkpoint", "Skipped At", "Skip Remarks", "Skip Photo"],
316
+ rows: skips,
317
+ });
318
+ }
319
+
320
+ return sections;
321
+ }
322
+
323
+ /** The Monthly report's CSV: the year's table as the screen shows it. */
324
+ export function monthlyReportCsv(report: NFCPatrolMonthlyReport): CsvSection[] {
325
+ const percent = (value: number | null) => (value === null ? "-" : `${value}%`);
326
+ return [
327
+ {
328
+ title: report.title,
329
+ header: ["Month", "Checked", "% Checked", "Missed", "% Missed"],
330
+ rows: report.rows.map((row) => [
331
+ row.month,
332
+ String(row.checked),
333
+ percent(row.checkedPercentage),
334
+ String(row.missed),
335
+ percent(row.missedPercentage),
336
+ ]),
337
+ },
338
+ ];
339
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A patrol route's schedule as a person reads it.
3
+ *
4
+ * The routes list printed its check windows as one comma-joined string in the
5
+ * order they were typed ("09:02-16:20, 09:01-14:40, 10:01-18:40, ...") and its
6
+ * days the same way ("Mon, Wed, Fri, Tue, Thu, Sat"). Here the windows come
7
+ * earliest first and say when one ends the next day, and the days come Monday
8
+ * first with a run of them written as a range.
9
+ */
10
+
11
+ /** One check window: when it opens, when it closes, and whether that is tomorrow. */
12
+ export type RouteWindow = { start: string; end: string; nextDay: boolean };
13
+
14
+ function toMinutes(clock: string) {
15
+ const match = /^(\d{1,2}):(\d{2})$/.exec(clock ?? "");
16
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
17
+ }
18
+
19
+ function toClock(minutes: number) {
20
+ const m = ((minutes % 1440) + 1440) % 1440;
21
+ return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
22
+ }
23
+
24
+ /**
25
+ * Each start time's window - start to start + tolerance, as the route form
26
+ * draws it - earliest first. A start that is not a time is kept as written,
27
+ * with no end, rather than dropped.
28
+ */
29
+ export function routeWindows(startTimes: string[] = [], tolerance: number | string = 0): RouteWindow[] {
30
+ const minutes = Number(tolerance) || 0;
31
+ return startTimes
32
+ .map((start) => {
33
+ const from = toMinutes(start);
34
+ if (from === null) return { start, end: "", nextDay: false };
35
+ return { start: toClock(from), end: toClock(from + minutes), nextDay: from + minutes >= 1440 };
36
+ })
37
+ .sort((a, b) => a.start.localeCompare(b.start));
38
+ }
39
+
40
+ /** The window as plain text, for a tooltip or a screen reader: "23:30 – 01:00 (next day)". */
41
+ export function routeWindowLabel(window: RouteWindow) {
42
+ return window.end ? `${window.start} – ${window.end}${window.nextDay ? " (next day)" : ""}` : window.start;
43
+ }
44
+
45
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
46
+ const MONDAY_FIRST = [1, 2, 3, 4, 5, 6, 0];
47
+
48
+ /**
49
+ * The days a route runs, Monday first: "Every day", a run of three or more
50
+ * as a range ("Mon – Fri"), anything else listed ("Mon, Wed, Sat").
51
+ * `days` are 0 (Sunday) to 6, as the route stores them.
52
+ */
53
+ export function formatRouteDays(days: number[] = []) {
54
+ const on = new Set(days.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
55
+ if (on.size === 0) return "";
56
+ if (on.size === 7) return "Every day";
57
+
58
+ const ordered = MONDAY_FIRST.filter((day) => on.has(day));
59
+ const parts: string[] = [];
60
+ for (let i = 0; i < ordered.length; ) {
61
+ let j = i;
62
+ while (
63
+ j + 1 < ordered.length &&
64
+ MONDAY_FIRST.indexOf(ordered[j + 1]) === MONDAY_FIRST.indexOf(ordered[j]) + 1
65
+ ) {
66
+ j++;
67
+ }
68
+ if (j - i >= 2) parts.push(`${DAY_NAMES[ordered[i]]} – ${DAY_NAMES[ordered[j]]}`);
69
+ else for (let k = i; k <= j; k++) parts.push(DAY_NAMES[ordered[k]]);
70
+ i = j + 1;
71
+ }
72
+ return parts.join(", ");
73
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * An application whose form template was deleted (owner 2026-10-06, option A):
3
+ * the review screen still shows what the resident sent, built from the
4
+ * submission alone. The submission stores answers by key only, so each key is
5
+ * turned into a readable label ("unitOwnerName" -> "Unit Owner Name").
6
+ */
7
+
8
+ /** Shown in the meta bar already, or a machine value - not repeated as an answer. */
9
+ const SKIP = new Set(["formDate", "formTo", "block", "level"]);
10
+
11
+ export function readableKey(key: string): string {
12
+ return key
13
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
14
+ .replace(/[_-]+/g, " ")
15
+ .replace(/\s+/g, " ")
16
+ .trim()
17
+ .replace(/\b\w/g, (c) => c.toUpperCase());
18
+ }
19
+
20
+ const isImage = (v: unknown) =>
21
+ typeof v === "string" && (/^data:image\//.test(v) || /^https?:\/\/\S+\.(png|jpe?g|webp)(\?|$)/i.test(v));
22
+
23
+ function asText(v: unknown): string {
24
+ if (v === true) return "Yes";
25
+ if (v === false) return "No";
26
+ if (Array.isArray(v)) return v.map(asText).join(", ");
27
+ if (v && typeof v === "object") return Object.entries(v).map(([k, x]) => `${readableKey(k)}: ${asText(x)}`).join(", ");
28
+ return v == null ? "" : String(v);
29
+ }
30
+
31
+ /** A read-only form definition + display values made from the submitted answers. */
32
+ export function definitionFromSubmission(title: string, fields: Record<string, unknown> = {}) {
33
+ const blocks: Array<Record<string, any>> = [];
34
+ const values: Record<string, unknown> = { ...fields };
35
+ for (const [key, value] of Object.entries(fields)) {
36
+ if (SKIP.has(key) || value === "" || value == null) continue;
37
+ if (isImage(value)) {
38
+ blocks.push({ type: "signature-row", label: readableKey(key), key });
39
+ continue;
40
+ }
41
+ const text = asText(value);
42
+ values[key] = text;
43
+ blocks.push({ type: "field", field: { key, label: readableKey(key), fieldType: text.length > 80 ? "textarea" : "text" } });
44
+ }
45
+ return { definition: { title, blocks }, values };
46
+ }
47
+
48
+ export const DELETED_TEMPLATE_NOTICE = "This form has since been deleted - showing the answers as submitted.";
@@ -0,0 +1,120 @@
1
+ /**
2
+ * What a report's PDF and CSV exports share: page planning for the PDF, the
3
+ * CSV file itself, and the dates and file names both are labelled with.
4
+ *
5
+ * Page planning for a report PDF.
6
+ *
7
+ * The report is drawn once, as one tall image, and laid onto A4 pages. It
8
+ * used to be cut every page-height wherever that fell - through a table row,
9
+ * through a photo - so a row's top half ended one page and its bottom half
10
+ * started the next. Here each page ends at the last place it can end cleanly
11
+ * (the bottom of a row, a photo or a section) and a page that carries on
12
+ * inside a table repeats that table's header row.
13
+ *
14
+ * Every measure is in the same unit (the report's CSS px, top = 0).
15
+ */
16
+
17
+ export type PdfRange = { top: number; bottom: number };
18
+
19
+ /** A table on the report: its whole extent and its header row. */
20
+ export type PdfTable = PdfRange & { head: PdfRange };
21
+
22
+ /** One page: the slice of the report it shows, and the header to repeat above it. */
23
+ export type PdfPage = PdfRange & { head?: PdfRange };
24
+
25
+ export function planPdfPages(
26
+ height: number,
27
+ pageHeight: number,
28
+ breaks: number[],
29
+ tables: PdfTable[] = [],
30
+ ): PdfPage[] {
31
+ const cuts = [...new Set(breaks)]
32
+ .filter((cut) => cut > 0 && cut < height)
33
+ .sort((a, b) => a - b);
34
+ const pages: PdfPage[] = [];
35
+ let top = 0;
36
+
37
+ while (height - top > 0.5) {
38
+ // A page that starts among a table's rows repeats the table's header -
39
+ // unless the header is so tall it would leave no room for the rows.
40
+ const table = tables.find((t) => top >= t.head.bottom && top < t.bottom);
41
+ const headHeight = table ? table.head.bottom - table.head.top : 0;
42
+ const head = table && headHeight < pageHeight / 2 ? table.head : undefined;
43
+ const limit = top + pageHeight - (head ? headHeight : 0);
44
+
45
+ let bottom = height;
46
+ if (limit < height) {
47
+ const fits = cuts.filter((cut) => cut > top && cut <= limit);
48
+ // Nothing ends on this page: a single block taller than a page is cut.
49
+ bottom = fits.length ? fits[fits.length - 1] : limit;
50
+ }
51
+
52
+ pages.push(head ? { top, bottom, head } : { top, bottom });
53
+ top = bottom;
54
+ }
55
+
56
+ return pages;
57
+ }
58
+
59
+ const SHORT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
60
+
61
+ /** "2026-10-07" as "7 Oct 2026"; anything else as it came. */
62
+ export function formatReportDate(date: string) {
63
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? "");
64
+ const month = match ? SHORT_MONTHS[Number(match[2]) - 1] : undefined;
65
+ return match && month ? `${Number(match[3])} ${month} ${match[1]}` : date;
66
+ }
67
+
68
+ /** "7 Oct 2026, 10:24": when an export was made. */
69
+ export function formatGeneratedAt(date = new Date()) {
70
+ return date.toLocaleString("en-GB", {
71
+ day: "numeric",
72
+ month: "short",
73
+ year: "numeric",
74
+ hour: "2-digit",
75
+ minute: "2-digit",
76
+ });
77
+ }
78
+
79
+ /** "Patrol Report - WH01 Patrol Route - 2026-10-07.pdf", minus what a file name cannot hold. */
80
+ export function reportFileName(parts: Array<string | undefined>, extension: "pdf" | "csv") {
81
+ const name = parts
82
+ .map((part) => (part ?? "").replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim())
83
+ .filter(Boolean)
84
+ .join(" - ");
85
+ return `${name || "report"}.${extension}`;
86
+ }
87
+
88
+ /** One table of a CSV export: a title line, a header row, the rows. */
89
+ export type CsvSection = { title: string; header: string[]; rows: string[][] };
90
+
91
+ /**
92
+ * One cell, quoted. A cell a spreadsheet would run as a formula - a guard's
93
+ * remarks starting "=" or "+", say - is kept as text with a leading
94
+ * apostrophe. A lone "-" is the reports' own blank, not a formula.
95
+ */
96
+ function csvCell(value: string | number | null | undefined) {
97
+ let text = value == null ? "" : String(value);
98
+ if (/^[=+\-@\t\r]/.test(text) && text !== "-") text = `'${text}`;
99
+ return `"${text.replace(/"/g, '""')}"`;
100
+ }
101
+
102
+ /**
103
+ * A report as CSV: its name and details (site, route, date...) on the first
104
+ * lines, then each table under its own title, a blank line between. With a
105
+ * byte order mark, so Excel reads it as UTF-8, and CRLF line ends (RFC 4180).
106
+ */
107
+ export function buildReportCsv(report: {
108
+ title: string;
109
+ details: Array<{ label: string; value?: string }>;
110
+ sections: CsvSection[];
111
+ }) {
112
+ const lines: Array<Array<string | undefined>> = [[report.title]];
113
+ for (const { label, value } of report.details) {
114
+ if (value) lines.push([label, value]);
115
+ }
116
+ for (const section of report.sections) {
117
+ lines.push([], [section.title], section.header, ...section.rows);
118
+ }
119
+ return `\uFEFF${lines.map((line) => line.map(csvCell).join(",")).join("\r\n")}\r\n`;
120
+ }