@7365admin1/layer-common 4.97.1-staging.586 → 4.97.1-staging.588

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.
@@ -6,7 +6,7 @@
6
6
  <v-card class="screen-modal qr-scanner">
7
7
  <div class="qr-scanner__head">
8
8
  <v-card-title>Scan QR Code</v-card-title>
9
- <AppButton variant="icon" icon="mdi-close" @click="closeDialog()" />
9
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeDialog()" />
10
10
  </div>
11
11
 
12
12
  <div class="screen-modal__body qr-scanner__body">
@@ -41,7 +41,7 @@
41
41
  fires, what it links to, or how long it stays; only how it is drawn.
42
42
  -->
43
43
  <v-snackbar
44
- v-if="!isUnregisteredVisitorRoute && enableUnregisteredSnackbar && canViewVisitor"
44
+ v-if="enableUnregisteredSnackbar && canViewVisitor"
45
45
  v-model="snackBarMessage.modal"
46
46
  :timeout="-1"
47
47
  location="bottom center"
@@ -75,8 +75,10 @@
75
75
  </div>
76
76
 
77
77
  <div class="anpr-toast__actions">
78
+ <!-- "Register" for a user who can register (Update right), as the
79
+ security mobile app; otherwise only through to the list. -->
78
80
  <AppButton variant="ghost" @click="goToUnregistered">
79
- Go to Unregistered
81
+ {{ canUpdateVisitor ? "Register" : "Go to Unregistered" }}
80
82
  </AppButton>
81
83
  </div>
82
84
  </v-snackbar>
@@ -131,6 +133,11 @@ const props = defineProps({
131
133
  type: Boolean,
132
134
  default: false,
133
135
  },
136
+ /** The visitor Update right: the notice's button reads "Register". */
137
+ canUpdateVisitor: {
138
+ type: Boolean,
139
+ default: false,
140
+ },
134
141
  });
135
142
 
136
143
  const config = useRuntimeConfig();
@@ -141,8 +148,36 @@ const { tab } = useVisitor();
141
148
  const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
142
149
  () => props.siteId
143
150
  );
144
- const enableUnregisteredSnackbar = computed(() => enableUnregistered.value);
145
- const { socketUnregisteredVisitorTrigger, visitorSocketData } =
151
+ // The security mobile app's rule (isecure365 `useUnregisteredTab`): the
152
+ // site's Unregistered switch AND an ANPR camera facing an entrance (entry /
153
+ // both). With the switch alone the notice could appear for a site whose
154
+ // cameras never register anyone arriving.
155
+ const { getAllSiteCameras } = useSiteSettings();
156
+ const hasEntryFacingAnprCamera = ref(false);
157
+
158
+ async function loadEntryFacingAnprCamera(siteId: string) {
159
+ hasEntryFacingAnprCamera.value = false;
160
+ if (!siteId) return;
161
+ try {
162
+ const res = await getAllSiteCameras({ site: siteId, type: "anpr", page: 1, limit: 100 });
163
+ if (siteId !== props.siteId) return;
164
+ const items = Array.isArray(res?.items)
165
+ ? res.items
166
+ : Array.isArray(res?.data?.items)
167
+ ? res.data.items
168
+ : [];
169
+ hasEntryFacingAnprCamera.value = items.some(
170
+ (camera: any) => camera?.direction === "entry" || camera?.direction === "both"
171
+ );
172
+ } catch {
173
+ hasEntryFacingAnprCamera.value = false;
174
+ }
175
+ }
176
+
177
+ const enableUnregisteredSnackbar = computed(
178
+ () => enableUnregistered.value && hasEntryFacingAnprCamera.value
179
+ );
180
+ const { socketUnregisteredVisitorTrigger, visitorSocketData, registerVisitorFormData } =
146
181
  useVisitorSocket();
147
182
 
148
183
  const APP_NAME = publicConfig.APP || "";
@@ -260,10 +295,10 @@ const showPlate = (plateNumber: string) => {
260
295
  }
261
296
 
262
297
  snackBarMessage.plateNumber = plateNumber;
298
+ // No timeout (security mobile app, 2026-10): it stays on every screen until
299
+ // it is closed or its button is used. A newer plate replaces it. (The
300
+ // window below is no longer started; its cancels are harmless.)
263
301
  snackBarMessage.modal = true;
264
- // Restart the 20s window from zero. A replacement plate gets a full window
265
- // of its own; without this it inherited whatever was left of the one before.
266
- plateToastWindow.restart();
267
302
  };
