@7365admin1/layer-common 4.147.2 → 4.148.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.148.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e403e7a: ANPR camera badge: Sync compares only the cameras holding the plate on its own list (blocklist for a Block Listed plate, allowed list otherwise), so a resident blocked while its camera was off is offered Sync once the camera is active.
8
+ - e403e7a: Camera form: Exit cameras now hold the blocklist (mirrors core). Changing a camera to Exit clears only its allowed list, and a new Exit camera is offered the site's blocked plates.
9
+ - e403e7a: ANPR camera: Gate (1 to 20) replaces Guard House. The add / edit form asks for it (required) and does not offer a gate another ANPR camera at the site uses; the preview shows it. Needs core with the `gate` field.
10
+ - 1250cb5: Vehicle Mgmt: Block List follows Manage Blocklist when the host passes `canManageBlocklist` (apps that do not pass it keep today's rules).
11
+
12
+ - Add Vehicle shows with Manage Blocklist alone; Whitelist, Whitelist (Bulk) and Season Pass need Add Vehicle (new `canAddWhitelist` prop on `VehicleAddSelection`).
13
+ - On a Block Listed plate (type blocklist): Edit, Delete, Restore, Sync and the QR sticker re-issue / undo / link / remove follow Manage Blocklist alone; Approve needs Manage Blocklist and Approve Vehicle. Whitelist plates keep their own boxes.
14
+ - `AnprCameraBadge` takes `canSync` (default on).
15
+ - A server refusal on a blocklist action hides only the blocklist actions (new `blocklist` refusal key).
16
+
17
+ - e403e7a: Visitors: the default ANPR camera. A camera icon beside the visitor list's pagination (only when the site has more than one active Entry, Both Entry and Exit or Visitors camera) opens a dialog to pick the user's default gate at the site - "Entry Gate 1" - saved on their user (`defaultCameraGate`, per site). Add, register and the guest check-in send it as `anprGate`; none picked, nothing is sent and the server uses the site's first camera. The camera choice on the forms is gone. New `useVisitorCameraChoices`, `utils/visitor-camera` and a `pagination-prepend` slot on `TableMain`.
18
+
19
+ After a save the message reads "Add Visitor Success." / "Register Visitor Success." / "Check In Visitor Success.", with "Opening Barrier Success." or "Opening Barrier Failed." (red) when the barrier was asked to open. `Snackbar` takes `detail` and `detailTone`; `VisitorForm` emits `done` / `done:more` with `{ action, barrier }`; new `utils/visitor-barrier`.
20
+
21
+ - e403e7a: Visitors: the barrier line names the camera. After an add, register or guest check-in the message reads "Opening Barrier Success at Entry Gate 1." (or "Failed", in red) when the server sends `barrierCamera`; older servers keep the plain line. New `barrierMessage` and `barrierCameraOf` in `utils/visitor-barrier.ts`.
22
+
23
+ ## 4.148.0
24
+
25
+ ### Minor Changes
26
+
27
+ - 4b2d44f: Daily Checklist: the help box points to the Task Calendar for extra checks; the day page names its day, says "Visits today", offers the Closed filter and stays view-only on a closed day even when opened from a link; the unit page explains Done / Needs redo; "Visit N" instead of "Set N"; the PDF file is named after its day; the setup banner uses the area list's unit counts instead of one request per area.
28
+ - 5276d06: Trade apps: Task Calendar becomes one page with two tabs, "From managing agent" (view only) and "My extra checks" (the Extra Checks list with add / edit / pause / delete), and one short "How the Task Calendar works" help box. New `TaskCalendarMain` component; `ScheduleTaskMain` and `TaskCalendarReadOnly` gain an `embedded` prop (default off, so existing pages are unchanged). Breadcrumb for the old Extra Checks address now reads "Task Calendar".
29
+
30
+ ### Patch Changes
31
+
32
+ - 2c5bc62: Task Calendar "From managing agent" tab: a plain sentence per tab when it is empty instead of "No data available".
33
+
3
34
  ## 4.147.2
4
35
 
5
36
  ### Patch Changes
@@ -3493,11 +3524,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3493
3524
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3494
3525
  own rule from two endpoints the console already calls, unprojected:
3495
3526
 
3496
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3497
- GET /api/roles/id/:role that membership's role document
3527
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3528
+ GET /api/roles/id/:role that membership's role document
3498
3529
 
3499
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3500
- staff = member.type === "admin" && role.type === "admin"
3530
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3531
+ staff = member.type === "admin" && role.type === "admin"
3501
3532
 
3502
3533
  `role.default` is the marker because it is the only property of a platform
3503
3534
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -18,7 +18,7 @@
18
18
  class="anpr-badge anpr-badge--sync"
19
19
  :disabled="syncing"
20
20
  :aria-label="`Sync ${plateNumber} to all active ANPR cameras`"
21
- v-tooltip:top="`On ${count} of ${expected} active camera${expected === 1 ? '' : 's'} - sync to all`"
21
+ v-tooltip:top="`On ${onItsList} of ${expected} active camera${expected === 1 ? '' : 's'} - sync to all`"
22
22
  @click.stop="sync"
23
23
  >
24
24
  <v-progress-circular v-if="syncing" indeterminate size="11" width="2" />
@@ -77,6 +77,8 @@ const props = defineProps({
77
77
  type: { type: String, default: "" },
78
78
  /** The pass's expiry: once passed, "Allowed" reads "Expired" in red. */
79
79
  end: { type: [String, Date] as PropType<string | Date | null>, default: null },
80
+ /** False hides Sync (the host's right to write this plate to the cameras). */
81
+ canSync: { type: Boolean, default: true },
80
82
  /** Active cameras per list at the site; null hides Sync. */
81
83
  activeCounts: {
82
84
  type: Object as PropType<{ allowed: number; blocked: number } | null>,
@@ -120,10 +122,11 @@ function listTone(list: string) {
120
122
  const showSync = computed(
121
123
  () =>
122
124
  !!props.plateId &&
125
+ props.canSync &&
123
126
  !expired.value &&
124
127
  LIVE.includes(props.status) &&
125
128
  expected.value > 0 &&
126
- count.value < expected.value
129
+ onItsList.value < expected.value
127
130
  );
128
131
 
129
132
  async function sync() {
@@ -166,6 +169,16 @@ const uniqueCameras = computed(() => {
166
169
  });
167
170
 
168
171
  const count = computed<number>(() => uniqueCameras.value.length);
172
+
173
+ /**
174
+ * Cameras holding the plate on ITS list - what Sync compares with. A resident
175
+ * blocked while its camera was off is still on that camera's allowed list,
176
+ * which is not a block (owner, 2026-10-23). An entry with no list still counts.
177
+ */
178
+ const onItsList = computed<number>(() => {
179
+ const wanted = props.type === "blocklist" ? "blocked" : "allowed";
180
+ return uniqueCameras.value.filter((cam) => !cam.list || cam.list === wanted).length;
181
+ });
169
182
  const isBlocked = computed(() => props.cameras?.some((c) => c.list === "blocked"));
170
183
  </script>
171
184
 
@@ -117,7 +117,7 @@
117
117
  v-if="checklistSet.set !== undefined"
118
118
  class="text-subtitle-2 font-weight-bold px-0"
119
119
  >
120
- Set {{ checklistSet.set }}
120
+ {{ visitLabel(checklistSet.set) }}
121
121
  <StatusChip
122
122
  v-if="checklistSet.isScheduleTask"
123
123
  tone="info"
@@ -262,7 +262,7 @@
262
262
  </template>
263
263
 
264
264
  <script setup lang="ts">
265
- import { dailyStatusLabel, extraCheckLabel, historyUnitLabel } from "../utils/schedule-task";
265
+ import { dailyStatusLabel, extraCheckLabel, historyUnitLabel, visitLabel } from "../utils/schedule-task";
266
266
  const props = defineProps({
267
267
  orgId: { type: String, required: true, default: "" },
268
268
  site: { type: String, required: true, default: "" },
@@ -139,15 +139,22 @@
139
139
  />
140
140
  </v-col>
141
141
 
142
- <!-- <v-col v-if="isANPR" cols="12">
143
- <InputLabel class="text-capitalize" title="Guard House" />
142
+ <!-- The gate this ANPR camera watches (owner, 2026-10-23), in place
143
+ of Guard House: 1 to 20, one camera per gate at a site - a gate
144
+ another camera uses cannot be picked. Messages name the camera
145
+ by it ("Entry Gate 1"). -->
146
+ <v-col v-if="isANPR" cols="12">
147
+ <InputLabel class="text-capitalize" title="Gate" required />
144
148
  <v-select
145
- v-model="camera.guardPost"
146
- :items="guardPostOptions"
149
+ v-model="camera.gate"
150
+ :items="gateOptions"
151
+ item-props
147
152
  class="filter-field"
148
153
  density="comfortable"
154
+ :rules="[gateRule]"
155
+ data-test="camera-gate"
149
156
  />
150
- </v-col> -->
157
+ </v-col>
151
158
 
152
159
  <v-col v-if="isANPR" cols="12">
153
160
  <InputLabel class="text-capitalize" title="User" required />
@@ -224,9 +231,8 @@
224
231
  </v-row>
225
232
  </v-card-actions>
226
233
 
227
- <!-- Changing a camera to Exit empties its allowed list and blocklist, and
228
- an Entry / Both / Residents camera changed to Visitors empties its
229
- allowed list - on the camera itself. Ask before saving; the API refuses
234
+ <!-- An Entry / Both / Residents camera changed to Visitors or Exit
235
+ empties its allowed list - on the camera itself; its blocklist stays. Ask before saving; the API refuses
230
236
  the change without this confirmation. -->
231
237
  <v-dialog v-model="confirmClear" persistent width="540">
232
238
  <DialogReusablePrompt
@@ -242,7 +248,7 @@
242
248
 
243
249
  <!-- A new Entry / Both / Residents / Visitors camera, or a type change
244
250
  that means the camera should now hold plates it did not (Visitors or
245
- Exit to Entry / Both / Residents, Exit to Visitors): ask first whether
251
+ Exit to Entry / Both / Residents; a new Exit camera too): ask first whether
246
252
  to add the site's vehicles to it now. "Not now" saves the camera
247
253
  only; its vehicles can be added later with Sync vehicles in the
248
254
  camera list. -->
@@ -264,13 +270,15 @@
264
270
  >
265
271
  Cancel
266
272
  </v-btn>
273
+ <!-- A short label: the long "Not now, save changes only" ran out of
274
+ the button. The message above says what it does. -->
267
275
  <v-btn
268
276
  class="text-none"
269
277
  variant="outlined"
270
278
  :loading="disable"
271
279
  @click="saveWithoutVehicles"
272
280
  >
273
- {{ prop.mode === "edit" ? "Not now, save changes only" : "Not now, add camera only" }}
281
+ {{ prop.mode === "edit" ? "Save changes only" : "Add camera only" }}
274
282
  </v-btn>
275
283
  <v-btn
276
284
  class="text-none screen-btn-primary"
@@ -421,6 +429,7 @@ const camera = ref<TSiteCamera>({
421
429
  direction: "both",
422
430
  name: "",
423
431
  guardPost: null,
432
+ gate: null,
424
433
  status: "active",
425
434
  });
426
435
 
@@ -456,7 +465,7 @@ const message = ref("");
456
465
  const isANPR = computed(() => prop.type === "anpr");
457
466
  const isCCTV = computed(() => prop.type === "ip");
458
467
 
459
- const { addCamera, updateSiteCamera, testSiteCamera } = useSiteSettings();
468
+ const { addCamera, updateSiteCamera, testSiteCamera, getAllSiteCameras } = useSiteSettings();
460
469
 
461
470
  /**
462
471
  * Two ways to say where a camera is, and the guided one is the default.
@@ -592,6 +601,7 @@ const hasChanges = computed(() => {
592
601
  camera.value.status !== prop.camera.status ||
593
602
  camera.value.category !== prop.camera.category ||
594
603
  camera.value.guardPost !== prop.camera.guardPost ||
604
+ (camera.value.gate ?? null) !== (prop.camera.gate ?? null) ||
595
605
  camera.value.direction !== prop.camera.direction ||
596
606
  camera.value.name !== prop.camera.name ||
597
607
  // Switching an existing camera over to the recorder fields is a change
@@ -640,8 +650,9 @@ const clearPromptMessage = computed(() =>
640
650
  "vehicles will no longer be let in through this camera. Vehicle " +
641
651
  "records and the plates on your other cameras are not affected."
642
652
  : "Every plate on this camera's allowed list will be cleared from the " +
643
- "camera, including plates added directly on it, because Visitors " +
644
- "cameras must not hold whitelist or season pass plates. Those vehicles " +
653
+ "camera, including plates added directly on it, because " +
654
+ `${directionLabel(camera.value.direction)} cameras must not hold ` +
655
+ "whitelist or season pass plates. Those vehicles " +
645
656
  "will no longer be let in through this camera. Blocked plates stay on " +
646
657
  "it. Vehicle records and the plates on your other cameras are not " +
647
658
  "affected."
@@ -726,8 +737,8 @@ const addVehiclesMessage = computed(
726
737
  `Yes adds ${plateSetWords.value} to this camera now, one plate at a ` +
727
738
  `time, and you can watch each one go on. ` +
728
739
  (prop.mode === "edit"
729
- ? `Not now saves your changes only - `
730
- : `Not now adds the camera only - `) +
740
+ ? `Save changes only saves your changes without the vehicles - `
741
+ : `Add camera only adds the camera without the vehicles - `) +
731
742
  `you can add them later with Sync vehicles in the camera list.`
732
743
  );
733
744
 
@@ -848,7 +859,7 @@ const clearOnAddMessage = computed(() => {
848
859
  (what
849
860
  ? ` Either way, ${what} is then added to it, one plate at a time, and ` +
850
861
  `you can watch each one go on.`
851
- : ` Exit cameras receive no vehicle plates.`)
862
+ : ` This camera receives no vehicle plates.`)
852
863
  );
853
864
  });
854
865
 
@@ -865,7 +876,7 @@ function submit() {
865
876
  return;
866
877
  }
867
878
 
868
- // A new Exit camera receives none, so only the clear question applies.
879
+ // A new camera that receives no plates: only the clear question applies.
869
880
  if (isNewActiveAnpr.value) {
870
881
  askClearOnAdd.value = true;
871
882
  return;
@@ -912,6 +923,7 @@ async function save(
912
923
  ...(camera.value.password ? { password: camera.value.password } : {}),
913
924
  category: camera.value.category,
914
925
  guardPost: camera.value.guardPost,
926
+ ...(isANPR.value && camera.value.gate ? { gate: camera.value.gate } : {}),
915
927
  direction: camera.value.direction,
916
928
  status: camera.value.status,
917
929
  name: camera.value.name,
@@ -958,6 +970,50 @@ async function save(
958
970
  }
959
971
  }
960
972
 
973
+ /*
974
+ * Gate 1-20. The gates other ANPR cameras at the site already use are shown
975
+ * but cannot be picked (the server refuses them too). Read in the background;
976
+ * a failed read leaves every gate open and the server decides.
977
+ */
978
+ const usedGates = ref<Map<number, string>>(new Map());
979
+
980
+ async function loadUsedGates() {
981
+ if (!isANPR.value || !prop.site) return;
982
+ try {
983
+ const res: any = await getAllSiteCameras({ site: prop.site, type: "anpr", limit: 100 });
984
+ const items: any[] = Array.isArray(res?.items) ? res.items : [];
985
+ const used = new Map<number, string>();
986
+ for (const item of items) {
987
+ const gate = Number(item?.gate);
988
+ if (!Number.isInteger(gate) || gate < 1) continue;
989
+ if (camera.value._id && String(item?._id) === String(camera.value._id)) continue;
990
+ used.set(gate, String(item?.direction ?? ""));
991
+ }
992
+ usedGates.value = used;
993
+ } catch {
994
+ usedGates.value = new Map();
995
+ }
996
+ }
997
+ onMounted(loadUsedGates);
998
+
999
+ const gateOptions = computed(() =>
1000
+ Array.from({ length: 20 }, (_, index) => {
1001
+ const gate = index + 1;
1002
+ const taken = usedGates.value.has(gate);
1003
+ return {
1004
+ title: taken ? `Gate ${gate} (used by another camera)` : `Gate ${gate}`,
1005
+ value: gate,
1006
+ disabled: taken,
1007
+ };
1008
+ })
1009
+ );
1010
+
1011
+ const gateRule = (value: unknown) => {
1012
+ if (!value) return "Gate is required.";
1013
+ if (usedGates.value.has(Number(value))) return "This gate is used by another camera.";
1014
+ return true;
1015
+ };
1016
+
961
1017
  const guardPostOptions = computed(() => {
962
1018
  const items = [];
963
1019
 
@@ -58,6 +58,12 @@
58
58
  <template #item.host="{ value }">
59
59
  {{ maskHostUrl(value) }}
60
60
  </template>
61
+ <template #item.gate="{ value }">
62
+ {{ value ? `Gate ${value}` : "-" }}
63
+ </template>
64
+ <template #item.status="{ value }">
65
+ <StatusChip class="text-capitalize" :status="value || 'active'" :label="value || 'active'" />
66
+ </template>
61
67
  </v-data-table>
62
68
  </AppCard>
63
69
  </v-col>
@@ -161,9 +167,9 @@
161
167
  </v-col>
162
168
 
163
169
  <v-col v-if="camera.type === 'anpr'" cols="12">
164
- Guard House:
170
+ Gate:
165
171
  <span class="font-weight-bold">
166
- {{ camera.guardPost }}
172
+ {{ camera.gate ? `Gate ${camera.gate}` : "-" }}
167
173
  </span>
168
174
  </v-col>
169
175
  </v-row>
@@ -378,9 +384,12 @@ const computedHeaders = computed(() => {
378
384
  { title: "URL", key: "host" },
379
385
  ]
380
386
  }else if(prop.type === "anpr"){
387
+ // Gate and Status beside the type (owner, 2026-10-23).
381
388
  return [
382
389
  { title: "URL", key: "host" },
383
390
  { title: "Type", key: "direction" },
391
+ { title: "Gate", key: "gate" },
392
+ { title: "Status", key: "status" },
384
393
  ]
385
394
  }
386
395
  // A THIRD TYPE FELL OFF THE END. Both arms above are early returns and the
@@ -600,7 +609,7 @@ const dialogSyncAsk = ref(false);
600
609
  const dialogSync = ref(false);
601
610
  const syncClear = ref(false);
602
611
 
603
- /** An active ANPR camera of a type that receives plates (not Exit). */
612
+ /** An active ANPR camera of a type that receives plates (every type, Exit included). */
604
613
  const canSyncVehicles = computed(
605
614
  () =>
606
615
  camera.value.type === "anpr" &&
@@ -14,8 +14,10 @@
14
14
  <ol class="sg-steps">
15
15
  <li>
16
16
  Every midnight a new row appears here for the day. It holds every
17
- area, with as many visits as the area's "visits per day", plus
18
- any <strong>Extra Checks</strong> set for that day.
17
+ area, with as many visits as the area's "visits per day". An
18
+ <strong>extra check</strong> from the <strong>Task Calendar</strong>
19
+ ("My extra checks") adds one more visit to its areas at its set
20
+ time that day.
19
21
  </li>
20
22
  <li>
21
23
  Staff open the same list in their mobile app and tick each unit
@@ -403,7 +405,7 @@ async function downloadItem(item: any) {
403
405
 
404
406
  try {
405
407
  downloadingId.value = id;
406
- await downloadChecklistPdf(id);
408
+ await downloadChecklistPdf(id, item?.createdAt);
407
409
  } catch (err) {
408
410
  showMessage(
409
411
  (err as any)?.data?.message ||
@@ -420,6 +422,8 @@ const selectedScheduleStatus = useState<string>(
420
422
  () => ""
421
423
  );
422
424
 
425
+ const selectedScheduleDay = useState<string>("selectedScheduleDay", () => "");
426
+
423
427
  const isItemClosed = (item: any): boolean =>
424
428
  String(item?.status ?? "").toLowerCase() === "closed";
425
429
 
@@ -435,8 +439,9 @@ function onRowClick(data: any) {
435
439
  return;
436
440
  }
437
441
 
438
- // Store status so nested pages know whether the schedule is closed
442
+ // Store status and day so nested pages know whether the list is closed and which day it is
439
443
  selectedScheduleStatus.value = String(item?.status ?? "");
444
+ selectedScheduleDay.value = String(item?.createdAt ?? "");
440
445
 
441
446
  if (props.type === "toilet") {
442
447
  const path = `/${props.orgId}/${props.site}/toilet-checklist/${id}`;
@@ -16,6 +16,13 @@
16
16
  </StatusChip>
17
17
  </v-col>
18
18
  </v-row>
19
+ <p class="checklist-intro" data-test="checklist-intro">
20
+ Each visit lists this area's units. Staff tick them on their phone; here
21
+ you can mark a unit <strong>Done</strong> (&#10003;) or send it back with
22
+ <strong>Needs redo</strong> (&#10007;, with a remark). The last unit of a
23
+ visit needs a photo. A visit marked "Extra check" was added from the Task
24
+ Calendar.
25
+ </p>
19
26
  <TableHygiene
20
27
  :title="scheduleTitle"
21
28
  :headers="headers"
@@ -249,8 +256,8 @@
249
256
  <v-card-title>
250
257
  {{
251
258
  modalData.currentSet !== undefined
252
- ? `Set ${modalData.currentSet} Completed`
253
- : "All Items Checked"
259
+ ? `${visitLabel(modalData.currentSet)} done`
260
+ : "All units ticked"
254
261
  }}
255
262
  </v-card-title>
256
263
 
@@ -285,7 +292,7 @@
285
292
  v-if="!modalData.photoIds.length"
286
293
  class="text-caption text-medium-emphasis mt-1"
287
294
  >
288
- At least one photo is required to complete this task. You can take
295
+ At least one photo is needed to finish this visit. You can take
289
296
  or upload more than one.
290
297
  </p>
291
298
  </v-col>
@@ -382,7 +389,7 @@
382
389
  <v-dialog v-model="showAttachmentDialog" max-width="700" scrollable>
383
390
  <v-card class="screen-modal">
384
391
  <v-card-title class="d-flex align-center">
385
- <span>Set {{ attachmentDialogSetNum }} — Attachments</span>
392
+ <span>{{ visitLabel(attachmentDialogSetNum) }} — photos</span>
386
393
  <v-spacer />
387
394
  <v-btn
388
395
  icon="mdi-close"
@@ -462,6 +469,7 @@
462
469
 
463
470
  <script setup lang="ts">
464
471
  import { readListPage } from "../utils/list-page";
472
+ import { isClosedDay, visitLabel } from "../utils/schedule-task";
465
473
  import { isPhotoRefusal, isSetFinished } from "../utils/checklist-set";
466
474
  import useCleaningSchedules from "../composables/useCleaningSchedules";
467
475
  import useWorkerTerm from "../composables/useWorkerTerm";
@@ -504,8 +512,9 @@ const selectedScheduleStatus = useState<string>(
504
512
  "selectedScheduleStatus",
505
513
  () => ""
506
514
  );
507
- const isScheduleClosed = computed(
508
- () => selectedScheduleStatus.value.toLowerCase() === "closed"
515
+ // Opened from the day list it knows; from a link or a refresh the units tell.
516
+ const isScheduleClosed = computed(() =>
517
+ isClosedDay(selectedScheduleStatus.value, items.value as any[]),
509
518
  );
510
519
 
511
520
  const page = ref<number>(1);
@@ -1075,13 +1084,13 @@ async function _updateUnitChecklist({
1075
1084
  );
1076
1085
  }
1077
1086
 
1078
- showMessage(`Set ${setNumber} done`, "success");
1087
+ showMessage(`${visitLabel(setNumber)} done`, "success");
1079
1088
 
1080
1089
  // Refresh data
1081
1090
  await getUnitCleanerChecklistRefresh();
1082
1091
  } catch (error: any) {
1083
1092
  console.error("Error approving set:", error);
1084
- showMessage(error?.message || "Could not finish this set", "error");
1093
+ showMessage(error?.message || "Could not finish this visit", "error");
1085
1094
  } finally {
1086
1095
  submitting.value = false;
1087
1096
  }
@@ -1089,6 +1098,11 @@ async function _updateUnitChecklist({
1089
1098
  </script>
1090
1099
 
1091
1100
  <style scoped>
1101
+ .checklist-intro {
1102
+ margin: 0 0 12px;
1103
+ font-size: var(--fs-cell);
1104
+ color: var(--text2);
1105
+ }
1092
1106
  /* The idle checklist circle. `--muted` is the design's de-emphasis token and
1093
1107
  clears the 3:1 bar a non-text indicator needs; Vuetify's `grey-lighten-2`
1094
1108
  read 1.32:1. */
@@ -6,10 +6,10 @@
6
6
  :site="site"
7
7
  :service-type="serviceType"
8
8
  :schedule-route="scheduleRoute"
9
- text="These are the areas on this day's checklist. Click an area to see each unit, tick it as done, or send it back to be redone."
9
+ text="These are the areas on this day's checklist. Click an area to see each unit, tick it as done, or send it back to be redone. Visits today counts the area's normal visits plus any extra checks added today."
10
10
  />
11
11
  <TableMain
12
- :title="pageTitle"
12
+ :title="dayTitle"
13
13
  :headers="headers"
14
14
  :items="items"
15
15
  :loading="loading"
@@ -143,7 +143,12 @@
143
143
 
144
144
  <script setup lang="ts">
145
145
  import useCleaningSchedules from "../composables/useCleaningSchedules";
146
- import { dailyChecklistTitle, dailyStatusLabel } from "../utils/schedule-task";
146
+ import {
147
+ dailyChecklistTitle,
148
+ dailyStatusLabel,
149
+ dayLabel,
150
+ isClosedDay,
151
+ } from "../utils/schedule-task";
147
152
  import useUtils from "../composables/useUtils";
148
153
 
149
154
  const props = defineProps({
@@ -177,7 +182,8 @@ const headers = [
177
182
  { title: "Area", value: "name" },
178
183
  { title: "Status", value: "status" },
179
184
  ...(props.showAreaType ? [{ title: "Type", value: "type" }] : []),
180
- { title: "Visits per day", value: "set" },
185
+ // The highest visit number on today's list: normal visits plus any extra checks added.
186
+ { title: "Visits today", value: "set" },
181
187
  ];
182
188
 
183
189
  const { formatDate, back } = useUtils();
@@ -190,9 +196,16 @@ const selectedScheduleStatus = useState<string>(
190
196
  "selectedScheduleStatus",
191
197
  () => ""
192
198
  );
193
- const isScheduleClosed = computed(
194
- () => selectedScheduleStatus.value.toLowerCase() === "closed"
199
+ // Opened from the day list it knows; from a link or a refresh the rows tell.
200
+ const isScheduleClosed = computed(() =>
201
+ isClosedDay(selectedScheduleStatus.value, items.value),
195
202
  );
203
+ const selectedScheduleDay = useState<string>("selectedScheduleDay", () => "");
204
+ // "Daily Checklist – Cleaning – Sat, 24 Oct 2026" (the day only when known).
205
+ const dayTitle = computed(() => {
206
+ const day = dayLabel(selectedScheduleDay.value);
207
+ return day ? `${pageTitle} – ${day}` : pageTitle;
208
+ });
196
209
 
197
210
  const items = ref<Array<Record<string, any>>>([]);
198
211
 
@@ -202,6 +215,7 @@ const statusOptions = [
202
215
  { title: "Not started", value: "open" },
203
216
  { title: "In progress", value: "ongoing" },
204
217
  { title: "Done", value: "completed" },
218
+ { title: "Closed", value: "closed" },
205
219
  ];
206
220
  const areaTypeFilter = ref<TAreaType>("all");
207
221
  const typeOptions = [
@@ -132,8 +132,14 @@ async function check() {
132
132
  }
133
133
 
134
134
  const noUnits: string[] = [];
135
+ // A newer server lists each area's unit count: no lookups at all. Older
136
+ // replies (or cached ones) fall back to one capped lookup per area.
137
+ for (const a of areas) {
138
+ if (typeof a.unitCount === "number" && a.unitCount === 0) noUnits.push(a.name);
139
+ }
140
+ const unknown = areas.filter((a) => typeof a.unitCount !== "number");
135
141
  await Promise.all(
136
- areas.slice(0, MAX_UNIT_CHECKS).map(async (a) => {
142
+ unknown.slice(0, MAX_UNIT_CHECKS).map(async (a) => {
137
143
  try {
138
144
  const full = await getAreaById(a._id);
139
145
  if (!(full?.units || []).length) noUnits.push(a.name);
@@ -5,8 +5,8 @@
5
5
  :org-id="orgId"
6
6
  :site="site"
7
7
  :service-type="serviceType"
8
- :text="FEATURE_SUBTITLE"
9
- how-title="How extra checks work"
8
+ :text="embedded ? '' : FEATURE_SUBTITLE"
9
+ :how-title="embedded ? '' : 'How extra checks work'"
10
10
  how-key="extra-visits"
11
11
  >
12
12
  <ol class="sg-steps">
@@ -509,6 +509,8 @@ const props = defineProps({
509
509
  canUpdateScheduleTask: { type: Boolean, default: false },
510
510
  canDeleteScheduleTask: { type: Boolean, default: false },
511
511
  canViewScheduleTaskDetails: { type: Boolean, default: false },
512
+ /** Inside the Task Calendar page: its one help box replaces this page's own. */
513
+ embedded: { type: Boolean, default: false },
512
514
  });
513
515
 
514
516
  const { formatDate, debounce } = useUtils();
@@ -6,6 +6,16 @@
6
6
  :z-index="100000000"
7
7
  >
8
8
  {{ props.text }}
9
+ <!-- A second line ("Opening Barrier Success."); a failure is red on a
10
+ light pill so it reads on every toast colour. -->
11
+ <div
12
+ v-if="props.detail"
13
+ class="app-toast__detail"
14
+ :class="{ 'app-toast__detail--error': props.detailTone === 'error' }"
15
+ data-test="app-toast-detail"
16
+ >
17
+ {{ props.detail }}
18
+ </div>
9
19
 
10
20
  <template #actions>
11
21
  <v-btn class="app-toast__close" variant="text" @click="snackbar = false">
@@ -52,5 +62,31 @@ const props = defineProps({
52
62
  type: String,
53
63
  default: "",
54
64
  },
65
+ /** A second line under `text` (e.g. "Opening Barrier Failed."). */
66
+ detail: {
67
+ type: String,
68
+ default: "",
69
+ },
70
+ /** "error" draws `detail` in red. */
71
+ detailTone: {
72
+ type: String as PropType<"default" | "error">,
73
+ default: "default",
74
+ },
55
75
  });
56
76
  </script>
77
+
78
+ <style scoped>
79
+ .app-toast__detail {
80
+ margin-top: 4px;
81
+ }
82
+
83
+ .app-toast__detail--error {
84
+ display: inline-block;
85
+ margin-top: 6px;
86
+ padding: 2px 8px;
87
+ border-radius: 6px;
88
+ background: rgb(var(--v-theme-surface));
89
+ color: rgb(var(--v-theme-error));
90
+ font-weight: 700;
91
+ }
92
+ </style>
@@ -83,7 +83,7 @@
83
83
  <span
84
84
  class="text-caption font-weight-bold text-medium-emphasis text-uppercase"
85
85
  >
86
- Set {{ group.set }}
86
+ {{ visitLabel(group.set) }}
87
87
  </span>
88
88
  <StatusChip
89
89
  v-if="group.isScheduleTask"
@@ -206,7 +206,7 @@
206
206
  </template>
207
207
 
208
208
  <script lang="ts" setup>
209
- import { extraCheckLabel } from "../utils/schedule-task";
209
+ import { extraCheckLabel, visitLabel } from "../utils/schedule-task";
210
210
  defineOptions({ inheritAttrs: false });
211
211
 
212
212
  const props = defineProps({