@7365admin1/layer-common 4.97.1-staging.585 → 4.97.1-staging.587

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.
@@ -0,0 +1,70 @@
1
+ <template>
2
+ <!-- Seven365 Super Admin only: everyone else never sees Manpower set-up. -->
3
+ <v-expansion-panel v-if="isStaff" data-test="manpower-site-switch">
4
+ <v-expansion-panel-title>
5
+ <v-icon class="mr-2">mdi-chart-bar</v-icon>
6
+ Manpower
7
+ </v-expansion-panel-title>
8
+ <v-expansion-panel-text>
9
+ <v-switch v-model="enabled" label="Show Manpower at this site" color="primary" hide-details
10
+ data-test="manpower-site-switch-toggle" />
11
+ <p class="text-caption text-medium-emphasis mt-1 mb-3">
12
+ Manpower (HRMLabs staff attendance) is for security agencies only. When on, the agency's managers see
13
+ Manpower in the menu and the guard app at this site. Only Seven365 can switch this on or off.
14
+ Now: <strong>{{ savedOn ? "On" : "Off" }}</strong>{{ saved ? "" : " (not set - default)" }}.
15
+ </p>
16
+ <v-btn class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
17
+ :loading="saving" :disabled="!loaded" data-test="manpower-site-switch-save" @click="save" />
18
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
19
+ </v-expansion-panel-text>
20
+ </v-expansion-panel>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
25
+ import useConsoleTier from "../composables/useConsoleTier";
26
+
27
+ /**
28
+ * Site setting `metadata.manpower` (core site-feature-settings, owner
29
+ * 2026-10-21). Not set = as before: on only at the one site Manpower was
30
+ * pinned to (core `MANPOWER_LEGACY_SITE_ID`), off everywhere else. The server
31
+ * refuses a change by anyone but the Seven365 Super Admin (403) and enforces
32
+ * the switch on every Manpower route; this only draws the switch.
33
+ */
34
+ const LEGACY_SITE_ID = "6a4496f47521150c58977c68";
35
+
36
+ const props = defineProps({
37
+ siteId: { type: String, required: true },
38
+ saved: { type: Object as PropType<{ enabled?: boolean } | null>, default: null },
39
+ /** The site has been read; Save stays off until then. */
40
+ loaded: { type: Boolean, default: false },
41
+ });
42
+ const emit = defineEmits(["saved"]);
43
+
44
+ const { isStaff, load: loadTier } = useConsoleTier();
45
+ onMounted(() => loadTier());
46
+
47
+ const savedOn = computed(() =>
48
+ typeof props.saved?.enabled === "boolean" ? props.saved.enabled : props.siteId === LEGACY_SITE_ID,
49
+ );
50
+ const enabled = ref(false);
51
+ watch(savedOn, (v) => (enabled.value = v), { immediate: true });
52
+
53
+ const { saveSiteFeature } = useSiteFeatureSettings();
54
+ const saving = ref(false);
55
+ const toast = reactive({ show: false, message: "", color: "" });
56
+
57
+ async function save() {
58
+ if (!props.loaded || !isStaff.value) return;
59
+ saving.value = true;
60
+ try {
61
+ await saveSiteFeature(props.siteId, "manpower", { enabled: enabled.value });
62
+ Object.assign(toast, { show: true, message: "Saved.", color: "success" });
63
+ emit("saved");
64
+ } catch (e: any) {
65
+ Object.assign(toast, { show: true, message: e?.data?.message || "Could not save.", color: "error" });
66
+ } finally {
67
+ saving.value = false;
68
+ }
69
+ }
70
+ </script>
@@ -33,17 +33,21 @@
33
33
  <label class="text-caption screen-hint mb-1 d-block">
34
34
  Checkpoints <span class="text-error">*</span>
35
35
  </label>
36
- <v-select
37
- v-model="form.checkPointNumber"
38
- :items="checkpointOptions"
36
+ <!-- Any number of checkpoints (it was a 1-10 list). -->
37
+ <v-text-field
38
+ :model-value="form.checkPointNumber"
39
+ type="number"
40
+ min="1"
41
+ inputmode="numeric"
39
42
  variant="outlined"
