@7365admin1/layer-common 4.144.0 → 4.147.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 +65 -4
- package/components/AddPassKeyToVisitor.vue +332 -54
- package/components/AreaChecklistHistoryLogs.vue +2 -2
- package/components/CleaningScheduleMain.vue +2 -2
- package/components/FileInput.vue +4 -0
- package/components/MemberInformation.vue +580 -281
- package/components/NricScanDialog.vue +512 -0
- package/components/PassInformation.vue +11 -2
- package/components/RolePermissionMain.vue +3 -1
- package/components/ScheduleTaskForm.vue +16 -17
- package/components/ScheduleTaskMain.vue +43 -39
- package/components/SubscriptionPlanTable.vue +45 -1
- package/components/TableHygiene.vue +4 -1
- package/components/VisitorForm.vue +1796 -642
- package/components/VisitorFormSelection.vue +51 -18
- package/components/VisitorManagement.vue +1641 -640
- package/components/VisitorPassKeyQRScanner.vue +1 -1
- package/components/VisitorSocketPopUp.vue +72 -23
- package/composables/usePeople.ts +54 -0
- package/composables/useVisitor.ts +4 -2
- package/composables/useVisitorSocket.ts +19 -0
- package/package.json +2 -1
- package/utils/breadcrumb.ts +2 -1
- package/utils/nav-sections.ts +1 -0
- package/utils/nric-card.ts +278 -0
- package/utils/permission-labels.ts +17 -6
- package/utils/role.ts +24 -0
- package/utils/schedule-task.ts +51 -9
- package/utils/visitor-people.ts +262 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
NRIC SCAN - the web side of the security mobile app's (isecure365) NRIC
|
|
3
|
+
"Scan" beside every visitor NRIC field.
|
|
4
|
+
|
|
5
|
+
The camera is pointed at the visitor's NRIC card. About once a second the
|
|
6
|
+
card frame is grabbed and its text read IN THE BROWSER (tesseract.js) by
|
|
7
|
+
`utils/nric-card.ts` - the app's own reader, ported as is.
|
|
8
|
+
|
|
9
|
+
Nothing is filled in by itself (the app, 2026-10-20). As soon as a number is
|
|
10
|
+
read, the scan pauses on "Check the details": the NRIC and the name exactly
|
|
11
|
+
as they will be filled in, with "Fill In" and "Scan Again", so the guard
|
|
12
|
+
compares them with the card and a wrong name never slips through.
|
|
13
|
+
- A number whose check letter matches is shown at once; if its name has not
|
|
14
|
+
been read yet, 3 reads of the same number are tried for it first.
|
|
15
|
+
- A number whose check letter does NOT match (a worn card, a specimen with a
|
|
16
|
+
dummy number), or an M-series FIN, must be read the same twice in a row,
|
|
17
|
+
so a one-off misread is never offered; the panel then warns about it.
|
|
18
|
+
|
|
19
|
+
Nothing is uploaded or kept: each frame lives on a canvas only while it is
|
|
20
|
+
read. (The reading engine and its English data are fetched once by the
|
|
21
|
+
browser from the tesseract.js CDN.)
|
|
22
|
+
-->
|
|
23
|
+
<template>
|
|
24
|
+
<v-dialog :model-value="modelValue" max-width="640" persistent
|
|
25
|
+
@update:model-value="emit('update:modelValue', $event)">
|
|
26
|
+
<v-card class="nric-scan screen-modal">
|
|
27
|
+
<v-card-title class="d-flex align-center justify-space-between">
|
|
28
|
+
<span>Scan NRIC</span>
|
|
29
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close NRIC scan"
|
|
30
|
+
@click="cancel" />
|
|
31
|
+
</v-card-title>
|
|
32
|
+
|
|
33
|
+
<v-card-text class="screen-modal__body">
|
|
34
|
+
<div class="nric-scan__viewport">
|
|
35
|
+
<video ref="videoElement" class="nric-scan__preview" autoplay muted playsinline />
|
|
36
|
+
|
|
37
|
+
<div v-if="!starting && !cameraError" class="nric-scan__frame-area">
|
|
38
|
+
<div class="nric-scan__frame" :style="{ width: `${FRAME_WIDTH * 100}%` }" />
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Paused on what was read: exactly what "Fill In" fills in. -->
|
|
42
|
+
<div v-if="found" class="nric-scan__panel-backdrop">
|
|
43
|
+
<div class="nric-scan__panel" data-test="nric-scan-check">
|
|
44
|
+
<div class="nric-scan__panel-title">Check the details</div>
|
|
45
|
+
<div class="nric-scan__panel-row">
|
|
46
|
+
<span class="nric-scan__panel-label">NRIC</span>
|
|
47
|
+
<span class="nric-scan__panel-value">{{ found.nric }}</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="nric-scan__panel-row">
|
|
50
|
+
<span class="nric-scan__panel-label">Name</span>
|
|
51
|
+
<span v-if="found.name" class="nric-scan__panel-value">{{ found.name }}</span>
|
|
52
|
+
<span v-else class="nric-scan__panel-missing">Not read. Type it in the form.</span>
|
|
53
|
+
</div>
|
|
54
|
+
<p v-if="!found.checkLetterOk" class="nric-scan__panel-warning">
|
|
55
|
+
The check letter does not match this number. Check it carefully.
|
|
56
|
+
</p>
|
|
57
|
+
<p class="nric-scan__panel-help">
|
|
58
|
+
Compare them with the card. If anything is wrong, scan again.
|
|
59
|
+
</p>
|
|
60
|
+
<div class="nric-scan__panel-actions">
|
|
61
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" @click="scanAgain">Scan Again</v-btn>
|
|
62
|
+
<v-btn class="text-none screen-btn-primary" variant="flat" @click="fillIn">Fill In</v-btn>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div v-if="starting" class="nric-scan__state">
|
|
68
|
+
<v-progress-circular indeterminate color="primary" />
|
|
69
|
+
<span class="mt-3">Starting camera...</span>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div v-else-if="cameraError" class="nric-scan__state px-6 text-center">
|
|
73
|
+
<v-icon icon="mdi-camera-off-outline" size="42" color="error" />
|
|
74
|
+
<span class="mt-3 text-error">{{ cameraError }}</span>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<canvas ref="canvasElement" class="d-none" />
|
|
79
|
+
|
|
80
|
+
<!-- Hidden while paused on the details: the panel says what to do. -->
|
|
81
|
+
<div v-if="!found" class="nric-scan__hint text-center" data-test="nric-scan-hint">
|
|
82
|
+
{{ hint }}
|
|
83
|
+
</div>
|
|
84
|
+
</v-card-text>
|
|
85
|
+
|
|
86
|
+
<div class="screen-modal__footer">
|
|
87
|
+
<v-btn class="text-none screen-btn-ghost" variant="flat" @click="cancel">Cancel</v-btn>
|
|
88
|
+
</div>
|
|
89
|
+
</v-card>
|
|
90
|
+
</v-dialog>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<script setup lang="ts">
|
|
94
|
+
import {
|
|
95
|
+
findUncheckedNric,
|
|
96
|
+
isNricChecksumValid,
|
|
97
|
+
readNricCard,
|
|
98
|
+
type CardLine,
|
|
99
|
+
} from "../utils/nric-card";
|
|
100
|
+
|
|
101
|
+
type NricScanResult = { nric: string; name: string | null };
|
|
102
|
+
|
|
103
|
+
const props = defineProps<{ modelValue: boolean }>();
|
|
104
|
+
const emit = defineEmits<{
|
|
105
|
+
"update:modelValue": [value: boolean];
|
|
106
|
+
read: [result: NricScanResult];
|
|
107
|
+
}>();
|
|
108
|
+
|
|
109
|
+
/** Reads of the same number tried for its name before the panel shows without one. */
|
|
110
|
+
const NAME_WAIT_READS = 3;
|
|
111
|
+
/** An NRIC is ID-1 size: 85.6 x 54 mm. */
|
|
112
|
+
const CARD_RATIO = 85.6 / 54;
|
|
113
|
+
/** The card frame's share of the picture's width. */
|
|
114
|
+
const FRAME_WIDTH = 0.85;
|
|
115
|
+
/** Width the frame is read at: text recognition wants the letters large. */
|
|
116
|
+
const READ_WIDTH = 1600;
|
|
117
|
+
/** Pause after a failed read, so a fault does not spin the camera. */
|
|
118
|
+
const RETRY_DELAY_MS = 500;
|
|
119
|
+
/** About once a second, as the app photographs the card. */
|
|
120
|
+
const READ_INTERVAL_MS = 1000;
|
|
121
|
+
|
|
122
|
+
const videoElement = ref<HTMLVideoElement | null>(null);
|
|
123
|
+
const canvasElement = ref<HTMLCanvasElement | null>(null);
|
|
124
|
+
const stream = shallowRef<MediaStream | null>(null);
|
|
125
|
+
const starting = ref(false);
|
|
126
|
+
const cameraError = ref("");
|
|
127
|
+
const preparing = ref(false);
|
|
128
|
+
const foundNumber = ref(false);
|
|
129
|
+
|
|
130
|
+
/** What the scan read, shown for the guard to check before "Fill In". */
|
|
131
|
+
type FoundCard = NricScanResult & {
|
|
132
|
+
/** False when the check letter does not match the number. */
|
|
133
|
+
checkLetterOk: boolean;
|
|
134
|
+
};
|
|
135
|
+
// The scan pauses on it until the guard fills it in or scans again.
|
|
136
|
+
const found = ref<FoundCard | null>(null);
|
|
137
|
+
|
|
138
|
+
const hint = computed(() => {
|
|
139
|
+
if (cameraError.value) return "You can close this and type the NRIC.";
|
|
140
|
+
if (preparing.value) return "Preparing the scanner...";
|
|
141
|
+
return foundNumber.value
|
|
142
|
+
? "NRIC found. Reading the name..."
|
|
143
|
+
: "Hold the front of the NRIC inside the frame.";
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
let worker: any = null;
|
|
147
|
+
let session = 0;
|
|
148
|
+
|
|
149
|
+
function wait(ms: number) {
|
|
150
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stopCamera() {
|
|
154
|
+
stream.value?.getTracks().forEach((track) => track.stop());
|
|
155
|
+
stream.value = null;
|
|
156
|
+
if (videoElement.value) videoElement.value.srcObject = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function stopWorker() {
|
|
160
|
+
const current = worker;
|
|
161
|
+
worker = null;
|
|
162
|
+
try {
|
|
163
|
+
await current?.terminate?.();
|
|
164
|
+
} catch {
|
|
165
|
+
// Already gone.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getCameraErrorMessage(caught: unknown) {
|
|
170
|
+
if (caught instanceof DOMException) {
|
|
171
|
+
if (caught.name === "NotAllowedError") {
|
|
172
|
+
return "Camera access is needed to scan the NRIC. Allow camera access and try again.";
|
|
173
|
+
}
|
|
174
|
+
if (caught.name === "NotFoundError") return "No camera was found on this device.";
|
|
175
|
+
if (caught.name === "NotReadableError") {
|
|
176
|
+
return "The camera is currently being used by another application.";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return "Unable to open the camera. Use HTTPS or check the browser camera permission.";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function startCamera(): Promise<boolean> {
|
|
183
|
+
stopCamera();
|
|
184
|
+
cameraError.value = "";
|
|
185
|
+
starting.value = true;
|
|
186
|
+
try {
|
|
187
|
+
if (!navigator.mediaDevices?.getUserMedia) throw new Error("Camera API is unavailable");
|
|
188
|
+
// The back camera on a tablet / phone; a laptop simply has the one.
|
|
189
|
+
stream.value = await navigator.mediaDevices.getUserMedia({
|
|
190
|
+
audio: false,
|
|
191
|
+
video: {
|
|
192
|
+
facingMode: { ideal: "environment" },
|
|
193
|
+
width: { ideal: 1920 },
|
|
194
|
+
height: { ideal: 1080 },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
await nextTick();
|
|
198
|
+
if (!videoElement.value) throw new Error("Camera preview is unavailable");
|
|
199
|
+
videoElement.value.srcObject = stream.value;
|
|
200
|
+
await videoElement.value.play();
|
|
201
|
+
return true;
|
|
202
|
+
} catch (caught: unknown) {
|
|
203
|
+
stopCamera();
|
|
204
|
+
cameraError.value = getCameraErrorMessage(caught);
|
|
205
|
+
return false;
|
|
206
|
+
} finally {
|
|
207
|
+
starting.value = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function getWorker() {
|
|
212
|
+
if (worker) return worker;
|
|
213
|
+
const { createWorker } = await import("tesseract.js");
|
|
214
|
+
worker = await createWorker("eng");
|
|
215
|
+
return worker;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The card frame of the current picture, enlarged, in grey. */
|
|
219
|
+
function grabFrame(): HTMLCanvasElement | null {
|
|
220
|
+
const video = videoElement.value;
|
|
221
|
+
const canvas = canvasElement.value;
|
|
222
|
+
if (!video || !canvas || !video.videoWidth || !video.videoHeight) return null;
|
|
223
|
+
|
|
224
|
+
const vw = video.videoWidth;
|
|
225
|
+
const vh = video.videoHeight;
|
|
226
|
+
const fw = Math.round(vw * FRAME_WIDTH);
|
|
227
|
+
const fh = Math.round(Math.min(fw / CARD_RATIO, vh));
|
|
228
|
+
const sx = Math.round((vw - fw) / 2);
|
|
229
|
+
const sy = Math.round((vh - fh) / 2);
|
|
230
|
+
|
|
231
|
+
const scale = Math.max(1, READ_WIDTH / fw);
|
|
232
|
+
canvas.width = Math.round(fw * scale);
|
|
233
|
+
canvas.height = Math.round(fh * scale);
|
|
234
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
235
|
+
if (!context) return null;
|
|
236
|
+
context.filter = "grayscale(1) contrast(1.2)";
|
|
237
|
+
context.drawImage(video, sx, sy, fw, fh, 0, 0, canvas.width, canvas.height);
|
|
238
|
+
return canvas;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
type OcrLine = { text?: string; bbox?: { x0: number; y0: number; x1: number; y1: number } };
|
|
242
|
+
|
|
243
|
+
/** tesseract.js lines as the reader's CardLine (v5 `lines`, v6 `blocks`). */
|
|
244
|
+
function toCardLines(data: any): CardLine[] {
|
|
245
|
+
let lines: OcrLine[] = Array.isArray(data?.lines) ? data.lines : [];
|
|
246
|
+
if (!lines.length && Array.isArray(data?.blocks)) {
|
|
247
|
+
lines = data.blocks.flatMap((block: any) =>
|
|
248
|
+
(block?.paragraphs ?? []).flatMap((paragraph: any) => paragraph?.lines ?? [])
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return lines
|
|
252
|
+
.filter((line) => line?.text && line.bbox)
|
|
253
|
+
.map((line) => ({
|
|
254
|
+
text: String(line.text).trim(),
|
|
255
|
+
x: line.bbox!.x0,
|
|
256
|
+
y: line.bbox!.y0,
|
|
257
|
+
width: line.bbox!.x1 - line.bbox!.x0,
|
|
258
|
+
height: line.bbox!.y1 - line.bbox!.y0,
|
|
259
|
+
}))
|
|
260
|
+
.filter((line) => line.text.length > 0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function readLoop(id: number) {
|
|
264
|
+
// The number read on the previous frame (an unchecked number must be read
|
|
265
|
+
// the same twice in a row).
|
|
266
|
+
let previousNumber: string | null = null;
|
|
267
|
+
// The number being held while its name is looked for, and that name.
|
|
268
|
+
let held: FoundCard | null = null;
|
|
269
|
+
let heldReads = 0;
|
|
270
|
+
|
|
271
|
+
preparing.value = true;
|
|
272
|
+
let recognizer: any;
|
|
273
|
+
try {
|
|
274
|
+
recognizer = await getWorker();
|
|
275
|
+
} catch {
|
|
276
|
+
if (id === session) cameraError.value = "The NRIC scanner could not be loaded. Please type the NRIC.";
|
|
277
|
+
return;
|
|
278
|
+
} finally {
|
|
279
|
+
preparing.value = false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
while (id === session && !found.value) {
|
|
283
|
+
const started = Date.now();
|
|
284
|
+
try {
|
|
285
|
+
const frame = grabFrame();
|
|
286
|
+
if (!frame) {
|
|
287
|
+
await wait(RETRY_DELAY_MS);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const { data } = await recognizer.recognize(frame, {}, { blocks: true });
|
|
291
|
+
if (id !== session) break;
|
|
292
|
+
|
|
293
|
+
const lines = toCardLines(data);
|
|
294
|
+
const read = readNricCard(lines);
|
|
295
|
+
// A number whose check letter matches (or an M-series FIN), else one
|
|
296
|
+
// with the right shape whose check letter does not.
|
|
297
|
+
const number = read.nric ?? findUncheckedNric(lines);
|
|
298
|
+
if (!number) {
|
|
299
|
+
previousNumber = null;
|
|
300
|
+
} else {
|
|
301
|
+
const check = isNricChecksumValid(number);
|
|
302
|
+
const sure = check === true || number === previousNumber;
|
|
303
|
+
previousNumber = number;
|
|
304
|
+
if (sure) {
|
|
305
|
+
foundNumber.value = true;
|
|
306
|
+
// Same number again: keep a name an earlier frame caught.
|
|
307
|
+
const previous: FoundCard | null = held;
|
|
308
|
+
const same = previous !== null && previous.nric === number;
|
|
309
|
+
const current: FoundCard = {
|
|
310
|
+
nric: number,
|
|
311
|
+
name: read.name ?? (same ? previous!.name : null),
|
|
312
|
+
checkLetterOk: check !== false,
|
|
313
|
+
};
|
|
314
|
+
held = current;
|
|
315
|
+
heldReads = same ? heldReads + 1 : 1;
|
|
316
|
+
if (current.name || heldReads >= NAME_WAIT_READS) {
|
|
317
|
+
found.value = current;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
// A frame grabbed while the camera closes, or an unreadable one.
|
|
324
|
+
await wait(RETRY_DELAY_MS);
|
|
325
|
+
}
|
|
326
|
+
const left = READ_INTERVAL_MS - (Date.now() - started);
|
|
327
|
+
if (left > 0) await wait(left);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function reset() {
|
|
332
|
+
session += 1;
|
|
333
|
+
foundNumber.value = false;
|
|
334
|
+
found.value = null;
|
|
335
|
+
stopCamera();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** "Fill In": exactly what the panel showed. */
|
|
339
|
+
function fillIn() {
|
|
340
|
+
const chosen = found.value;
|
|
341
|
+
if (!chosen) return;
|
|
342
|
+
reset();
|
|
343
|
+
emit("read", { nric: chosen.nric, name: chosen.name });
|
|
344
|
+
emit("update:modelValue", false);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** "Scan Again": the camera is still running; read afresh. */
|
|
348
|
+
function scanAgain() {
|
|
349
|
+
found.value = null;
|
|
350
|
+
foundNumber.value = false;
|
|
351
|
+
if (stream.value) void readLoop(session);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function cancel() {
|
|
355
|
+
reset();
|
|
356
|
+
emit("update:modelValue", false);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
watch(
|
|
360
|
+
() => props.modelValue,
|
|
361
|
+
async (isOpen) => {
|
|
362
|
+
if (!isOpen) {
|
|
363
|
+
reset();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
reset();
|
|
367
|
+
const id = session;
|
|
368
|
+
if (await startCamera()) {
|
|
369
|
+
if (id === session) void readLoop(id);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
onBeforeUnmount(() => {
|
|
375
|
+
reset();
|
|
376
|
+
void stopWorker();
|
|
377
|
+
});
|
|
378
|
+
</script>
|
|
379
|
+
|
|
380
|
+
<style scoped>
|
|
381
|
+
.nric-scan {
|
|
382
|
+
overflow: hidden;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.nric-scan__viewport {
|
|
386
|
+
position: relative;
|
|
387
|
+
width: 100%;
|
|
388
|
+
min-height: 240px;
|
|
389
|
+
overflow: hidden;
|
|
390
|
+
border-radius: var(--r-inner);
|
|
391
|
+
/* The stage a video sits on: dark in both themes, as the facial photo
|
|
392
|
+
camera's, so the preview does not flash white on light. */
|
|
393
|
+
background: #111722;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* Natural size, not `cover`: the frame drawn here is the part that is read. */
|
|
397
|
+
.nric-scan__preview {
|
|
398
|
+
display: block;
|
|
399
|
+
width: 100%;
|
|
400
|
+
height: auto;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.nric-scan__frame-area {
|
|
404
|
+
position: absolute;
|
|
405
|
+
inset: 0;
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: center;
|
|
408
|
+
justify-content: center;
|
|
409
|
+
pointer-events: none;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.nric-scan__frame {
|
|
413
|
+
aspect-ratio: 85.6 / 54;
|
|
414
|
+
max-height: 100%;
|
|
415
|
+
border: 3px solid #ffffff;
|
|
416
|
+
border-radius: 12px;
|
|
417
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* The details panel sits over the paused picture. Its own colours, not the
|
|
421
|
+
theme's surface: it is drawn on the dark camera stage in both themes. */
|
|
422
|
+
.nric-scan__panel-backdrop {
|
|
423
|
+
position: absolute;
|
|
424
|
+
inset: 0;
|
|
425
|
+
display: flex;
|
|
426
|
+
align-items: center;
|
|
427
|
+
justify-content: center;
|
|
428
|
+
padding: 16px;
|
|
429
|
+
background: rgba(0, 0, 0, 0.55);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.nric-scan__panel {
|
|
433
|
+
width: 100%;
|
|
434
|
+
max-width: 420px;
|
|
435
|
+
max-height: 100%;
|
|
436
|
+
overflow-y: auto;
|
|
437
|
+
padding: 16px;
|
|
438
|
+
border-radius: 12px;
|
|
439
|
+
background: #ffffff;
|
|
440
|
+
color: #111722;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.nric-scan__panel-title {
|
|
444
|
+
font-size: 16px;
|
|
445
|
+
font-weight: 800;
|
|
446
|
+
margin-bottom: 10px;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.nric-scan__panel-row {
|
|
450
|
+
display: grid;
|
|
451
|
+
grid-template-columns: 64px minmax(0, 1fr);
|
|
452
|
+
gap: 8px;
|
|
453
|
+
padding: 6px 0;
|
|
454
|
+
border-bottom: 1px solid rgba(17, 23, 34, 0.12);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.nric-scan__panel-label {
|
|
458
|
+
font-size: 13px;
|
|
459
|
+
color: #5b6372;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.nric-scan__panel-value {
|
|
463
|
+
font-size: 15px;
|
|
464
|
+
font-weight: 700;
|
|
465
|
+
word-break: break-word;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.nric-scan__panel-missing {
|
|
469
|
+
font-size: 13px;
|
|
470
|
+
font-style: italic;
|
|
471
|
+
color: #5b6372;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.nric-scan__panel-warning {
|
|
475
|
+
margin: 10px 0 0;
|
|
476
|
+
font-size: 13px;
|
|
477
|
+
font-weight: 600;
|
|
478
|
+
color: #b42318;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.nric-scan__panel-help {
|
|
482
|
+
margin: 8px 0 0;
|
|
483
|
+
font-size: 13px;
|
|
484
|
+
color: #5b6372;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.nric-scan__panel-actions {
|
|
488
|
+
display: flex;
|
|
489
|
+
justify-content: flex-end;
|
|
490
|
+
gap: 8px;
|
|
491
|
+
margin-top: 14px;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.nric-scan__state {
|
|
495
|
+
position: absolute;
|
|
496
|
+
inset: 0;
|
|
497
|
+
display: flex;
|
|
498
|
+
flex-direction: column;
|
|
499
|
+
align-items: center;
|
|
500
|
+
justify-content: center;
|
|
501
|
+
background: #111722;
|
|
502
|
+
color: #ffffff;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.nric-scan__hint {
|
|
506
|
+
margin-top: 12px;
|
|
507
|
+
font-family: var(--font-sans);
|
|
508
|
+
font-size: var(--fs-cell);
|
|
509
|
+
font-weight: 500;
|
|
510
|
+
color: var(--muted);
|
|
511
|
+
}
|
|
512
|
+
</style>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
v-model:search="passInput"
|
|
16
16
|
:hide-no-data="false"
|
|
17
17
|
class="mt-3 filter-field"
|
|
18
|
-
:items="
|
|
18
|
+
:items="passItemsOffered"
|
|
19
19
|
:rules="props.passRules"
|
|
20
20
|
item-title="prefixAndName"
|
|
21
21
|
item-value="_id"
|
|
@@ -113,6 +113,8 @@ type PassInformationProps = {
|
|
|
113
113
|
clearable?: boolean;
|
|
114
114
|
pass?: TPassKeyPayload[];
|
|
115
115
|
keys?: TPassKeyPayload[];
|
|
116
|
+
/** Passes picked elsewhere on the form (a contractor member's): not offered. */
|
|
117
|
+
excludeIds?: string[];
|
|
116
118
|
};
|
|
117
119
|
|
|
118
120
|
const props = withDefaults(defineProps<PassInformationProps>(), {
|
|
@@ -124,6 +126,7 @@ const props = withDefaults(defineProps<PassInformationProps>(), {
|
|
|
124
126
|
clearable: false,
|
|
125
127
|
pass: [],
|
|
126
128
|
keys: [],
|
|
129
|
+
excludeIds: () => [],
|
|
127
130
|
});
|
|
128
131
|
|
|
129
132
|
const emit = defineEmits(["update:pass", "update:keys"]);
|
|
@@ -148,6 +151,12 @@ const scannedValue = ref("");
|
|
|
148
151
|
|
|
149
152
|
const { getPassKeysByPageSearch } = usePassKey();
|
|
150
153
|
|
|
154
|
+
const passItemsOffered = computed(() =>
|
|
155
|
+
(passItems.value ?? []).filter(
|
|
156
|
+
(item) => !props.excludeIds.includes(item._id ?? "") || item._id === selectedPass.value
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
|
|
151
160
|
const typeItems = [
|
|
152
161
|
{
|
|
153
162
|
label: "QR Pass",
|
|
@@ -281,7 +290,7 @@ watch(scannedValue, async (newValue) => {
|
|
|
281
290
|
await fetchKeys();
|
|
282
291
|
}
|
|
283
292
|
|
|
284
|
-
const matchedPass =
|
|
293
|
+
const matchedPass = passItemsOffered.value.find((item) => {
|
|
285
294
|
return getItemLabel(item).toLowerCase() === scanned.toLowerCase();
|
|
286
295
|
});
|
|
287
296
|
|
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
-->
|
|
69
69
|
<!-- "Admin · St. Regis Residences": names are unique per site (2026-10-01) -->
|
|
70
70
|
<template #item.name="{ item }">{{ roleLabel(item) }}</template>
|
|
71
|
+
<!-- "App" column: plain app name, not the stored code (security_agency) -->
|
|
72
|
+
<template #item.type="{ item }">{{ roleAppLabel(item.type) }}</template>
|
|
71
73
|
<template #item.scope="{ item }">
|
|
72
74
|
<StatusChip
|
|
73
75
|
:dot="false"
|
|
@@ -214,7 +216,7 @@
|
|
|
214
216
|
|
|
215
217
|
<script setup lang="ts">
|
|
216
218
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
217
|
-
import { roleLabel, safeReturnTo, showSiteField, sitePickerRoleLabel } from "../utils/role";
|
|
219
|
+
import { roleAppLabel, roleLabel, safeReturnTo, showSiteField, sitePickerRoleLabel } from "../utils/role";
|
|
218
220
|
import { holdsPermission } from "../utils/permission-spellings";
|
|
219
221
|
import { countRealPermissions } from "../utils/security-catalogue";
|
|
220
222
|
import {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
ADD / EDIT AN EXTRA
|
|
2
|
+
ADD / EDIT AN EXTRA CHECK - four short steps: What, Where, When, Who is told.
|
|
3
3
|
|
|
4
4
|
The last step shows one sentence saying exactly what will be created
|
|
5
|
-
("Adds 1 extra
|
|
5
|
+
("Adds 1 extra check to 3 areas (24 units) at 08:00 every weekday ... next:
|
|
6
6
|
Wed 21 Oct"). The form checks what the server checks (utils/schedule-task.ts)
|
|
7
7
|
so a schedule that would never run is caught before saving; whatever the
|
|
8
8
|
server still refuses is shown in its own words.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<v-dialog v-model="show" max-width="760" persistent scrollable>
|
|
12
12
|
<v-card class="screen-modal">
|
|
13
13
|
<v-card-title class="d-flex align-center">
|
|
14
|
-
{{ props.mode === "edit" ? "Edit Extra
|
|
14
|
+
{{ props.mode === "edit" ? "Edit Extra Check" : "Add Extra Check" }}
|
|
15
15
|
<v-spacer />
|
|
16
16
|
<v-btn
|
|
17
17
|
icon="mdi-close"
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
<v-stepper-window-item :value="1">
|
|
50
50
|
<div class="st-step">
|
|
51
51
|
<p class="st-step__help">
|
|
52
|
-
Give the extra
|
|
52
|
+
Give the extra check a name staff will recognise, and say
|
|
53
53
|
what they should do.
|
|
54
54
|
</p>
|
|
55
55
|
<InputLabel for="title" title="Title" required />
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
class="filter-field mb-4"
|
|
62
62
|
placeholder="For example: Lunch toilet check"
|
|
63
63
|
:error-messages="fieldError('title')"
|
|
64
|
-
hint="Staff see this name on their phone
|
|
64
|
+
hint="Staff see this name on their phone and on the Daily Checklist."
|
|
65
65
|
persistent-hint
|
|
66
66
|
hide-details="auto"
|
|
67
67
|
/>
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
density="comfortable"
|
|
74
74
|
class="filter-field"
|
|
75
75
|
rows="3"
|
|
76
|
-
placeholder="Optional. Anything staff should know for this
|
|
76
|
+
placeholder="Optional. Anything staff should know for this check."
|
|
77
77
|
hint="Optional. For example: refill soap and tissue as well."
|
|
78
78
|
persistent-hint
|
|
79
79
|
hide-details="auto"
|
|
@@ -85,9 +85,8 @@
|
|
|
85
85
|
<v-stepper-window-item :value="2">
|
|
86
86
|
<div class="st-step">
|
|
87
87
|
<p class="st-step__help">
|
|
88
|
-
Pick the areas
|
|
89
|
-
|
|
90
|
-
for this visit.
|
|
88
|
+
Pick the areas to check. Every unit in a picked area gets
|
|
89
|
+
one more tick box on the Daily Checklist for this check.
|
|
91
90
|
</p>
|
|
92
91
|
<InputLabel for="selected-areas" title="Areas" required />
|
|
93
92
|
<v-select
|
|
@@ -170,7 +169,7 @@
|
|
|
170
169
|
<div class="st-step">
|
|
171
170
|
<p class="st-step__help">
|
|
172
171
|
Choose the time, and whether it happens once or repeats.
|
|
173
|
-
Times are Singapore time. The
|
|
172
|
+
Times are Singapore time. The check appears on staff's
|
|
174
173
|
checklist at this time.
|
|
175
174
|
</p>
|
|
176
175
|
<v-row dense>
|
|
@@ -305,8 +304,8 @@
|
|
|
305
304
|
class="mb-4"
|
|
306
305
|
title="Who is told"
|
|
307
306
|
>
|
|
308
|
-
Staff
|
|
309
|
-
|
|
307
|
+
Staff of this site who use the mobile app get a phone
|
|
308
|
+
notification when the check is added to their checklist.
|
|
310
309
|
</v-alert>
|
|
311
310
|
|
|
312
311
|
<div class="st-summary">
|
|
@@ -321,7 +320,7 @@
|
|
|
321
320
|
<div class="st-preview">
|
|
322
321
|
<div class="st-preview__head">
|
|
323
322
|
<v-icon size="18" class="mr-1">mdi-clock-outline</v-icon>
|
|
324
|
-
{{ previewTime }} · Extra
|
|
323
|
+
{{ previewTime }} · Extra check
|
|
325
324
|
</div>
|
|
326
325
|
<div class="st-preview__title">
|
|
327
326
|
{{ subject.trim() || "(your title)" }}
|
|
@@ -391,7 +390,7 @@
|
|
|
391
390
|
:loading="submitting"
|
|
392
391
|
@click="submitTask"
|
|
393
392
|
>
|
|
394
|
-
{{ props.mode === "edit" ? "Save Changes" : "Add Extra
|
|
393
|
+
{{ props.mode === "edit" ? "Save Changes" : "Add Extra Check" }}
|
|
395
394
|
</v-btn>
|
|
396
395
|
</v-card-actions>
|
|
397
396
|
</v-card>
|
|
@@ -667,7 +666,7 @@ const loadTaskData = async () => {
|
|
|
667
666
|
} catch (error: any) {
|
|
668
667
|
if ((props.taskData as any)?.title) setFormFromTask(props.taskData);
|
|
669
668
|
showMessage(
|
|
670
|
-
error?.data?.message || "Could not load this extra
|
|
669
|
+
error?.data?.message || "Could not load this extra check.",
|
|
671
670
|
"error",
|
|
672
671
|
);
|
|
673
672
|
} finally {
|
|
@@ -719,7 +718,7 @@ const submitTask = async () => {
|
|
|
719
718
|
|
|
720
719
|
showMessage(
|
|
721
720
|
response?.message ||
|
|
722
|
-
`Extra
|
|
721
|
+
`Extra check ${props.mode === "edit" ? "saved" : "added"}.`,
|
|
723
722
|
"success",
|
|
724
723
|
);
|
|
725
724
|
|
|
@@ -729,7 +728,7 @@ const submitTask = async () => {
|
|
|
729
728
|
} catch (error: any) {
|
|
730
729
|
// The server's own plain-English message (permission, validation, ...).
|
|
731
730
|
serverError.value =
|
|
732
|
-
error?.data?.message || "Could not save this extra
|
|
731
|
+
error?.data?.message || "Could not save this extra check. Please try again.";
|
|
733
732
|
showMessage(serverError.value, "error");
|
|
734
733
|
} finally {
|
|
735
734
|
submitting.value = false;
|