@7365admin1/layer-common 4.146.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 +53 -4
- package/components/AddPassKeyToVisitor.vue +332 -54
- 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/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/nric-card.ts +278 -0
- package/utils/visitor-people.ts +262 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.147.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a071f19: Visitor list, Add and Register follow the security mobile app (isecure365) for every visitor type - the same functions and process, the web keeps its own styling.
|
|
8
|
+
|
|
9
|
+
Visitor form (Drive-In, Walk-In, Contractor, Delivery, Pick-Up, Drop-Off):
|
|
10
|
+
|
|
11
|
+
- One page with only Submit ("Update" when saving onto a record) - Contractor no longer pages. Order as the app: NRIC, Phone, Vehicle, Name, Contractor / Delivery Type, Block, Level, Unit, Registered Company Name, Company Name, Photos (Delivery, up to 5), Remarks, then Pass & Keys, Members and Entry Pass.
|
|
12
|
+
- Pass & Keys (and a member's Visitor Pass) only with See All Passes And Keys AND Update Passes And Keys (new `canAssignPassKeys` prop, default off). A contractor's pass waits for the Contractor Type; changing to a type that takes another pass asks first and clears the passes picked.
|
|
13
|
+
- Delivery type saved as `food` / `parcel` (Food by default); the delivery company snaps to the listed spelling and a new one is added to the site's list after the save.
|
|
14
|
+
- Saved the same way for every type: empty fields left out of a new visit and sent as null on an update; checked in on save (a back-office user sends `clientUserType`, no times); `createdBy` / `updatedBy`; `status: "registered"` and no `unitName` when registering; pick-up / drop-off remarks default to the type; contractor passes / keys / members as the app (`add: true` on a record).
|
|
15
|
+
- Checks in the app's order: unregistered plate, then not-checked-out (only with an NRIC or phone; not on a plain edit). Skip NRIC and each check-out from the list carry the Submit on by itself; a visitor holding a pass or key records what came back before the check-out.
|
|
16
|
+
- Ban / white list (900 / 901) and a refused pass ("Pass VP01 is In Use") handled for every type. Entry pass only for a contractor with the NFC pass switch, issued for a register too; a failed issue says "Visitor saved, pass not issued".
|
|
17
|
+
- Autofill: looked up across every visitor type (walk-in included, never pick-up / drop-off); one match fills straight away; a registered camera plate fills only the empty fields; companies offered only from the same type; never block / level / unit; chooser Back / Fill In; delete a wrongly saved person from the match cards.
|
|
18
|
+
- Contractor members: "Add Member" opens a dialog (NRIC, Phone, Name, Visitor Pass), the app's duplicate / required checks and the member's not-checked-out check; cards with Remove; up to 10.
|
|
19
|
+
|
|
20
|
+
Visitor list: Visitors / Guests / Unregistered Visitor tabs and filters as the app (Unregistered needs an entry ANPR camera too), 30-day Singapore dates, the app's row content, check-out / pass & key / NFC return dialogs with `updatedBy`, pass & key assigning gated on `canAssignPassKeys`, register picker "Register {plate} as".
|
|
21
|
+
|
|
22
|
+
Earlier in this change (Drive-In and Walk-In):
|
|
23
|
+
|
|
24
|
+
- Pass & Keys sit under Remarks; no keys for Drive-In / Walk-In (`passKeys: []`), one visitor pass (`add: true` when registering a record).
|
|
25
|
+
- Order of checks: unregistered plate, then not-checked-out (now also when registering an unregistered record).
|
|
26
|
+
- New visit: empty fields left out, checked in on save (`checkIn` now); a back-office user (service-provider / site) sends `clientUserType` with no check-in / check-out.
|
|
27
|
+
- Registering: emptied fields are sent as null; no unitName.
|
|
28
|
+
- Picking an unregistered plate match keeps what was typed and takes only the record's plate.
|
|
29
|
+
- A ban / white list answer (code 900 / 901) is shown as an error instead of a save; a pass the server says is in use is unselected.
|
|
30
|
+
|
|
31
|
+
NRIC, as the security mobile app, for every visitor type with an NRIC (Drive-In, Walk-In, Contractor, Delivery) and the contractor member input:
|
|
32
|
+
|
|
33
|
+
- an eye to show / hide the NRIC;
|
|
34
|
+
- "Scan" opens the camera (new `NricScanDialog`) and reads the NRIC and name off the card in the browser (tesseract.js), with the app's own reader (`utils/nric-card.ts`, ported as is): accepted only on a matching check letter (an M-series FIN read twice), number alone after 3 reads without a name. Nothing is uploaded or kept.
|
|
35
|
+
|
|
36
|
+
New plate notice and Register, as the security mobile app:
|
|
37
|
+
|
|
38
|
+
- shown only for a read with both the plate and the record id, when the site's Unregistered switch is on AND it has an entry / both ANPR camera; on every page, the Unregistered tab included; stays until closed (no 20 s timeout);
|
|
39
|
+
- the record for Register is kept apart (`registerVisitorFormData`) and only a new arrival replaces it - camera alerts and list reloads no longer wipe it or cancel a pending Register; cleared on a site switch and when the layout unmounts (logout);
|
|
40
|
+
- the button reads "Register" with the visitor Update right (new `canUpdateVisitor` prop), otherwise "Go to Unregistered"; the type picker opens only with the Update right and the record is used once; the row's Register button now needs Update (was Add);
|
|
41
|
+
- registering an unregistered record runs the not-checked-out check for every type.
|
|
42
|
+
- Register (row or notice) opens the form straight away with only the record's id and the camera's plate (editable) - nothing fetched or looked up in the loaded rows, so a record not on the current page no longer leaves Submit doing nothing; the guard keys in the rest and the save updates the record by its id.
|
|
43
|
+
- Unregistered-plate list: "Cancel & Add New" now carries on with the paused Submit (or Next) straight away as a new visitor, without checking that plate against the Unregistered list again.
|
|
44
|
+
- NRIC scan, as the security mobile app (2026-10-20): pauses on "Check the details" (NRIC and name) with "Fill In" / "Scan Again" instead of filling in by itself; a number whose check letter fails is offered, with a warning, once read the same twice in a row; the name is read without its small "Name" label and the Race value is never taken for it.
|
|
45
|
+
|
|
46
|
+
Delivery photos and company list, richer Preview (2026-10-22):
|
|
47
|
+
|
|
48
|
+
- Delivery form: photos as the app (Gallery / Camera, up to 5, carousel with Edit / Delete, uploaded on Submit as `attachments`); the delivery company list is read when the input is clicked, and each listed company can be renamed or removed (the site's latest list is re-read first). `FileInput` exposes `openCameraDialog`.
|
|
49
|
+
- Preview: Type and Contractor Type first; a delivery's Company; Pass and Keys as the list's chips; the Lead card's pass (names looked up from ids); Photos and ANPR Snapshots in carousels with a full-screen view; the Manual Checkout "i" only for a manual check-out.
|
|
50
|
+
- Pass & key menu and dialog: "Add Key" with no keys held, "Add/Remove Key" once the visitor holds keys.
|
|
51
|
+
|
|
3
52
|
## 4.146.0
|
|
4
53
|
|
|
5
54
|
### Minor Changes
|
|
@@ -3432,11 +3481,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3432
3481
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3433
3482
|
own rule from two endpoints the console already calls, unprojected:
|
|
3434
3483
|
|
|
3435
|
-
|
|
3436
|
-
|
|
3484
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3485
|
+
GET /api/roles/id/:role that membership's role document
|
|
3437
3486
|
|
|
3438
|
-
|
|
3439
|
-
|
|
3487
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3488
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3440
3489
|
|
|
3441
3490
|
`role.default` is the marker because it is the only property of a platform
|
|
3442
3491
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1,41 +1,51 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-card width="100%" class="screen-modal passkey" :loading="processing">
|
|
3
3
|
<div class="passkey__head">
|
|
4
|
-
<v-card-title>{{
|
|
5
|
-
<
|
|
6
|
-
variant="icon"
|
|
7
|
-
icon="mdi-close"
|
|
8
|
-
aria-label="Close"
|
|
9
|
-
@click="emit('close')"
|
|
10
|
-
/>
|
|
4
|
+
<v-card-title>{{ dialogTitle }}</v-card-title>
|
|
5
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close" @click="emit('close')" />
|
|
11
6
|
</div>
|
|
12
7
|
|
|
13
8
|
<div class="screen-modal__body passkey__body">
|
|
14
9
|
<!-- `.filter-field` is the design's input, and it is a descendant
|
|
15
10
|
selector, so both autocompletes get it from this one class. -->
|
|
16
11
|
<v-row no-gutters class="ga-1 filter-field">
|
|
12
|
+
<!-- Whose passes and keys these are, as the return dialog. -->
|
|
17
13
|
<v-col cols="12">
|
|
18
|
-
<
|
|
14
|
+
<div class="passkey__who">
|
|
15
|
+
<div class="passkey__who-line font-weight-bold">
|
|
16
|
+
<v-icon icon="mdi-account-outline" size="18" />
|
|
17
|
+
{{ prop.visitor.name || "N/A" }}
|
|
18
|
+
</div>
|
|
19
|
+
<div class="passkey__who-line">
|
|
20
|
+
<v-icon icon="mdi-storefront-outline" size="18" />
|
|
21
|
+
{{ visitorLocation }}
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
19
24
|
</v-col>
|
|
20
25
|
|
|
21
26
|
<!-- Pass section -->
|
|
22
|
-
<v-col cols="12" class="mt-3">
|
|
23
|
-
<
|
|
27
|
+
<v-col v-if="showPass" cols="12" class="mt-3">
|
|
28
|
+
<p class="passkey__label">Pass</p>
|
|
24
29
|
|
|
25
30
|
<!-- Existing pass -->
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
<template v-if="existingPass">
|
|
32
|
+
<p class="passkey__sublabel">Current</p>
|
|
33
|
+
<div class="d-flex flex-wrap ga-2 mb-3">
|
|
34
|
+
<v-chip variant="outlined" prepend-icon="mdi-card-bulleted-outline"
|
|
35
|
+
class="font-weight-bold passkey-chip passkey-chip--pass">
|
|
36
|
+
{{ existingPass.prefixAndName }}
|
|
37
|
+
</v-chip>
|
|
38
|
+
</div>
|
|
39
|
+
<p class="passkey__sublabel">New pass</p>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<!-- New pass selector, with Scan Pass beside it (as iSecure). -->
|
|
43
|
+
<div class="scan-row">
|
|
35
44
|
<v-autocomplete
|
|
36
45
|
v-model="selectedPass"
|
|
46
|
+
class="scan-row__field"
|
|
37
47
|
v-model:search="passInput"
|
|
38
|
-
|
|
48
|
+
|
|
39
49
|
:hide-no-data="false"
|
|
40
50
|
:items="passItems"
|
|
41
51
|
item-title="prefixAndName"
|
|
@@ -61,49 +71,52 @@
|
|
|
61
71
|
</v-list-item>
|
|
62
72
|
</template>
|
|
63
73
|
</v-autocomplete>
|
|
74
|
+
<v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn" prepend-icon="mdi-qrcode"
|
|
75
|
+
data-test="passkey-scan-pass" :disabled="processing" @click="openScanner('pass')">
|
|
76
|
+
Scan Pass
|
|
77
|
+
</v-btn>
|
|
78
|
+
</div>
|
|
79
|
+
<p v-if="passNotice" :class="['passkey__notice', passNotice.error && 'passkey__notice--error']">
|
|
80
|
+
{{ passNotice.text }}
|
|
81
|
+
</p>
|
|
64
82
|
</v-col>
|
|
65
83
|
|
|
66
84
|
<!-- Keys section -->
|
|
67
85
|
<v-col v-if="showKeys" cols="12" class="mt-3">
|
|
68
|
-
<
|
|
86
|
+
<p class="passkey__label">Keys</p>
|
|
69
87
|
|
|
70
|
-
<!-- Existing keys -->
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
class="d-flex flex-wrap ga-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
>
|
|
84
|
-
<v-icon icon="mdi-key" size="13" />
|
|
85
|
-
{{ key.prefixAndName }}
|
|
86
|
-
<button
|
|
87
|
-
class="passkey__remove"
|
|
88
|
-
:aria-label="`Remove ${key.prefixAndName}`"
|
|
89
|
-
@click="removeExistingKey(key)"
|
|
88
|
+
<!-- Existing keys: ✕ takes one off (saved as "Removed"). -->
|
|
89
|
+
<template v-if="existingKeys.some((k) => k.status != 'Removed')">
|
|
90
|
+
<p class="passkey__sublabel">Current</p>
|
|
91
|
+
<div class="d-flex flex-wrap ga-2 mb-3">
|
|
92
|
+
<v-chip
|
|
93
|
+
v-for="key in existingKeys.filter((k) => k.status != 'Removed')"
|
|
94
|
+
:key="key.keyId"
|
|
95
|
+
variant="outlined"
|
|
96
|
+
prepend-icon="mdi-key"
|
|
97
|
+
closable
|
|
98
|
+
:close-label="`Remove ${key.prefixAndName}`"
|
|
99
|
+
class="font-weight-bold passkey-chip passkey-chip--key"
|
|
100
|
+
@click:close="removeExistingKey(key)"
|
|
90
101
|
>
|
|
91
|
-
|
|
92
|
-
</
|
|
93
|
-
</
|
|
94
|
-
<
|
|
95
|
-
</
|
|
96
|
-
|
|
97
|
-
<!-- New keys selector -->
|
|
102
|
+
{{ key.prefixAndName }}
|
|
103
|
+
</v-chip>
|
|
104
|
+
</div>
|
|
105
|
+
<p class="passkey__sublabel">Add more keys</p>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<!-- New keys selector, with Scan Key beside it (as iSecure). -->
|
|
109
|
+
<div class="scan-row">
|
|
98
110
|
<v-autocomplete
|
|
99
111
|
v-model="selectedKeys"
|
|
112
|
+
class="scan-row__field"
|
|
100
113
|
v-model:search="keyInput"
|
|
101
114
|
:hide-no-data="false"
|
|
102
115
|
:items="keyItems"
|
|
103
116
|
item-title="prefixAndName"
|
|
104
117
|
item-value="_id"
|
|
105
118
|
:item-props="keyItemProps"
|
|
106
|
-
|
|
119
|
+
|
|
107
120
|
multiple
|
|
108
121
|
variant="outlined"
|
|
109
122
|
hide-details
|
|
@@ -127,6 +140,14 @@
|
|
|
127
140
|
</v-list-item>
|
|
128
141
|
</template>
|
|
129
142
|
</v-autocomplete>
|
|
143
|
+
<v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn" prepend-icon="mdi-qrcode"
|
|
144
|
+
data-test="passkey-scan-key" :disabled="processing" @click="openScanner('key')">
|
|
145
|
+
Scan Key
|
|
146
|
+
</v-btn>
|
|
147
|
+
</div>
|
|
148
|
+
<p v-if="keyNotice" :class="['passkey__notice', keyNotice.error && 'passkey__notice--error']">
|
|
149
|
+
{{ keyNotice.text }}
|
|
150
|
+
</p>
|
|
130
151
|
</v-col>
|
|
131
152
|
</v-row>
|
|
132
153
|
|
|
@@ -135,10 +156,13 @@
|
|
|
135
156
|
</v-row>
|
|
136
157
|
</div>
|
|
137
158
|
|
|
159
|
+
<VisitorPassKeyQRScanner v-if="scanner.open" :dialog="scanner.open" v-model:scanned-value="scanner.value"
|
|
160
|
+
@close-dialog="scanner.open = false" />
|
|
161
|
+
|
|
138
162
|
<div class="passkey__footer">
|
|
139
163
|
<AppButton variant="ghost" @click="emit('close')">Cancel</AppButton>
|
|
140
164
|
<AppButton :disabled="!canSubmit" @click="handleSubmit">
|
|
141
|
-
{{
|
|
165
|
+
{{ submitLabel }}
|
|
142
166
|
</AppButton>
|
|
143
167
|
</div>
|
|
144
168
|
</v-card>
|
|
@@ -149,6 +173,7 @@ import type { PropType } from "vue";
|
|
|
149
173
|
import usePassKey from "../composables/usePassKey";
|
|
150
174
|
import useVisitor from "../composables/useVisitor";
|
|
151
175
|
import useKeyPresses from "../composables/useKeyPresses";
|
|
176
|
+
import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
|
|
152
177
|
|
|
153
178
|
const prop = defineProps({
|
|
154
179
|
visitor: {
|
|
@@ -159,8 +184,13 @@ const prop = defineProps({
|
|
|
159
184
|
type: String,
|
|
160
185
|
required: true,
|
|
161
186
|
},
|
|
187
|
+
/**
|
|
188
|
+
* iSecure's pass & key dialogs: "add" is Assign (Assign Pass & Keys for a
|
|
189
|
+
* contractor, Add Pass otherwise), "add-keys" is Add Key(s) (contractor),
|
|
190
|
+
* "edit-pass" is Change Pass, "edit" the combined edit.
|
|
191
|
+
*/
|
|
162
192
|
mode: {
|
|
163
|
-
type: String as PropType<"add" | "edit">,
|
|
193
|
+
type: String as PropType<"add" | "edit" | "add-keys" | "edit-pass">,
|
|
164
194
|
default: "add",
|
|
165
195
|
},
|
|
166
196
|
});
|
|
@@ -172,6 +202,17 @@ const emit = defineEmits<{
|
|
|
172
202
|
|
|
173
203
|
const { getPassKeysByPageSearch } = usePassKey();
|
|
174
204
|
const { updateVisitor } = useVisitor();
|
|
205
|
+
const { currentUser } = useLocalAuth();
|
|
206
|
+
|
|
207
|
+
const { formatLocation } = useSecurityUtils();
|
|
208
|
+
const visitorLocation = computed(
|
|
209
|
+
() =>
|
|
210
|
+
formatLocation({
|
|
211
|
+
block: (prop.visitor as any).block,
|
|
212
|
+
level: (prop.visitor as any).level,
|
|
213
|
+
unit: (prop.visitor as any).unit,
|
|
214
|
+
}) || "N/A"
|
|
215
|
+
);
|
|
175
216
|
|
|
176
217
|
const processing = ref(false);
|
|
177
218
|
const errorMessage = ref("");
|
|
@@ -195,8 +236,40 @@ const existingKeys = ref<
|
|
|
195
236
|
{ keyId: string; prefixAndName: string; status?: string }[]
|
|
196
237
|
>([]);
|
|
197
238
|
|
|
198
|
-
|
|
199
|
-
const
|
|
239
|
+
// Every mode but Assign starts from what the visitor holds now.
|
|
240
|
+
const isEditMode = computed(() => prop.mode !== "add");
|
|
241
|
+
const isContractor = computed(() => prop.visitor.type === "contractor");
|
|
242
|
+
const showPass = computed(() => prop.mode !== "add-keys");
|
|
243
|
+
const showKeys = computed(() => isContractor.value && prop.mode !== "edit-pass");
|
|
244
|
+
|
|
245
|
+
const dialogTitle = computed(() => {
|
|
246
|
+
switch (prop.mode) {
|
|
247
|
+
case "add-keys":
|
|
248
|
+
// Once the visitor holds keys the dialog adds AND removes them.
|
|
249
|
+
return prop.visitor.passKeys?.some((key: any) => key?.status != "Removed")
|
|
250
|
+
? "Add/Remove Key(s)"
|
|
251
|
+
: "Add Key(s)";
|
|
252
|
+
case "edit-pass":
|
|
253
|
+
return "Change Pass";
|
|
254
|
+
case "edit":
|
|
255
|
+
return "Edit Pass & Keys";
|
|
256
|
+
default:
|
|
257
|
+
return isContractor.value ? "Assign Pass & Keys" : "Add Pass";
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const submitLabel = computed(() => {
|
|
262
|
+
switch (prop.mode) {
|
|
263
|
+
case "add-keys":
|
|
264
|
+
return "Add";
|
|
265
|
+
case "edit-pass":
|
|
266
|
+
return "Update";
|
|
267
|
+
case "edit":
|
|
268
|
+
return "Save";
|
|
269
|
+
default:
|
|
270
|
+
return isContractor.value ? "Save" : "Add";
|
|
271
|
+
}
|
|
272
|
+
});
|
|
200
273
|
// The key's press (if the site has any) under each key in the picker. Read
|
|
201
274
|
// only when the key picker is shown.
|
|
202
275
|
const { keyItemProps } = useKeyPresses(() => (showKeys.value ? prop.site : ""));
|
|
@@ -364,8 +437,136 @@ watch(
|
|
|
364
437
|
{ deep: true, immediate: true }
|
|
365
438
|
);
|
|
366
439
|
|
|
440
|
+
/*
|
|
441
|
+
* Scan Pass / Scan Key (iSecure `onPassScanned` / `onScanned`): the QR holds
|
|
442
|
+
* the pass or key name (`prefixAndName`, e.g. VP01). It is looked up on the
|
|
443
|
+
* server across every status, so a pass or key that is not Available is
|
|
444
|
+
* reported with its status instead of "not found".
|
|
445
|
+
*/
|
|
446
|
+
const PASS_TYPE_LABELS: Record<string, string> = {
|
|
447
|
+
"visitor-pass": "Visitor Pass",
|
|
448
|
+
"contractor-pass": "Contractor Pass",
|
|
449
|
+
"agent-pass": "Agent Pass",
|
|
450
|
+
"pass-key": "Key",
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
type TNotice = { text: string; error: boolean } | null;
|
|
454
|
+
const passNotice = ref<TNotice>(null);
|
|
455
|
+
const keyNotice = ref<TNotice>(null);
|
|
456
|
+
const scanner = reactive<{ open: boolean; value: string; target: "pass" | "key" }>({
|
|
457
|
+
open: false,
|
|
458
|
+
value: "",
|
|
459
|
+
target: "pass",
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
function openScanner(target: "pass" | "key") {
|
|
463
|
+
if (target === "pass") passNotice.value = null;
|
|
464
|
+
else keyNotice.value = null;
|
|
465
|
+
scanner.target = target;
|
|
466
|
+
scanner.value = "";
|
|
467
|
+
scanner.open = true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
type TScanned = { _id: string; prefixAndName?: string; status?: string; passType?: string };
|
|
471
|
+
|
|
472
|
+
async function findScanned(code: string): Promise<TScanned[]> {
|
|
473
|
+
const same = (item: TScanned) =>
|
|
474
|
+
(item.prefixAndName ?? "").trim().toLowerCase() === code.toLowerCase();
|
|
475
|
+
try {
|
|
476
|
+
const res: any = await getPassKeysByPageSearch({
|
|
477
|
+
search: code,
|
|
478
|
+
page: 1,
|
|
479
|
+
limit: 50,
|
|
480
|
+
sites: [prop.site],
|
|
481
|
+
});
|
|
482
|
+
const items: TScanned[] = res?.items ?? res?.data?.items ?? [];
|
|
483
|
+
return items.filter(same);
|
|
484
|
+
} catch {
|
|
485
|
+
// Offline / failed: the Available lists already loaded.
|
|
486
|
+
return [
|
|
487
|
+
...((passesData.value as any)?.items ?? []),
|
|
488
|
+
...((keysData.value as any)?.items ?? []),
|
|
489
|
+
].filter(same);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function onPassScanned(code: string) {
|
|
494
|
+
const fail = (text: string) => (passNotice.value = { text, error: true });
|
|
495
|
+
const found = await findScanned(code);
|
|
496
|
+
if (!found.length) return fail(`Pass or Key ${code} not found.`);
|
|
497
|
+
const item = found[0];
|
|
498
|
+
const isKey = item.passType === "pass-key";
|
|
499
|
+
const label = PASS_TYPE_LABELS[item.passType ?? ""] ?? (isKey ? "Key" : "Pass");
|
|
500
|
+
const name = item.prefixAndName ?? code;
|
|
501
|
+
if (found.length > 1) return fail(`${label} ${name} found duplicates.`);
|
|
502
|
+
if (isKey) return fail(`${label} ${name} is a key, not a pass.`);
|
|
503
|
+
if (item.status && item.status !== "Available") return fail(`${label} ${name} is ${item.status}.`);
|
|
504
|
+
if (existingPassId.value === item._id)
|
|
505
|
+
return fail(`${label} ${name} is already selected to this visitor.`);
|
|
506
|
+
if (!passTypesComputed.value.includes(item.passType ?? "")) {
|
|
507
|
+
const allowed = passTypesComputed.value
|
|
508
|
+
.map((t) => PASS_TYPE_LABELS[t] ?? t)
|
|
509
|
+
.join(" / ");
|
|
510
|
+
return fail(`${label} ${name} cannot be used for this visitor type (${allowed}).`);
|
|
511
|
+
}
|
|
512
|
+
// Shown in the picker even if the loaded list did not have it yet.
|
|
513
|
+
if (!passItems.value.some((p) => p._id === item._id)) {
|
|
514
|
+
passItems.value = [...passItems.value, item as TPassKey];
|
|
515
|
+
}
|
|
516
|
+
selectedPass.value = item._id;
|
|
517
|
+
passNotice.value = { text: `${label} ${name} selected.`, error: false };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function onKeyScanned(code: string) {
|
|
521
|
+
const fail = (text: string) => (keyNotice.value = { text, error: true });
|
|
522
|
+
const found = await findScanned(code);
|
|
523
|
+
if (!found.length) return fail(`Pass or Key ${code} not found.`);
|
|
524
|
+
const item = found[0];
|
|
525
|
+
const isKey = item.passType === "pass-key";
|
|
526
|
+
const label = PASS_TYPE_LABELS[item.passType ?? ""] ?? (isKey ? "Key" : "Pass");
|
|
527
|
+
const name = item.prefixAndName ?? code;
|
|
528
|
+
if (found.length > 1) return fail(`${label} ${name} found duplicates.`);
|
|
529
|
+
if (!isKey) return fail(`You are scanning a key, but ${label} ${name} is a pass, not a key.`);
|
|
530
|
+
// A current key marked for removal: scanning it just undoes that.
|
|
531
|
+
if (existingKeys.value.some((k) => k.keyId === item._id && k.status === "Removed")) {
|
|
532
|
+
selectedKeys.value = [...selectedKeys.value, item._id];
|
|
533
|
+
selectKeys();
|
|
534
|
+
keyNotice.value = { text: `${label} ${name} restored.`, error: false };
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
selectedKeys.value.includes(item._id) ||
|
|
539
|
+
existingKeys.value.some((k) => k.keyId === item._id && k.status !== "Removed")
|
|
540
|
+
)
|
|
541
|
+
return fail(`${label} ${name} is already selected to this visitor.`);
|
|
542
|
+
if (item.status && item.status !== "Available") return fail(`${label} ${name} is ${item.status}.`);
|
|
543
|
+
if (!keyItems.value.some((k) => k._id === item._id)) {
|
|
544
|
+
keyItems.value = [...keyItems.value, item as TPassKey];
|
|
545
|
+
}
|
|
546
|
+
selectedKeys.value = [...selectedKeys.value, item._id];
|
|
547
|
+
keyNotice.value = { text: `${label} ${name} selected.`, error: false };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
watch(
|
|
551
|
+
() => scanner.value,
|
|
552
|
+
(value) => {
|
|
553
|
+
const code = String(value ?? "").trim();
|
|
554
|
+
if (!code) return;
|
|
555
|
+
scanner.value = "";
|
|
556
|
+
if (scanner.target === "pass") void onPassScanned(code);
|
|
557
|
+
else void onKeyScanned(code);
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
// Picking by hand clears the last scan message.
|
|
562
|
+
watch(selectedPass, () => {
|
|
563
|
+
if (passNotice.value?.error) passNotice.value = null;
|
|
564
|
+
});
|
|
565
|
+
|
|
367
566
|
const canSubmit = computed(() => {
|
|
368
567
|
const hasRemovedKeys = existingKeys.value.some((k) => k.status === "Removed");
|
|
568
|
+
if (prop.mode === "edit-pass") return !!selectedPass.value;
|
|
569
|
+
if (prop.mode === "add-keys") return selectedKeys.value.length > 0 || hasRemovedKeys;
|
|
369
570
|
return (
|
|
370
571
|
!!selectedPass.value || selectedKeys.value.length > 0 || hasRemovedKeys
|
|
371
572
|
);
|
|
@@ -394,7 +595,8 @@ async function handleSubmit() {
|
|
|
394
595
|
add: true,
|
|
395
596
|
});
|
|
396
597
|
}
|
|
397
|
-
|
|
598
|
+
// Add Key(s) changes the keys only (iSecure `addKeysPayload`).
|
|
599
|
+
if (showPass.value) payload.visitorPass = finalPasses;
|
|
398
600
|
|
|
399
601
|
// Build final keys list: kept existing + newly selected
|
|
400
602
|
if (showKeys.value) {
|
|
@@ -413,6 +615,7 @@ async function handleSubmit() {
|
|
|
413
615
|
payload.passKeys = finalKeys;
|
|
414
616
|
}
|
|
415
617
|
|
|
618
|
+
payload.updatedBy = currentUser.value?._id;
|
|
416
619
|
await updateVisitor(prop.visitor._id, payload);
|
|
417
620
|
emit("done");
|
|
418
621
|
emit("close");
|
|
@@ -468,6 +671,81 @@ async function handleSubmit() {
|
|
|
468
671
|
opacity: 1;
|
|
469
672
|
}
|
|
470
673
|
|
|
674
|
+
/* A Scan button beside its input, the same height, as on the visitor form. */
|
|
675
|
+
.scan-row {
|
|
676
|
+
display: flex;
|
|
677
|
+
align-items: flex-start;
|
|
678
|
+
gap: 8px;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.scan-row__field {
|
|
682
|
+
flex: 1 1 auto;
|
|
683
|
+
min-width: 0;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.scan-row__btn {
|
|
687
|
+
flex: 0 0 auto;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.passkey__notice {
|
|
691
|
+
margin: 6px 0 0;
|
|
692
|
+
font-size: var(--fs-cell);
|
|
693
|
+
font-weight: 600;
|
|
694
|
+
color: rgb(var(--v-theme-success));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.passkey__notice--error {
|
|
698
|
+
color: var(--err);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.passkey__who {
|
|
702
|
+
display: flex;
|
|
703
|
+
flex-direction: column;
|
|
704
|
+
gap: 4px;
|
|
705
|
+
padding: 10px 12px;
|
|
706
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
707
|
+
border-radius: 8px;
|
|
708
|
+
font-size: 0.9375rem;
|
|
709
|
+
color: rgb(var(--v-theme-on-surface));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.passkey__who-line {
|
|
713
|
+
display: flex;
|
|
714
|
+
align-items: center;
|
|
715
|
+
gap: 8px;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.passkey__label {
|
|
719
|
+
margin: 6px 0 6px;
|
|
720
|
+
font-size: 1rem;
|
|
721
|
+
font-weight: 600;
|
|
722
|
+
color: rgb(var(--v-theme-on-surface));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.passkey__sublabel {
|
|
726
|
+
margin: 0 0 6px;
|
|
727
|
+
font-size: var(--fs-cell);
|
|
728
|
+
color: var(--text2);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/* The same pass / key chips as the visitor list and the return dialog. */
|
|
732
|
+
.passkey-chip.v-chip {
|
|
733
|
+
--v-chip-height: 32px;
|
|
734
|
+
font-size: 0.875rem;
|
|
735
|
+
padding: 0 12px;
|
|
736
|
+
background: rgb(var(--v-theme-surface));
|
|
737
|
+
color: rgb(var(--v-theme-on-surface));
|
|
738
|
+
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.87);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
.passkey-chip--pass :deep(.v-chip__prepend .v-icon) {
|
|
742
|
+
color: #1565c0;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.passkey-chip--key :deep(.v-chip__prepend .v-icon) {
|
|
746
|
+
color: #e65100;
|
|
747
|
+
}
|
|
748
|
+
|
|
471
749
|
.passkey__footer {
|
|
472
750
|
display: flex;
|
|
473
751
|
justify-content: flex-end;
|
package/components/FileInput.vue
CHANGED
|
@@ -288,6 +288,10 @@ const removeFile = (index) => {
|
|
|
288
288
|
onUnmounted(() => {
|
|
289
289
|
stopCamera();
|
|
290
290
|
});
|
|
291
|
+
|
|
292
|
+
// Lets a screen open the camera from its own button (the visitor form's
|
|
293
|
+
// "Camera") while the picker itself stays hidden.
|
|
294
|
+
defineExpose({ openCameraDialog });
|
|
291
295
|
</script>
|
|
292
296
|
|
|
293
297
|
<style scoped>
|