@7365admin1/layer-common 4.142.0 → 4.144.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.144.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b64093c: NFC patrol reports: the Monthly Report has a Year picker (this year and the five before it), so past years can be reported; each month's row still opens that month's patrols, and the export heading and file name use the picked year. Route form: the Route timing Off hint now says the route still runs only on its selected Days (use Select all for every day).
8
+ - a5acba6: Visitor list shows NFC card numbers (not database ids) for a visitor's extra cards; AreaMain / ScheduleAreaMain take `showAreaType` (default true) to hide the cleaning-only Common/Toilet type; AreaFormDialog `hideTypeField`; M&E unit import example; Blocks screen action labels say Block.
9
+
10
+ ## 4.143.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 87617ca: NFC patrol: route timing On/Off switch on the route form (Off = no start times or tolerance, patrol at any time; routes list and preview show "Anytime (timing off)"); "Select all / Unselect all" for the route's days; any number of checkpoints (was a 1-10 list) and every tag of the site offered (was the first 100); tolerance sent as a number. Reports: a checkpoint still Pending after its timed round's window closed counts as missed and the round reads "Incomplete" (was "Completed, 0 missed"), a round still open reads "In progress", untimed rounds are never missed for time and show "Anytime"; the Patrol tab offers an untimed route's rounds of the day; the report's route picker reads every page. Routes search returns to page 1.
15
+
3
16
  ## 4.142.0
4
17
 
5
18
  ### Minor Changes
@@ -3407,11 +3420,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3407
3420
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3408
3421
  own rule from two endpoints the console already calls, unprojected:
3409
3422
 
3410
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3411
- GET /api/roles/id/:role that membership's role document
3423
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3424
+ GET /api/roles/id/:role that membership's role document
3412
3425
 
3413
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3414
- staff = member.type === "admin" && role.type === "admin"
3426
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3427
+ staff = member.type === "admin" && role.type === "admin"
3415
3428
 
3416
3429
  `role.default` is the marker because it is the only property of a platform
3417
3430
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -37,7 +37,7 @@
37
37
  <v-card-text class="screen-modal__body">
38
38
  <v-form ref="formRef" v-model="valid">
39
39
  <v-row no-gutters class="pa-0">
40
- <v-col v-if="prop.showType" cols="12" class="pa-0 mb-2">
40
+ <v-col v-if="prop.showType && !prop.hideTypeField" cols="12" class="pa-0 mb-2">
41
41
  <InputLabel for="type" title="Type" required class="mb-1" />
42
42
  <v-select
43
43
  v-model="typeModel"
