@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 +35 -4
- package/components/AnprCameraBadge.vue +15 -2
- package/components/AreaChecklistHistoryLogs.vue +2 -2
- package/components/CameraForm.vue +73 -17
- package/components/CameraMain.vue +12 -3
- package/components/CleaningScheduleMain.vue +9 -4
- package/components/ManageChecklistMain.vue +22 -8
- package/components/ScheduleAreaMain.vue +20 -6
- package/components/ScheduleGuide.vue +7 -1
- package/components/ScheduleTaskMain.vue +4 -2
- package/components/Snackbar.vue +36 -0
- package/components/TableHygiene.vue +2 -2
- package/components/TableMain.vue +12 -1
- package/components/TaskCalendarMain.vue +200 -0
- package/components/TaskCalendarReadOnly.vue +22 -3
- package/components/VehicleAddSelection.vue +11 -4
- package/components/VehicleManagement.vue +83 -30
- package/components/VisitorDataFromScannedQRCodeForm.vue +6 -1
- package/components/VisitorForm.vue +43 -4
- package/components/VisitorManagement.vue +145 -8
- package/composables/useCleaningSchedules.ts +5 -9
- package/composables/useVisitorCameraChoices.ts +61 -0
- package/package.json +1 -1
- package/types/camera.d.ts +2 -0
- package/utils/anpr-direction.ts +7 -6
- package/utils/breadcrumb.ts +3 -2
- package/utils/schedule-task.ts +45 -0
- package/utils/task-calendar-tabs.ts +56 -0
- package/utils/vehicle-actions.ts +23 -5
- package/utils/visitor-barrier.ts +42 -0
- package/utils/visitor-camera.ts +80 -0
|
@@ -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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
3430
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
package/utils/anpr-direction.ts
CHANGED
|
@@ -12,8 +12,11 @@ export const isAllowCameraDirection = (direction: unknown): boolean =>
|
|
|
12
12
|
String(direction ?? "").trim().toLowerCase(),
|
|
13
13
|
);
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
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
|
-
* -
|
|
52
|
-
*
|
|
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,
|
package/utils/breadcrumb.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
["
|
|
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"],
|
package/utils/schedule-task.ts
CHANGED
|
@@ -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
|
+
}
|
package/utils/vehicle-actions.ts
CHANGED
|
@@ -27,7 +27,11 @@ export type VehicleActionPermissions = {
|
|
|
27
27
|
canUpdateVehicle?: boolean;
|
|
28
28
|
canDeleteVehicle?: boolean;
|
|
29
29
|
canApproveVehicle?: boolean;
|
|
30
|
-
/**
|
|
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 (
|
|
112
|
+
if (canEdit && (isAllowed || isBlocked)) {
|
|
95
113
|
actions.push({ key: "edit", title: "Edit Vehicle", icon: "mdi-pencil" });
|
|
96
114
|
}
|
|
97
115
|
|
|
98
|
-
if (
|
|
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 (
|
|
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 (
|
|
165
|
+
if (canDelete && (isAllowed || isBlocked)) {
|
|
148
166
|
actions.push({
|
|
149
167
|
key: "delete",
|
|
150
168
|
title: "Delete",
|