@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.
@@ -843,6 +843,8 @@ import usePeople from "../composables/usePeople";
843
843
  import useHidAmico from "../composables/useHidAmico";
844
844
  import useFile from "../composables/useFile";
845
845
  import usePassKey from "../composables/usePassKey";
846
+ import { defaultGateForSite } from "../utils/visitor-camera";
847
+ import { barrierCameraOf, barrierOf, type TBarrierOutcome } from "../utils/visitor-barrier";
846
848
  import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
847
849
  import {
848
850
  DELETE_PERSON_TITLE,
@@ -978,6 +980,18 @@ const hidQrVisitorId = ref("");
978
980
  const hidQrNow = ref(Date.now());
979
981
  let hidQrTimer: ReturnType<typeof setInterval> | null = null;
980
982
  const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
983
+ /**
984
+ * What this save was and whether it opened the ANPR barrier, sent with
985
+ * `done` / `done:more` for the list's message (owner, 2026-10-23).
986
+ */
987
+ const saveResult = ref<{
988
+ action: "add" | "register" | "edit";
989
+ barrier?: TBarrierOutcome;
990
+ /** The camera the barrier is at, "Entry Gate 1". */
991
+ barrierCamera?: string;
992
+ }>({
993
+ action: "add",
994
+ });
981
995
  const hidQrPreview = reactive({
982
996
  imageUrl: "",
983
997
  issuedAt: "",
@@ -1098,6 +1112,16 @@ const formMode = computed<"add" | "edit" | "register">(() => {
1098
1112
  return selectedUnregisteredVisitor.value ? "register" : prop.mode;
1099
1113
  });
1100
1114
 
1115
+ /*
1116
+ * The user's default ANPR gate at this site (owner, 2026-10-23), picked on the
1117
+ * visitor list: sent with an add or a register (an edit opens nothing) so that
1118
+ * camera is checked for a blocked plate and opens the barrier. None picked:
1119
+ * not sent, and the server uses the site's first camera.
1120
+ */
1121
+ const defaultCameraGate = computed(() =>
1122
+ defaultGateForSite((currentUser.value as any)?.defaultCameraGate, prop.site)
1123
+ );
1124
+
1101
1125
  // Fields this form actually has for the visitor type - drives both what is
1102
1126
  // shown and what is sent on submit, so nothing outside the form is saved.
1103
1127
  // typeFieldMap also feeds the details dialog, so it can list more than this:
@@ -3138,6 +3162,16 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
3138
3162
  }
3139
3163
  }
3140
3164
  const payload = buildPayload(isUpdate);
3165
+ // The camera chosen at the bottom of the form (not stored on the visit).
3166
+ if (
3167
+ (formMode.value === "add" || formMode.value === "register") &&
3168
+ defaultCameraGate.value &&
3169
+ payload.plateNumber
3170
+ ) {
3171
+ payload.anprGate = defaultCameraGate.value;
3172
+ }
3173
+ // A new visit replies `{ _id, barrier }`: whether the barrier opened.
3174
+ if (!isUpdate) payload.reportBarrier = true;
3141
3175
 
3142
3176
  try {
3143
3177
  const res = isUpdate
@@ -3158,6 +3192,11 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
3158
3192
  }
3159
3193
 
3160
3194
  const visitorId = isUpdate ? recordId : getVisitorIdFromResponse(res);
3195
+ saveResult.value = {
3196
+ action: !isUpdate ? "add" : formMode.value === "register" ? "register" : "edit",
3197
+ barrier: barrierOf(res),
3198
+ barrierCamera: barrierCameraOf(res),
3199
+ };
3161
3200
 
3162
3201
  if (passType.value === "HID_QR") {
3163
3202
  const previewReady = await prepareHidQrCodePreview(visitorId);
@@ -3177,8 +3216,8 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
3177
3216
  }
3178
3217
 
3179
3218
  if (createMore.value) {
3180
- emit("done:more");
3181
- } else emit("done");
3219
+ emit("done:more", saveResult.value);
3220
+ } else emit("done", saveResult.value);
3182
3221
  } catch (error: any) {
3183
3222
  const err = error?.data?.message || error?.response?._data?.message;
3184
3223
  errorMessage.value =
@@ -3470,8 +3509,8 @@ function closeHidQrPreview() {
3470
3509
  hidQrPreviewDialog.value = false;
3471
3510
  const completion = hidQrPendingCompletion.value;
3472
3511
  hidQrPendingCompletion.value = null;
3473
- if (completion === "done:more") emit("done:more");
3474
- else if (completion === "done") emit("done");
3512
+ if (completion === "done:more") emit("done:more", saveResult.value);
3513
+ else if (completion === "done") emit("done", saveResult.value);
3475
3514
  }
3476
3515
 
3477
3516
  watch(
@@ -57,6 +57,26 @@
57
57
  show-header
58
58
  @row-click="handleRowClick"
59
59
  >
60
+ <!-- The default ANPR camera (owner, 2026-10-23): only when the site has
61
+ more than one active Entry / Both / Visitors camera. Add, register
62
+ and guest check-in use it. -->
63
+ <template #pagination-prepend>
64
+ <v-btn
65
+ v-if="anprCameraCount > 1"
66
+ icon
67
+ density="comfortable"
68
+ variant="text"
69
+ :color="defaultCameraGate ? 'primary' : undefined"
70
+ :aria-label="'Default ANPR camera'"
71
+ data-test="visitor-default-camera"
72
+ @click="openDefaultCamera"
73
+ >
74
+ <v-icon>mdi-cctv</v-icon>
75
+ <v-tooltip activator="parent" location="top">
76
+ {{ defaultCameraGateLabel }}
77
+ </v-tooltip>
78
+ </v-btn>
79
+ </template>
60
80
  <template #extension>
61
81
  <v-row no-gutters class="w-100 d-flex flex-column">
62
82
  <v-tabs
@@ -1814,7 +1834,44 @@
1814
1834
  </v-card>
1815
1835
  </v-dialog>
1816
1836
 
1817
- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
1837
+ <v-dialog v-model="dialog.defaultCamera" width="450" persistent>
1838
+ <v-card class="screen-modal" width="100%" :loading="savingDefaultCamera">
1839
+ <v-card-title>{{ VISITOR_CAMERA_LABEL }}</v-card-title>
1840
+ <v-card-text>
1841
+ <p class="mb-4 text-body-2">
1842
+ Add, register and guest check-in use this camera: it is checked for a
1843
+ blocked vehicle and opens the barrier. None chosen: the site's first camera.
1844
+ </p>
1845
+ <v-select
1846
+ v-if="anprGateChoices.length"
1847
+ v-model="pickedDefaultGate"
1848
+ :items="anprGateChoices"
1849
+ item-title="title"
1850
+ item-value="value"
1851
+ density="comfortable"
1852
+ clearable
1853
+ placeholder="None (the site's first camera)"
1854
+ data-test="visitor-default-camera-select"
1855
+ />
1856
+ <p v-else class="text-body-2 text-medium-emphasis">
1857
+ No ANPR camera at this site has a gate yet. Set the gates in Settings first.
1858
+ </p>
1859
+ <p v-if="defaultCameraError" class="text-error text-body-2 mt-2">{{ defaultCameraError }}</p>
1860
+ </v-card-text>
1861
+ <v-card-actions>
1862
+ <v-spacer />
1863
+ <AppButton variant="ghost" :disabled="savingDefaultCamera" @click="dialog.defaultCamera = false">
1864
+ Cancel
1865
+ </AppButton>
1866
+ <AppButton :disabled="savingDefaultCamera" data-test="visitor-default-camera-submit" @click="submitDefaultCamera">
1867
+ Submit
1868
+ </AppButton>
1869
+ </v-card-actions>
1870
+ </v-card>
1871
+ </v-dialog>
1872
+
1873
+ <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor"
1874
+ :detail="messageDetail.text" :detail-tone="messageDetail.tone" />
1818
1875
  </v-row>
1819
1876
  </template>
1820
1877
 
@@ -1823,6 +1880,9 @@ import type { PropType } from "vue";
1823
1880
  import moment from "moment-timezone";
1824
1881
  import QRCode from "qrcode";
1825
1882
  import { isVisitorPassSmsOn } from "../composables/useVisitor";
1883
+ import { barrierCameraOf, barrierMessage, barrierOf, VISITOR_ACTION_SUCCESS } from "../utils/visitor-barrier";
1884
+ import useVisitorCameraChoices from "../composables/useVisitorCameraChoices";
1885
+ import { VISITOR_CAMERA_LABEL } from "../utils/visitor-camera";
1826
1886
  import usePassKey from "../composables/usePassKey";
1827
1887
  import {
1828
1888
  VISITOR_SEARCH_DEBOUNCE_MS,
@@ -2236,6 +2296,45 @@ const mode = ref<"add" | "edit" | "register">("add");
2236
2296
  const message = ref("");
2237
2297
  const messageColor = ref("");
2238
2298
  const messageSnackbar = ref(false);
2299
+
2300
+ /* The default ANPR camera (gate) for add / register / guest check-in. */
2301
+ const {
2302
+ count: anprCameraCount,
2303
+ choices: anprGateChoices,
2304
+ defaultGate: defaultCameraGate,
2305
+ saveDefaultGate,
2306
+ } = useVisitorCameraChoices(() => siteId);
2307
+ const pickedDefaultGate = ref<number | null>(null);
2308
+ const savingDefaultCamera = ref(false);
2309
+ const defaultCameraError = ref("");
2310
+ const defaultCameraGateLabel = computed(() => {
2311
+ const gate = defaultCameraGate.value;
2312
+ if (!gate) return "Default ANPR camera: the site's first camera";
2313
+ const choice = anprGateChoices.value.find((item) => item.value === gate);
2314
+ return `Default ANPR camera: ${choice?.title ?? `Gate ${gate}`}`;
2315
+ });
2316
+
2317
+ function openDefaultCamera() {
2318
+ pickedDefaultGate.value = defaultCameraGate.value ?? null;
2319
+ defaultCameraError.value = "";
2320
+ dialog.defaultCamera = true;
2321
+ }
2322
+
2323
+ async function submitDefaultCamera() {
2324
+ savingDefaultCamera.value = true;
2325
+ defaultCameraError.value = "";
2326
+ try {
2327
+ const res = await saveDefaultGate(pickedDefaultGate.value || null);
2328
+ dialog.defaultCamera = false;
2329
+ showMessage(res?.message || "Saved.", "success");
2330
+ } catch (error) {
2331
+ defaultCameraError.value = errorConverter(error);
2332
+ } finally {
2333
+ savingDefaultCamera.value = false;
2334
+ }
2335
+ }
2336
+ // The toast's second line: whether the barrier opened.
2337
+ const messageDetail = ref<{ text: string; tone: "default" | "error" }>({ text: "", tone: "default" });
2239
2338
  /** Why the list could not be loaded (shown in the empty table). */
2240
2339
  const visitorListError = ref("");
2241
2340
 
@@ -2286,6 +2385,7 @@ const dialog = reactive({
2286
2385
  showSelection: false,
2287
2386
  showForm: false,
2288
2387
  viewVisitor: false,
2388
+ defaultCamera: false,
2289
2389
  snapshotImage: false,
2290
2390
  remarks: false,
2291
2391
  returnPassesKeys: false,
@@ -3182,8 +3282,13 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
3182
3282
  ? visitorData.visitorPass
3183
3283
  : [];
3184
3284
 
3185
- await visitorDataFromScannedQRCodeCheckIn({
3285
+ const checkInReply: any = await visitorDataFromScannedQRCodeCheckIn({
3186
3286
  type,
3287
+ // The user's default ANPR gate at this site: checked for a blocked
3288
+ // plate, opens the barrier. None: the site's first camera.
3289
+ ...(defaultCameraGate.value && String(visitorData.plateNumber ?? "").trim()
3290
+ ? { anprGate: defaultCameraGate.value }
3291
+ : {}),
3187
3292
  filter: {
3188
3293
  _id: visitorId,
3189
3294
  site: visitorData.site ?? siteId,
@@ -3200,7 +3305,13 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
3200
3305
  },
3201
3306
  userType: "site",
3202
3307
  });
3203
- showMessage("Visitor successfully checked-in!", "info");
3308
+ // "Check In Visitor Success." and whether the barrier opened.
3309
+ showMessage(
3310
+ VISITOR_ACTION_SUCCESS.checkIn,
3311
+ "success",
3312
+ barrierOf(checkInReply),
3313
+ barrierCameraOf(checkInReply),
3314
+ );
3204
3315
  } else {
3205
3316
  selectedVisitorId.value = visitorData._id;
3206
3317
  await proceedCheckout();
@@ -3426,23 +3537,49 @@ function handleClickBack() {
3426
3537
  /** iSecure's toast after an add, an update or a register. */
3427
3538
  const VISITOR_SAVED_MESSAGE = "Great! A new visitor has been successfully added.";
3428
3539
 
3429
- function handleVisitorFormDone() {
3430
- showMessage(VISITOR_SAVED_MESSAGE, "success");
3540
+ /*
3541
+ * After a save (owner, 2026-10-23): "Add Visitor Success." / "Register Visitor
3542
+ * Success." and, when the barrier was asked to open, "Opening Barrier
3543
+ * Success." or "Opening Barrier Failed." (red). An edit keeps its message.
3544
+ */
3545
+ type TVisitorFormResult = {
3546
+ action?: "add" | "register" | "edit";
3547
+ barrier?: "opened" | "failed";
3548
+ /** The camera the barrier is at, "Entry Gate 1". */
3549
+ barrierCamera?: string;
3550
+ };
3551
+
3552
+ function visitorSavedMessage(result?: TVisitorFormResult) {
3553
+ if (result?.action === "add") return VISITOR_ACTION_SUCCESS.add;
3554
+ if (result?.action === "register") return VISITOR_ACTION_SUCCESS.register;
3555
+ return VISITOR_SAVED_MESSAGE;
3556
+ }
3557
+
3558
+ function handleVisitorFormDone(result?: TVisitorFormResult) {
3559
+ showMessage(visitorSavedMessage(result), "success", result?.barrier, result?.barrierCamera);
3431
3560
  getVisitorRefresh();
3432
3561
  dialog.showSelection = false;
3433
3562
  dialog.showForm = false;
3434
3563
  }
3435
3564
 
3436
- function handleVisitorFormCreateMore() {
3437
- showMessage(VISITOR_SAVED_MESSAGE, "success");
3565
+ function handleVisitorFormCreateMore(result?: TVisitorFormResult) {
3566
+ showMessage(visitorSavedMessage(result), "success", result?.barrier, result?.barrierCamera);
3438
3567
  getVisitorRefresh();
3439
3568
  dialog.showSelection = true;
3440
3569
  dialog.showForm = false;
3441
3570
  }
3442
3571
 
3443
- function showMessage(msg: string, color: string) {
3572
+ function showMessage(
3573
+ msg: string,
3574
+ color: string,
3575
+ barrier?: "opened" | "failed",
3576
+ barrierCamera?: string,
3577
+ ) {
3444
3578
  message.value = msg;
3445
3579
  messageColor.value = color;
3580
+ messageDetail.value = barrier
3581
+ ? { text: barrierMessage(barrier, barrierCamera), tone: barrier === "failed" ? "error" : "default" }
3582
+ : { text: "", tone: "default" };
3446
3583
  messageSnackbar.value = true;
3447
3584
  }
3448
3585
 
@@ -1,3 +1,5 @@
1
+ import { checklistPdfName } from "../utils/schedule-task";
2
+
1
3
  export default function useCleaningSchedules() {
2
4
  const _cleanerChecklists = ref({
3
5
  _id: "",
@@ -199,7 +201,8 @@ export default function useCleaningSchedules() {
199
201
  window.URL.revokeObjectURL(downloadUrl);
200
202
  }
201
203
 
202
- async function downloadChecklistPdf(scheduleId: string) {
204
+ /** `day`: the day the list is for (the row's createdAt), used to name the file. */
205
+ async function downloadChecklistPdf(scheduleId: string, day?: string | Date) {
203
206
  const res = await useNuxtApp().$api<Blob | MediaSource>(
204
207
  `/api/hygiene-area-checklist/schedule/${scheduleId}/pdf`,
205
208
  {
@@ -210,17 +213,10 @@ export default function useCleaningSchedules() {
210
213
 
211
214
  if (!res) throw new Error("Error downloading checklist PDF");
212
215
 
213
- const date = new Date();
214
- const formattedDate = `${String(date.getDate()).padStart(2, "0")}_${String(
215
- date.getMonth() + 1,
216
- ).padStart(2, "0")}_${date.getFullYear()} ${String(
217
- date.getHours(),
218
- ).padStart(2, "0")}_${String(date.getMinutes()).padStart(2, "0")}`;
219
-
220
216
  const downloadUrl = window.URL.createObjectURL(res);
221
217
  const a = document.createElement("a");
222
218
  a.href = downloadUrl;
223
- a.download = `CHECKLIST_DOWNLOADED_${formattedDate}.pdf`;
219
+ a.download = checklistPdfName(day);
224
220
  a.target = "_blank";
225
221
  a.rel = "noopener noreferrer";
226
222
  document.body.appendChild(a);
@@ -0,0 +1,61 @@
1
+ import { defaultGateForSite, visitorCameras, visitorGateChoices } from "../utils/visitor-camera";
2
+ import useSiteSettings from "./useSiteSettings";
3
+ import useLocalAuth from "./useLocalAuth";
4
+
5
+ /**
6
+ * The site's ANPR cameras a visit can be checked at (active Entry, Both Entry
7
+ * and Exit, Visitors - see `utils/visitor-camera.ts`) and the signed-in user's
8
+ * default gate there (`defaultCameraGate: [{ site, cameraGateNumber }]` on
9
+ * their user, picked on the visitor list). The cameras are read in the
10
+ * background; a failed read shows no camera icon and the server uses the
11
+ * site's first camera.
12
+ */
13
+ export default function useVisitorCameraChoices(site: () => string) {
14
+ const { getAllSiteCameras } = useSiteSettings();
15
+ const { currentUser } = useLocalAuth();
16
+ const cameras = ref<unknown[]>([]);
17
+
18
+ async function load() {
19
+ const id = site();
20
+ if (!id) {
21
+ cameras.value = [];
22
+ return;
23
+ }
24
+ try {
25
+ const res = await getAllSiteCameras({ site: id, type: "anpr", limit: 100 });
26
+ cameras.value = Array.isArray((res as any)?.items) ? (res as any).items : [];
27
+ } catch {
28
+ cameras.value = [];
29
+ }
30
+ }
31
+
32
+ watch(site, load, { immediate: true });
33
+
34
+ /** The user's default gate at this site (sent as `anprGate`), or undefined. */
35
+ const defaultGate = computed(() =>
36
+ defaultGateForSite((currentUser.value as any)?.defaultCameraGate, site())
37
+ );
38
+
39
+ /** Save (or clear, `null`) the default gate at this site, on the user. */
40
+ async function saveDefaultGate(gate: number | null) {
41
+ const res = await useNuxtApp().$api<Record<string, any>>("/api/users/default-camera-gate", {
42
+ method: "PATCH",
43
+ body: { site: site(), gate },
44
+ });
45
+ if (currentUser.value) {
46
+ (currentUser.value as any).defaultCameraGate = Array.isArray(res?.defaultCameraGate)
47
+ ? res.defaultCameraGate
48
+ : [];
49
+ }
50
+ return res;
51
+ }
52
+
53
+ return {
54
+ /** How many active Entry / Both / Visitors cameras (the icon shows with more than one). */
55
+ count: computed(() => visitorCameras(cameras.value).length),
56
+ choices: computed(() => visitorGateChoices(cameras.value)),
57
+ defaultGate,
58
+ saveDefaultGate,
59
+ reload: load,
60
+ };
61
+ }
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.147.2",
5
+ "version": "4.148.1",
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.",
package/types/camera.d.ts CHANGED
@@ -28,5 +28,7 @@ declare type TSiteCamera = {
28
28
  direction: "entry" | "exit" | "both" | "none";
29
29
  name?: string;
30
30
  guardPost: number | null;
31
+ /** ANPR cameras: the gate (1-20) it watches; one camera per gate at a site. */
32
+ gate?: number | null;
31
33
  status: string;
32
34
  };
@@ -12,8 +12,11 @@ export const isAllowCameraDirection = (direction: unknown): boolean =>
12
12
  String(direction ?? "").trim().toLowerCase(),
13
13
  );
14
14
 
15
- /** ANPR camera types that carry blocked plates: every type except Exit. */
16
- export const BLOCK_CAMERA_DIRECTIONS = ["entry", "both", "residents", "visitors"];
15
+ /**
16
+ * ANPR camera types that carry blocked plates: every type, Exit included
17
+ * (owner, 2026-10-23). Mirrors `BLOCK_CAMERA_DIRECTIONS` in iservice365-core.
18
+ */
19
+ export const BLOCK_CAMERA_DIRECTIONS = ["entry", "both", "residents", "visitors", "exit"];
17
20
 
18
21
  export const isBlockCameraDirection = (direction: unknown): boolean =>
19
22
  BLOCK_CAMERA_DIRECTIONS.includes(
@@ -48,8 +51,8 @@ export const listsFilledByDirectionChange = (
48
51
  * Mirrors `listsClearedByDirectionChange` in iservice365-core, which refuses
49
52
  * the change unless it is confirmed, so the form asks first.
50
53
  *
51
- * - To Exit: the allowed list and the blocklist.
52
- * - Entry / Both / Residents to Visitors: the allowed list.
54
+ * - Entry / Both / Residents to Visitors or Exit: the allowed list. Both
55
+ * keep their blocklist (every camera holds the blocked plates).
53
56
  * - Anything else: nothing.
54
57
  */
55
58
  export const listsClearedByDirectionChange = (
@@ -61,8 +64,6 @@ export const listsClearedByDirectionChange = (
61
64
 
62
65
  if (!after || after === before) return { allow: false, block: false };
63
66
 
64
- if (after === "exit") return { allow: true, block: true };
65
-
66
67
  return {
67
68
  allow: isAllowCameraDirection(before) && !isAllowCameraDirection(after),
68
69
  block: false,
@@ -26,8 +26,9 @@ const RENAMED: [string, string][] = [
26
26
  ["Building Mgmt", "Occupancy Mgmt"],
27
27
  ["Event Mgmt", "Task Calendar"],
28
28
  // Schedule screens (owner 21 Oct): the same names the menu and the mobile apps use.
29
- ["Schedule Task", "Extra Checks"],
30
- ["Extra Visits", "Extra Checks"],
29
+ // Extra Checks live inside the Task Calendar page since 24 Oct 2026.
30
+ ["Schedule Task", "Task Calendar"],
31
+ ["Extra Visits", "Task Calendar"],
31
32
  ...["Cleaning", "Technician", "Pest", "Pool", "Landscape"].flatMap((t): [string, string][] => [
32
33
  [`${t} Schedule Area`, "Area"],
33
34
  [`${t} Schedule Unit`, "Unit"],
@@ -405,3 +405,48 @@ export function historyUnitLabel(status: string | null | undefined): "Done" | "N
405
405
  if (s === "rejected" || s === "needs redo") return "Needs redo";
406
406
  return "Missed";
407
407
  }
408
+
409
+ /** "Visit 2": one round of an area's units ("set" in the data). Same word as "Visits per day". */
410
+ export function visitLabel(set: number | string | null | undefined): string {
411
+ return set === null || set === undefined || set === "" ? "Visit" : `Visit ${set}`;
412
+ }
413
+
414
+ /**
415
+ * Is this day's list closed (view only)? The day list says so when it is
416
+ * opened from there; opened from a link or after a refresh only the rows can
417
+ * tell, and at midnight the server closes every area and unit at once.
418
+ */
419
+ export function isClosedDay(
420
+ dayStatus: string | null | undefined,
421
+ rows: { status?: string | null; units?: { status?: string | null }[] | null }[] | null | undefined = [],
422
+ ): boolean {
423
+ const closed = (s: unknown) => String(s ?? "").trim().toLowerCase() === "closed";
424
+ if (closed(dayStatus)) return true;
425
+ return (rows ?? []).some((r) => closed(r?.status) || (r?.units ?? []).some((u) => closed(u?.status)));
426
+ }
427
+
428
+ /** "Fri 24 Oct 2026" (Singapore), the day a list is for; "" when unknown. */
429
+ export function dayLabel(value: string | Date | null | undefined): string {
430
+ if (!value) return "";
431
+ const d = new Date(value);
432
+ if (Number.isNaN(d.getTime())) return "";
433
+ return d.toLocaleDateString("en-SG", {
434
+ weekday: "short",
435
+ day: "numeric",
436
+ month: "short",
437
+ year: "numeric",
438
+ timeZone: "Asia/Singapore",
439
+ });
440
+ }
441
+
442
+ /**
443
+ * The saved PDF's name: the DAY the list is for (Singapore), so three past
444
+ * days downloaded in a row do not all carry today's time.
445
+ * "Daily-Checklist-2026-10-24.pdf"; without a day, the old download-time name.
446
+ */
447
+ export function checklistPdfName(day: string | Date | null | undefined, now: Date = new Date()): string {
448
+ const d = day ? new Date(day) : null;
449
+ if (d && !Number.isNaN(d.getTime())) return `Daily-Checklist-${sgtParts(d).date}.pdf`;
450
+ const p = (n: number) => String(n).padStart(2, "0");
451
+ return `CHECKLIST_DOWNLOADED_${p(now.getDate())}_${p(now.getMonth() + 1)}_${now.getFullYear()} ${p(now.getHours())}_${p(now.getMinutes())}.pdf`;
452
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Trade apps' Task Calendar (owner 24 Oct 2026): which tab to show, and the
3
+ * words of its one help box. Pure: no Vue, unit-tested with `node --test`.
4
+ */
5
+
6
+ export type TTaskCalendarTab = "agent" | "extra";
7
+
8
+ /**
9
+ * The tab to open: the address (?tab=) when the viewer may see it, else the
10
+ * page's own start tab (an old Extra Checks link starts on "extra"), else the
11
+ * first tab the viewer may see. "" = no tab at all (role has neither).
12
+ */
13
+ export function pickTaskCalendarTab(
14
+ query: unknown,
15
+ initial: unknown,
16
+ allowed: { agent: boolean; extra: boolean },
17
+ ): TTaskCalendarTab | "" {
18
+ const ok = (t: unknown): t is TTaskCalendarTab =>
19
+ (t === "agent" && allowed.agent) || (t === "extra" && allowed.extra);
20
+ const asked = Array.isArray(query) ? query[0] : query;
21
+ if (ok(asked)) return asked;
22
+ if (ok(initial)) return initial;
23
+ if (allowed.agent) return "agent";
24
+ if (allowed.extra) return "extra";
25
+ return "";
26
+ }
27
+
28
+ /** One line per status word, only for the tabs the viewer sees. */
29
+ export function taskCalendarWords(show: { agent: boolean; extra: boolean }): { word: string; meaning: string; tone?: "info" | "err" }[] {
30
+ const words: { word: string; meaning: string; tone?: "info" | "err" }[] = [];
31
+ if (show.agent) {
32
+ words.push(
33
+ { word: "Upcoming", meaning: "Managing agent job, not due yet.", tone: "info" },
34
+ { word: "Overdue", meaning: "Managing agent job, due date passed and not marked done.", tone: "err" },
35
+ );
36
+ }
37
+ if (show.extra) {
38
+ words.push(
39
+ { word: "Active", meaning: "Extra check: it will be added at its next date and time." },
40
+ { word: "Paused", meaning: "Extra check: stopped for now. Press Resume to start it again." },
41
+ { word: "Finished", meaning: "Extra check: its last date has passed. Press \"Extend or repeat\" to keep it going." },
42
+ { word: "In progress", meaning: "Last check: some units are ticked, the rest can still be done today." },
43
+ { word: "Missed", meaning: "Last check: the day ended with units not done." },
44
+ { word: "Needs redo", meaning: "Last check: a supervisor sent a unit back to be done again." },
45
+ );
46
+ }
47
+ const done = [
48
+ show.agent ? "the managing agent marked the job done" : "",
49
+ show.extra ? "staff ticked every unit of the extra check" : "",
50
+ ].filter(Boolean);
51
+ if (done.length) {
52
+ const text = done.join("; or ");
53
+ words.push({ word: "Done", meaning: `${text.charAt(0).toUpperCase()}${text.slice(1)}.` });
54
+ }
55
+ return words;
56
+ }
@@ -27,7 +27,11 @@ export type VehicleActionPermissions = {
27
27
  canUpdateVehicle?: boolean;
28
28
  canDeleteVehicle?: boolean;
29
29
  canApproveVehicle?: boolean;
30
- /** Block / Unblock. Omitted = allowed (today's behaviour). */
30
+ /**
31
+ * Block / Unblock, and every write on a Block Listed plate (type blocklist):
32
+ * Edit, Delete and Restore on it alone, Approve together with
33
+ * `canApproveVehicle`. Omitted = today's rule (Update / Delete / Approve).
34
+ */
31
35
  canManageBlocklist?: boolean;
32
36
  };
33
37
 
@@ -89,13 +93,27 @@ export function vehiclePlateActions(
89
93
  permissions.canManageBlocklist === undefined
90
94
  ? canUpdate
91
95
  : !!permissions.canManageBlocklist;
96
+ // A Block Listed plate's Edit and Delete follow Manage Blocklist when the
97
+ // host passes it (owner, 2026-10-23); other plates keep their own boxes.
98
+ const blocklistGoverned =
99
+ type === "blocklist" && permissions.canManageBlocklist !== undefined;
100
+ const canEdit = blocklistGoverned ? canBlock : canUpdate;
101
+ const canDelete = blocklistGoverned
102
+ ? canBlock
103
+ : !!permissions.canDeleteVehicle;
104
+ // Approve a pending Block Listed plate: Manage Blocklist AND Approve Vehicle.
105
+ const canApprove = blocklistGoverned
106
+ ? canBlock && !!permissions.canApproveVehicle
107
+ : !!permissions.canApproveVehicle;
108
+ // Restore (a write, through the approve endpoint): Manage Blocklist alone.
109
+ const canRestore = blocklistGoverned ? canBlock : !!permissions.canApproveVehicle;
92
110
  const actions: VehicleAction[] = [];
93
111
 
94
- if (canUpdate && (isAllowed || isBlocked)) {
112
+ if (canEdit && (isAllowed || isBlocked)) {
95
113
  actions.push({ key: "edit", title: "Edit Vehicle", icon: "mdi-pencil" });
96
114
  }
97
115
 
98
- if (permissions.canApproveVehicle && isPending) {
116
+ if (canApprove && isPending) {
99
117
  actions.push({
100
118
  key: "approve",
101
119
  title: "Approve",
@@ -109,7 +127,7 @@ export function vehiclePlateActions(
109
127
  // with "You do not have permission to approve vehicles." without it. It
110
128
  // used to be offered to everyone, so a role without the right saw Restore,
111
129
  // pressed it and was refused - and it could not be hidden on that refusal.
112
- if (permissions.canApproveVehicle && isRestorable) {
130
+ if (canRestore && isRestorable) {
113
131
  actions.push({
114
132
  key: "restore",
115
133
  title: "Restore",
@@ -144,7 +162,7 @@ export function vehiclePlateActions(
144
162
  });
145
163
  }
146
164
 
147
- if (permissions.canDeleteVehicle && (isAllowed || isBlocked)) {
165
+ if (canDelete && (isAllowed || isBlocked)) {
148
166
  actions.push({
149
167
  key: "delete",
150
168
  title: "Delete",