40
43
  density="comfortable"
41
- hide-details
42
- @update:model-value="updateCheckpointsCount"
44
+ :rules="[rules.checkpointCount]"
45
+ hide-details="auto"
46
+ @update:model-value="onCheckpointCountInput"
43
47
  />
44
48
  </v-col>
45
49
 
46
- <v-col cols="12" md="4" class="mb-4 px-md-1">
50
+ <v-col v-if="form.timed" cols="12" md="4" class="mb-4 px-md-1">
47
51
  <label class="text-caption screen-hint mb-1 d-block">
48
52
  Tolerance (mins) <span class="text-error">*</span>
49
53
  </label>
@@ -74,11 +78,46 @@
74
78
  density="comfortable"
75
79
  hide-details
76
80
  chips
81
+ >
82
+ <template #prepend-item>
83
+ <v-list-item
84
+ :title="allDaysSelected ? 'Unselect all' : 'Select all'"
85
+ @click="toggleAllDays"
86
+ >
87
+ <template #prepend>
88
+ <v-checkbox-btn
89
+ :model-value="allDaysSelected"
90
+ :indeterminate="someDaysSelected"
91
+ @click.stop="toggleAllDays"
92
+ />
93
+ </template>
94
+ </v-list-item>
95
+ <v-divider class="mt-1" />
96
+ </template>
97
+ </v-select>
98
+ </v-col>
99
+
100
+ <!-- Route timing. Off: no start times or tolerance, and the route can be
101
+ patrolled at any time of the day. -->
102
+ <v-col cols="12" class="mb-4">
103
+ <v-switch
104
+ v-model="form.timed"
105
+ color="primary"
106
+ inset
107
+ hide-details
108
+ :label="form.timed ? 'Route timing: On' : 'Route timing: Off'"
77
109
  />
110
+ <div class="text-caption screen-hint">
111
+ {{
112
+ form.timed
113
+ ? "Patrols start at the route's start times, within the tolerance."
114
+ : "No time window: this route can be patrolled at any time."
115
+ }}
116
+ </div>
78
117
  </v-col>
79
118
 
80
119
  <!-- Start Times -->
81
- <v-col cols="12" class="mb-4">
120
+ <v-col v-if="form.timed" cols="12" class="mb-4">
82
121
  <v-card variant="outlined" class="pa-4" rounded="lg">
83
122
  <div class="d-flex align-center justify-space-between mb-3">
84
123
  <span class="text-h6 font-weight-medium">Start Times</span>
@@ -264,6 +303,8 @@
264
303
  <script setup lang="ts">
265
304
  import useNFCPatrolRoute from "../../composables/useNFCPatrolRoute";
266
305
  import useNFCPatrolTag from "../../composables/useNFCPatrolTag";
306
+ import { collectAllPages } from "../../utils/nfc-patrol-report";
307
+ import { allDaysToggled, checkpointCountError } from "../../utils/nfc-patrol-schedule";
267
308
 
