@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.
- 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/DashboardMain.vue +7 -7
- 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/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/nav-sections.ts +1 -0
- package/utils/nfc-patrol-report.ts +239 -1
- package/utils/nfc-patrol-schedule.ts +73 -0
- 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 =
|
|
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,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
|
+
}
|