@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 +17 -4
- package/components/AreaFormDialog.vue +4 -1
- package/components/AreaMain.vue +10 -3
- package/components/BuildingForm.vue +1 -1
- package/components/BuildingManagement/buildings.vue +3 -3
- package/components/Nfc/NFCPatrolReportMain.vue +4 -2
- package/components/Nfc/NFCPatrolRouteForm.vue +81 -13
- package/components/Nfc/NFCPatrolRouteMain.vue +17 -5
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +4 -4
- package/components/Nfc/PatrolReport/ReportFilters.vue +23 -1
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +1 -0
- package/components/ScheduleAreaMain.vue +5 -1
- package/components/UnitMain.vue +3 -1
- package/components/VisitorManagement.vue +34 -7
- package/composables/useNFCPatrolMonthlyReport.ts +5 -3
- package/composables/useNFCPatrolReportFilters.ts +56 -15
- package/composables/useNFCPatrolRoute.ts +4 -2
- package/package.json +1 -1
- package/types/nfc-patrol-report.ts +6 -2
- package/utils/nfc-patrol-report.ts +62 -10
- package/utils/nfc-patrol-schedule.ts +20 -0
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
|
-
|
|
3411
|
-
|
|
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
|
-
|
|
3414
|
-
|
|
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: "" },
|
package/components/AreaMain.vue
CHANGED
|
@@ -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="
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
:
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
378
|
-
nfcTags.value =
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/components/UnitMain.vue
CHANGED
|
@@ -187,7 +187,9 @@
|
|
|
187
187
|
title="Import Units from Excel"
|
|
188
188
|
noun="units"
|
|
189
189
|
name-label="Unit"
|
|
190
|
-
fill-hint="
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
84
|
+
let startTimesRequest = 0;
|
|
85
|
+
async function updateStartTimes() {
|
|
72
86
|
const route = options.value.routes.find(({ value }) => value === filters.route);
|
|
73
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
177
|
-
end:
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|