268
309
  const props = defineProps({
269
310
  title: {
@@ -329,11 +370,12 @@ const form = ref({
329
370
  startTimes: ["08:00"] as string[],
330
371
  endTimes: ["09:00"] as string[],
331
372
  checkPoints: [] as Checkpoint[],
373
+ // Route timing; false = no time window (patrol at any time).
374
+ timed: true,
332
375
  });
333
376
 
334
377
  const startTimeKeys = ref<number[]>([Date.now()]);
335
378
 
336
- const checkpointOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
337
379
  const dayOptions = [
338
380
  { title: "Monday", value: 1 },
339
381
  { title: "Tuesday", value: 2 },
@@ -344,10 +386,29 @@ const dayOptions = [
344
386
  { title: "Sunday", value: 0 },
345
387
  ];
346
388
 
389
+ const allDaysSelected = computed(() => form.value.days.length === dayOptions.length);
390
+ const someDaysSelected = computed(
391
+ () => form.value.days.length > 0 && !allDaysSelected.value,
392
+ );
393
+
394
+ function toggleAllDays() {
395
+ form.value.days = allDaysToggled(
396
+ form.value.days,
397
+ dayOptions.map((day) => day.value),
398
+ );
399
+ }
400
+
401
+ function onCheckpointCountInput(value: string | number) {
402
+ const count = Number(value);
403
+ form.value.checkPointNumber = count;
404
+ if (Number.isInteger(count) && count >= 1) updateCheckpointsCount(count);
405
+ }
406
+
347
407
  const MAX_TOLERANCE_MINUTES = 60;
348
408
 
349
409
  const rules = {
350
410
  required: (v: any) => !!v || "This field is required",
411
+ checkpointCount: (v: any) => checkpointCountError(v) ?? true,
351
412
  tolerance: (v: any) => {
352
413
  if (!/^\d+$/.test(String(v ?? ""))) return "Numbers only";
353
414
  if (Number(v) > MAX_TOLERANCE_MINUTES)
@@ -374,8 +435,11 @@ const { getAll: getAllNFCTags } = useNFCPatrolTag();
374
435
 
375
436
  async function fetchNFCTags() {
376
437
  try {
377
- const response = await getAllNFCTags({ site: props.site, limit: 100 });
378
- nfcTags.value = response.items || [];
438
+ // Every page: a site can have any number of tags (it read the first 100).
439
+ nfcTags.value = await collectAllPages(
440
+ (page) => getAllNFCTags({ site: props.site, page, limit: 100 }) as any,
441
+ 1000,
442
+ );
379
443
  } catch (error) {
380
444
  console.error("Error fetching NFC tags:", error);
381
445
  }
@@ -554,6 +618,7 @@ async function initializeEditForm(newRoute: any) {
554
618
  form.value.startTimes = startTimes;
555
619
  form.value.endTimes = endTimes;
556
620
  form.value.checkPoints = existingCheckPoints;
621
+ form.value.timed = newRoute.timed !== false;
557
622
 
558
623
  // Reinitialize keys for each start time
559
624
  startTimeKeys.value = startTimes.map(() => Date.now() + Math.random());
@@ -598,14 +663,17 @@ async function submit() {
598
663
  ),
599
664
  ];
600
665
 
666
+ // Timing off keeps the tolerance as it was (the server requires one) but
667
+ // sends no start times: the route has no schedule.
601
668
  const payload = {
602
669
  name: form.value.name,
603
- checkPointNumber: form.value.checkPointNumber,
604
- tolerance: form.value.tolerance,
670
+ checkPointNumber: Number(form.value.checkPointNumber),
671
+ tolerance: Number(form.value.tolerance) || 0,
605
672
  days: form.value.days,
606
- startTimes: uniqueStartTimes,
673
+ startTimes: form.value.timed ? uniqueStartTimes : [],
607
674
  checkPoints: form.value.checkPoints,
608
675
  site: props.site,
676
+ timed: form.value.timed,
609
677
  };
610
678
 
611
679
  if (props.mode === "create") {
@@ -50,14 +50,15 @@
50
50
 
51
51
  <template #item.tolerance="{ item }">
52
52
  <div class="text-center">
53
- {{ item.tolerance ? `${item.tolerance} mins` : "--" }}
53
+ {{ item.timed && item.tolerance ? `${item.tolerance} mins` : "--" }}
54
54
  </div>
55
55
  </template>
56
56
 
57
57
  <!-- Earliest first, one unbreakable pill per window; past three the
58
58
  rest fold into "+N more" so a busy route keeps a normal row. -->
59
- <template #item.schedules="{ value }">
60
- <NFCRouteScheduleTimes :windows="value || []" :limit="3" />
59
+ <template #item.schedules="{ item, value }">
60
+ <span v-if="!item.timed">Anytime (timing off)</span>
61
+ <NFCRouteScheduleTimes v-else :windows="value || []" :limit="3" />
61
62
  </template>
62
63
 
63
64
  <template #item.repeatDays="{ value }">
@@ -132,7 +133,12 @@
132
133
  </span>
133
134
  </v-col>
134
135
 
135
- <v-col cols="12" class="mb-2" v-if="selectedRoute.tolerance">
136
+ <v-col cols="12" class="mb-2" v-if="selectedRoute.timed === false">
137
+ Security Check Schedule:
138
+ <span class="font-weight-bold">Anytime (timing off)</span>
139
+ </v-col>
140
+
141
+ <v-col cols="12" class="mb-2" v-if="selectedRoute.timed !== false && selectedRoute.tolerance">
136
142
  Tolerance:
137
143
  <span class="font-weight-bold">
138
144
  {{ selectedRoute.tolerance }} mins
@@ -149,7 +155,7 @@
149
155
  <v-col
150
156
  cols="12"
151
157
  class="mb-2"
152
- v-if="selectedRoute.schedules && selectedRoute.schedules.length"
158
+ v-if="selectedRoute.timed !== false && selectedRoute.schedules && selectedRoute.schedules.length"
153
159
  >
154
160
  <div class="mb-2">
155
161
  Security Check Schedule
@@ -376,6 +382,7 @@ watchEffect(() => {
376
382
  days: item.days,
377
383
  startTimes: item.startTimes || [],
378
384
  checkPoints: item.checkPoints || [],
385
+ timed: item.timed !== false,
379
386
  // Display fields for the table
380
387
  routeName: item.name,
381
388
  numberOfCheckpoints: item.checkPointNumber,
@@ -392,6 +399,11 @@ watchEffect(() => {
392
399
  }
393
400
  });
394
401
 
402
+ // A new search starts on page 1 (from page 2 or later it could show nothing).
403
+ watch(searchInput, () => {
404
+ page.value = 1;
405
+ });
406
+
395
407
  const dialogAdd = ref(false);
396
408
  const dialogEdit = ref(false);
397
409
  const dialogPreview = ref(false);
@@ -108,6 +108,7 @@ function skipped(row: NFCPatrolReportSummaryRow) {
108
108
  }
109
109
 
110
110
  function statusClass(status: NFCPatrolReportSummaryStatus) {
111
+ if (status === "In progress") return "text-warning";
111
112
  return status === "Completed" ? "text-success" : "text-error";
112
113
  }
113
114
  </script>
@@ -269,6 +269,9 @@
269
269
  </v-expansion-panel-text>
270
270
  </v-expansion-panel>
271
271
 
272
+ <ManpowerSiteSwitch v-if="showManpowerSwitch" :site-id="siteId"
273
+ :saved="(siteData as any)?.metadata?.manpower" :loaded="!!siteData" @saved="refreshSiteData" />
274
+
272
275
  <slot
273
276
  name="panels"
274
277
  :can-manage="canManageSiteSettings"
@@ -298,6 +301,8 @@ const props = defineProps({
298
301
  showResidentVisitors: { type: Boolean, default: false },
299
302
  /** Visitor registration, Passes & keys and Incident report emails: PM and Security only. */
300
303
  showSiteSwitches: { type: Boolean, default: false },
304
+ /** Manpower per-site switch (Seven365 Super Admin only): the Security host only. */
305
+ showManpowerSwitch: { type: Boolean, default: false },
301
306
  });
302
307
 
303
308
  const openPanels = ref([]); // default open first
@@ -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}`, {
@@ -17,6 +17,7 @@ export const SITE_FEATURE_KEYS = [
17
17
  "maStaffRegistration",
18
18
  "visitorPassSms",
19
19
  "robotSharedSpace",
20
+ "manpower",
20
21
  ] as const;
21
22
 
22
23
  export type TSiteFeatureKey = (typeof SITE_FEATURE_KEYS)[number];
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.585",
5
+ "version": "4.97.1-staging.587",
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.",
@@ -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
+ }