@@ -169,6 +169,9 @@ const prop = defineProps({
169
169
  label: { type: String, default: "Name" },
170
170
  entityType: { type: String, default: "Area" }, // values: "Area", "Unit"
171
171
  showType: { type: Boolean, default: true },
172
+ // Keeps the type in the saved payload (as loaded, or "Common") but does not
173
+ // offer the Common / Toilet choice - for apps where it means nothing.
174
+ hideTypeField: { type: Boolean, default: false },
172
175
  showOptional: { type: Boolean, default: true },
173
176
  showUnits: { type: Boolean, default: false },
174
177
  site: { type: String, default: "" },
@@ -17,7 +17,7 @@
17
17
  @create="onCreateItem"
18
18
  @row-click="handleRowClick"
19
19
  >
20
- <template #extension>
20
+ <template v-if="props.showAreaType" #extension>
21
21
  <v-row no-gutters class="w-100 d-flex flex-column">
22
22
  <!--
23
23
  A bare `v-card` here paints Vuetify's own `surface`, a slab a
@@ -138,6 +138,7 @@
138
138
  :label="formLabel"
139
139
  :entityType="entityTypeLabel"
140
140
  :showUnits="true"
141
+ :hideTypeField="!props.showAreaType"
141
142
  :site="props.site"
142
143
  :serviceType="props.serviceType"
143
144
  @saved="_createArea"
@@ -314,7 +315,9 @@
314
315
  title="Import Areas from Excel"
315
316
  noun="areas"
316
317
  name-label="Area"
317
- fill-hint="One area per row: its name, Type (Common or Toilet, from the dropdown), Set and the units to check, separated by commas."
318
+ :fill-hint="props.showAreaType
319
+ ? 'One area per row: its name, Type (Common or Toilet, from the dropdown), Set and the units to check, separated by commas.'
320
+ : 'One area per row: its name, Set and the units to check, separated by commas. Leave Type as Common.'"
318
321
  duplicate-note="Areas already on the list (same name and type) are skipped and left unchanged."
319
322
  template-file-name="areas-import-template.xlsx"
320
323
  :download-template="() => downloadTemplate('areas', importQuery)"
@@ -345,6 +348,10 @@ const props = defineProps({
345
348
  canUpdateArea: { type: Boolean, default: false },
346
349
  canDeleteArea: { type: Boolean, default: false },
347
350
  canImportArea: { type: Boolean, default: false },
351
+ // Common / Toilet is a cleaning distinction (Toilet areas get the toilet
352
+ // checklist QR). Apps without it hide the Type filter, column and field;
353
+ // the saved `type` value is untouched (new areas stay "common").
354
+ showAreaType: { type: Boolean, default: true },
348
355
  });
349
356
 
350
357
  const isCleanerArea = computed(() => {
@@ -403,7 +410,7 @@ const isDownloading = ref(false);
403
410
 
404
411
  const headers = [
405
412
  { title: "AREA", value: "name" },
406
- { title: "TYPE", value: "type" },
413
+ ...(props.showAreaType ? [{ title: "TYPE", value: "type" }] : []),
407
414
  { title: "SETS", value: "set" },
408
415
  ];
409
416
 
@@ -120,7 +120,7 @@
120
120
 
121
121
 
122
122
  <v-col cols="12" class="px-6">
123
- <InputLabel class="text-capitalize font-weight-bold" title="Building Floor plan" />
123
+ <InputLabel class="text-capitalize font-weight-bold" title="Block floor plan" />
124
124
  <InputFileV2 v-model="building.buildingFiles" :multiple="true" accept="*" :max-length="10"
125
125
  title="Upload Files" />
126
126
  </v-col>
@@ -97,13 +97,13 @@
97
97
  <v-list class="pa-0">
98
98
  <v-list-item v-if="canUpdateBuilding" @click="openEditDialog()">
99
99
  <v-list-item-title class="text-subtitle-2">
100
- Edit Building
100
+ Edit Block
101
101
  </v-list-item-title>
102
102
  </v-list-item>
103
103
 
104
104
  <v-list-item v-if="canDeleteBuilding" @click="openDeleteDialog()" class="text-red">
105
105
  <v-list-item-title class="text-subtitle-2">
106
- Delete Building
106
+ Delete Block
107
107
  </v-list-item-title>
108
108
  </v-list-item>
109
109
  </v-list>
@@ -144,7 +144,7 @@
144
144
  <v-col cols="6">
145
145
  <v-btn tile block size="48" color="black" variant="flat" class="text-none" @click="handleDeleteBuilding"
146
146
  :loading="deleteLoading">
147
- Delete Building
147
+ Delete Block
148
148
  </v-btn>
149
149
  </v-col>
150
150
  </v-row>
@@ -100,6 +100,7 @@ import {
100
100
  reportFileName,
101
101
  type CsvSection,
102
102
  } from "../../utils/report-export";
103
+ import { reportYear } from "../../utils/nfc-patrol-report";
103
104
 
104
105
  import PatrolReportTab from "./PatrolReport/PatrolReportTab.vue";
105
106
  import DailyReportTab from "./PatrolReport/DailyReportTab.vue";
@@ -137,6 +138,7 @@ function updateFilters(value: NFCPatrolReportFilters) {
137
138
  filters.route = value.route;
138
139
  filters.timeRange = value.timeRange;
139
140
  filters.date = value.date;
141
+ filters.year = value.year;
140
142
  }
141
143
 
142
144
  const { exportReportPdf } = useReportPdf();
@@ -147,10 +149,10 @@ function reportMeta(report: HTMLElement, extension: "pdf" | "csv"): ReportPdfMet
147
149
  const route = options.value.routes.find(({ value }) => value === filters.route)?.title;
148
150
  const text = (selector: string) => report.querySelector(selector)?.textContent?.trim() || undefined;
149
151
 
150
- // The yearly table always covers the current year (useNFCPatrolMonthlyReport).
152
+ // The yearly table covers the year picked on the Monthly Report.
151
153
  const period =
152
154
  activeTab.value === "monthly"
153
- ? { label: "Year", value: String(new Date().getFullYear()) }
155
+ ? { label: "Year", value: reportYear(filters.year) }
154
156
  : { label: "Date", value: formatReportDate(filters.date) };
155
157
  const details = [{ label: "Route", value: route ?? "-" }, period];
156
158
  if (activeTab.value === "patrol" && filters.timeRange) {
@@ -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: patrol at any time of day, on the selected Days only. Use Select all for every day."
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);
@@ -5,6 +5,7 @@
5
5
  :options="options"
6
6
  :show-time-filter="false"
7
7
  :show-date-filter="false"
8
+ show-year-filter
8
9
  @update:model-value="$emit('update:filters', $event)"
9
10
  @export="(type) => $emit('export', type, csvSections())"
10
11
  />
@@ -53,7 +54,7 @@ import type {
53
54
  } from "../../../types/nfc-patrol-report";
54
55
 
55
56
  import { useNFCPatrolMonthlyReport } from "../../../composables/useNFCPatrolMonthlyReport";
56
- import { monthlyReportCsv } from "../../../utils/nfc-patrol-report";
57
+ import { monthlyReportCsv, reportYear } from "../../../utils/nfc-patrol-report";
57
58
  import PatrolMonthDialog from "./PatrolMonthDialog.vue";
58
59
  import type { CsvSection } from "../../../utils/report-export";
59
60
 
@@ -84,8 +85,7 @@ const monthTitle = ref("");
84
85
  const monthRows = shallowRef<NFCPatrolReportSummaryRow[]>([]);
85
86
 
86
87
  function openMonth(monthIndex: number) {
87
- // The yearly report always covers the current year (useNFCPatrolMonthlyReport).
88
- monthTitle.value = `${report.value?.rows[monthIndex]?.month ?? ""} ${new Date().getFullYear()}`;
88
+ monthTitle.value = `${report.value?.rows[monthIndex]?.month ?? ""} ${reportYear(props.filters.year)}`;
89
89
  monthRows.value = monthRuns(monthIndex);
90
90
  monthOpen.value = true;
91
91
  }
@@ -95,7 +95,7 @@ function csvSections() {
95
95
  }
96
96
 
97
97
  watch(
98
- () => [props.filters.route, props.active],
98
+ () => [props.filters.route, props.filters.year, props.active],
99
99
  () => {
100
100
  if (props.active) {
101
101
  void fetchReport();
@@ -63,6 +63,25 @@
63
63
  />
64
64
  </v-col>
65
65
 
66
+ <!-- Monthly Report: which year. Each month's row then opens that month. -->
67
+ <v-col
68
+ v-if="showYearFilter"
69
+ cols="12"
70
+ md="2"
71
+ class="px-md-2 mb-2 mb-md-0"
72
+ >
73
+ <v-select
74
+ :model-value="reportYear(modelValue.year)"
75
+ :items="reportYears()"
76
+ label="Year"
77
+ class="filter-field"
78
+ variant="outlined"
79
+ density="comfortable"
80
+ hide-details
81
+ @update:model-value="updateFilter('year', $event)"
82
+ />
83
+ </v-col>
84
+
66
85
  <v-spacer />
67
86
 
68
87
  <v-col cols="12" md="2" class="pl-md-2">
@@ -99,6 +118,7 @@ import type {
99
118
  NFCPatrolReportFilters,
100
119
  } from "../../../types/nfc-patrol-report";
101
120
  import ReportDatePicker from "./ReportDatePicker.vue";
121
+ import { reportYear, reportYears } from "../../../utils/nfc-patrol-report";
102
122
 
103
123
  const props = withDefaults(
104
124
  defineProps<{
@@ -106,12 +126,14 @@ const props = withDefaults(
106
126
  options: NFCPatrolReportFilterOptions;
107
127
  showTimeFilter?: boolean;
108
128
  showDateFilter?: boolean;
129
+ showYearFilter?: boolean;
109
130
  }>(),
110
131
  {
111
132
  showTimeFilter: true,
112
133
  showDateFilter: true,
134
+ showYearFilter: false,
113
135
  },
114
- );;
136
+ );
115
137
 
116
138
  const emit = defineEmits<{
117
139
  "update:modelValue": [value: NFCPatrolReportFilters];
@@ -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>
@@ -56,6 +56,7 @@
56
56
  />
57
57
 
58
58
  <v-select
59
+ v-if="props.showAreaType"
59
60
  v-model="areaTypeFilter"
60
61
  :items="typeOptions"
61
62
  density="compact"
@@ -155,6 +156,9 @@ const props = defineProps({
155
156
  canGenerateChecklist: { type: Boolean, default: false },
156
157
  canViewHistory: { type: Boolean, default: false },
157
158
  canManageScheduleTasks: { type: Boolean, default: false },
159
+ // Common / Toilet is a cleaning distinction; apps without it hide the Type
160
+ // filter and column (saved `type` values are untouched).
161
+ showAreaType: { type: Boolean, default: true },
158
162
  });
159
163
 
160
164
  // "Daily Checklist – Cleaning" / "– Landscape" / ... (same title as the day list)
@@ -172,7 +176,7 @@ const pageRange = ref("-- - -- of --");
172
176
  const headers = [
173
177
  { title: "Area", value: "name" },
174
178
  { title: "Status", value: "status" },
175
- { title: "Type", value: "type" },
179
+ ...(props.showAreaType ? [{ title: "Type", value: "type" }] : []),
176
180
  { title: "Visits per day", value: "set" },
177
181
  ];
178
182
 
@@ -187,7 +187,9 @@
187
187
  title="Import Units from Excel"
188
188
  noun="units"
189
189
  name-label="Unit"
190
- fill-hint="One unit per row, e.g. Toilet Bowl, Sofa."
190
+ :fill-hint="/mechanical/i.test(props.serviceType)
191
+ ? 'One unit per row, e.g. Air-con Unit, Water Pump.'
192
+ : 'One unit per row, e.g. Toilet Bowl, Sofa.'"
191
193
  duplicate-note="Units already on the list are skipped and left unchanged."
192
194
  template-file-name="units-import-template.xlsx"
193
195
  :download-template="() => downloadTemplate('units', importQuery)"
@@ -368,6 +368,7 @@
368
368
  <v-menu
369
369
  v-if="item.accessCards?.type === 'NFC' && item.accessCards?.cardNo"
370
370
  :close-on-content-click="false"
371
+ @update:model-value="(open: boolean) => open && loadNfcCardNos(item)"
371
372
  >
372
373
  <template #activator="{ props: nfcMenuProps }">
373
374
  <div
@@ -400,7 +401,7 @@
400
401
  v-for="card in cardGroup.filter((c: any) => c._id !== item.accessCards._id)"
401
402
  :key="card._id"
402
403
  prepend-icon="mdi-credit-card-outline"
403
- :title="card._id"
404
+ :title="nfcCardLabel(card, item)"
404
405
  />
405
406
  </template>
406
407
  </v-list>
@@ -1272,7 +1273,7 @@
1272
1273
  <div class="d-flex align-center ga-2 mb-2">
1273
1274
  <v-icon size="18" icon="mdi-credit-card-outline" color="purple" />
1274
1275
  <v-chip size="small" variant="tonal" color="purple">{{
1275
- card.cardNo
1276
+ nfcCardNos[card.cardId] || card.cardNo
1276
1277
  }}</v-chip>
1277
1278
  </div>
1278
1279
  <v-select
@@ -1687,7 +1688,35 @@ const {
1687
1688
  visitorDataFromScannedQRCodeCheckIn,
1688
1689
  sendPassSms,
1689
1690
  } = useVisitor();
1690
- const { visitorCheckout } = useAccessManagement();
1691
+ const { visitorCheckout, getCardDetails } = useAccessManagement();
1692
+
1693
+ // A visitor's extra NFC cards are stored as ids only, so their card numbers
1694
+ // are read when the card list (or the return dialog) opens. Until then, or if
1695
+ // the card can't be read, a plain placeholder shows - never the database id.
1696
+ const nfcCardNos = reactive<Record<string, string>>({});
1697
+ function nfcCardLabel(card: any, visitor: any): string {
1698
+ const id = String(card?._id ?? "");
1699
+ if (card?.cardNo) return card.cardNo;
1700
+ if (id && id === String(visitor?.accessCards?._id ?? ""))
1701
+ return visitor.accessCards.cardNo;
1702
+ return nfcCardNos[id] || "Card number not available";
1703
+ }
1704
+ async function loadNfcCardNos(visitor: any) {
1705
+ const ids = ((visitor?.cards?.flat?.() ?? []) as any[])
1706
+ .filter((c) => c?._id && !c.cardNo && !nfcCardNos[String(c._id)])
1707
+ .map((c) => String(c._id))
1708
+ .filter((id) => id !== String(visitor?.accessCards?._id ?? ""));
1709
+ await Promise.all(
1710
+ ids.map(async (cardId) => {
1711
+ try {
1712
+ const res = await getCardDetails({ siteId, cardId });
1713
+ if (res?.data?.cardNo) nfcCardNos[cardId] = String(res.data.cardNo);
1714
+ } catch {
1715
+ // left as the placeholder
1716
+ }
1717
+ })
1718
+ );
1719
+ }
1691
1720
  const {
1692
1721
  debounce,
1693
1722
  formatCamelCaseToWords,
@@ -2903,10 +2932,7 @@ function handleCheckout(userId: string) {
2903
2932
  const allCards: any[] = visitor.cards?.flat() ?? [];
2904
2933
  nfcCardReturnStatuses.value = allCards.map((card: any) => ({
2905
2934
  cardId: card._id,
2906
- cardNo:
2907
- card._id === visitor.accessCards._id
2908
- ? visitor.accessCards.cardNo
2909
- : card._id,
2935
+ cardNo: nfcCardLabel(card, visitor),
2910
2936
  status: "In Use",
2911
2937
  remarks: "",
2912
2938
  }));
@@ -2920,6 +2946,7 @@ function handleCheckout(userId: string) {
2920
2946
  },
2921
2947
  ];
2922
2948
  }
2949
+ loadNfcCardNos(visitor);
2923
2950
  dialog.returnNfcCard = true;
2924
2951
  return;
2925
2952
  }
@@ -11,6 +11,7 @@ import {
11
11
  mapLogsToMonthlyRows,
12
12
  mapLogsToMonthRuns,
13
13
  monthIndexOf,
14
+ reportYear,
14
15
  } from "../utils/nfc-patrol-report";
15
16
 
16
17
  interface NFCPatrolLogListResponse {
@@ -53,7 +54,7 @@ export function useNFCPatrolMonthlyReport(
53
54
  */
54
55
  const failed = ref(false);
55
56
 
56
- // The year's patrol logs, kept so a month's row can open its list without
57
+ // The picked year's patrol logs, kept so a month's row can open its list without
57
58
  // asking the server again. Not deep-reactive: nothing edits them.
58
59
  const logs = shallowRef<Record<string, any>[]>([]);
59
60
  let siteTimezone: string | undefined;
@@ -70,7 +71,7 @@ export function useNFCPatrolMonthlyReport(
70
71
  loading.value = true;
71
72
  notFound.value = false;
72
73
  failed.value = false;
73
- const currentYear = new Date().getFullYear().toString();
74
+ const year = reportYear(filters.year);
74
75
  try {
75
76
  // The whole year, not page one of it. `collectAllPages` reads the page
76
77
  // count out of the first reply and walks the rest.
@@ -81,7 +82,8 @@ export function useNFCPatrolMonthlyReport(
81
82
  limit: 100,
82
83
  site,
83
84
  routeId: filters.route,
84
- date: currentYear,
85
+ // "YYYY" + type "month": the server returns that whole year.
86
+ date: year,
85
87
  type: "month",
86
88
  }),
87
89
  ),
@@ -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,11 +15,13 @@ 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: "",
19
22
  timeRange: "",
20
23
  date: getTodayDate(),
24
+ year: String(new Date().getFullYear()),
21
25
  });
22
26
 
23
27
  const options = ref<NFCPatrolReportFilterOptions>({
@@ -34,17 +38,25 @@ export function useNFCPatrolReportFilters(site: string) {
34
38
  // Deleted routes too, labelled, so their past patrols can still be
35
39
  // reported on. An API from before route deletion refuses the parameter
36
40
  // 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 ?? [];
41
+ // Every page: a site may have more than 100 routes (deleted ones count).
42
+ const allRoutes = (includeDeleted: boolean) =>
43
+ collectAllPages(
44
+ (page) =>
45
+ getPatrolRoutes({
46
+ site,
47
+ page,
48
+ limit: 100,
49
+ ...(includeDeleted ? { includeDeleted: true } : {}),
50
+ }) as any,
51
+ 1000,
52
+ );
53
+ const items: Array<Record<string, any>> = await allRoutes(true).catch(
54
+ (error: any) => {
55
+ const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
56
+ if (status !== 400) throw error;
57
+ return allRoutes(false);
58
+ },
59
+ );
48
60
  const isDeleted = (route: Record<string, any>) => route.status === "Deleted";
49
61
  // Routes still in use first, so the report opens on one of them.
50
62
  const routes = [
@@ -54,6 +66,7 @@ export function useNFCPatrolReportFilters(site: string) {
54
66
  title: isDeleted(route) ? `${route.name} (deleted)` : route.name,
55
67
  value: route._id,
56
68
  startTimes: route.startTimes ?? [],
69
+ timed: route.timed !== false,
57
70
  }));
58
71
 
59
72
  options.value.routes = routes;
@@ -62,22 +75,50 @@ export function useNFCPatrolReportFilters(site: string) {
62
75
  filters.route = routes[0]?.value ?? "";
63
76
  }
64
77
 
65
- updateStartTimes();
78
+ await updateStartTimes();
66
79
  } finally {
67
80
  loading.value = false;
68
81
  }
69
82
  }
70
83
 
71
- function updateStartTimes() {
84
+ let startTimesRequest = 0;
85
+ async function updateStartTimes() {
72
86
  const route = options.value.routes.find(({ value }) => value === filters.route);
73
- options.value.timeRanges = route?.startTimes ?? [];
87
+ const request = ++startTimesRequest;
88
+
89
+ // An untimed route has no start times: its rounds start whenever the guard
90
+ // starts them, so offer the start times of that day's rounds instead.
91
+ let times: string[] = route?.startTimes ?? [];
92
+ if (route && route.timed === false && filters.date) {
93
+ try {
94
+ const logs = await collectAllPages(
95
+ (page) =>
96
+ getPatrolLogs({
97
+ page,
98
+ limit: 100,
99
+ site,
100
+ date: filters.date,
101
+ routeId: route.value,
102
+ }) as any,
103
+ 1000,
104
+ );
105
+ times = [...new Set(logs.map((log) => String(log.route?.startTime ?? "")))]
106
+ .filter(Boolean)
107
+ .sort();
108
+ } catch {
109
+ times = [];
110
+ }
111
+ if (request !== startTimesRequest) return;
112
+ }
113
+
114
+ options.value.timeRanges = times;
74
115
 
75
116
  if (!options.value.timeRanges.includes(filters.timeRange)) {
76
117
  filters.timeRange = options.value.timeRanges[0] ?? "";
77
118
  }
78
119
  }
79
120
 
80
- watch(() => filters.route, updateStartTimes);
121
+ watch(() => [filters.route, filters.date], () => void updateStartTimes());
81
122
 
82
123
  return {
83
124
  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}`, {
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.142.0",
5
+ "version": "4.144.0",
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,11 +1,13 @@
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;
7
7
  timeRange: string;
8
8
  date: string;
9
+ /** The Monthly Report's year, "YYYY". Absent = the current year. */
10
+ year?: string;
9
11
  }
10
12
 
11
13
  export interface NFCPatrolReportExportRequest {
@@ -17,6 +19,8 @@ export interface NFCPatrolReportRouteOption {
17
19
  title: string;
18
20
  value: string;
19
21
  startTimes: string[];
22
+ /** false = route timing switched off (rounds start any time). */
23
+ timed?: boolean;
20
24
  }
21
25
 
22
26
  export interface NFCPatrolReportFilterOptions {
@@ -69,7 +73,7 @@ export interface NFCPatrolReport {
69
73
  activities: NFCPatrolActivity[];
70
74
  }
71
75
 
72
- export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete";
76
+ export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete" | "In progress";
73
77
 
74
78
  export interface NFCPatrolReportSummaryRow {
75
79
  id: string;
@@ -78,6 +78,45 @@ 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
+
105
+ /** The Monthly Report's year: the one picked, or the current year. */
106
+ export function reportYear(year?: string, now = new Date()) {
107
+ return /^\d{4}$/.test(year ?? "") ? (year as string) : String(now.getFullYear());
108
+ }
109
+
110
+ /**
111
+ * The years the Monthly Report offers, newest first: this year and the five
112
+ * before it.
113
+ */
114
+ // ponytail: fixed 6-year window; derive from the oldest log if sites need more.
115
+ export function reportYears(now = new Date(), count = 6) {
116
+ const current = now.getFullYear();
117
+ return Array.from({ length: count }, (_, i) => String(current - i));
118
+ }
119
+
81
120
  /** The month (0-11) a patrol log counts under; NaN for a date that is not one. */
82
121
  export function monthIndexOf(log: Record<string, any>) {
83
122
  return new Date(log?.date).getMonth();
@@ -91,6 +130,7 @@ export function monthIndexOf(log: Record<string, any>) {
91
130
  */
92
131
  export function mapLogsToMonthlyRows(
93
132
  logs: Record<string, any>[],
133
+ now: Date = new Date(),
94
134
  ): TPatrolMonthRow[] {
95
135
  const rows: TPatrolMonthRow[] = MONTHS.map((month) => ({
96
136
  month,
@@ -106,7 +146,7 @@ export function mapLogsToMonthlyRows(
106
146
 
107
147
  for (const checkpoint of log?.checkPoints ?? []) {
108
148
  if (checkpoint?.status === "Completed") rows[monthIndex].checked++;
109
- else if (checkpoint?.status === "Skipped") rows[monthIndex].missed++;
149
+ else if (isMissedCheckpoint(log, checkpoint, now)) rows[monthIndex].missed++;
110
150
  }
111
151
  }
112
152
 
@@ -168,15 +208,20 @@ export function mapLogToPatrolRun(
168
208
  };
169
209
  });
170
210
 
211
+ const untimed = isUntimedLog(log);
212
+
171
213
  return {
172
214
  routeSummary: {
173
215
  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
- },
216
+ tolerance: untimed ? 0 : Number(log.route?.tolerance) || 0,
217
+ // An untimed round has no schedule to compare against.
218
+ schedule: untimed
219
+ ? { start: "Anytime", end: "-", totalTime: "-" }
220
+ : {
221
+ start: log.route?.startTime,
222
+ end: log.route?.endTime,
223
+ totalTime: diffMinutes(log.startDateTime, log.endDateTime),
224
+ },
180
225
  actual: {
181
226
  start: clockTime(actual.start, timezone),
182
227
  end: clockTime(actual.end, timezone),
@@ -196,21 +241,28 @@ export function mapLogToSummaryRow(
196
241
  log: Record<string, any>,
197
242
  index: number,
198
243
  siteTimezone?: string,
244
+ now: Date = new Date(),
199
245
  ): NFCPatrolReportSummaryRow {
200
246
  const checkpoints: Record<string, any>[] = log.checkPoints ?? [];
201
247
  const checked = checkpoints.filter((cp) => cp.status === "Completed").length;
202
- const missed = checkpoints.filter((cp) => cp.status === "Skipped").length;
248
+ const missed = checkpoints.filter((cp) => isMissedCheckpoint(log, cp, now)).length;
249
+ const allDone =
250
+ checkpoints.length > 0 &&
251
+ checkpoints.every((cp) => cp.status === "Completed" || cp.status === "Skipped");
252
+ const status = missed > 0 ? "Incomplete" : allDone ? "Completed" : "In progress";
203
253
 
204
254
  return {
205
255
  id: log._id,
206
256
  date: log.date,
207
257
  routeName: `${index + 1}. ${log.route?.name ?? "-"}`,
208
- securityCheckSchedule: log.route?.startTime ?? "-",
258
+ securityCheckSchedule: isUntimedLog(log)
259
+ ? `Anytime (started ${log.route?.startTime ?? "-"})`
260
+ : log.route?.startTime ?? "-",
209
261
  guardName: log.sign?.guardName,
210
262
  guardSignatureFileId: log.sign?.guardSignatureFileId,
211
263
  checked,
212
264
  missed,
213
- status: missed === 0 ? "Completed" : "Incomplete",
265
+ status,
214
266
  detail: mapLogToPatrolRun(log, siteTimezone),
215
267
  };
216
268
  }
@@ -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
+ }