268
303
 
269
304
  const connectSocket = () => {
@@ -284,17 +319,27 @@ const connectSocket = () => {
284
319
 
285
320
  socket.value.on("anpr-messages", (data: TVisitorSocketData) => {
286
321
  const plateNumber = data?.plateNumber?.trim() || "";
287
- const isCheckIn = Boolean(plateNumber);
288
- const isPlateNumberChanging =
289
- Boolean(lastPlateNumber.value) && lastPlateNumber.value !== plateNumber;
322
+ const recordId = String(data?._id ?? "").trim();
290
323
 
291
- socketUnregisteredVisitorTrigger.value = false;
324
+ // Every message still reaches the visitor list (its reload).
292
325
  visitorSocketData.value = null;
293
326
  visitorSocketData.value = data;
294
327
  newVisitor.value = data;
295
328
 
296
- if (isCheckIn) {
297
- showPlate(plateNumber);
329
+ if (plateNumber) {
330
+ // An arrival. Only one with both the record id and the plate is kept for
331
+ // Register, and it drops any outstanding trigger; a read with no record
332
+ // has nothing to register and raises no notice (security mobile app).
333
+ // Nothing else on the feed touches the stored arrival.
334
+ if (recordId) {
335
+ registerVisitorFormData.value = {
336
+ _id: recordId,
337
+ plateNumber,
338
+ site: data?.site,
339
+ };
340
+ socketUnregisteredVisitorTrigger.value = false;
341
+ showPlate(plateNumber);
342
+ }
298
343
  lastPlateNumber.value = plateNumber;
299
344
  return;
300
345
  }
@@ -354,24 +399,27 @@ const goToUnregistered = async () => {
354
399
  });
355
400
  };
356
401
 
402
+ // A site switch, or leaving the signed-in layout (logout): the open notice and
403
+ // the stored arrival belong to the old site, so neither survives it.
404
+ const clearPlateNotice = () => {
405
+ snackBarMessage.modal = false;
406
+ registerVisitorFormData.value = null;
407
+ socketUnregisteredVisitorTrigger.value = false;
408
+ };
409
+
357
410
  watch(
358
411
  () => props.siteId,
359
- () => {
412
+ (siteId, previous) => {
413
+ if (previous !== undefined && siteId !== previous) clearPlateNotice();
360
414
  disconnectSocket();
361
415
  connectSocket();
416
+ void loadEntryFacingAnprCamera(siteId);
362
417
  },
363
418
  { immediate: true, deep: true }
364
419
  );
365
420
 
366
- watch(
367
- () => route.fullPath,
368
- () => {
369
- if (isUnregisteredVisitorRoute.value) {
370
- snackBarMessage.modal = false;
371
- }
372
- },
373
- { immediate: true }
374
- );
421
+ // The notice now also shows on the Unregistered tab (security mobile app):
422
+ // its button opens Register for the record there.
375
423
 
376
424
  watch(enableUnregisteredSnackbar, (enabled) => {
377
425
  if (!enabled) {
@@ -402,6 +450,7 @@ watch(
402
450
  );
403
451
 
404
452
  onUnmounted(() => {
453
+ clearPlateNotice();
405
454
  plateToastWindow.cancel();
406
455
  if (typeof document !== "undefined") {
407
456
  document.removeEventListener("keydown", onEscape);
@@ -3,6 +3,8 @@ import type {
3
3
  NFCPatrolReportFilters,
4
4
  } from "../types/nfc-patrol-report";
5
5
  import useNFCPatrolRoute from "./useNFCPatrolRoute";
6
+ import useNFCPatrolLog from "./useNFCPatrolLog";
7
+ import { collectAllPages } from "../utils/nfc-patrol-report";
6
8
 
7
9
  function getTodayDate() {
8
10
  const now = new Date();
@@ -13,6 +15,7 @@ function getTodayDate() {
13
15
  }
14
16
  export function useNFCPatrolReportFilters(site: string) {
15
17
  const { getAll: getPatrolRoutes } = useNFCPatrolRoute();
18
+ const { getAll: getPatrolLogs } = useNFCPatrolLog();
16
19
 
17
20
  const filters = reactive<NFCPatrolReportFilters>({
18
21
  route: "",
@@ -34,17 +37,25 @@ export function useNFCPatrolReportFilters(site: string) {
34
37
  // Deleted routes too, labelled, so their past patrols can still be
35
38
  // reported on. An API from before route deletion refuses the parameter
36
39
  // with a 400: ask again without it, and the list is what it was.
37
- const response = await getPatrolRoutes({
38
- site,
39
- page: 1,
40
- limit: 100,
41
- includeDeleted: true,
42
- }).catch((error: any) => {
43
- const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
44
- if (status !== 400) throw error;
45
- return getPatrolRoutes({ site, page: 1, limit: 100 });
46
- });
47
- const items: Array<Record<string, any>> = response.items ?? [];
40
+ // Every page: a site may have more than 100 routes (deleted ones count).
41
+ const allRoutes = (includeDeleted: boolean) =>
42
+ collectAllPages(
43
+ (page) =>
44
+ getPatrolRoutes({
45
+ site,
46
+ page,
47
+ limit: 100,
48
+ ...(includeDeleted ? { includeDeleted: true } : {}),
49
+ }) as any,
50
+ 1000,
51
+ );
52
+ const items: Array<Record<string, any>> = await allRoutes(true).catch(
53
+ (error: any) => {
54
+ const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
55
+ if (status !== 400) throw error;
56
+ return allRoutes(false);
57
+ },
58
+ );
48
59
  const isDeleted = (route: Record<string, any>) => route.status === "Deleted";
49
60
  // Routes still in use first, so the report opens on one of them.
50
61
  const routes = [
@@ -54,6 +65,7 @@ export function useNFCPatrolReportFilters(site: string) {
54
65
  title: isDeleted(route) ? `${route.name} (deleted)` : route.name,
55
66
  value: route._id,
56
67
  startTimes: route.startTimes ?? [],
68
+ timed: route.timed !== false,
57
69
  }));
58
70
 
59
71
  options.value.routes = routes;
@@ -62,22 +74,50 @@ export function useNFCPatrolReportFilters(site: string) {
62
74
  filters.route = routes[0]?.value ?? "";
63
75
  }
64
76
 
65
- updateStartTimes();
77
+ await updateStartTimes();
66
78
  } finally {
67
79
  loading.value = false;
68
80
  }
69
81
  }
70
82
 
71
- function updateStartTimes() {
83
+ let startTimesRequest = 0;
84
+ async function updateStartTimes() {
72
85
  const route = options.value.routes.find(({ value }) => value === filters.route);
73
- options.value.timeRanges = route?.startTimes ?? [];
86
+ const request = ++startTimesRequest;
87
+
88
+ // An untimed route has no start times: its rounds start whenever the guard
89
+ // starts them, so offer the start times of that day's rounds instead.
90
+ let times: string[] = route?.startTimes ?? [];
91
+ if (route && route.timed === false && filters.date) {
92
+ try {
93
+ const logs = await collectAllPages(
94
+ (page) =>
95
+ getPatrolLogs({
96
+ page,
97
+ limit: 100,
98
+ site,
99
+ date: filters.date,
100
+ routeId: route.value,
101
+ }) as any,
102
+ 1000,
103
+ );
104
+ times = [...new Set(logs.map((log) => String(log.route?.startTime ?? "")))]
105
+ .filter(Boolean)
106
+ .sort();
107
+ } catch {
108
+ times = [];
109
+ }
110
+ if (request !== startTimesRequest) return;
111
+ }
112
+
113
+ options.value.timeRanges = times;
74
114
 
75
115
  if (!options.value.timeRanges.includes(filters.timeRange)) {
76
116
  filters.timeRange = options.value.timeRanges[0] ?? "";
77
117
  }
78
118
  }
79
119
 
80
- watch(() => filters.route, updateStartTimes);
120
+ watch(() => [filters.route, filters.date], () => void updateStartTimes());
81
121
 
82
122
  return {
83
123
  filters,
@@ -32,11 +32,12 @@ export default function useNFCPatrolRoute() {
32
32
  function add(value: {
33
33
  name: string;
34
34
  checkPointNumber: number;
35
- tolerance: string;
35
+ tolerance: string | number;
36
36
  days: number[];
37
37
  startTimes: string[];
38
38
  checkPoints: Array<{ nfcTag_id: string }>;
39
39
  site: string;
40
+ timed?: boolean;
40
41
  }) {
41
42
  return useNuxtApp().$api<Record<string, any>>("/api/nfc-patrol-route", {
42
43
  method: "POST",
@@ -61,11 +62,12 @@ export default function useNFCPatrolRoute() {
61
62
  value: {
62
63
  name: string;
63
64
  checkPointNumber: number;
64
- tolerance: string;
65
+ tolerance: string | number;
65
66
  days: number[];
66
67
  startTimes: string[];
67
68
  checkPoints: Array<{ nfcTag_id: string }>;
68
69
  site: string;
70
+ timed?: boolean;
69
71
  }
70
72
  ) {
71
73
  return useNuxtApp().$api(`/api/nfc-patrol-route/id/${id}`, {
@@ -1,3 +1,10 @@
1
+ import {
2
+ VISITOR_PERSON_TYPES,
3
+ recordIdsOf,
4
+ visitorPeopleResults,
5
+ type VisitorPerson,
6
+ } from "../utils/visitor-people";
7
+
1
8
  export default function () {
2
9
  async function getAll({
3
10
  page = 1,
@@ -66,6 +73,51 @@ export default function () {
66
73
  });
67
74
  }
68
75
 
76
+ /**
77
+ * The visitor form's autofill lookup across EVERY visitor type, as the
78
+ * security mobile app (isecure365 `findAnyVisitorType`): one request with
79
+ * `type[]=guest&type[]=contractor&type[]=delivery`, the type being registered
80
+ * listed first, and one person on file under several types merged into one
81
+ * result. `walk-in` is looked up as `guest`.
82
+ */
83
+ async function findVisitorPeopleAnyType(
84
+ by: "nric" | "contact" | "plateNumber",
85
+ term: string,
86
+ site?: string,
87
+ preferType?: string
88
+ ): Promise<VisitorPerson[]> {
89
+ const res = await $fetch<any>(`/api/people`, {
90
+ method: "GET",
91
+ query: {
92
+ [by]: term,
93
+ ...(site ? { site } : {}),
94
+ "type[]": [...VISITOR_PERSON_TYPES],
95
+ },
96
+ });
97
+ const items: VisitorPerson[] = Array.isArray(res)
98
+ ? res
99
+ : Array.isArray(res?.items)
100
+ ? res.items
101
+ : Array.isArray(res?.data?.items)
102
+ ? res.data.items
103
+ : Array.isArray(res?.data)
104
+ ? res.data
105
+ : [];
106
+ const type = preferType === "walk-in" ? "guest" : preferType;
107
+ return visitorPeopleResults(items, type);
108
+ }
109
+
110
+ /**
111
+ * Deletes the People records behind one autofill result (a merged result is
112
+ * several): `PUT /api/people/:id`, a soft delete. For a person saved with a
113
+ * wrong NRIC; past visits are not touched.
114
+ */
115
+ async function deletePersonRecords(person: VisitorPerson) {
116
+ for (const id of recordIdsOf(person)) {
117
+ await deleteById(id);
118
+ }
119
+ }
120
+
69
121
  async function findPersonByContact(
70
122
  contact: string
71
123
  ): Promise<null | Partial<TPeople>> {
@@ -164,6 +216,8 @@ export default function () {
164
216
  findPersonByNRICMultipleResult,
165
217
  findPersonByPhoneNumberMultipleResult,
166
218
  findPersonByPlateNumberMultipleResult,
219
+ findVisitorPeopleAnyType,
220
+ deletePersonRecords,
167
221
  findPersonByContact,
168
222
  getPeopleByUnit,
169
223
  getOccupiedStructure,
@@ -4,11 +4,13 @@ export default function () {
4
4
  value: TVisitorType;
5
5
  };
6
6
 
7
+ // The Add type picker, in iSecure's order and words (`VISITOR_FORM_TYPES` /
8
+ // `VISITOR_FORM_TITLES`). The values are the stored types - never rename.
7
9
  const visitorSelection: TVisitorSelection[] = [
8
- { label: "Drive-in", value: "guest" },
10
+ { label: "Guest", value: "guest" },
9
11
  { label: "Contractor", value: "contractor" },
10
- { label: "Walk-In", value: "walk-in" },
11
12
  { label: "Delivery", value: "delivery" },
13
+ { label: "Walk-In", value: "walk-in" },
12
14
  { label: "Pick-Up", value: "pick-up" },
13
15
  { label: "Drop-Off", value: "drop-off" },
14
16
  ];
@@ -64,7 +64,25 @@ export const plateToast = (
64
64
  };
65
65
  };
66
66
 
67
+ /**
68
+ * The last vehicle ARRIVAL the ANPR camera read, kept so the plate notice's
69
+ * Register / "Go to Unregistered" opens the Register flow for THAT record.
70
+ * As the security mobile app (isecure365 `anpr-unregistered.ts`): only a read
71
+ * with both the record id and the plate is kept, and nothing else on the feed
72
+ * (a camera alert, a reload) replaces it.
73
+ */
74
+ export type TRegisterVisitorFormData = {
75
+ /** The unregistered record the camera created - the one the form updates. */
76
+ _id: string;
77
+ plateNumber: string;
78
+ site?: string;
79
+ };
80
+
67
81
  export const useVisitorSocket = () => {
82
+ const registerVisitorFormData = useState(
83
+ "registerVisitorFormData",
84
+ (): TRegisterVisitorFormData | null => null,
85
+ );
68
86
  const socketUnregisteredVisitorTrigger = useState(
69
87
  "socketUnregisteredVisitorTrigger",
70
88
  () => false,
@@ -76,6 +94,7 @@ export const useVisitorSocket = () => {
76
94
 
77
95
 
78
96
  return {
97
+ registerVisitorFormData,
79
98
  socketUnregisteredVisitorTrigger,
80
99
  visitorSocketData,
81
100
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.97.1-staging.586",
5
+ "version": "4.97.1-staging.588",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -69,6 +69,7 @@
69
69
  "sass": "^1.80.6",
70
70
  "sip.js": "0.21.2",
71
71
  "socket.io-client": "^4.8.3",
72
+ "tesseract.js": "^5.1.1",
72
73
  "vue-draggable-next": "^2.3.0",
73
74
  "vue3-signature": "^0.2.4",
74
75
  "vuetify-pro-tiptap": "^2.8.2",
@@ -1,6 +1,6 @@
1
1
  export type NFCPatrolReportTab = "patrol" | "daily" | "monthly";
2
2
 
3
- export type NFCPatrolActivityStatus = "Completed" | "Skipped";
3
+ export type NFCPatrolActivityStatus = "Completed" | "Skipped" | "Pending";
4
4
 
5
5
  export interface NFCPatrolReportFilters {
6
6
  route: string;
@@ -17,6 +17,8 @@ export interface NFCPatrolReportRouteOption {
17
17
  title: string;
18
18
  value: string;
19
19
  startTimes: string[];
20
+ /** false = route timing switched off (rounds start any time). */
21
+ timed?: boolean;
20
22
  }
21
23
 
22
24
  export interface NFCPatrolReportFilterOptions {
@@ -69,7 +71,7 @@ export interface NFCPatrolReport {
69
71
  activities: NFCPatrolActivity[];
70
72
  }
71
73
 
72
- export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete";
74
+ export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete" | "In progress";
73
75
 
74
76
  export interface NFCPatrolReportSummaryRow {
75
77
  id: string;
@@ -78,6 +78,30 @@ export async function collectAllPages(
78
78
  return items;
79
79
  }
80
80
 
81
+ /** A round of a route whose timing is switched off: no window, never late. */
82
+ export function isUntimedLog(log: Record<string, any>) {
83
+ return log?.route?.timed === false;
84
+ }
85
+
86
+ /**
87
+ * Was this checkpoint missed? A skip always is. A checkpoint still Pending
88
+ * once its timed round's window has closed is too: nothing on the server ever
89
+ * moves it off Pending, so a round the guard walked away from used to read
90
+ * "Completed, 0 missed". A round of an untimed route has no window and is
91
+ * never missed for time.
92
+ */
93
+ export function isMissedCheckpoint(
94
+ log: Record<string, any>,
95
+ checkpoint: Record<string, any>,
96
+ now: Date = new Date(),
97
+ ) {
98
+ if (checkpoint?.status === "Skipped") return true;
99
+ if (checkpoint?.status === "Completed") return false;
100
+ if (isUntimedLog(log)) return false;
101
+ const end = new Date(log?.endDateTime).getTime();
102
+ return Number.isFinite(end) && now.getTime() > end;
103
+ }
104
+
81
105
  /** The month (0-11) a patrol log counts under; NaN for a date that is not one. */
82
106
  export function monthIndexOf(log: Record<string, any>) {
83
107
  return new Date(log?.date).getMonth();
@@ -91,6 +115,7 @@ export function monthIndexOf(log: Record<string, any>) {
91
115
  */
92
116
  export function mapLogsToMonthlyRows(
93
117
  logs: Record<string, any>[],
118
+ now: Date = new Date(),
94
119
  ): TPatrolMonthRow[] {
95
120
  const rows: TPatrolMonthRow[] = MONTHS.map((month) => ({
96
121
  month,
@@ -106,7 +131,7 @@ export function mapLogsToMonthlyRows(
106
131
 
107
132
  for (const checkpoint of log?.checkPoints ?? []) {
108
133
  if (checkpoint?.status === "Completed") rows[monthIndex].checked++;
109
- else if (checkpoint?.status === "Skipped") rows[monthIndex].missed++;
134
+ else if (isMissedCheckpoint(log, checkpoint, now)) rows[monthIndex].missed++;
110
135
  }
111
136
  }
112
137
 
@@ -168,15 +193,20 @@ export function mapLogToPatrolRun(
168
193
  };
169
194
  });
170
195
 
196
+ const untimed = isUntimedLog(log);
197
+
171
198
  return {
172
199
  routeSummary: {
173
200
  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
- },
201
+ tolerance: untimed ? 0 : Number(log.route?.tolerance) || 0,
202
+ // An untimed round has no schedule to compare against.
203
+ schedule: untimed
204
+ ? { start: "Anytime", end: "-", totalTime: "-" }
205
+ : {
206
+ start: log.route?.startTime,
207
+ end: log.route?.endTime,
208
+ totalTime: diffMinutes(log.startDateTime, log.endDateTime),
209
+ },
180
210
  actual: {
181
211
  start: clockTime(actual.start, timezone),
182
212
  end: clockTime(actual.end, timezone),
@@ -196,21 +226,28 @@ export function mapLogToSummaryRow(
196
226
  log: Record<string, any>,
197
227
  index: number,
198
228
  siteTimezone?: string,
229
+ now: Date = new Date(),
199
230
  ): NFCPatrolReportSummaryRow {
200
231
  const checkpoints: Record<string, any>[] = log.checkPoints ?? [];
201
232
  const checked = checkpoints.filter((cp) => cp.status === "Completed").length;
202
- const missed = checkpoints.filter((cp) => cp.status === "Skipped").length;
233
+ const missed = checkpoints.filter((cp) => isMissedCheckpoint(log, cp, now)).length;
234
+ const allDone =
235
+ checkpoints.length > 0 &&
236
+ checkpoints.every((cp) => cp.status === "Completed" || cp.status === "Skipped");
237
+ const status = missed > 0 ? "Incomplete" : allDone ? "Completed" : "In progress";
203
238
 
204
239
  return {
205
240
  id: log._id,
206
241
  date: log.date,
207
242
  routeName: `${index + 1}. ${log.route?.name ?? "-"}`,
208
- securityCheckSchedule: log.route?.startTime ?? "-",
243
+ securityCheckSchedule: isUntimedLog(log)
244
+ ? `Anytime (started ${log.route?.startTime ?? "-"})`
245
+ : log.route?.startTime ?? "-",
209
246
  guardName: log.sign?.guardName,
210
247
  guardSignatureFileId: log.sign?.guardSignatureFileId,
211
248
  checked,
212
249
  missed,
213
- status: missed === 0 ? "Completed" : "Incomplete",
250
+ status,
214
251
  detail: mapLogToPatrolRun(log, siteTimezone),
215
252
  };
216
253
  }
@@ -71,3 +71,23 @@ export function formatRouteDays(days: number[] = []) {
71
71
  }
72
72
  return parts.join(", ");
73
73
  }
74
+
75
+ /**
76
+ * The route form's "Select all" for days: every day when not all are
77
+ * selected yet, none when they all are.
78
+ */
79
+ export function allDaysToggled(selected: number[], allDays: number[]): number[] {
80
+ const everyDay = allDays.every((day) => selected.includes(day));
81
+ return everyDay ? [] : [...allDays];
82
+ }
83
+
84
+ /**
85
+ * Why a checkpoint count is not usable, or null when it is. Any whole number
86
+ * from 1 up: a route takes as many NFC tags as the site has (no cap).
87
+ */
88
+ export function checkpointCountError(value: unknown): string | null {
89
+ const count = Number(value);
90
+ if (value === "" || value === null || value === undefined) return "This field is required";
91
+ if (!Number.isInteger(count) || count < 1) return "Enter a whole number of 1 or more";
92
+ return null;
93
+ }