@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
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
three buttons, same handlers, same permission gates - moved, not changed.
|
|
10
10
|
-->
|
|
11
11
|
<v-col cols="12">
|
|
12
|
-
<PageHeader title="Visitor
|
|
12
|
+
<PageHeader title="Visitor Management">
|
|
13
13
|
<template #actions>
|
|
14
14
|
<AppButton
|
|
15
15
|
v-if="canScanVisitorQRCode"
|
|
16
|
-
variant="ghost"
|
|
17
16
|
@click="dialog.scanVisitorQRCode = true"
|
|
18
17
|
>
|
|
19
18
|
Scan QR Code
|
|
@@ -93,7 +92,9 @@
|
|
|
93
92
|
append-inner-icon="mdi-magnify"
|
|
94
93
|
hide-details
|
|
95
94
|
/>
|
|
95
|
+
<!-- iSecure: the Visitors tab only. -->
|
|
96
96
|
<v-checkbox
|
|
97
|
+
v-if="activeTab === 'registered'"
|
|
97
98
|
v-model="displayNotCheckedOut"
|
|
98
99
|
class="text-subtitle-2"
|
|
99
100
|
hide-details
|
|
@@ -108,13 +109,15 @@
|
|
|
108
109
|
hide-details
|
|
109
110
|
/>
|
|
110
111
|
<InputDatePicker v-model="dateTo" density="compact" hide-details />
|
|
112
|
+
<!-- iSecure: every tab but Unregistered; the Guests tab offers
|
|
113
|
+
Guest / Contractor, the Visitors tab the eight types. -->
|
|
111
114
|
<AppSelect
|
|
112
|
-
v-if="
|
|
115
|
+
v-if="typeFilterOptions.length"
|
|
113
116
|
v-model="filterTypes"
|
|
114
|
-
label="Filter by
|
|
117
|
+
label="Filter by type"
|
|
115
118
|
item-title="label"
|
|
116
119
|
item-value="value"
|
|
117
|
-
:items="
|
|
120
|
+
:items="typeFilterOptions"
|
|
118
121
|
class="visitor-type-filter"
|
|
119
122
|
compact
|
|
120
123
|
clearable
|
|
@@ -124,16 +127,66 @@
|
|
|
124
127
|
</v-row>
|
|
125
128
|
</template>
|
|
126
129
|
|
|
130
|
+
<!-- iSecure's first column: Name / Phone / NRIC (NRIC masked to the
|
|
131
|
+
last 4), "(+N members)" on a lead, "(+1 Lead)" on a member's own
|
|
132
|
+
row, and "Security Invite" on an invitation raised at the gate. -->
|
|
127
133
|
<template v-slot:item.name="{ item }">
|
|
134
|
+
<v-chip
|
|
135
|
+
v-if="activeTab !== 'overnight-parking' && isGateInvitation(item)"
|
|
136
|
+
size="x-small"
|
|
137
|
+
color="primary"
|
|
138
|
+
variant="tonal"
|
|
139
|
+
class="mb-1"
|
|
140
|
+
>Security Invite</v-chip
|
|
141
|
+
>
|
|
128
142
|
<span class="d-flex align-center ga-2">
|
|
129
143
|
<span>
|
|
130
144
|
<AvatarMain :name="item?.name" :size="20" :id="item?._id" />
|
|
131
145
|
</span>
|
|
132
|
-
<span class="text-capitalize">{{ item?.name }}</span>
|
|
146
|
+
<span class="text-capitalize">{{ item?.name || "N/A" }}</span>
|
|
133
147
|
</span>
|
|
134
148
|
<span class="text-grey text-caption" v-if="item?.members?.length > 0"
|
|
135
|
-
>(
|
|
149
|
+
>(+{{ item?.members?.length }} members)</span
|
|
136
150
|
>
|
|
151
|
+
<span class="text-grey text-caption" v-else-if="item?.leadId"
|
|
152
|
+
>(+1 Lead)</span
|
|
153
|
+
>
|
|
154
|
+
<template v-if="activeTab !== 'overnight-parking'">
|
|
155
|
+
<span class="d-flex align-center ga-2">
|
|
156
|
+
<v-icon icon="mdi-phone" size="15" />
|
|
157
|
+
<span>{{ item?.contact || "N/A" }}</span>
|
|
158
|
+
</span>
|
|
159
|
+
<span class="d-flex align-center ga-2">
|
|
160
|
+
<v-icon icon="mdi-card-account-details-outline" size="15" />
|
|
161
|
+
<span>{{ item?.nric ? maskNRIC(item.nric) : "N/A" }}</span>
|
|
162
|
+
</span>
|
|
163
|
+
</template>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
166
|
+
<!-- Unregistered tab: the plate the camera read is all a row has. -->
|
|
167
|
+
<template v-slot:item.vehicle-number="{ item }">
|
|
168
|
+
<span class="d-flex align-center ga-2">
|
|
169
|
+
<v-icon icon="mdi-car-back" size="15" />
|
|
170
|
+
<span>{{ item?.plateNumber || "N/A" }}</span>
|
|
171
|
+
</span>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<!-- iSecure's second column: Vehicle # / Type / Company. -->
|
|
175
|
+
<template v-slot:item.vehicle-type-company="{ item }">
|
|
176
|
+
<span class="d-flex align-center ga-2">
|
|
177
|
+
<v-icon icon="mdi-car-back" size="15" />
|
|
178
|
+
<span>{{ item?.plateNumber ? String(item.plateNumber).toUpperCase() : "N/A" }}</span>
|
|
179
|
+
</span>
|
|
180
|
+
<span class="d-flex align-center ga-2">
|
|
181
|
+
<v-icon icon="mdi-account" size="15" />
|
|
182
|
+
<v-chip v-if="item.type === 'contractor' && item.contractorType === 'managing-agent-staff'" size="x-small"
|
|
183
|
+
color="primary" variant="tonal">MA staff</v-chip>
|
|
184
|
+
<span v-else class="text-capitalize">{{ visitorTypeLabel(item) }}</span>
|
|
185
|
+
</span>
|
|
186
|
+
<span class="d-flex align-center ga-2">
|
|
187
|
+
<v-icon icon="mdi-domain" size="15" />
|
|
188
|
+
<span class="text-capitalize">{{ item?.companyName || item?.company || "N/A" }}</span>
|
|
189
|
+
</span>
|
|
137
190
|
</template>
|
|
138
191
|
|
|
139
192
|
<template v-slot:item.email-vehicleNumber="{ item }">
|
|
@@ -159,22 +212,6 @@
|
|
|
159
212
|
</span>
|
|
160
213
|
</template>
|
|
161
214
|
|
|
162
|
-
<template v-slot:item.type-company="{ item }">
|
|
163
|
-
<span class="d-flex align-center ga-2">
|
|
164
|
-
<v-icon icon="mdi-user" size="15" />
|
|
165
|
-
<v-chip v-if="item.type === 'contractor' && item.contractorType === 'managing-agent-staff'" size="x-small"
|
|
166
|
-
color="primary" variant="tonal">MA staff</v-chip>
|
|
167
|
-
<span v-else-if="item.type === 'contractor'" class="text-capitalize">{{
|
|
168
|
-
formatCamelCaseToWords(item.contractorType)
|
|
169
|
-
}}</span>
|
|
170
|
-
<span v-else class="text-capitalize">{{ formatType(item) }}</span>
|
|
171
|
-
</span>
|
|
172
|
-
<span class="d-flex align-center ga-2">
|
|
173
|
-
<v-icon icon="mdi-domain" size="15" />
|
|
174
|
-
<span class="text-capitalize">{{ item?.company || "N/A" }}</span>
|
|
175
|
-
</span>
|
|
176
|
-
</template>
|
|
177
|
-
|
|
178
215
|
<template v-slot:item.location="{ item }">
|
|
179
216
|
<span class="d-flex align-center ga-2">
|
|
180
217
|
<v-icon icon="mdi-storefront-outline" size="15" />
|
|
@@ -188,112 +225,176 @@
|
|
|
188
225
|
</span>
|
|
189
226
|
</template>
|
|
190
227
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
</template>
|
|
201
|
-
|
|
228
|
+
<!--
|
|
229
|
+
Check In / Check Out, as iSecure's row (`visitor-row.tsx`):
|
|
230
|
+
- no check-in yet: a "Check In" button that opens the visitor (the
|
|
231
|
+
preview has the check-in), else the time and its snapshot;
|
|
232
|
+
- Check Out only once checked in and not out, never on Guests, and
|
|
233
|
+
only with the Update right; a manual check-out shows the "Manual Checkout" info icon;
|
|
234
|
+
- the pass / key chips and "Assign" sit above Check In, on the
|
|
235
|
+
Visitors tab only.
|
|
236
|
+
-->
|
|
202
237
|
<template v-slot:item.checkin-out="{ item }">
|
|
203
238
|
<v-row no-gutters class="d-flex flex-column py-2">
|
|
239
|
+
<template v-if="activeTab === 'registered'">
|
|
240
|
+
<div
|
|
241
|
+
v-if="showPassKeyChips(item)"
|
|
242
|
+
class="d-flex flex-wrap ga-1 mb-1"
|
|
243
|
+
>
|
|
244
|
+
<!-- Keys: by name for a contractor (each opens the menu),
|
|
245
|
+
a single count otherwise (straight to the return statuses). -->
|
|
246
|
+
<template v-if="item.type === 'contractor'">
|
|
247
|
+
<v-chip
|
|
248
|
+
v-for="(key, ki) in activeKeys(item).filter((k) => k.keyId)"
|
|
249
|
+
:key="key.keyId ?? ki"
|
|
250
|
+
prepend-icon="mdi-key"
|
|
251
|
+
variant="outlined"
|
|
252
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--key"
|
|
253
|
+
:disabled="!canUpdateVisitor"
|
|
254
|
+
@click.stop="openPassChipMenu(item, $event)"
|
|
255
|
+
>{{ key.prefixAndName || "Key" }}</v-chip
|
|
256
|
+
>
|
|
257
|
+
</template>
|
|
258
|
+
<v-chip
|
|
259
|
+
v-else-if="activeKeys(item).length"
|
|
260
|
+
prepend-icon="mdi-key"
|
|
261
|
+
variant="outlined"
|
|
262
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--key"
|
|
263
|
+
:disabled="!canUpdateVisitor"
|
|
264
|
+
@click.stop="openPassAction(item, 'return-pass-keys')"
|
|
265
|
+
>{{ activeKeys(item).length }}</v-chip
|
|
266
|
+
>
|
|
267
|
+
<v-chip
|
|
268
|
+
v-if="activePasses(item).length"
|
|
269
|
+
prepend-icon="mdi-card-bulleted-outline"
|
|
270
|
+
variant="outlined"
|
|
271
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--pass"
|
|
272
|
+
:disabled="!canUpdateVisitor"
|
|
273
|
+
@click.stop="openPassChipMenu(item, $event)"
|
|
274
|
+
>{{
|
|
275
|
+
activePasses(item)
|
|
276
|
+
.map((pass) => pass.prefixAndName || "Pass")
|
|
277
|
+
.join(", ")
|
|
278
|
+
}}</v-chip
|
|
279
|
+
>
|
|
280
|
+
<v-chip
|
|
281
|
+
v-if="showAssignChip(item)"
|
|
282
|
+
prepend-icon="mdi-plus"
|
|
283
|
+
variant="outlined"
|
|
284
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--assign"
|
|
285
|
+
@click.stop="openAssign(item)"
|
|
286
|
+
>Assign</v-chip
|
|
287
|
+
>
|
|
288
|
+
|
|
289
|
+
<!-- A contractor's access cards: the NFC card number with
|
|
290
|
+
"(+n)" (a menu of every card number), or "QR · (n)". -->
|
|
291
|
+
<template v-if="item.type === 'contractor'">
|
|
292
|
+
<v-menu
|
|
293
|
+
v-if="item.accessCards?.type === 'NFC' && item.accessCards?.cardNo"
|
|
294
|
+
:close-on-content-click="false"
|
|
295
|
+
@update:model-value="(open: boolean) => open && loadNfcCardNos(item)"
|
|
296
|
+
>
|
|
297
|
+
<template #activator="{ props: nfcMenuProps }">
|
|
298
|
+
<v-chip
|
|
299
|
+
v-bind="nfcMenuProps"
|
|
300
|
+
variant="outlined"
|
|
301
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--nfc"
|
|
302
|
+
prepend-icon="mdi-credit-card-outline"
|
|
303
|
+
@click.stop
|
|
304
|
+
>
|
|
305
|
+
{{ item.accessCards.cardNo }}
|
|
306
|
+
<template v-if="allCards(item).length > 1">
|
|
307
|
+
(+{{ allCards(item).length - 1 }})
|
|
308
|
+
</template>
|
|
309
|
+
</v-chip>
|
|
310
|
+
</template>
|
|
311
|
+
<v-list density="compact" min-width="200">
|
|
312
|
+
<v-list-subheader>NFC Cards</v-list-subheader>
|
|
313
|
+
<v-list-item
|
|
314
|
+
v-for="card in nfcCardNumbers(item)"
|
|
315
|
+
:key="card.id"
|
|
316
|
+
prepend-icon="mdi-credit-card-outline"
|
|
317
|
+
:title="card.label"
|
|
318
|
+
/>
|
|
319
|
+
</v-list>
|
|
320
|
+
</v-menu>
|
|
321
|
+
<v-chip
|
|
322
|
+
v-else-if="item.accessCards?.type === 'QRCODE' && item.accessCards?.cardNo"
|
|
323
|
+
variant="outlined"
|
|
324
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--qr"
|
|
325
|
+
prepend-icon="mdi-qrcode"
|
|
326
|
+
>QR · ({{ allCards(item).length || 1 }})</v-chip
|
|
327
|
+
>
|
|
328
|
+
</template>
|
|
329
|
+
</div>
|
|
330
|
+
</template>
|
|
331
|
+
|
|
204
332
|
<span class="d-flex align-center ga-2">
|
|
205
333
|
<v-icon
|
|
206
334
|
icon="mdi-clock-time-four-outline"
|
|
207
335
|
color="green"
|
|
208
336
|
size="20"
|
|
209
337
|
/>
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
</span>
|
|
221
|
-
</span>
|
|
222
|
-
|
|
223
|
-
<span
|
|
224
|
-
v-if="
|
|
225
|
-
!item.checkOut &&
|
|
226
|
-
(item?.status === 'registered' ||
|
|
227
|
-
item?.status === 'unregistered') &&
|
|
228
|
-
canCheckoutVisitor
|
|
229
|
-
"
|
|
230
|
-
>
|
|
231
|
-
<span
|
|
232
|
-
class="d-flex align-center ga-2"
|
|
233
|
-
v-bind="menuProps"
|
|
234
|
-
style="cursor: pointer"
|
|
235
|
-
>
|
|
236
|
-
<v-icon
|
|
237
|
-
icon="mdi-clock-time-eight-outline"
|
|
238
|
-
color="red"
|
|
239
|
-
size="20"
|
|
240
|
-
/>
|
|
241
|
-
<v-btn
|
|
242
|
-
size="x-small"
|
|
243
|
-
class="text-capitalize"
|
|
244
|
-
color="red"
|
|
245
|
-
text="Checkout"
|
|
246
|
-
:loading="
|
|
247
|
-
loading.checkingOut && item?._id === selectedVisitorId
|
|
248
|
-
"
|
|
249
|
-
@click.stop="handleCheckout(item._id)"
|
|
250
|
-
/>
|
|
251
|
-
</span>
|
|
252
|
-
</span>
|
|
253
|
-
<span v-else class="d-flex align-center ga-2">
|
|
254
|
-
<v-icon icon="mdi-clock-time-eight-outline" color="red" size="20" />
|
|
255
|
-
<template v-if="item.checkOut">
|
|
256
|
-
<span class="text-capitalize">{{
|
|
257
|
-
UTCToLocalTIme(item.checkOut)
|
|
258
|
-
}}</span>
|
|
338
|
+
<v-btn
|
|
339
|
+
v-if="!item.checkIn && canOpenVisitorDetails && activeTab !== 'overnight-parking'"
|
|
340
|
+
size="small"
|
|
341
|
+
class="text-capitalize font-weight-bold"
|
|
342
|
+
color="success"
|
|
343
|
+
text="Check In"
|
|
344
|
+
@click.stop="openVisitorDetails(item)"
|
|
345
|
+
/>
|
|
346
|
+
<template v-else>
|
|
347
|
+
<span>{{ item.checkIn ? formatDateTime(item.checkIn) : "—" }}</span>
|
|
259
348
|
<span>
|
|
260
349
|
<v-icon
|
|
261
|
-
v-if="item?.
|
|
262
|
-
size="
|
|
350
|
+
v-if="item.checkIn && item?.snapshotEntryImage"
|
|
351
|
+
size="20"
|
|
263
352
|
icon="mdi-image"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
</span>
|
|
267
|
-
<span v-if="item?.manualCheckout">
|
|
268
|
-
<TooltipInfo
|
|
269
|
-
text="Manual Checkout"
|
|
270
|
-
density="compact"
|
|
271
|
-
size="x-small"
|
|
353
|
+
class="cursor-pointer"
|
|
354
|
+
@click.stop="handleViewImage(item.snapshotEntryImage, 'IN')"
|
|
272
355
|
/>
|
|
273
356
|
</span>
|
|
274
357
|
</template>
|
|
275
|
-
<template v-else> <span class="mt-1"> N/A </span> </template>
|
|
276
358
|
</span>
|
|
277
359
|
|
|
278
|
-
<
|
|
279
|
-
v-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<v-
|
|
285
|
-
size="
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
360
|
+
<span v-if="showCheckOutButton(item)" class="d-flex align-center ga-2">
|
|
361
|
+
<v-icon
|
|
362
|
+
icon="mdi-clock-time-eight-outline"
|
|
363
|
+
color="red"
|
|
364
|
+
size="20"
|
|
365
|
+
/>
|
|
366
|
+
<v-btn
|
|
367
|
+
size="small"
|
|
368
|
+
class="text-capitalize font-weight-bold"
|
|
369
|
+
color="red"
|
|
370
|
+
text="Check Out"
|
|
371
|
+
:loading="loading.checkingOut && item?._id === selectedVisitorId"
|
|
372
|
+
@click.stop="handleCheckout(item._id, item)"
|
|
373
|
+
/>
|
|
374
|
+
</span>
|
|
375
|
+
<span v-else class="d-flex align-center ga-2">
|
|
376
|
+
<v-icon icon="mdi-clock-time-eight-outline" color="red" size="20" />
|
|
377
|
+
<span>
|
|
378
|
+
{{ item.checkOut ? formatDateTime(item.checkOut) : "—" }}
|
|
379
|
+
</span>
|
|
380
|
+
<span>
|
|
381
|
+
<v-icon
|
|
382
|
+
v-if="item.checkOut && item?.snapshotExitImage"
|
|
383
|
+
size="20"
|
|
384
|
+
icon="mdi-image"
|
|
385
|
+
class="cursor-pointer"
|
|
386
|
+
@click.stop="handleViewImage(item.snapshotExitImage, 'OUT')"
|
|
387
|
+
/>
|
|
388
|
+
</span>
|
|
389
|
+
<span v-if="item.checkOut && isManualCheckout(item)">
|
|
390
|
+
<TooltipInfo text="Manual Checkout" density="compact" size="small" />
|
|
391
|
+
</span>
|
|
392
|
+
</span>
|
|
292
393
|
|
|
293
394
|
<v-btn
|
|
294
395
|
v-if="hidQrEnabled"
|
|
295
396
|
icon="mdi-qrcode"
|
|
296
|
-
size="
|
|
397
|
+
size="small"
|
|
297
398
|
variant="text"
|
|
298
399
|
color="primary"
|
|
299
400
|
title="View HID QR code"
|
|
@@ -301,124 +402,6 @@
|
|
|
301
402
|
class="align-self-start mt-1"
|
|
302
403
|
@click.stop="openVisitorHidQr(item)"
|
|
303
404
|
/>
|
|
304
|
-
|
|
305
|
-
<v-menu :close-on-content-click="true">
|
|
306
|
-
<template v-slot:activator="{ props: menuProps }">
|
|
307
|
-
<div
|
|
308
|
-
v-if="
|
|
309
|
-
(item.visitorPass?.length ?? 0) > 0 ||
|
|
310
|
-
(item.passKeys?.length ?? 0) > 0
|
|
311
|
-
"
|
|
312
|
-
class="d-flex flex-wrap ga-1 mt-1"
|
|
313
|
-
v-bind="menuProps"
|
|
314
|
-
style="cursor: pointer"
|
|
315
|
-
>
|
|
316
|
-
<v-chip
|
|
317
|
-
v-for="pass in item.visitorPass.filter(
|
|
318
|
-
(i) => i.status != 'Removed'
|
|
319
|
-
)"
|
|
320
|
-
:key="(pass as any)._id ?? (pass as any).keyId"
|
|
321
|
-
prepend-icon="mdi-card-bulleted-outline"
|
|
322
|
-
size="x-small"
|
|
323
|
-
variant="tonal"
|
|
324
|
-
color="blue"
|
|
325
|
-
>
|
|
326
|
-
{{ (pass as any)?.prefixAndName }}
|
|
327
|
-
</v-chip>
|
|
328
|
-
<v-chip
|
|
329
|
-
v-for="key in item.passKeys.filter(
|
|
330
|
-
(i) => i.status != 'Removed'
|
|
331
|
-
)"
|
|
332
|
-
:key="(key as any)._id ?? (key as any).keyId"
|
|
333
|
-
prepend-icon="mdi-key"
|
|
334
|
-
size="x-small"
|
|
335
|
-
variant="tonal"
|
|
336
|
-
color="orange"
|
|
337
|
-
>
|
|
338
|
-
{{ (key as any)?.prefixAndName }}
|
|
339
|
-
</v-chip>
|
|
340
|
-
</div>
|
|
341
|
-
</template>
|
|
342
|
-
<v-list density="compact">
|
|
343
|
-
<v-list-item
|
|
344
|
-
v-if="!item?.checkOut"
|
|
345
|
-
prepend-icon="mdi-logout"
|
|
346
|
-
title="Checkout"
|
|
347
|
-
@click.stop="handleCheckout(item._id)"
|
|
348
|
-
/>
|
|
349
|
-
<v-list-item
|
|
350
|
-
v-if="
|
|
351
|
-
(item.visitorPass?.length ?? 0) > 0 ||
|
|
352
|
-
(item.passKeys?.length ?? 0) > 0
|
|
353
|
-
"
|
|
354
|
-
prepend-icon="mdi-card-bulleted-outline"
|
|
355
|
-
:title="item.checkOut ? 'View Pass/Key' : 'Edit Pass/Key'"
|
|
356
|
-
@click.stop="handleOpenEditPassKey(item)"
|
|
357
|
-
/>
|
|
358
|
-
<v-list-item
|
|
359
|
-
v-if="showAddPassKeyButton(item)"
|
|
360
|
-
prepend-icon="mdi-plus"
|
|
361
|
-
title="Add Pass/Key"
|
|
362
|
-
@click.stop="handleOpenAddPassKey(item)"
|
|
363
|
-
/>
|
|
364
|
-
</v-list>
|
|
365
|
-
</v-menu>
|
|
366
|
-
|
|
367
|
-
<!-- NFC Access Cards -->
|
|
368
|
-
<v-menu
|
|
369
|
-
v-if="item.accessCards?.type === 'NFC' && item.accessCards?.cardNo"
|
|
370
|
-
:close-on-content-click="false"
|
|
371
|
-
@update:model-value="(open: boolean) => open && loadNfcCardNos(item)"
|
|
372
|
-
>
|
|
373
|
-
<template #activator="{ props: nfcMenuProps }">
|
|
374
|
-
<div
|
|
375
|
-
class="d-flex align-center ga-1 mt-1"
|
|
376
|
-
style="cursor: pointer"
|
|
377
|
-
v-bind="nfcMenuProps"
|
|
378
|
-
@click.stop
|
|
379
|
-
>
|
|
380
|
-
<v-icon
|
|
381
|
-
size="15"
|
|
382
|
-
icon="mdi-credit-card-outline"
|
|
383
|
-
color="purple"
|
|
384
|
-
/>
|
|
385
|
-
<v-chip size="x-small" variant="tonal" color="purple">
|
|
386
|
-
{{ item.accessCards.cardNo }}
|
|
387
|
-
<template v-if="(item.cards?.flat().length ?? 0) > 1">
|
|
388
|
-
(+{{ item.cards.flat().length - 1 }})
|
|
389
|
-
</template>
|
|
390
|
-
</v-chip>
|
|
391
|
-
</div>
|
|
392
|
-
</template>
|
|
393
|
-
<v-list density="compact" min-width="200">
|
|
394
|
-
<v-list-subheader>NFC Cards</v-list-subheader>
|
|
395
|
-
<v-list-item
|
|
396
|
-
prepend-icon="mdi-credit-card-outline"
|
|
397
|
-
:title="item.accessCards.cardNo"
|
|
398
|
-
/>
|
|
399
|
-
<template v-for="(cardGroup, gi) in item.cards" :key="gi">
|
|
400
|
-
<v-list-item
|
|
401
|
-
v-for="card in cardGroup.filter((c: any) => c._id !== item.accessCards._id)"
|
|
402
|
-
:key="card._id"
|
|
403
|
-
prepend-icon="mdi-credit-card-outline"
|
|
404
|
-
:title="nfcCardLabel(card, item)"
|
|
405
|
-
/>
|
|
406
|
-
</template>
|
|
407
|
-
</v-list>
|
|
408
|
-
</v-menu>
|
|
409
|
-
|
|
410
|
-
<!-- QRCODE identifier -->
|
|
411
|
-
<div
|
|
412
|
-
v-if="
|
|
413
|
-
item.accessCards?.type === 'QRCODE' && item.accessCards?.cardNo
|
|
414
|
-
"
|
|
415
|
-
class="d-flex align-center ga-1 mt-1"
|
|
416
|
-
>
|
|
417
|
-
<v-icon size="15" icon="mdi-qrcode" color="teal" />
|
|
418
|
-
<v-chip size="x-small" variant="tonal" color="teal"
|
|
419
|
-
>QR · ({{ item.cards?.flat().length ?? 1 }})</v-chip
|
|
420
|
-
>
|
|
421
|
-
</div>
|
|
422
405
|
</v-row>
|
|
423
406
|
</template>
|
|
424
407
|
|
|
@@ -441,8 +424,8 @@
|
|
|
441
424
|
size="20"
|
|
442
425
|
/>
|
|
443
426
|
<v-btn
|
|
444
|
-
size="
|
|
445
|
-
class="text-capitalize"
|
|
427
|
+
size="small"
|
|
428
|
+
class="text-capitalize font-weight-bold"
|
|
446
429
|
color="success"
|
|
447
430
|
text="Checkin"
|
|
448
431
|
@click.stop="handleCheckin(item)"
|
|
@@ -475,13 +458,28 @@
|
|
|
475
458
|
</template>
|
|
476
459
|
|
|
477
460
|
<template v-slot:item.action="{ item }">
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
461
|
+
<!-- iSecure: Register only while the record is not checked out;
|
|
462
|
+
without the Update right it is greyed and says why. -->
|
|
463
|
+
<template v-if="activeTab === 'unregistered'">
|
|
464
|
+
<v-btn
|
|
465
|
+
v-if="!item?.checkOut && canUpdateVisitor"
|
|
466
|
+
size="small"
|
|
467
|
+
color="primary"
|
|
468
|
+
text="Register"
|
|
469
|
+
@click.stop="handleRegistrationUnregisteredVisitor(item)"
|
|
470
|
+
/>
|
|
471
|
+
<v-tooltip
|
|
472
|
+
v-else-if="!item?.checkOut"
|
|
473
|
+
text="You do not have permission to update visitors."
|
|
474
|
+
location="top"
|
|
475
|
+
>
|
|
476
|
+
<template #activator="{ props: tipProps }">
|
|
477
|
+
<span v-bind="tipProps" @click.stop>
|
|
478
|
+
<v-btn size="small" color="primary" text="Register" disabled />
|
|
479
|
+
</span>
|
|
480
|
+
</template>
|
|
481
|
+
</v-tooltip>
|
|
482
|
+
</template>
|
|
485
483
|
<v-menu v-else-if="activeTab === 'overnight-parking'">
|
|
486
484
|
<template #activator="{ props }">
|
|
487
485
|
<v-avatar v-bind="props" class="rounded-xl border-md">
|
|
@@ -492,7 +490,7 @@
|
|
|
492
490
|
<v-card>
|
|
493
491
|
<v-list-item
|
|
494
492
|
v-if="
|
|
495
|
-
!overNightParkingListActions[0].disabled
|
|
493
|
+
!overNightParkingListActions[0].disabled &&
|
|
496
494
|
!['approved', 'rejected'].includes(
|
|
497
495
|
item?.overnightParking?.status
|
|
498
496
|
)
|
|
@@ -513,7 +511,7 @@
|
|
|
513
511
|
<v-divider />
|
|
514
512
|
<v-list-item
|
|
515
513
|
v-if="
|
|
516
|
-
!overNightParkingListActions[1].disabled
|
|
514
|
+
!overNightParkingListActions[1].disabled &&
|
|
517
515
|
!['approved', 'rejected'].includes(
|
|
518
516
|
item?.overnightParking?.status
|
|
519
517
|
)
|
|
@@ -615,17 +613,62 @@
|
|
|
615
613
|
>
|
|
616
614
|
</span>
|
|
617
615
|
</template>
|
|
616
|
+
<!-- iSecure: "No visitors" / "You don't have any visitor(s) yet.", or
|
|
617
|
+
the server's reason when the list was refused. A search keeps its
|
|
618
|
+
own "nothing matches" line. -->
|
|
618
619
|
<template #no-data>
|
|
619
620
|
<div class="table-card__empty">
|
|
620
|
-
<v-
|
|
621
|
-
|
|
621
|
+
<template v-if="visitorListError">
|
|
622
|
+
<v-icon icon="mdi-alert-circle-outline" size="32" />
|
|
623
|
+
<span>No visitors</span>
|
|
624
|
+
<span class="text-caption">{{ visitorListError }}</span>
|
|
625
|
+
</template>
|
|
626
|
+
<template v-else-if="searchQuery">
|
|
627
|
+
<v-icon icon="mdi-magnify-close" size="32" />
|
|
628
|
+
<span>{{ visitorSearchEmptyText(searchQuery) }}</span>
|
|
629
|
+
</template>
|
|
630
|
+
<template v-else>
|
|
631
|
+
<v-icon icon="mdi-account-search-outline" size="32" />
|
|
632
|
+
<span>No visitors</span>
|
|
633
|
+
<span class="text-caption">You don't have any visitor(s) yet.</span>
|
|
634
|
+
</template>
|
|
622
635
|
</div>
|
|
623
636
|
</template>
|
|
624
637
|
</TableMain>
|
|
625
638
|
|
|
639
|
+
<!-- The pass / key chip menu (iSecure `PassKeyChips`): "Pass & Key"
|
|
640
|
+
(the return statuses), "Add Key" for a contractor and "Edit Pass". -->
|
|
641
|
+
<v-menu
|
|
642
|
+
v-model="passChipMenu.open"
|
|
643
|
+
:activator="passChipMenu.target ?? undefined"
|
|
644
|
+
location="bottom start"
|
|
645
|
+
>
|
|
646
|
+
<v-list v-if="passChipMenu.visitor" density="compact">
|
|
647
|
+
<v-list-subheader>{{ passChipMenu.visitor.name || "Pass & Key" }}</v-list-subheader>
|
|
648
|
+
<v-list-item
|
|
649
|
+
prepend-icon="mdi-card-bulleted-outline"
|
|
650
|
+
title="Pass & Key"
|
|
651
|
+
@click="choosePassAction('return-pass-keys')"
|
|
652
|
+
/>
|
|
653
|
+
<v-list-item
|
|
654
|
+
v-if="passChipMenu.visitor.type === 'contractor'"
|
|
655
|
+
prepend-icon="mdi-key-plus"
|
|
656
|
+
:title="activeKeys(passChipMenu.visitor).length ? 'Add/Remove Key' : 'Add Key'"
|
|
657
|
+
@click="choosePassAction('add-keys')"
|
|
658
|
+
/>
|
|
659
|
+
<v-list-item
|
|
660
|
+
v-if="activePasses(passChipMenu.visitor).length"
|
|
661
|
+
prepend-icon="mdi-swap-horizontal"
|
|
662
|
+
title="Edit Pass"
|
|
663
|
+
@click="choosePassAction('edit-pass')"
|
|
664
|
+
/>
|
|
665
|
+
</v-list>
|
|
666
|
+
</v-menu>
|
|
667
|
+
|
|
626
668
|
<v-dialog v-model="dialog.showSelection" width="450" persistent>
|
|
627
669
|
<VisitorFormSelection
|
|
628
670
|
:mode="mode"
|
|
671
|
+
:plate-number="mode === 'register' ? registerRecord?.plateNumber ?? '' : ''"
|
|
629
672
|
@cancel="dialog.showSelection = false"
|
|
630
673
|
@select="handleSelectVisitorType"
|
|
631
674
|
/>
|
|
@@ -638,13 +681,15 @@
|
|
|
638
681
|
persistent
|
|
639
682
|
>
|
|
640
683
|
<VisitorForm
|
|
684
|
+
:key="visitorFormKey"
|
|
641
685
|
:mode="mode"
|
|
642
686
|
:org="orgId"
|
|
643
687
|
:site="siteId"
|
|
644
|
-
:visitor-data="selectedVisitorDataObject"
|
|
688
|
+
:visitor-data="mode === 'register' ? registerRecord : selectedVisitorDataObject"
|
|
645
689
|
:current-user="currentUser?._id"
|
|
646
690
|
:type="activeVisitorFormType"
|
|
647
691
|
:enable-unregistered="enableUnregistered"
|
|
692
|
+
:can-assign-pass-keys="canAssignPassKeys"
|
|
648
693
|
@back="handleClickBack"
|
|
649
694
|
@done="handleVisitorFormDone"
|
|
650
695
|
@done:more="handleVisitorFormCreateMore"
|
|
@@ -652,6 +697,36 @@
|
|
|
652
697
|
/>
|
|
653
698
|
</v-dialog>
|
|
654
699
|
|
|
700
|
+
<!-- The preview's photos or ANPR snapshots, full screen. -->
|
|
701
|
+
<v-dialog v-model="fullscreenOpen" fullscreen>
|
|
702
|
+
<v-card class="visitor-photos-full">
|
|
703
|
+
<div class="visitor-photos-full__head">
|
|
704
|
+
<span class="font-weight-bold">
|
|
705
|
+
{{ fullscreenImages[fullscreenIndex]?.title }}
|
|
706
|
+
</span>
|
|
707
|
+
<v-btn
|
|
708
|
+
icon="mdi-close"
|
|
709
|
+
variant="text"
|
|
710
|
+
aria-label="Close"
|
|
711
|
+
@click="fullscreenOpen = false"
|
|
712
|
+
/>
|
|
713
|
+
</div>
|
|
714
|
+
<v-carousel
|
|
715
|
+
v-model="fullscreenIndex"
|
|
716
|
+
height="calc(100vh - 64px)"
|
|
717
|
+
hide-delimiters
|
|
718
|
+
:show-arrows="fullscreenImages.length > 1"
|
|
719
|
+
>
|
|
720
|
+
<v-carousel-item
|
|
721
|
+
v-for="image in fullscreenImages"
|
|
722
|
+
:key="image.src"
|
|
723
|
+
>
|
|
724
|
+
<v-img :src="image.src" contain height="100%" :alt="image.title" />
|
|
725
|
+
</v-carousel-item>
|
|
726
|
+
</v-carousel>
|
|
727
|
+
</v-card>
|
|
728
|
+
</v-dialog>
|
|
729
|
+
|
|
655
730
|
<v-dialog v-model="dialog.viewVisitor" width="450" persistent>
|
|
656
731
|
<VehicleUpdateMoreAction
|
|
657
732
|
title="Preview"
|
|
@@ -808,44 +883,174 @@
|
|
|
808
883
|
key === 'attachments' &&
|
|
809
884
|
selectedVisitorObject?.[key]?.length > 0
|
|
810
885
|
"
|
|
811
|
-
class="visitor-preview__row visitor-preview__row--
|
|
886
|
+
class="visitor-preview__row visitor-preview__row--block"
|
|
887
|
+
>
|
|
888
|
+
<!-- The photos in a carousel (as the visitor form takes them);
|
|
889
|
+
a photo, or Full screen, opens them large. -->
|
|
890
|
+
<div class="visitor-preview__label">
|
|
891
|
+
Photos ({{ selectedVisitorObject.attachments.length }})
|
|
892
|
+
</div>
|
|
893
|
+
<div class="w-100">
|
|
894
|
+
<v-carousel
|
|
895
|
+
v-model="previewPhotoIndex"
|
|
896
|
+
height="220"
|
|
897
|
+
hide-delimiters
|
|
898
|
+
:show-arrows="selectedVisitorObject.attachments.length > 1 ? 'hover' : false"
|
|
899
|
+
class="visitor-preview__photos"
|
|
900
|
+
>
|
|
901
|
+
<v-carousel-item
|
|
902
|
+
v-for="(id, pi) in selectedVisitorObject.attachments"
|
|
903
|
+
:key="id"
|
|
904
|
+
>
|
|
905
|
+
<v-img
|
|
906
|
+
:src="getFileUrl(id)"
|
|
907
|
+
cover
|
|
908
|
+
height="100%"
|
|
909
|
+
class="cursor-pointer"
|
|
910
|
+
:alt="`Photo ${pi + 1}`"
|
|
911
|
+
@click="openPreviewPhotos(pi)"
|
|
912
|
+
/>
|
|
913
|
+
</v-carousel-item>
|
|
914
|
+
</v-carousel>
|
|
915
|
+
<div class="d-flex align-center justify-space-between mt-2">
|
|
916
|
+
<span class="text-caption text-medium-emphasis">
|
|
917
|
+
Photo {{ previewPhotoIndex + 1 }} of
|
|
918
|
+
{{ selectedVisitorObject.attachments.length }}
|
|
919
|
+
</span>
|
|
920
|
+
<AppButton
|
|
921
|
+
variant="ghost"
|
|
922
|
+
icon="mdi-fullscreen"
|
|
923
|
+
@click="openPreviewPhotos(previewPhotoIndex)"
|
|
924
|
+
>
|
|
925
|
+
Full screen
|
|
926
|
+
</AppButton>
|
|
927
|
+
</div>
|
|
928
|
+
</div>
|
|
929
|
+
</div>
|
|
930
|
+
|
|
931
|
+
<!-- Type, first, as iSecure's visitor details: the type the
|
|
932
|
+
list shows ("Drive-In", "contractor", "delivery (food)", a
|
|
933
|
+
resident's invite type). -->
|
|
934
|
+
<div
|
|
935
|
+
v-else-if="key === 'type' && selectedVisitorDataObject?.type"
|
|
936
|
+
class="visitor-preview__row"
|
|
812
937
|
>
|
|
813
938
|
<div class="visitor-preview__label">{{ label }}</div>
|
|
814
|
-
<div class="
|
|
815
|
-
|
|
816
|
-
:files="mappedAttachments(selectedVisitorObject.attachments)"
|
|
817
|
-
title=""
|
|
818
|
-
show-file-type-title
|
|
819
|
-
/>
|
|
939
|
+
<div class="visitor-preview__value text-capitalize">
|
|
940
|
+
{{ visitorTypeLabel(selectedVisitorDataObject) }}
|
|
820
941
|
</div>
|
|
821
942
|
</div>
|
|
822
943
|
|
|
823
|
-
<!--
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
`[undefined]` on every visitor without snapshots - which is
|
|
828
|
-
every visitor the ANPR cameras never saw. And BOTH keys read
|
|
829
|
-
`snapshotEntryImage`, so a visitor with both snapshots was
|
|
830
|
-
shown the entry photo twice, the second time labelled "Exit
|
|
831
|
-
Image".
|
|
832
|
-
-->
|
|
944
|
+
<!-- Pass and Keys, as iSecure's visitor details: the held
|
|
945
|
+
pass(es) and keys drawn as the list's chips, read only;
|
|
946
|
+
N/A when none. Pass for drive-in, walk-in and contractor,
|
|
947
|
+
Keys for a contractor. -->
|
|
833
948
|
<div
|
|
834
949
|
v-else-if="
|
|
835
|
-
(key === '
|
|
836
|
-
|
|
837
|
-
selectedVisitorObject[key]
|
|
950
|
+
(key === 'visitorPass' && previewShowsPass) ||
|
|
951
|
+
(key === 'passKeys' && previewShowsKeys)
|
|
838
952
|
"
|
|
839
953
|
class="visitor-preview__row visitor-preview__row--tall"
|
|
840
954
|
>
|
|
841
955
|
<div class="visitor-preview__label">{{ label }}</div>
|
|
842
|
-
<div class="
|
|
843
|
-
<
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
:
|
|
847
|
-
|
|
848
|
-
|
|
956
|
+
<div v-if="previewPassKeyChips(key).length" class="visitor-preview__chips">
|
|
957
|
+
<v-chip
|
|
958
|
+
v-for="(chip, ci) in previewPassKeyChips(key)"
|
|
959
|
+
:key="`${chip}-${ci}`"
|
|
960
|
+
:prepend-icon="key === 'visitorPass' ? 'mdi-card-bulleted-outline' : 'mdi-key'"
|
|
961
|
+
variant="outlined"
|
|
962
|
+
:class="[
|
|
963
|
+
'font-weight-bold visitor-pass-chip',
|
|
964
|
+
key === 'visitorPass' ? 'visitor-pass-chip--pass' : 'visitor-pass-chip--key',
|
|
965
|
+
]"
|
|
966
|
+
>{{ chip }}</v-chip
|
|
967
|
+
>
|
|
968
|
+
</div>
|
|
969
|
+
<div v-else class="visitor-preview__value">N/A</div>
|
|
970
|
+
</div>
|
|
971
|
+
|
|
972
|
+
<!-- The entry and exit ANPR snapshots in one carousel, as
|
|
973
|
+
iSecure's visitor details; a snapshot, or Full screen,
|
|
974
|
+
opens them large. -->
|
|
975
|
+
<div
|
|
976
|
+
v-else-if="key === 'snapshotEntryImage' && previewSnapshots.length"
|
|
977
|
+
class="visitor-preview__row visitor-preview__row--block"
|
|
978
|
+
>
|
|
979
|
+
<div class="visitor-preview__label">{{ label }}</div>
|
|
980
|
+
<div class="w-100">
|
|
981
|
+
<v-carousel
|
|
982
|
+
v-model="previewSnapshotIndex"
|
|
983
|
+
height="220"
|
|
984
|
+
hide-delimiters
|
|
985
|
+
:show-arrows="previewSnapshots.length > 1 ? 'hover' : false"
|
|
986
|
+
class="visitor-preview__photos"
|
|
987
|
+
>
|
|
988
|
+
<v-carousel-item
|
|
989
|
+
v-for="(snapshot, si) in previewSnapshots"
|
|
990
|
+
:key="snapshot.side"
|
|
991
|
+
>
|
|
992
|
+
<v-img
|
|
993
|
+
:src="snapshot.src"
|
|
994
|
+
cover
|
|
995
|
+
height="100%"
|
|
996
|
+
class="cursor-pointer"
|
|
997
|
+
:alt="`${snapshot.label} snapshot`"
|
|
998
|
+
@click="openPreviewSnapshots(si)"
|
|
999
|
+
/>
|
|
1000
|
+
</v-carousel-item>
|
|
1001
|
+
</v-carousel>
|
|
1002
|
+
<div class="d-flex align-center justify-space-between mt-2">
|
|
1003
|
+
<span class="text-caption text-medium-emphasis">
|
|
1004
|
+
{{ previewSnapshots[previewSnapshotIndex]?.label }}
|
|
1005
|
+
<template v-if="previewSnapshots.length > 1">
|
|
1006
|
+
({{ previewSnapshotIndex + 1 }} of {{ previewSnapshots.length }})
|
|
1007
|
+
</template>
|
|
1008
|
+
</span>
|
|
1009
|
+
<AppButton
|
|
1010
|
+
variant="ghost"
|
|
1011
|
+
icon="mdi-fullscreen"
|
|
1012
|
+
@click="openPreviewSnapshots(previewSnapshotIndex)"
|
|
1013
|
+
>
|
|
1014
|
+
Full screen
|
|
1015
|
+
</AppButton>
|
|
1016
|
+
</div>
|
|
1017
|
+
</div>
|
|
1018
|
+
</div>
|
|
1019
|
+
|
|
1020
|
+
<!-- A contractor member's own record: the lead they came with,
|
|
1021
|
+
the counterpart of a lead's Members (iSecure). Opens the
|
|
1022
|
+
lead's own preview. -->
|
|
1023
|
+
<div
|
|
1024
|
+
v-else-if="key === 'lead' && selectedVisitorLead"
|
|
1025
|
+
class="visitor-preview__row visitor-preview__row--block"
|
|
1026
|
+
>
|
|
1027
|
+
<div class="visitor-preview__label">{{ label }}</div>
|
|
1028
|
+
<div class="visitor-preview__members">
|
|
1029
|
+
<button
|
|
1030
|
+
type="button"
|
|
1031
|
+
class="visitor-preview__member visitor-preview__lead"
|
|
1032
|
+
aria-label="Open lead"
|
|
1033
|
+
@click="openLeadPreview"
|
|
1034
|
+
>
|
|
1035
|
+
<div class="d-flex align-center justify-space-between ga-2">
|
|
1036
|
+
<div class="visitor-preview__value">
|
|
1037
|
+
{{ selectedVisitorLead.name || "N/A" }}
|
|
1038
|
+
</div>
|
|
1039
|
+
<v-icon size="20" icon="mdi-chevron-right" />
|
|
1040
|
+
</div>
|
|
1041
|
+
<div class="visitor-preview__member-line">
|
|
1042
|
+
<span class="visitor-preview__member-key">Contact</span>
|
|
1043
|
+
<span>{{ selectedVisitorLead.contact || "N/A" }}</span>
|
|
1044
|
+
</div>
|
|
1045
|
+
<div class="visitor-preview__member-line">
|
|
1046
|
+
<span class="visitor-preview__member-key">NRIC</span>
|
|
1047
|
+
<span>{{ maskNRIC(selectedVisitorLead.nric) || "N/A" }}</span>
|
|
1048
|
+
</div>
|
|
1049
|
+
<div v-if="leadPassName" class="visitor-preview__member-line">
|
|
1050
|
+
<span class="visitor-preview__member-key">Pass</span>
|
|
1051
|
+
<span>{{ leadPassName }}</span>
|
|
1052
|
+
</div>
|
|
1053
|
+
</button>
|
|
849
1054
|
</div>
|
|
850
1055
|
</div>
|
|
851
1056
|
|
|
@@ -974,10 +1179,10 @@
|
|
|
974
1179
|
<div class="visitor-preview__value d-flex align-center ga-2">
|
|
975
1180
|
{{ formatValues(key, selectedVisitorObject[key]) }}
|
|
976
1181
|
<TooltipInfo
|
|
977
|
-
v-if="key === 'checkOut'"
|
|
1182
|
+
v-if="key === 'checkOut' && isManualCheckout(selectedVisitorObject)"
|
|
978
1183
|
text="Manual Checkout"
|
|
979
1184
|
density="compact"
|
|
980
|
-
size="
|
|
1185
|
+
size="small"
|
|
981
1186
|
/>
|
|
982
1187
|
</div>
|
|
983
1188
|
</div>
|
|
@@ -1008,7 +1213,7 @@
|
|
|
1008
1213
|
<v-dialog v-model="dialog.snapshotImage" max-width="700">
|
|
1009
1214
|
<v-card>
|
|
1010
1215
|
<v-card-title class="d-flex justify-space-between align-center">
|
|
1011
|
-
<span>
|
|
1216
|
+
<span>{{ snapshotImageSide === "OUT" ? "Check Out" : "Check In" }}</span>
|
|
1012
1217
|
<v-btn
|
|
1013
1218
|
icon="mdi-close"
|
|
1014
1219
|
variant="text"
|
|
@@ -1074,6 +1279,16 @@
|
|
|
1074
1279
|
</v-card>
|
|
1075
1280
|
</v-dialog>
|
|
1076
1281
|
|
|
1282
|
+
<!--
|
|
1283
|
+
"Update Pass or Key(s)" - iSecure `PassKeyReturnDialog` (Capacitor
|
|
1284
|
+
PassKeyDialog.vue, return-pass-keys). Every pass with a key id and every
|
|
1285
|
+
key the visit holds is marked Not Returned / Returned / Damaged / Lost (a
|
|
1286
|
+
pass or key already "Removed" stays, locked, as history); remarks are
|
|
1287
|
+
required for Damaged and Lost. Keys are listed for a contractor only, but
|
|
1288
|
+
every key still counts towards Check Out. "Scan QR Code" marks the scanned
|
|
1289
|
+
pass or key Returned. Update saves the statuses only (once something
|
|
1290
|
+
changed) and closes; Check Out stays disabled while anything is still out.
|
|
1291
|
+
-->
|
|
1077
1292
|
<v-dialog v-model="dialog.returnPassesKeys" max-width="450" persistent>
|
|
1078
1293
|
<v-card>
|
|
1079
1294
|
<v-toolbar density="compact" color="">
|
|
@@ -1081,7 +1296,7 @@
|
|
|
1081
1296
|
no-gutters
|
|
1082
1297
|
class="d-flex fill-height justify-space-between align-center px-4"
|
|
1083
1298
|
>
|
|
1084
|
-
<span class="font-weight-bold">
|
|
1299
|
+
<span class="font-weight-bold">Update Pass or Key(s)</span>
|
|
1085
1300
|
<v-btn
|
|
1086
1301
|
icon="mdi-close"
|
|
1087
1302
|
variant="text"
|
|
@@ -1090,53 +1305,60 @@
|
|
|
1090
1305
|
</v-row>
|
|
1091
1306
|
</v-toolbar>
|
|
1092
1307
|
<v-card-text class="px-4 pb-2">
|
|
1093
|
-
<
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
<
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
<v-text-field
|
|
1109
|
-
v-model="selectedVisitorObject.location"
|
|
1110
|
-
density="comfortable"
|
|
1111
|
-
readonly
|
|
1112
|
-
/>
|
|
1308
|
+
<div class="visitor-return__who">
|
|
1309
|
+
<div class="visitor-return__who-line font-weight-bold">
|
|
1310
|
+
<v-icon icon="mdi-account-outline" size="18" />
|
|
1311
|
+
{{ returnVisitor?.name || "N/A" }}
|
|
1312
|
+
</div>
|
|
1313
|
+
<div class="visitor-return__who-line">
|
|
1314
|
+
<v-icon icon="mdi-storefront-outline" size="18" />
|
|
1315
|
+
{{
|
|
1316
|
+
formatLocation({
|
|
1317
|
+
block: returnVisitor?.block,
|
|
1318
|
+
level: returnVisitor?.level,
|
|
1319
|
+
unit: returnVisitor?.unit,
|
|
1320
|
+
})
|
|
1321
|
+
}}
|
|
1322
|
+
</div>
|
|
1113
1323
|
</div>
|
|
1114
1324
|
|
|
1115
|
-
<
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1325
|
+
<v-btn
|
|
1326
|
+
block
|
|
1327
|
+
variant="flat"
|
|
1328
|
+
class="text-capitalize mb-3 screen-btn-primary"
|
|
1329
|
+
prepend-icon="mdi-qrcode-scan"
|
|
1330
|
+
@click="returnScan.open = true"
|
|
1331
|
+
>
|
|
1332
|
+
Scan QR Code
|
|
1333
|
+
</v-btn>
|
|
1334
|
+
|
|
1335
|
+
<div class="mb-2">
|
|
1336
|
+
<p class="visitor-return__label">Pass ({{ passReturnStatuses.length }})</p>
|
|
1337
|
+
<p v-if="passReturnStatuses.length === 0" class="text-body-2 mb-2">
|
|
1338
|
+
No current pass assigned.
|
|
1339
|
+
</p>
|
|
1340
|
+
<v-row
|
|
1341
|
+
no-gutters
|
|
1119
1342
|
v-for="pass in passReturnStatuses"
|
|
1120
|
-
:key="
|
|
1343
|
+
:key="pass.keyId"
|
|
1121
1344
|
class="d-flex flex-wrap justify-space-between align-center ga-5 mb-2"
|
|
1122
1345
|
>
|
|
1123
1346
|
<v-chip
|
|
1124
1347
|
prepend-icon="mdi-card-bulleted-outline"
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
color="blue"
|
|
1348
|
+
variant="outlined"
|
|
1349
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--field visitor-pass-chip--pass"
|
|
1128
1350
|
>
|
|
1129
|
-
{{
|
|
1351
|
+
{{ pass.prefixAndName || returnKeyNames[pass.keyId] || "Pass" }}
|
|
1130
1352
|
</v-chip>
|
|
1131
1353
|
<v-select
|
|
1132
1354
|
hide-details
|
|
1133
1355
|
max-width="200px"
|
|
1134
1356
|
density="compact"
|
|
1135
|
-
:items="
|
|
1357
|
+
:items="returnStatusItems(pass)"
|
|
1136
1358
|
item-title="label"
|
|
1137
1359
|
item-value="value"
|
|
1138
1360
|
v-model="pass.status"
|
|
1139
|
-
:disabled="pass.
|
|
1361
|
+
:disabled="pass.locked"
|
|
1140
1362
|
/>
|
|
1141
1363
|
<v-textarea
|
|
1142
1364
|
v-if="pass.status === 'Lost' || pass.status === 'Damaged'"
|
|
@@ -1144,35 +1366,39 @@
|
|
|
1144
1366
|
rows="3"
|
|
1145
1367
|
class="w-100"
|
|
1146
1368
|
density="compact"
|
|
1369
|
+
label="Remarks"
|
|
1370
|
+
placeholder="Enter remarks"
|
|
1147
1371
|
v-model="pass.remarks"
|
|
1148
1372
|
/>
|
|
1149
1373
|
</v-row>
|
|
1150
1374
|
</div>
|
|
1151
|
-
<div v-if="
|
|
1152
|
-
<p class="
|
|
1375
|
+
<div v-if="returnVisitor?.type === 'contractor'" class="mb-2">
|
|
1376
|
+
<p class="visitor-return__label">Keys ({{ keyReturnStatuses.length }})</p>
|
|
1377
|
+
<p v-if="keyReturnStatuses.length === 0" class="text-body-2 mb-2">
|
|
1378
|
+
No current key(s) assigned.
|
|
1379
|
+
</p>
|
|
1153
1380
|
<v-row
|
|
1154
1381
|
no-gutters
|
|
1155
1382
|
v-for="key in keyReturnStatuses"
|
|
1156
|
-
:key="
|
|
1383
|
+
:key="key.keyId"
|
|
1157
1384
|
class="d-flex flex-wrap justify-space-between align-center ga-5 mb-2"
|
|
1158
1385
|
>
|
|
1159
1386
|
<v-chip
|
|
1160
1387
|
prepend-icon="mdi-key"
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
color="orange"
|
|
1388
|
+
variant="outlined"
|
|
1389
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--field visitor-pass-chip--key"
|
|
1164
1390
|
>
|
|
1165
|
-
{{
|
|
1391
|
+
{{ key.prefixAndName || returnKeyNames[key.keyId] || "Key" }}
|
|
1166
1392
|
</v-chip>
|
|
1167
1393
|
<v-select
|
|
1168
1394
|
hide-details
|
|
1169
1395
|
max-width="200px"
|
|
1170
1396
|
density="compact"
|
|
1171
|
-
:items="
|
|
1397
|
+
:items="returnStatusItems(key)"
|
|
1172
1398
|
item-title="label"
|
|
1173
1399
|
item-value="value"
|
|
1174
1400
|
v-model="key.status"
|
|
1175
|
-
:disabled="key.
|
|
1401
|
+
:disabled="key.locked"
|
|
1176
1402
|
/>
|
|
1177
1403
|
<v-textarea
|
|
1178
1404
|
v-if="key.status === 'Lost' || key.status === 'Damaged'"
|
|
@@ -1180,33 +1406,31 @@
|
|
|
1180
1406
|
rows="3"
|
|
1181
1407
|
class="w-100"
|
|
1182
1408
|
density="compact"
|
|
1409
|
+
label="Remarks"
|
|
1410
|
+
placeholder="Enter remarks"
|
|
1183
1411
|
v-model="key.remarks"
|
|
1184
1412
|
/>
|
|
1185
1413
|
</v-row>
|
|
1186
1414
|
</div>
|
|
1187
1415
|
|
|
1188
|
-
<v-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
color="blue"
|
|
1192
|
-
density="comfortable"
|
|
1193
|
-
class="text-capitalize"
|
|
1194
|
-
:loading="loading.updatingPassKeys"
|
|
1195
|
-
@click.stop="handleUpdatePassKeys"
|
|
1196
|
-
>
|
|
1197
|
-
Update Pass/Keys
|
|
1198
|
-
</v-btn>
|
|
1199
|
-
</v-row>
|
|
1416
|
+
<p v-if="returnDialogError" class="text-error text-body-2 mb-2">
|
|
1417
|
+
{{ returnDialogError }}
|
|
1418
|
+
</p>
|
|
1200
1419
|
</v-card-text>
|
|
1201
1420
|
<v-toolbar class="pa-0" density="compact">
|
|
1202
1421
|
<v-row no-gutters>
|
|
1203
1422
|
<v-col cols="6">
|
|
1204
1423
|
<v-btn
|
|
1205
|
-
|
|
1424
|
+
color="blue"
|
|
1425
|
+
variant="flat"
|
|
1426
|
+
height="48"
|
|
1427
|
+
rounded="0"
|
|
1206
1428
|
block
|
|
1207
|
-
|
|
1429
|
+
:loading="loading.updatingPassKeys"
|
|
1430
|
+
:disabled="!returnStatusesChanged || loading.checkingOut"
|
|
1431
|
+
@click="handleUpdatePassKeys"
|
|
1208
1432
|
>
|
|
1209
|
-
|
|
1433
|
+
Update
|
|
1210
1434
|
</v-btn>
|
|
1211
1435
|
</v-col>
|
|
1212
1436
|
<v-col cols="6">
|
|
@@ -1217,12 +1441,10 @@
|
|
|
1217
1441
|
rounded="0"
|
|
1218
1442
|
block
|
|
1219
1443
|
:loading="loading.checkingOut"
|
|
1220
|
-
:disabled="
|
|
1221
|
-
|
|
1222
|
-
"
|
|
1223
|
-
@click="proceedCheckout"
|
|
1444
|
+
:disabled="returnCheckOutBlocked || loading.updatingPassKeys"
|
|
1445
|
+
@click="confirmReturnCheckout"
|
|
1224
1446
|
>
|
|
1225
|
-
|
|
1447
|
+
Check Out
|
|
1226
1448
|
</v-btn>
|
|
1227
1449
|
</v-col>
|
|
1228
1450
|
</v-row>
|
|
@@ -1230,6 +1452,16 @@
|
|
|
1230
1452
|
</v-card>
|
|
1231
1453
|
</v-dialog>
|
|
1232
1454
|
|
|
1455
|
+
<VisitorPassKeyQRScanner
|
|
1456
|
+
v-if="returnScan.open"
|
|
1457
|
+
:dialog="returnScan.open"
|
|
1458
|
+
v-model:scanned-value="returnScan.value"
|
|
1459
|
+
@close-dialog="returnScan.open = false"
|
|
1460
|
+
/>
|
|
1461
|
+
|
|
1462
|
+
<!-- "Return NFC Card(s)" - iSecure `CardReturnDialog`: every card set to
|
|
1463
|
+
Returned, Lost or Damage (a reason for Lost / Damage) before checking
|
|
1464
|
+
out. Stays open with the reason when the check-out fails. -->
|
|
1233
1465
|
<v-dialog v-model="dialog.returnNfcCard" max-width="450" persistent>
|
|
1234
1466
|
<v-card>
|
|
1235
1467
|
<v-toolbar density="compact" color="">
|
|
@@ -1237,7 +1469,7 @@
|
|
|
1237
1469
|
no-gutters
|
|
1238
1470
|
class="d-flex fill-height justify-space-between align-center px-4"
|
|
1239
1471
|
>
|
|
1240
|
-
<span class="font-weight-bold">Return NFC Card</span>
|
|
1472
|
+
<span class="font-weight-bold">Return NFC Card(s)</span>
|
|
1241
1473
|
<v-btn
|
|
1242
1474
|
icon="mdi-close"
|
|
1243
1475
|
variant="text"
|
|
@@ -1246,41 +1478,29 @@
|
|
|
1246
1478
|
</v-row>
|
|
1247
1479
|
</v-toolbar>
|
|
1248
1480
|
<v-card-text class="px-4 pb-2">
|
|
1249
|
-
<p class="text-body-2 mb-3">
|
|
1250
|
-
|
|
1481
|
+
<p v-if="nfcCardReturnStatuses.length === 0" class="text-body-2 mb-3">
|
|
1482
|
+
No cards are recorded against this visitor.
|
|
1483
|
+
</p>
|
|
1484
|
+
<p v-else class="text-body-2 mb-3">
|
|
1485
|
+
Set every card to Returned, Lost or Damage — and give a reason for a
|
|
1486
|
+
lost or damaged card — before checking out.
|
|
1251
1487
|
</p>
|
|
1252
|
-
<div>
|
|
1253
|
-
<InputLabel class="text-capitalize" title="Full Name" />
|
|
1254
|
-
<v-text-field
|
|
1255
|
-
v-model="selectedVisitorObject.name"
|
|
1256
|
-
density="comfortable"
|
|
1257
|
-
readonly
|
|
1258
|
-
/>
|
|
1259
|
-
</div>
|
|
1260
|
-
<div>
|
|
1261
|
-
<InputLabel class="text-capitalize" title="Location" />
|
|
1262
|
-
<v-text-field
|
|
1263
|
-
v-model="selectedVisitorObject.location"
|
|
1264
|
-
density="comfortable"
|
|
1265
|
-
readonly
|
|
1266
|
-
/>
|
|
1267
|
-
</div>
|
|
1268
1488
|
<div
|
|
1269
1489
|
v-for="(card, idx) in nfcCardReturnStatuses"
|
|
1270
1490
|
:key="card.cardId"
|
|
1271
1491
|
class="mb-4"
|
|
1272
1492
|
>
|
|
1273
1493
|
<div class="d-flex align-center ga-2 mb-2">
|
|
1274
|
-
<v-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1494
|
+
<v-chip
|
|
1495
|
+
prepend-icon="mdi-credit-card-outline"
|
|
1496
|
+
variant="outlined"
|
|
1497
|
+
class="font-weight-bold visitor-pass-chip visitor-pass-chip--field visitor-pass-chip--nfc"
|
|
1498
|
+
>{{ nfcCardNos[card.cardId] || card.cardNo }}</v-chip
|
|
1499
|
+
>
|
|
1278
1500
|
</div>
|
|
1279
1501
|
<v-select
|
|
1280
1502
|
v-model="nfcCardReturnStatuses[idx].status"
|
|
1281
1503
|
:items="nfcPassStatusOptions"
|
|
1282
|
-
item-title="label"
|
|
1283
|
-
item-value="value"
|
|
1284
1504
|
density="comfortable"
|
|
1285
1505
|
hide-details
|
|
1286
1506
|
/>
|
|
@@ -1294,6 +1514,9 @@
|
|
|
1294
1514
|
density="compact"
|
|
1295
1515
|
/>
|
|
1296
1516
|
</div>
|
|
1517
|
+
<p v-if="nfcDialogError" class="text-error text-body-2 mb-2">
|
|
1518
|
+
{{ nfcDialogError }}
|
|
1519
|
+
</p>
|
|
1297
1520
|
</v-card-text>
|
|
1298
1521
|
<v-toolbar class="pa-0" density="compact">
|
|
1299
1522
|
<v-row no-gutters>
|
|
@@ -1312,7 +1535,7 @@
|
|
|
1312
1535
|
:loading="loading.checkingOut"
|
|
1313
1536
|
:disabled="!canConfirmNfcCheckout"
|
|
1314
1537
|
@click="handleNfcCheckout"
|
|
1315
|
-
>Confirm
|
|
1538
|
+
>Confirm & Check Out</v-btn
|
|
1316
1539
|
>
|
|
1317
1540
|
</v-col>
|
|
1318
1541
|
</v-row>
|
|
@@ -1320,33 +1543,16 @@
|
|
|
1320
1543
|
</v-card>
|
|
1321
1544
|
</v-dialog>
|
|
1322
1545
|
|
|
1546
|
+
<!-- Assign / Add Key / Edit Pass (iSecure: See All AND Update Passes And
|
|
1547
|
+
Keys, plus the visitor Update right). -->
|
|
1323
1548
|
<v-dialog v-model="dialog.addPassKey" width="450" persistent>
|
|
1324
1549
|
<AddPassKeyToVisitor
|
|
1325
|
-
|
|
1326
|
-
:
|
|
1550
|
+
v-if="dialog.addPassKey && passKeyDialogVisitor"
|
|
1551
|
+
:mode="passKeyDialogMode"
|
|
1552
|
+
:visitor="passKeyDialogVisitor"
|
|
1327
1553
|
:site="siteId"
|
|
1328
1554
|
@close="dialog.addPassKey = false"
|
|
1329
|
-
@done="
|
|
1330
|
-
() => {
|
|
1331
|
-
dialog.addPassKey = false;
|
|
1332
|
-
getVisitorRefresh();
|
|
1333
|
-
}
|
|
1334
|
-
"
|
|
1335
|
-
/>
|
|
1336
|
-
</v-dialog>
|
|
1337
|
-
|
|
1338
|
-
<v-dialog v-model="dialog.editPassKey" width="450" persistent>
|
|
1339
|
-
<AddPassKeyToVisitor
|
|
1340
|
-
mode="edit"
|
|
1341
|
-
:visitor="selectedVisitorDataObject"
|
|
1342
|
-
:site="siteId"
|
|
1343
|
-
@close="dialog.editPassKey = false"
|
|
1344
|
-
@done="
|
|
1345
|
-
() => {
|
|
1346
|
-
// dialog.editPassKey = false;
|
|
1347
|
-
getVisitorRefresh();
|
|
1348
|
-
}
|
|
1349
|
-
"
|
|
1555
|
+
@done="handlePassKeyDialogDone"
|
|
1350
1556
|
/>
|
|
1351
1557
|
</v-dialog>
|
|
1352
1558
|
|
|
@@ -1613,9 +1819,11 @@
|
|
|
1613
1819
|
</template>
|
|
1614
1820
|
|
|
1615
1821
|
<script setup lang="ts">
|
|
1822
|
+
import type { PropType } from "vue";
|
|
1616
1823
|
import moment from "moment-timezone";
|
|
1617
1824
|
import QRCode from "qrcode";
|
|
1618
1825
|
import { isVisitorPassSmsOn } from "../composables/useVisitor";
|
|
1826
|
+
import usePassKey from "../composables/usePassKey";
|
|
1619
1827
|
import {
|
|
1620
1828
|
VISITOR_SEARCH_DEBOUNCE_MS,
|
|
1621
1829
|
visitorSearchEmptyText,
|
|
@@ -1656,8 +1864,31 @@ const props = defineProps({
|
|
|
1656
1864
|
type: Boolean,
|
|
1657
1865
|
default: false,
|
|
1658
1866
|
},
|
|
1867
|
+
/**
|
|
1868
|
+
* Visitor Management > See Visitor Details - opening a row (the preview) and
|
|
1869
|
+
* the row's Check In, as iSecure's `visitors:details`. Left out, it follows
|
|
1870
|
+
* `canViewVisitor` (what opened a row before).
|
|
1871
|
+
*/
|
|
1872
|
+
canViewVisitorDetails: {
|
|
1873
|
+
type: Boolean as PropType<boolean | null>,
|
|
1874
|
+
default: null,
|
|
1875
|
+
},
|
|
1876
|
+
/**
|
|
1877
|
+
* Passes And Keys Management > See All Passes And Keys AND Update Passes And
|
|
1878
|
+
* Keys (iSecure `PASS_KEY_ASSIGN_GATES`, owner 2026-10-20). Either one
|
|
1879
|
+
* unticked hides Assign / Add Key / Edit Pass here and the pass & key
|
|
1880
|
+
* pickers on Add and Register; returning what a visitor holds is not affected.
|
|
1881
|
+
*/
|
|
1882
|
+
canAssignPassKeys: {
|
|
1883
|
+
type: Boolean,
|
|
1884
|
+
default: false,
|
|
1885
|
+
},
|
|
1659
1886
|
});
|
|
1660
1887
|
|
|
1888
|
+
const canOpenVisitorDetails = computed(
|
|
1889
|
+
() => props.canViewVisitorDetails ?? props.canViewVisitor
|
|
1890
|
+
);
|
|
1891
|
+
|
|
1661
1892
|
const { authenticate, currentUser } = useLocalAuth();
|
|
1662
1893
|
authenticate();
|
|
1663
1894
|
|
|
@@ -1675,11 +1906,46 @@ const {
|
|
|
1675
1906
|
loaded: anprSettingsLoaded,
|
|
1676
1907
|
ensureANPRSettings,
|
|
1677
1908
|
} = useANPRSettings(siteId);
|
|
1678
|
-
const enableUnregisteredTab = computed(() => enableUnregistered.value);
|
|
1679
1909
|
ensureANPRSettings(true).catch(() => {
|
|
1680
1910
|
// No settings = no Unregistered tab, same as before.
|
|
1681
1911
|
});
|
|
1682
1912
|
|
|
1913
|
+
/*
|
|
1914
|
+
* iSecure `useUnregisteredTab`: the Unregistered tab needs BOTH the site's
|
|
1915
|
+
* Unregistered switch AND an ANPR camera facing an entrance (entry / both) -
|
|
1916
|
+
* the same rule the plate notice (VisitorSocketPopUp) already applies.
|
|
1917
|
+
*/
|
|
1918
|
+
const { getAllSiteCameras } = useSiteSettings();
|
|
1919
|
+
const hasEntryFacingAnprCamera = ref(false);
|
|
1920
|
+
const anprCamerasLoaded = ref(false);
|
|
1921
|
+
|
|
1922
|
+
async function loadEntryFacingAnprCamera() {
|
|
1923
|
+
try {
|
|
1924
|
+
const res: any = await getAllSiteCameras({ site: siteId, type: "anpr", page: 1, limit: 100 });
|
|
1925
|
+
const cameras = Array.isArray(res?.items)
|
|
1926
|
+
? res.items
|
|
1927
|
+
: Array.isArray(res?.data?.items)
|
|
1928
|
+
? res.data.items
|
|
1929
|
+
: [];
|
|
1930
|
+
hasEntryFacingAnprCamera.value = cameras.some(
|
|
1931
|
+
(camera: any) => camera?.direction === "entry" || camera?.direction === "both"
|
|
1932
|
+
);
|
|
1933
|
+
} catch {
|
|
1934
|
+
hasEntryFacingAnprCamera.value = false;
|
|
1935
|
+
} finally {
|
|
1936
|
+
anprCamerasLoaded.value = true;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
void loadEntryFacingAnprCamera();
|
|
1940
|
+
|
|
1941
|
+
const enableUnregisteredTab = computed(
|
|
1942
|
+
() => enableUnregistered.value && hasEntryFacingAnprCamera.value
|
|
1943
|
+
);
|
|
1944
|
+
/** Both answers are in - only then is a hidden Unregistered tab really hidden. */
|
|
1945
|
+
const unregisteredTabKnown = computed(
|
|
1946
|
+
() => anprSettingsLoaded.value && anprCamerasLoaded.value
|
|
1947
|
+
);
|
|
1948
|
+
|
|
1683
1949
|
const {
|
|
1684
1950
|
getVisitors,
|
|
1685
1951
|
visitorSelection,
|
|
@@ -1687,6 +1953,7 @@ const {
|
|
|
1687
1953
|
updateVisitor,
|
|
1688
1954
|
visitorDataFromScannedQRCodeCheckIn,
|
|
1689
1955
|
sendPassSms,
|
|
1956
|
+
contractorTypes,
|
|
1690
1957
|
} = useVisitor();
|
|
1691
1958
|
const { visitorCheckout, getCardDetails } = useAccessManagement();
|
|
1692
1959
|
|
|
@@ -1726,7 +1993,53 @@ const {
|
|
|
1726
1993
|
maskNRIC,
|
|
1727
1994
|
} = useUtils();
|
|
1728
1995
|
const { formatLocation } = useSecurityUtils();
|
|
1729
|
-
const { getFileUrlAnpr } = useFile();
|
|
1996
|
+
const { getFileUrlAnpr, getFileUrl } = useFile();
|
|
1997
|
+
|
|
1998
|
+
// The preview's photo and ANPR snapshot carousels, and their full-screen view.
|
|
1999
|
+
const previewPhotoIndex = ref(0);
|
|
2000
|
+
const previewSnapshotIndex = ref(0);
|
|
2001
|
+
const fullscreenOpen = ref(false);
|
|
2002
|
+
const fullscreenIndex = ref(0);
|
|
2003
|
+
const fullscreenImages = ref<{ src: string; title: string }[]>([]);
|
|
2004
|
+
|
|
2005
|
+
/** The entry and exit snapshots (iSecure: "Entry" / "Exit", full screen "Check In" / "Check Out"). */
|
|
2006
|
+
const previewSnapshots = computed(() => {
|
|
2007
|
+
const record = selectedVisitorObject.value ?? {};
|
|
2008
|
+
return [
|
|
2009
|
+
record.snapshotEntryImage
|
|
2010
|
+
? { side: "IN", label: "Entry", title: "Check In", id: record.snapshotEntryImage }
|
|
2011
|
+
: null,
|
|
2012
|
+
record.snapshotExitImage
|
|
2013
|
+
? { side: "OUT", label: "Exit", title: "Check Out", id: record.snapshotExitImage }
|
|
2014
|
+
: null,
|
|
2015
|
+
]
|
|
2016
|
+
.filter((snapshot) => snapshot !== null)
|
|
2017
|
+
.map((snapshot) => ({
|
|
2018
|
+
...snapshot!,
|
|
2019
|
+
src: getFileUrlAnpr(`${siteId}/${snapshot!.id}`),
|
|
2020
|
+
}));
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
function openFullscreen(images: { src: string; title: string }[], index: number) {
|
|
2024
|
+
fullscreenImages.value = images;
|
|
2025
|
+
fullscreenIndex.value = index;
|
|
2026
|
+
fullscreenOpen.value = true;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function openPreviewPhotos(index: number) {
|
|
2030
|
+
const ids: string[] = selectedVisitorObject.value?.attachments ?? [];
|
|
2031
|
+
openFullscreen(
|
|
2032
|
+
ids.map((id, i) => ({ src: getFileUrl(id), title: `Photo ${i + 1} of ${ids.length}` })),
|
|
2033
|
+
index
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function openPreviewSnapshots(index: number) {
|
|
2038
|
+
openFullscreen(
|
|
2039
|
+
previewSnapshots.value.map(({ src, title }) => ({ src, title })),
|
|
2040
|
+
index
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
1730
2043
|
const { getSiteById } = useSite();
|
|
1731
2044
|
const { getIdentities, issueVisitorQr } = useHidAmico();
|
|
1732
2045
|
// const { status: visitorStatus, search } = useRoute().query as { status: string, search: string};
|
|
@@ -1891,20 +2204,74 @@ watch(searchInput, (value) => {
|
|
|
1891
2204
|
else applySearch();
|
|
1892
2205
|
});
|
|
1893
2206
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2207
|
+
/*
|
|
2208
|
+
* iSecure: the last 30 days through today, and the dates cannot be cleared -
|
|
2209
|
+
* a cleared date goes back to its default. Sent as Singapore day bounds
|
|
2210
|
+
* (`toSgDayRange`: 00:00:00 to 23:59:59.999 at +08:00; SG has no DST).
|
|
2211
|
+
*/
|
|
2212
|
+
function daysAgo(days: number) {
|
|
2213
|
+
const date = new Date();
|
|
2214
|
+
date.setDate(date.getDate() - days);
|
|
2215
|
+
return date;
|
|
2216
|
+
}
|
|
2217
|
+
const defaultDateTo = () => normalizeDateOnly(new Date().toISOString());
|
|
2218
|
+
const defaultDateFrom = () => normalizeDateOnly(daysAgo(30).toISOString());
|
|
2219
|
+
|
|
2220
|
+
const dateTo = ref(defaultDateTo());
|
|
2221
|
+
const dateFrom = ref(defaultDateFrom());
|
|
1897
2222
|
|
|
1898
|
-
|
|
1899
|
-
|
|
2223
|
+
function sgDayStart(day: string) {
|
|
2224
|
+
return day ? new Date(`${day}T00:00:00+08:00`).toISOString() : undefined;
|
|
2225
|
+
}
|
|
2226
|
+
function sgDayEnd(day: string) {
|
|
2227
|
+
return day ? new Date(`${day}T23:59:59.999+08:00`).toISOString() : undefined;
|
|
2228
|
+
}
|
|
1900
2229
|
|
|
1901
|
-
|
|
2230
|
+
// Stored type strings - the "Guest (…)" literals are not a TVisitorType.
|
|
2231
|
+
const filterTypes = ref<string[]>([]);
|
|
1902
2232
|
const displayNotCheckedOut = ref<boolean>(false);
|
|
1903
2233
|
const mode = ref<"add" | "edit" | "register">("add");
|
|
1904
2234
|
|
|
1905
2235
|
const message = ref("");
|
|
1906
2236
|
const messageColor = ref("");
|
|
1907
2237
|
const messageSnackbar = ref(false);
|
|
2238
|
+
/** Why the list could not be loaded (shown in the empty table). */
|
|
2239
|
+
const visitorListError = ref("");
|
|
2240
|
+
|
|
2241
|
+
/* ---- Visitor type filter (iSecure visitor.constants.ts) ----------------- */
|
|
2242
|
+
|
|
2243
|
+
/** Visitors tab: the eight stored types, "Guest (…)" literals included. */
|
|
2244
|
+
const VISITOR_TYPE_FILTER_OPTIONS = [
|
|
2245
|
+
{ label: "Drive In", value: "guest" },
|
|
2246
|
+
{ label: "Contractor", value: "contractor" },
|
|
2247
|
+
{ label: "Delivery (Food & Parcel)", value: "delivery" },
|
|
2248
|
+
{ label: "Walk-In", value: "walk-in" },
|
|
2249
|
+
{ label: "Pick-Up", value: "pick-up" },
|
|
2250
|
+
{ label: "Drop-Off", value: "drop-off" },
|
|
2251
|
+
{ label: "Guest (Drive-In)", value: "Guest (Drive-In)" },
|
|
2252
|
+
{ label: "Guest (Walk-In)", value: "Guest (Walk-In)" },
|
|
2253
|
+
];
|
|
2254
|
+
/** Guests tab: the two kinds of invitation. */
|
|
2255
|
+
const GUEST_TYPE_FILTER_OPTIONS = [
|
|
2256
|
+
{ label: "Guest", value: "guest" },
|
|
2257
|
+
{ label: "Contractor", value: "contractor" },
|
|
2258
|
+
];
|
|
2259
|
+
const DEFAULT_VISITOR_TAB_TYPES = "contractor,delivery,walk-in,pick-up,drop-off,guest";
|
|
2260
|
+
const DEFAULT_GUEST_TAB_TYPES = "guest,contractor";
|
|
2261
|
+
|
|
2262
|
+
const typeFilterOptions = computed(() => {
|
|
2263
|
+
if (activeTab.value === "registered") return VISITOR_TYPE_FILTER_OPTIONS;
|
|
2264
|
+
if (activeTab.value === "guests") return GUEST_TYPE_FILTER_OPTIONS;
|
|
2265
|
+
return [];
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
/** The `type` the list (and the CSV report) is asked for on this tab. */
|
|
2269
|
+
function typeParamForTab(): string | undefined {
|
|
2270
|
+
const picked = filterTypes.value.filter(Boolean).join(",");
|
|
2271
|
+
if (activeTab.value === "registered") return picked || DEFAULT_VISITOR_TAB_TYPES;
|
|
2272
|
+
if (activeTab.value === "guests") return picked || DEFAULT_GUEST_TAB_TYPES;
|
|
2273
|
+
return undefined;
|
|
2274
|
+
}
|
|
1908
2275
|
|
|
1909
2276
|
const loading = reactive({
|
|
1910
2277
|
fetchingVisitors: false,
|
|
@@ -1922,7 +2289,6 @@ const dialog = reactive({
|
|
|
1922
2289
|
remarks: false,
|
|
1923
2290
|
returnPassesKeys: false,
|
|
1924
2291
|
addPassKey: false,
|
|
1925
|
-
editPassKey: false,
|
|
1926
2292
|
scanVisitorQRCode: false,
|
|
1927
2293
|
showVisitorDataFromScannedQRCode: false,
|
|
1928
2294
|
approveRejectOvernightParkingRequestDialog: false,
|
|
@@ -2050,15 +2416,22 @@ const headers = computed(() => {
|
|
|
2050
2416
|
];
|
|
2051
2417
|
}
|
|
2052
2418
|
|
|
2053
|
-
// 2.
|
|
2054
|
-
|
|
2419
|
+
// 2. Unregistered (iSecure): the plate, Check In/Out and Register only.
|
|
2420
|
+
if (tab === "unregistered") {
|
|
2421
|
+
return [
|
|
2422
|
+
{ title: "Vehicle Number", value: "vehicle-number" },
|
|
2423
|
+
{ title: "Check In/Out", value: "checkin-out" },
|
|
2424
|
+
{ title: "Action", value: "action" },
|
|
2425
|
+
];
|
|
2426
|
+
}
|
|
2055
2427
|
|
|
2056
|
-
//
|
|
2057
|
-
|
|
2058
|
-
|
|
2428
|
+
// 3. iSecure's columns: Name / Phone / NRIC, Vehicle # / Type / Company,
|
|
2429
|
+
// then Location and Check In/Out (or the arrival on Guests).
|
|
2430
|
+
const list = [
|
|
2431
|
+
{ title: "Name / Phone / NRIC", value: "name" },
|
|
2432
|
+
{ title: "Vehicle # / Type / Company", value: "vehicle-type-company" },
|
|
2059
2433
|
{ title: "Location", value: "location" },
|
|
2060
|
-
|
|
2061
|
-
);
|
|
2434
|
+
];
|
|
2062
2435
|
|
|
2063
2436
|
// Conditional logic for Guests vs Others
|
|
2064
2437
|
if (tab === "guests") {
|
|
@@ -2075,10 +2448,6 @@ const headers = computed(() => {
|
|
|
2075
2448
|
);
|
|
2076
2449
|
}
|
|
2077
2450
|
|
|
2078
|
-
if (tab === "unregistered") {
|
|
2079
|
-
list.push({ title: "Action", value: "action" });
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
2451
|
// Guests is the only non-overnight-parking tab whose rows carry a
|
|
2083
2452
|
// top-level status (approved/pending/rejected) and an Approve/Reject
|
|
2084
2453
|
// action - without these two, the item.status/item.action template slots
|
|
@@ -2115,12 +2484,14 @@ function permissionDenied(error: any): string | null {
|
|
|
2115
2484
|
}
|
|
2116
2485
|
|
|
2117
2486
|
const tabOptions = computed(() => {
|
|
2487
|
+
// iSecure's three tabs first, in its order and words; then the web's own.
|
|
2488
|
+
// The values (and the `tab` query) are unchanged so links keep working.
|
|
2118
2489
|
const baseTabs = [
|
|
2119
|
-
{ name: "
|
|
2490
|
+
{ name: "Visitors", value: "registered" },
|
|
2491
|
+
{ name: "Guests", value: "guests" },
|
|
2120
2492
|
...(enableUnregisteredTab.value
|
|
2121
|
-
? [{ name: "Unregistered", value: "unregistered" }]
|
|
2493
|
+
? [{ name: "Unregistered Visitor", value: "unregistered" }]
|
|
2122
2494
|
: []),
|
|
2123
|
-
{ name: "Guests", value: "guests" },
|
|
2124
2495
|
// Its own right in the role editor; the server refuses the list without it
|
|
2125
2496
|
// - and once it has, the tab is hidden for the rest of the visit.
|
|
2126
2497
|
...(showResidentTransactionsTab.value
|
|
@@ -2144,15 +2515,17 @@ function getOvernightParkingRequestStatusColor(status: string) {
|
|
|
2144
2515
|
}
|
|
2145
2516
|
|
|
2146
2517
|
const overNightParkingListActions = [
|
|
2518
|
+
// Was `disabled: props.canUpdateVisitor` - inverted: a role WITHOUT the
|
|
2519
|
+
// Update right always saw Approve / Reject, even on a decided request.
|
|
2147
2520
|
{
|
|
2148
2521
|
text: "Approve",
|
|
2149
2522
|
status: "approved",
|
|
2150
|
-
disabled: props.canUpdateVisitor,
|
|
2523
|
+
disabled: !props.canUpdateVisitor,
|
|
2151
2524
|
},
|
|
2152
2525
|
{
|
|
2153
2526
|
text: "Reject",
|
|
2154
2527
|
status: "rejected",
|
|
2155
|
-
disabled: props.canUpdateVisitor,
|
|
2528
|
+
disabled: !props.canUpdateVisitor,
|
|
2156
2529
|
},
|
|
2157
2530
|
{
|
|
2158
2531
|
text: "View Remarks",
|
|
@@ -2324,10 +2697,103 @@ function normalizeDateOnly(value: string) {
|
|
|
2324
2697
|
return `${year}-${month}-${day}`;
|
|
2325
2698
|
}
|
|
2326
2699
|
|
|
2700
|
+
// A record opened from the preview's Lead that is not on the loaded page.
|
|
2701
|
+
const previewRecord = ref<Record<string, any> | null>(null);
|
|
2702
|
+
|
|
2327
2703
|
const selectedVisitorDataObject = computed(() => {
|
|
2328
|
-
return
|
|
2704
|
+
return (
|
|
2705
|
+
items.value.find((x: any) => x?._id === selectedVisitorId.value) ||
|
|
2706
|
+
(previewRecord.value?._id === selectedVisitorId.value ? previewRecord.value : null) ||
|
|
2707
|
+
{}
|
|
2708
|
+
);
|
|
2329
2709
|
});
|
|
2330
2710
|
|
|
2711
|
+
/**
|
|
2712
|
+
* A contractor member's lead (iSecure `[id].tsx` Lead section): the record
|
|
2713
|
+
* its `leadId` names, read after the preview opens (never holds it back). A
|
|
2714
|
+
* lead, or an older member row without `leadId`, shows none.
|
|
2715
|
+
*/
|
|
2716
|
+
const selectedVisitorLead = ref<Record<string, any> | null>(null);
|
|
2717
|
+
|
|
2718
|
+
watch(
|
|
2719
|
+
() => [dialog.viewVisitor, (selectedVisitorDataObject.value as any)?.leadId] as const,
|
|
2720
|
+
async ([open, leadId]) => {
|
|
2721
|
+
selectedVisitorLead.value = null;
|
|
2722
|
+
if (!open || !leadId) return;
|
|
2723
|
+
try {
|
|
2724
|
+
const res: any = await useNuxtApp().$api(
|
|
2725
|
+
`/api/visitor-transactions/id/${leadId}`
|
|
2726
|
+
);
|
|
2727
|
+
const record = res?._id ? res : res?.data;
|
|
2728
|
+
// Still the same member's preview when the answer comes back.
|
|
2729
|
+
if (
|
|
2730
|
+
dialog.viewVisitor &&
|
|
2731
|
+
String((selectedVisitorDataObject.value as any)?.leadId ?? "") === String(leadId)
|
|
2732
|
+
) {
|
|
2733
|
+
selectedVisitorLead.value = record?._id ? record : null;
|
|
2734
|
+
// The lead's record carries its pass as an id only: its name is
|
|
2735
|
+
// looked up, as iSecure's Lead card does.
|
|
2736
|
+
void loadReturnKeyNames(passIdsOf(record));
|
|
2737
|
+
}
|
|
2738
|
+
} catch {
|
|
2739
|
+
// No Lead section rather than an error over the preview.
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
);
|
|
2743
|
+
|
|
2744
|
+
/** The pass / key ids a record holds, a bare pass id string included. */
|
|
2745
|
+
function passIdsOf(record: Record<string, any> | null | undefined): string[] {
|
|
2746
|
+
if (!record) return [];
|
|
2747
|
+
const pass =
|
|
2748
|
+
typeof record.visitorPass === "string"
|
|
2749
|
+
? [record.visitorPass]
|
|
2750
|
+
: activePasses(record).map((entry) => String(entry.keyId ?? ""));
|
|
2751
|
+
return [...pass, ...activeKeys(record).map((entry) => String(entry.keyId ?? ""))].filter(
|
|
2752
|
+
Boolean
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/**
|
|
2757
|
+
* The lead's pass on its card (iSecure `[id].tsx` Lead): its own name, or the
|
|
2758
|
+
* name looked up from its id; nothing when it holds none.
|
|
2759
|
+
*/
|
|
2760
|
+
const leadPassName = computed(() => {
|
|
2761
|
+
const lead = selectedVisitorLead.value;
|
|
2762
|
+
if (!lead) return "";
|
|
2763
|
+
if (typeof lead.visitorPass === "string") return returnKeyNames[lead.visitorPass] ?? "";
|
|
2764
|
+
const pass = activePasses(lead)[0];
|
|
2765
|
+
if (!pass) return "";
|
|
2766
|
+
return pass.prefixAndName || returnKeyNames[String(pass.keyId ?? "")] || "";
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
// The previewed record's own Pass / Keys chips: names looked up for entries
|
|
2770
|
+
// that carry an id only (a lead opened from its member's preview).
|
|
2771
|
+
watch(
|
|
2772
|
+
() => [dialog.viewVisitor, (selectedVisitorDataObject.value as any)?._id] as const,
|
|
2773
|
+
([open]) => {
|
|
2774
|
+
if (!open) return;
|
|
2775
|
+
const record = selectedVisitorDataObject.value as Record<string, any>;
|
|
2776
|
+
const unnamed = [
|
|
2777
|
+
...activePasses(record),
|
|
2778
|
+
...activeKeys(record),
|
|
2779
|
+
].filter((entry) => !entry.prefixAndName);
|
|
2780
|
+
void loadReturnKeyNames([
|
|
2781
|
+
...unnamed.map((entry) => String(entry.keyId ?? "")),
|
|
2782
|
+
...(typeof record?.visitorPass === "string" ? [record.visitorPass] : []),
|
|
2783
|
+
]);
|
|
2784
|
+
}
|
|
2785
|
+
);
|
|
2786
|
+
|
|
2787
|
+
/** "Open lead": the lead's own preview, in the same dialog. */
|
|
2788
|
+
function openLeadPreview() {
|
|
2789
|
+
const lead = selectedVisitorLead.value;
|
|
2790
|
+
if (!lead?._id) return;
|
|
2791
|
+
previewRecord.value = lead;
|
|
2792
|
+
selectedVisitorId.value = lead._id;
|
|
2793
|
+
showVisitorNric.value = false;
|
|
2794
|
+
shownMemberNrics.value = [];
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2331
2797
|
const selectedVisitorStatus = computed(
|
|
2332
2798
|
() => (selectedVisitorDataObject.value?.status as string) ?? ""
|
|
2333
2799
|
);
|
|
@@ -2386,6 +2852,9 @@ watch(
|
|
|
2386
2852
|
if (open) return;
|
|
2387
2853
|
showVisitorNric.value = false;
|
|
2388
2854
|
shownMemberNrics.value = [];
|
|
2855
|
+
previewRecord.value = null;
|
|
2856
|
+
previewPhotoIndex.value = 0;
|
|
2857
|
+
previewSnapshotIndex.value = 0;
|
|
2389
2858
|
}
|
|
2390
2859
|
);
|
|
2391
2860
|
|
|
@@ -2407,7 +2876,42 @@ const memberDetailFields = [
|
|
|
2407
2876
|
const formatType = (item: any) =>
|
|
2408
2877
|
(item.deliveryType ? item.deliveryType + "-" : "") + item.type;
|
|
2409
2878
|
|
|
2879
|
+
/**
|
|
2880
|
+
* The type a row shows (iSecure `typeLabel`): a resident-created guest keeps
|
|
2881
|
+
* its `residentGuestType`, `guest` reads "Drive-In", a delivery says which
|
|
2882
|
+
* kind it is ("delivery (food)"), anything else its stored type.
|
|
2883
|
+
*/
|
|
2884
|
+
function visitorTypeLabel(item: Record<string, any>) {
|
|
2885
|
+
if (item?.residentGuestType) return item.residentGuestType;
|
|
2886
|
+
if (!item?.type) return "N/A";
|
|
2887
|
+
if (item.type === "guest") return "Drive-In";
|
|
2888
|
+
if (item.type === "delivery" && item.deliveryType) {
|
|
2889
|
+
return `${item.type} (${item.deliveryType})`;
|
|
2890
|
+
}
|
|
2891
|
+
return item.type;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/** iSecure `isGateInvitation`: an invitation raised at the gate names no home. */
|
|
2895
|
+
function isGateInvitation(item: Record<string, any>) {
|
|
2896
|
+
return Boolean(item?.inviterId) && !item?.unit;
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/** The API's manual check-out flag (iSecure reads `isManualCheckout`). */
|
|
2900
|
+
function isManualCheckout(item: Record<string, any>) {
|
|
2901
|
+
return Boolean(item?.isManualCheckout ?? item?.manualCheckout);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/** iSecure's list time: `DD/MM/YYYY h:mm AM/PM`, in this browser's zone. */
|
|
2905
|
+
function formatDateTime(value: string) {
|
|
2906
|
+
if (!value) return "";
|
|
2907
|
+
const parsed = moment(value);
|
|
2908
|
+
return parsed.isValid() ? parsed.format("DD/MM/YYYY h:mm A") : "";
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2410
2911
|
const formattedFields = {
|
|
2912
|
+
type: "Type",
|
|
2913
|
+
// A contractor's type, only when the record has one.
|
|
2914
|
+
contractorType: "Contractor Type",
|
|
2411
2915
|
name: "Name",
|
|
2412
2916
|
nric: "NRIC",
|
|
2413
2917
|
contact: "Phone Number",
|
|
@@ -2417,34 +2921,41 @@ const formattedFields = {
|
|
|
2417
2921
|
level: "Level",
|
|
2418
2922
|
unit: "Unit",
|
|
2419
2923
|
purpose: "Purpose",
|
|
2924
|
+
lead: "Lead",
|
|
2420
2925
|
members: "Members",
|
|
2926
|
+
visitorPass: "Pass",
|
|
2927
|
+
passKeys: "Keys",
|
|
2421
2928
|
checkIn: "Check In",
|
|
2422
|
-
snapshotEntryImage: "Entry Image",
|
|
2423
2929
|
checkOut: "Check Out",
|
|
2424
|
-
|
|
2930
|
+
// Entry and exit snapshots together, one carousel.
|
|
2931
|
+
snapshotEntryImage: "ANPR Snapshots",
|
|
2425
2932
|
remarks: "Remarks",
|
|
2426
2933
|
attachments: "Attachments",
|
|
2427
2934
|
} as const;
|
|
2428
2935
|
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
{ label: "Not Returned", value: "In Use" },
|
|
2432
|
-
{ label: "Damaged", value: "Damaged" },
|
|
2433
|
-
{ label: "Lost", value: "Lost" },
|
|
2434
|
-
];
|
|
2936
|
+
/** iSecure `RETURN_STATUSES`; "Removed" only shows on an entry already removed. */
|
|
2937
|
+
const RETURN_STATUSES = ["Not Returned", "Returned", "Damaged", "Lost"] as const;
|
|
2435
2938
|
|
|
2436
|
-
|
|
2437
|
-
{ label:
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2939
|
+
function returnStatusItems(entry: { status: string }) {
|
|
2940
|
+
const items = RETURN_STATUSES.map((value) => ({ label: value, value }));
|
|
2941
|
+
return entry.status === "Removed"
|
|
2942
|
+
? [...items, { label: "Removed", value: "Removed" }]
|
|
2943
|
+
: items;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
/** iSecure `CardReturnDialog` statuses, shown as stored. */
|
|
2947
|
+
const nfcPassStatusOptions = ["In Use", "Returned", "Lost", "Damage"];
|
|
2442
2948
|
|
|
2443
2949
|
function toRoute(tab: any) {
|
|
2444
2950
|
items.value = [];
|
|
2445
2951
|
|
|
2446
2952
|
const obj = tabOptions.value.find((x) => x.value === tab);
|
|
2447
2953
|
if (!obj) return;
|
|
2954
|
+
// iSecure `applyTab`: the type filter is per tab and the search is
|
|
2955
|
+
// cleared - carried across they show an empty list.
|
|
2956
|
+
filterTypes.value = [];
|
|
2957
|
+
searchInput.value = "";
|
|
2958
|
+
searchQuery.value = "";
|
|
2448
2959
|
// Don't call getVisitorRefresh() here - activeTab is already in the
|
|
2449
2960
|
// useLazyAsyncData `watch` array below, so changing it (which already
|
|
2450
2961
|
// happened via v-model before this handler runs) already triggers a
|
|
@@ -2463,25 +2974,21 @@ const {
|
|
|
2463
2974
|
page: page.value,
|
|
2464
2975
|
site: siteId,
|
|
2465
2976
|
search: searchQuery.value,
|
|
2466
|
-
dateTo: dateTo.value
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
? "contractor,delivery,walk-in,pick-up,drop-off,guest"
|
|
2475
|
-
: filterTypes.value.filter(Boolean).join(","),
|
|
2476
|
-
checkedOut: displayNotCheckedOut.value ? false : undefined,
|
|
2977
|
+
dateTo: sgDayEnd(dateTo.value),
|
|
2978
|
+
dateFrom: sgDayStart(dateFrom.value),
|
|
2979
|
+
type: typeParamForTab() ?? "",
|
|
2980
|
+
// iSecure: the Visitors tab only.
|
|
2981
|
+
checkedOut:
|
|
2982
|
+
displayNotCheckedOut.value && activeTab.value === "registered"
|
|
2983
|
+
? false
|
|
2984
|
+
: undefined,
|
|
2477
2985
|
};
|
|
2478
2986
|
|
|
2479
2987
|
if (activeTab.value === "guests") {
|
|
2480
|
-
//
|
|
2481
|
-
// (
|
|
2482
|
-
//
|
|
2483
|
-
//
|
|
2484
|
-
params.type = "guest,contractor";
|
|
2988
|
+
// iSecure: Guest and Contractor invitations, or the types the user
|
|
2989
|
+
// picked (`typeParamForTab`). Self-service Contractor submissions land
|
|
2990
|
+
// here too - the Visitors tab hard-filters status="registered", which a
|
|
2991
|
+
// pending/approved self-service contractor never has.
|
|
2485
2992
|
// A self-service submission awaiting Approve/Reject (Self-Service
|
|
2486
2993
|
// Auto-Approvals off) lands here as "pending" alongside already
|
|
2487
2994
|
// "approved" guests, rather than being hidden until someone acts on it.
|
|
@@ -2502,6 +3009,7 @@ const {
|
|
|
2502
3009
|
// delete params.checkedOut;
|
|
2503
3010
|
// }
|
|
2504
3011
|
|
|
3012
|
+
visitorListError.value = "";
|
|
2505
3013
|
try {
|
|
2506
3014
|
const res = await getVisitors(params);
|
|
2507
3015
|
// Remember which search this answers, so a late reply is not shown.
|
|
@@ -2511,7 +3019,13 @@ const {
|
|
|
2511
3019
|
// go back to Registered - no empty "No data available" table.
|
|
2512
3020
|
const denied =
|
|
2513
3021
|
activeTab.value === "resident-transactions" ? permissionDenied(error) : null;
|
|
2514
|
-
if (!denied)
|
|
3022
|
+
if (!denied) {
|
|
3023
|
+
// iSecure: the server's reason in place of "no visitors yet".
|
|
3024
|
+
items.value = [];
|
|
3025
|
+
visitorListError.value =
|
|
3026
|
+
errorConverter(error) || "Unable to load the visitors. Please try again.";
|
|
3027
|
+
return null;
|
|
3028
|
+
}
|
|
2515
3029
|
residentTransactionsDenied.value = true;
|
|
2516
3030
|
items.value = [];
|
|
2517
3031
|
showMessage(denied, "error");
|
|
@@ -2558,8 +3072,13 @@ const selectedVisitorObject = computed<Record<string, any>>(() => {
|
|
|
2558
3072
|
"plateNumber",
|
|
2559
3073
|
"snapshotEntryImage",
|
|
2560
3074
|
"snapshotExitImage",
|
|
3075
|
+
"isManualCheckout",
|
|
3076
|
+
"manualCheckout",
|
|
2561
3077
|
];
|
|
2562
3078
|
includedKeys.unshift(...(typeFieldMap[type] ?? []));
|
|
3079
|
+
// A delivery's company is saved as `company`, but the form's field map
|
|
3080
|
+
// names it "delivery-company", so it was never picked up for the preview.
|
|
3081
|
+
if (type === "delivery") includedKeys.push("company");
|
|
2563
3082
|
|
|
2564
3083
|
/**
|
|
2565
3084
|
* V-07: THE DETAIL DIALOG PRINTED `undefined / undefined / 12-34`.
|
|
@@ -2604,6 +3123,16 @@ function formatValues(key: string, value: any) {
|
|
|
2604
3123
|
return formatDate(value);
|
|
2605
3124
|
case "checkOut":
|
|
2606
3125
|
return formatDate(value);
|
|
3126
|
+
case "contractorType":
|
|
3127
|
+
// "estate-contractor" -> "Estate Contractor" (the form's titles), and a
|
|
3128
|
+
// type the form does not list (e.g. managing-agent-staff) worded the same way.
|
|
3129
|
+
return (
|
|
3130
|
+
contractorTypes.find((type) => type.value === value)?.title ??
|
|
3131
|
+
String(value)
|
|
3132
|
+
.split("-")
|
|
3133
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
3134
|
+
.join(" ")
|
|
3135
|
+
);
|
|
2607
3136
|
}
|
|
2608
3137
|
|
|
2609
3138
|
return value;
|
|
@@ -2616,12 +3145,12 @@ function handleShowVisitorDataFromScannedQRCodeDialog(
|
|
|
2616
3145
|
) {
|
|
2617
3146
|
visitorDataFromScannedQRCode.value = scannedData;
|
|
2618
3147
|
|
|
2619
|
-
const { checkIn, checkOut, _id
|
|
2620
|
-
const hasPasses = (visitorPass?.length ?? 0) > 0;
|
|
3148
|
+
const { checkIn, checkOut, _id } = scannedData;
|
|
2621
3149
|
|
|
2622
|
-
// 1.
|
|
2623
|
-
|
|
2624
|
-
|
|
3150
|
+
// 1. Checked in and holding a pass or key: the return statuses first
|
|
3151
|
+
// (iSecure `holdsPassOrKeys`, "Removed" history not counted).
|
|
3152
|
+
if (checkIn && !checkOut && holdsPassOrKeys(scannedData)) {
|
|
3153
|
+
handleCheckout(_id, scannedData);
|
|
2625
3154
|
return;
|
|
2626
3155
|
}
|
|
2627
3156
|
|
|
@@ -2696,33 +3225,193 @@ function handleAddNew() {
|
|
|
2696
3225
|
mode.value = "add";
|
|
2697
3226
|
}
|
|
2698
3227
|
|
|
2699
|
-
|
|
2700
|
-
|
|
3228
|
+
/**
|
|
3229
|
+
* Opening a visitor (iSecure `openDetails`, "See Visitor Details"): the
|
|
3230
|
+
* preview, which also holds Check In / Check Out.
|
|
3231
|
+
*/
|
|
3232
|
+
function openVisitorDetails(item: Record<string, any>) {
|
|
3233
|
+
if (!canOpenVisitorDetails.value) {
|
|
3234
|
+
showMessage("You do not have permission to view visitor details.", "error");
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
selectedVisitorId.value = item?._id;
|
|
2701
3238
|
dialog.viewVisitor = true;
|
|
2702
3239
|
}
|
|
2703
3240
|
|
|
3241
|
+
function handleRowClick(data: any) {
|
|
3242
|
+
const item = data?.item;
|
|
3243
|
+
if (!item?._id) return;
|
|
3244
|
+
openVisitorDetails(item);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
2704
3247
|
function handleUpdatePage(newPageNum: number) {
|
|
2705
3248
|
page.value = newPageNum;
|
|
2706
3249
|
}
|
|
2707
3250
|
|
|
2708
|
-
|
|
2709
|
-
const hasPasses = (item?.visitorPass?.length ?? 0) > 0;
|
|
2710
|
-
const hasKeys = (item?.passKeys?.length ?? 0) > 0;
|
|
3251
|
+
/* ---- Pass & key (iSecure use-checkout-with-keys.ts / pass-keys.tsx) ------ */
|
|
2711
3252
|
|
|
2712
|
-
|
|
2713
|
-
|
|
3253
|
+
type TPassEntry = Record<string, any> & {
|
|
3254
|
+
keyId?: string;
|
|
3255
|
+
status?: string;
|
|
3256
|
+
prefixAndName?: string;
|
|
3257
|
+
remarks?: string;
|
|
3258
|
+
};
|
|
3259
|
+
|
|
3260
|
+
function passEntries(value: unknown): TPassEntry[] {
|
|
3261
|
+
return Array.isArray(value) ? (value.filter(Boolean) as TPassEntry[]) : [];
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** The passes a visit still holds: one swapped out stays as "Removed" history. */
|
|
3265
|
+
function activePasses(item: Record<string, any> | null | undefined) {
|
|
3266
|
+
return passEntries(item?.visitorPass).filter((pass) => pass.status !== "Removed");
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/** Keys still with the visitor ("Removed" ones are history). */
|
|
3270
|
+
function activeKeys(item: Record<string, any> | null | undefined) {
|
|
3271
|
+
return passEntries(item?.passKeys).filter((key) => key.status !== "Removed");
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
/** Check Out goes through the return statuses first: an active pass, a pass id string, or any key. */
|
|
3275
|
+
function holdsPassOrKeys(item: Record<string, any> | null | undefined) {
|
|
3276
|
+
return (
|
|
3277
|
+
activePasses(item).length > 0 ||
|
|
3278
|
+
(typeof item?.visitorPass === "string" && Boolean(item.visitorPass)) ||
|
|
3279
|
+
activeKeys(item).length > 0
|
|
2714
3280
|
);
|
|
3281
|
+
}
|
|
2715
3282
|
|
|
3283
|
+
/** NFC access cards go through the card return before a check-out. */
|
|
3284
|
+
function holdsNfcCards(item: Record<string, any> | null | undefined) {
|
|
3285
|
+
return item?.accessCards?.type === "NFC" && Boolean(item?.accessCards?.cardNo);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
/**
|
|
3289
|
+
* "Assign": no active pass, not checked out, and not a pick-up, drop-off or
|
|
3290
|
+
* delivery (a contractor always qualifies).
|
|
3291
|
+
*/
|
|
3292
|
+
function canAssignPass(item: Record<string, any>) {
|
|
3293
|
+
if (activePasses(item).length > 0 || item?.checkOut) return false;
|
|
3294
|
+
if (item?.type === "contractor") return true;
|
|
3295
|
+
return !["pick-up", "drop-off", "delivery"].includes(String(item?.type ?? ""));
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
function showAssignChip(item: Record<string, any>) {
|
|
3299
|
+
return props.canAssignPassKeys && props.canUpdateVisitor && canAssignPass(item);
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
function showPassKeyChips(item: Record<string, any>) {
|
|
2716
3303
|
return (
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
item?.
|
|
2721
|
-
!item?.checkOut
|
|
3304
|
+
activePasses(item).length > 0 ||
|
|
3305
|
+
activeKeys(item).length > 0 ||
|
|
3306
|
+
showAssignChip(item) ||
|
|
3307
|
+
(item?.type === "contractor" && Boolean(item?.accessCards?.cardNo))
|
|
2722
3308
|
);
|
|
2723
3309
|
}
|
|
2724
3310
|
|
|
3311
|
+
function allCards(item: Record<string, any>): any[] {
|
|
3312
|
+
const cards = item?.cards;
|
|
3313
|
+
return Array.isArray(cards) ? cards.flat().filter(Boolean) : [];
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
/**
|
|
3317
|
+
* The NFC menu: the primary card, then every other card once, each by its
|
|
3318
|
+
* number - an extra card stored as an id only by the number read for it
|
|
3319
|
+
* (`nfcCardLabel` / `loadNfcCardNos`).
|
|
3320
|
+
*/
|
|
3321
|
+
function nfcCardNumbers(item: Record<string, any>): { id: string; label: string }[] {
|
|
3322
|
+
const primary = item?.accessCards;
|
|
3323
|
+
const primaryId = primary?._id?.toString() ?? "";
|
|
3324
|
+
const others = allCards(item)
|
|
3325
|
+
.filter((card: any) => card?._id && card._id.toString() !== primaryId)
|
|
3326
|
+
.map((card: any) => ({ id: String(card._id), label: nfcCardLabel(card, item) }));
|
|
3327
|
+
return [
|
|
3328
|
+
...(primary?.cardNo ? [{ id: primaryId || "primary", label: String(primary.cardNo) }] : []),
|
|
3329
|
+
...others,
|
|
3330
|
+
];
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
type TPassAction = "return-pass-keys" | "add-keys" | "edit-pass";
|
|
3334
|
+
|
|
3335
|
+
const passChipMenu = reactive<{
|
|
3336
|
+
open: boolean;
|
|
3337
|
+
target: Element | null;
|
|
3338
|
+
visitor: Record<string, any> | null;
|
|
3339
|
+
}>({ open: false, target: null, visitor: null });
|
|
3340
|
+
|
|
3341
|
+
const passKeyDialogVisitor = ref<Record<string, any> | null>(null);
|
|
3342
|
+
const passKeyDialogMode = ref<"add" | "add-keys" | "edit-pass">("add");
|
|
3343
|
+
|
|
3344
|
+
/**
|
|
3345
|
+
* A pass / key chip: checked out, or without the pass & key rights, it goes
|
|
3346
|
+
* straight to the return statuses; otherwise the menu.
|
|
3347
|
+
*/
|
|
3348
|
+
function openPassChipMenu(item: Record<string, any>, event: Event) {
|
|
3349
|
+
if (!props.canUpdateVisitor) {
|
|
3350
|
+
showMessage("You do not have permission to update visitors.", "error");
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
if (item?.checkOut || !props.canAssignPassKeys) {
|
|
3354
|
+
openPassAction(item, "return-pass-keys");
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
passChipMenu.visitor = item;
|
|
3358
|
+
passChipMenu.target = (event?.currentTarget as Element) ?? null;
|
|
3359
|
+
passChipMenu.open = true;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
function choosePassAction(action: TPassAction) {
|
|
3363
|
+
const visitor = passChipMenu.visitor;
|
|
3364
|
+
passChipMenu.open = false;
|
|
3365
|
+
if (visitor) openPassAction(visitor, action);
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
function openPassAction(item: Record<string, any>, action: TPassAction) {
|
|
3369
|
+
if (!props.canUpdateVisitor) {
|
|
3370
|
+
showMessage("You do not have permission to update visitors.", "error");
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
// Checked out: only the return statuses can still change.
|
|
3374
|
+
if (action === "return-pass-keys" || item?.checkOut) {
|
|
3375
|
+
openReturnDialog(item);
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
if (!props.canAssignPassKeys) return;
|
|
3379
|
+
passKeyDialogVisitor.value = item;
|
|
3380
|
+
passKeyDialogMode.value = action;
|
|
3381
|
+
dialog.addPassKey = true;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/** "Assign": Assign Pass & Keys for a contractor, Add Pass otherwise. */
|
|
3385
|
+
function openAssign(item: Record<string, any>) {
|
|
3386
|
+
if (!props.canUpdateVisitor || !props.canAssignPassKeys) return;
|
|
3387
|
+
passKeyDialogVisitor.value = item;
|
|
3388
|
+
passKeyDialogMode.value = "add";
|
|
3389
|
+
dialog.addPassKey = true;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
function handlePassKeyDialogDone() {
|
|
3393
|
+
const mode = passKeyDialogMode.value;
|
|
3394
|
+
const visitor = passKeyDialogVisitor.value;
|
|
3395
|
+
dialog.addPassKey = false;
|
|
3396
|
+
const done =
|
|
3397
|
+
mode === "add-keys"
|
|
3398
|
+
? "Successfully added the new key(s)."
|
|
3399
|
+
: mode === "edit-pass"
|
|
3400
|
+
? "Pass successfully updated."
|
|
3401
|
+
: visitor?.type !== "contractor"
|
|
3402
|
+
? "Successfully added the new pass."
|
|
3403
|
+
: "";
|
|
3404
|
+
if (done) showMessage(done, "success");
|
|
3405
|
+
getVisitorRefresh();
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
// Every time a type is picked the form starts afresh (a new instance), as the
|
|
3409
|
+
// security mobile app opens a new form: nothing typed for the last visitor or
|
|
3410
|
+
// type is carried over, and switching type never patches one form into another.
|
|
3411
|
+
const visitorFormKey = ref(0);
|
|
3412
|
+
|
|
2725
3413
|
function handleSelectVisitorType(type: TVisitorType) {
|
|
3414
|
+
visitorFormKey.value++;
|
|
2726
3415
|
dialog.showSelection = false;
|
|
2727
3416
|
dialog.showForm = true;
|
|
2728
3417
|
activeVisitorFormType.value = type;
|
|
@@ -2733,13 +3422,18 @@ function handleClickBack() {
|
|
|
2733
3422
|
dialog.showForm = false;
|
|
2734
3423
|
}
|
|
2735
3424
|
|
|
3425
|
+
/** iSecure's toast after an add, an update or a register. */
|
|
3426
|
+
const VISITOR_SAVED_MESSAGE = "Great! A new visitor has been successfully added.";
|
|
3427
|
+
|
|
2736
3428
|
function handleVisitorFormDone() {
|
|
3429
|
+
showMessage(VISITOR_SAVED_MESSAGE, "success");
|
|
2737
3430
|
getVisitorRefresh();
|
|
2738
3431
|
dialog.showSelection = false;
|
|
2739
3432
|
dialog.showForm = false;
|
|
2740
3433
|
}
|
|
2741
3434
|
|
|
2742
3435
|
function handleVisitorFormCreateMore() {
|
|
3436
|
+
showMessage(VISITOR_SAVED_MESSAGE, "success");
|
|
2743
3437
|
getVisitorRefresh();
|
|
2744
3438
|
dialog.showSelection = true;
|
|
2745
3439
|
dialog.showForm = false;
|
|
@@ -2756,36 +3450,35 @@ function handleCloseAll() {
|
|
|
2756
3450
|
dialog.showForm = false;
|
|
2757
3451
|
}
|
|
2758
3452
|
|
|
3453
|
+
/*
|
|
3454
|
+
* Register an unregistered camera record (the row's Register, or the plate
|
|
3455
|
+
* notice). Only its id and plate are needed and nothing is fetched first, so
|
|
3456
|
+
* the form opens straight away (as the security mobile app): the plate the
|
|
3457
|
+
* camera read is filled in and can be edited, the guard keys in the rest, and
|
|
3458
|
+
* the save updates the record by its id. Not looked up in the loaded rows - a
|
|
3459
|
+
* row not on this page used to give the form no id, and Submit did nothing.
|
|
3460
|
+
*/
|
|
3461
|
+
const registerRecord = ref<{ _id: string; plateNumber: string } | null>(null);
|
|
3462
|
+
|
|
2759
3463
|
function handleRegistrationUnregisteredVisitor(item: Partial<TVisitor>) {
|
|
2760
3464
|
const id = item?._id || null;
|
|
2761
3465
|
selectedVisitorId.value = id;
|
|
3466
|
+
registerRecord.value = id
|
|
3467
|
+
? { _id: String(id), plateNumber: String(item?.plateNumber ?? "") }
|
|
3468
|
+
: null;
|
|
2762
3469
|
dialog.showSelection = true;
|
|
2763
3470
|
mode.value = "register";
|
|
2764
|
-
|
|
2765
|
-
if (!selectedVisitorDataObject.value) {
|
|
2766
|
-
getVisitorRefresh();
|
|
2767
|
-
}
|
|
2768
3471
|
}
|
|
2769
3472
|
|
|
2770
|
-
|
|
3473
|
+
const snapshotImageSide = ref<"IN" | "OUT">("IN");
|
|
3474
|
+
|
|
3475
|
+
function handleViewImage(imageId: string, side: "IN" | "OUT" = "IN") {
|
|
2771
3476
|
const imageEndpoint = `${siteId}/${imageId}`;
|
|
2772
3477
|
snapshotImageUrl.value = getFileUrlAnpr(imageEndpoint);
|
|
3478
|
+
snapshotImageSide.value = side;
|
|
2773
3479
|
dialog.snapshotImage = true;
|
|
2774
3480
|
}
|
|
2775
3481
|
|
|
2776
|
-
function handleOpenAddPassKey(item: any) {
|
|
2777
|
-
selectedVisitorId.value = item?._id;
|
|
2778
|
-
dialog.addPassKey = true;
|
|
2779
|
-
}
|
|
2780
|
-
|
|
2781
|
-
function handleOpenEditPassKey(item: any) {
|
|
2782
|
-
selectedVisitorId.value = item?._id;
|
|
2783
|
-
if (selectedVisitorDataObject.value.checkOut) {
|
|
2784
|
-
dialog.returnPassesKeys = true;
|
|
2785
|
-
handleCheckout(selectedVisitorId.value as string);
|
|
2786
|
-
} else dialog.editPassKey = true;
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
3482
|
function handleOpenRemarks(item: any, type: "checkIn" | "checkOut") {
|
|
2790
3483
|
selectedVisitorId.value = item?._id;
|
|
2791
3484
|
remarksType.value = type;
|
|
@@ -2819,64 +3512,254 @@ async function handleSaveRemarks() {
|
|
|
2819
3512
|
}
|
|
2820
3513
|
}
|
|
2821
3514
|
|
|
3515
|
+
/* ---- Return statuses: "Update Pass or Key(s)" (iSecure) ------------------ */
|
|
3516
|
+
|
|
3517
|
+
type TReturnEntry = TPassEntry & {
|
|
3518
|
+
keyId: string;
|
|
3519
|
+
status: string;
|
|
3520
|
+
remarks: string;
|
|
3521
|
+
locked: boolean;
|
|
3522
|
+
};
|
|
3523
|
+
|
|
3524
|
+
const returnVisitor = ref<Record<string, any> | null>(null);
|
|
3525
|
+
const passReturnStatuses = ref<TReturnEntry[]>([]);
|
|
3526
|
+
const keyReturnStatuses = ref<TReturnEntry[]>([]);
|
|
3527
|
+
const returnInitialSignature = ref("");
|
|
3528
|
+
const returnDialogError = ref("");
|
|
3529
|
+
const returnScan = reactive({ open: false, value: "" });
|
|
3530
|
+
// Names of passes / keys whose entry carries none, looked up by id after the
|
|
3531
|
+
// dialog opens (never holds it back).
|
|
3532
|
+
const returnKeyNames = reactive<Record<string, string>>({});
|
|
3533
|
+
|
|
3534
|
+
/** The preview's Pass row: drive-in, walk-in, contractor and a resident's invite (iSecure). */
|
|
3535
|
+
const previewShowsPass = computed(() => {
|
|
3536
|
+
const record = selectedVisitorDataObject.value as Record<string, any>;
|
|
3537
|
+
return (
|
|
3538
|
+
Boolean(record?.residentGuestType) ||
|
|
3539
|
+
["guest", "walk-in", "contractor"].includes(record?.type)
|
|
3540
|
+
);
|
|
3541
|
+
});
|
|
3542
|
+
|
|
3543
|
+
/** The preview's Keys row: a contractor only (iSecure). */
|
|
3544
|
+
const previewShowsKeys = computed(
|
|
3545
|
+
() => (selectedVisitorDataObject.value as Record<string, any>)?.type === "contractor"
|
|
3546
|
+
);
|
|
3547
|
+
|
|
3548
|
+
/**
|
|
3549
|
+
* The preview's Pass / Keys chips, as the list draws them: the held passes in
|
|
3550
|
+
* one chip, each held key its own ("Removed" ones are history).
|
|
3551
|
+
*/
|
|
3552
|
+
function previewPassKeyChips(key: string): string[] {
|
|
3553
|
+
const record = selectedVisitorDataObject.value as Record<string, any>;
|
|
3554
|
+
if (key === "visitorPass") {
|
|
3555
|
+
const passes = activePasses(record);
|
|
3556
|
+
return passes.length
|
|
3557
|
+
? [
|
|
3558
|
+
passes
|
|
3559
|
+
.map((pass) => pass.prefixAndName || returnKeyNames[pass.keyId ?? ""] || "Pass")
|
|
3560
|
+
.join(", "),
|
|
3561
|
+
]
|
|
3562
|
+
: [];
|
|
3563
|
+
}
|
|
3564
|
+
return activeKeys(record)
|
|
3565
|
+
.filter((entry) => entry.keyId)
|
|
3566
|
+
.map((entry) => entry.prefixAndName || returnKeyNames[entry.keyId ?? ""] || "Key");
|
|
3567
|
+
}
|
|
3568
|
+
const { getPassKeysByPageSearch: searchPassKeysForNames } = usePassKey();
|
|
3569
|
+
|
|
3570
|
+
async function loadReturnKeyNames(ids: string[]) {
|
|
3571
|
+
const missing = ids.filter((id) => id && !returnKeyNames[id]);
|
|
3572
|
+
if (!missing.length) return;
|
|
3573
|
+
try {
|
|
3574
|
+
const res: any = await searchPassKeysForNames({
|
|
3575
|
+
_ids: missing,
|
|
3576
|
+
page: 1,
|
|
3577
|
+
limit: missing.length,
|
|
3578
|
+
sites: [siteId],
|
|
3579
|
+
});
|
|
3580
|
+
const items: any[] = res?.items ?? res?.data?.items ?? [];
|
|
3581
|
+
for (const item of items) {
|
|
3582
|
+
if (item?._id && item?.prefixAndName) returnKeyNames[item._id] = item.prefixAndName;
|
|
3583
|
+
}
|
|
3584
|
+
} catch {
|
|
3585
|
+
// The chip falls back to "Pass" / "Key".
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
/** "In Use" (or no status) reads as "Not Returned"; what was recorded stays. */
|
|
3590
|
+
function startReturnEntry(entry: TPassEntry): TReturnEntry {
|
|
3591
|
+
const known: string[] = [...RETURN_STATUSES, "Removed"];
|
|
3592
|
+
const status =
|
|
3593
|
+
entry.status && entry.status !== "In Use" && known.includes(entry.status)
|
|
3594
|
+
? entry.status
|
|
3595
|
+
: "Not Returned";
|
|
3596
|
+
return {
|
|
3597
|
+
...entry,
|
|
3598
|
+
keyId: String(entry.keyId ?? ""),
|
|
3599
|
+
status,
|
|
3600
|
+
remarks: entry.remarks ?? "",
|
|
3601
|
+
locked: status === "Removed",
|
|
3602
|
+
};
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
function returnSignature() {
|
|
3606
|
+
const sig = (entries: TReturnEntry[]) =>
|
|
3607
|
+
entries
|
|
3608
|
+
.map((entry) => `${entry.keyId}|${entry.status}|${String(entry.remarks ?? "").trim()}`)
|
|
3609
|
+
.join(",");
|
|
3610
|
+
return `${sig(passReturnStatuses.value)}#${sig(keyReturnStatuses.value)}`;
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
function openReturnDialog(item: Record<string, any>) {
|
|
3614
|
+
returnVisitor.value = item;
|
|
3615
|
+
selectedVisitorId.value = item?._id ?? null;
|
|
3616
|
+
passReturnStatuses.value = passEntries(item?.visitorPass)
|
|
3617
|
+
.filter((pass) => Boolean(pass.keyId))
|
|
3618
|
+
.map(startReturnEntry);
|
|
3619
|
+
keyReturnStatuses.value = passEntries(item?.passKeys).map(startReturnEntry);
|
|
3620
|
+
returnInitialSignature.value = returnSignature();
|
|
3621
|
+
returnDialogError.value = "";
|
|
3622
|
+
dialog.returnPassesKeys = true;
|
|
3623
|
+
void loadReturnKeyNames(
|
|
3624
|
+
[...passReturnStatuses.value, ...keyReturnStatuses.value]
|
|
3625
|
+
.filter((entry) => !entry.prefixAndName)
|
|
3626
|
+
.map((entry) => entry.keyId)
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
/** Update only once a status or remark differs from what was loaded. */
|
|
3631
|
+
const returnStatusesChanged = computed(
|
|
3632
|
+
() => returnSignature() !== returnInitialSignature.value
|
|
3633
|
+
);
|
|
3634
|
+
|
|
3635
|
+
/** Check Out stays disabled while anything is still out (or already checked out). */
|
|
3636
|
+
const returnCheckOutBlocked = computed(
|
|
3637
|
+
() =>
|
|
3638
|
+
[...passReturnStatuses.value, ...keyReturnStatuses.value].some(
|
|
3639
|
+
(entry) => !["Returned", "Damaged", "Lost", "Removed"].includes(entry.status)
|
|
3640
|
+
) || Boolean(returnVisitor.value?.checkOut)
|
|
3641
|
+
);
|
|
3642
|
+
|
|
3643
|
+
function returnProblem(): string | null {
|
|
3644
|
+
return [...passReturnStatuses.value, ...keyReturnStatuses.value].some(
|
|
3645
|
+
(entry) =>
|
|
3646
|
+
["Damaged", "Lost"].includes(entry.status) && !String(entry.remarks ?? "").trim()
|
|
3647
|
+
)
|
|
3648
|
+
? "Remarks cannot be empty"
|
|
3649
|
+
: null;
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
const RETURN_DISPLAY_ONLY = [
|
|
3653
|
+
"lastUpdate",
|
|
3654
|
+
"description",
|
|
3655
|
+
"templatePrefixPass",
|
|
3656
|
+
"prefixAndName",
|
|
3657
|
+
"add",
|
|
3658
|
+
"locked",
|
|
3659
|
+
];
|
|
3660
|
+
|
|
3661
|
+
function returnEntryForServer(entry: TReturnEntry) {
|
|
3662
|
+
const rest: Record<string, any> = { ...entry };
|
|
3663
|
+
for (const key of RETURN_DISPLAY_ONLY) delete rest[key];
|
|
3664
|
+
return rest;
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/** "Scan QR Code": the scanned pass or key is marked Returned. */
|
|
3668
|
+
watch(
|
|
3669
|
+
() => returnScan.value,
|
|
3670
|
+
(value) => {
|
|
3671
|
+
if (!value) return;
|
|
3672
|
+
returnScan.value = "";
|
|
3673
|
+
let found = false;
|
|
3674
|
+
let problem: string | null = null;
|
|
3675
|
+
const mark = (entries: TReturnEntry[], already: string) =>
|
|
3676
|
+
entries.map((entry) => {
|
|
3677
|
+
if ((entry.prefixAndName || returnKeyNames[entry.keyId]) !== value) return entry;
|
|
3678
|
+
found = true;
|
|
3679
|
+
if (entry.status === "Returned") {
|
|
3680
|
+
problem = already.replace("{value}", value);
|
|
3681
|
+
return entry;
|
|
3682
|
+
}
|
|
3683
|
+
return { ...entry, status: "Returned" };
|
|
3684
|
+
});
|
|
3685
|
+
passReturnStatuses.value = mark(
|
|
3686
|
+
passReturnStatuses.value,
|
|
3687
|
+
"This pass {value} has already been selected as Returned."
|
|
3688
|
+
);
|
|
3689
|
+
keyReturnStatuses.value = mark(
|
|
3690
|
+
keyReturnStatuses.value,
|
|
3691
|
+
"This key {value} has already been selected as Returned."
|
|
3692
|
+
);
|
|
3693
|
+
if (!found) problem = `Pass or key: ${value} not found in this visitor transaction.`;
|
|
3694
|
+
returnDialogError.value = problem ?? "";
|
|
3695
|
+
}
|
|
3696
|
+
);
|
|
3697
|
+
|
|
3698
|
+
/** "Update": the statuses and remarks only, no check-out; closes on success. */
|
|
2822
3699
|
async function handleUpdatePassKeys() {
|
|
2823
|
-
|
|
3700
|
+
const id = returnVisitor.value?._id;
|
|
3701
|
+
if (!id) return;
|
|
3702
|
+
const problem = returnProblem();
|
|
3703
|
+
if (problem) {
|
|
3704
|
+
returnDialogError.value = problem;
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
returnDialogError.value = "";
|
|
2824
3708
|
try {
|
|
2825
3709
|
loading.updatingPassKeys = true;
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
payload.visitorPass = passReturnStatusesPayload;
|
|
2834
|
-
}
|
|
2835
|
-
if (keyReturnStatuses.value.length > 0) {
|
|
2836
|
-
const keyReturnStatusesPayload = keyReturnStatuses.value.map((k) => ({
|
|
2837
|
-
keyId: k.keyId,
|
|
2838
|
-
status: k.status,
|
|
2839
|
-
remarks: k.remarks,
|
|
2840
|
-
}));
|
|
2841
|
-
payload.passKeys = keyReturnStatusesPayload;
|
|
2842
|
-
}
|
|
2843
|
-
await updateVisitor(selectedVisitorId.value, payload);
|
|
2844
|
-
showMessage("Pass/Key statuses updated successfully!", "info");
|
|
3710
|
+
await updateVisitor(id, {
|
|
3711
|
+
visitorPass: passReturnStatuses.value.map(returnEntryForServer),
|
|
3712
|
+
passKeys: keyReturnStatuses.value.map(returnEntryForServer),
|
|
3713
|
+
updatedBy: currentUser.value?._id,
|
|
3714
|
+
} as any);
|
|
3715
|
+
dialog.returnPassesKeys = false;
|
|
3716
|
+
showMessage("Successfully updated.", "success");
|
|
2845
3717
|
await getVisitorRefresh();
|
|
2846
3718
|
} catch (error: any) {
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
errorMessage || "Something went wrong. Please try again later.",
|
|
2850
|
-
"error"
|
|
2851
|
-
);
|
|
3719
|
+
returnDialogError.value =
|
|
3720
|
+
errorConverter(error) || "Something went wrong. Please try again later.";
|
|
2852
3721
|
} finally {
|
|
2853
3722
|
loading.updatingPassKeys = false;
|
|
2854
3723
|
}
|
|
2855
3724
|
}
|
|
2856
3725
|
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
3726
|
+
/** "Check Out": what came back and the check-out, in one update. */
|
|
3727
|
+
async function confirmReturnCheckout() {
|
|
3728
|
+
const visitor = returnVisitor.value;
|
|
3729
|
+
if (!visitor?._id || returnCheckOutBlocked.value) return;
|
|
3730
|
+
const problem = returnProblem();
|
|
3731
|
+
if (problem) {
|
|
3732
|
+
returnDialogError.value = problem;
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
returnDialogError.value = "";
|
|
3736
|
+
try {
|
|
3737
|
+
loading.checkingOut = true;
|
|
3738
|
+
await updateVisitor(visitor._id, {
|
|
3739
|
+
visitorPass: passReturnStatuses.value.map(returnEntryForServer),
|
|
3740
|
+
passKeys: keyReturnStatuses.value.map(returnEntryForServer),
|
|
3741
|
+
checkOut: new Date().toISOString(),
|
|
3742
|
+
updatedBy: currentUser.value?._id,
|
|
3743
|
+
} as any);
|
|
3744
|
+
dialog.returnPassesKeys = false;
|
|
3745
|
+
dialog.viewVisitor = false;
|
|
3746
|
+
showMessage("Visitor information successfully updated.", "success");
|
|
3747
|
+
await getVisitorRefresh();
|
|
3748
|
+
} catch (error: any) {
|
|
3749
|
+
// Stays open so what the guard recorded is not lost.
|
|
3750
|
+
returnDialogError.value =
|
|
3751
|
+
errorConverter(error) || "Something went wrong. Please try again later.";
|
|
3752
|
+
} finally {
|
|
3753
|
+
loading.checkingOut = false;
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
/* ---- NFC card return (iSecure CardReturnDialog) -------------------------- */
|
|
3758
|
+
|
|
2863
3759
|
const nfcCardReturnStatuses = ref<
|
|
2864
3760
|
{ cardId: string; cardNo: string; status: string; remarks: string }[]
|
|
2865
3761
|
>([]);
|
|
2866
|
-
|
|
2867
|
-
const canConfirmCheckout = computed(() => {
|
|
2868
|
-
const allEntries = [...passReturnStatuses.value, ...keyReturnStatuses.value];
|
|
2869
|
-
return allEntries.every((entry) => {
|
|
2870
|
-
if (!entry.status || ["In Use", "Not Returned"].includes(entry.status))
|
|
2871
|
-
return false;
|
|
2872
|
-
if (
|
|
2873
|
-
(entry.status === "Lost" || entry.status === "Damaged") &&
|
|
2874
|
-
!entry.remarks.trim()
|
|
2875
|
-
)
|
|
2876
|
-
return false;
|
|
2877
|
-
return true;
|
|
2878
|
-
});
|
|
2879
|
-
});
|
|
3762
|
+
const nfcDialogError = ref("");
|
|
2880
3763
|
|
|
2881
3764
|
const canConfirmNfcCheckout = computed(() => {
|
|
2882
3765
|
if (nfcCardReturnStatuses.value.length === 0) return false;
|
|
@@ -2888,110 +3771,97 @@ const canConfirmNfcCheckout = computed(() => {
|
|
|
2888
3771
|
});
|
|
2889
3772
|
});
|
|
2890
3773
|
|
|
3774
|
+
/** The primary card, then every other card once - each by its number. */
|
|
3775
|
+
function openNfcDialog(visitor: Record<string, any>) {
|
|
3776
|
+
const cards: any[] = [];
|
|
3777
|
+
if (visitor?.accessCards?.cardNo) cards.push(visitor.accessCards);
|
|
3778
|
+
const primaryId = visitor?.accessCards?._id?.toString();
|
|
3779
|
+
for (const card of allCards(visitor)) {
|
|
3780
|
+
// An extra card may be stored as an id only: it is returned too, shown by
|
|
3781
|
+
// the number read for it.
|
|
3782
|
+
if (card?._id && card._id.toString() !== primaryId) cards.push(card);
|
|
3783
|
+
}
|
|
3784
|
+
nfcCardReturnStatuses.value = cards.map((card: any) => ({
|
|
3785
|
+
cardId: card._id,
|
|
3786
|
+
cardNo: nfcCardLabel(card, visitor),
|
|
3787
|
+
status: "In Use",
|
|
3788
|
+
remarks: "",
|
|
3789
|
+
}));
|
|
3790
|
+
nfcDialogError.value = "";
|
|
3791
|
+
void loadNfcCardNos(visitor);
|
|
3792
|
+
dialog.returnNfcCard = true;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
2891
3795
|
function handleCheckin(user: Record<string, any>) {
|
|
2892
3796
|
visitorDataFromScannedQRCode.value = user;
|
|
2893
3797
|
dialog.showVisitorDataFromScannedQRCode = true;
|
|
2894
3798
|
}
|
|
2895
3799
|
|
|
2896
|
-
|
|
3800
|
+
/** iSecure's row: Check Out once checked in and not out, never on Guests, with the Update right. */
|
|
3801
|
+
function showCheckOutButton(item: Record<string, any>) {
|
|
3802
|
+
if (!props.canCheckoutVisitor) return false;
|
|
3803
|
+
if (!item?.checkIn || item?.checkOut) return false;
|
|
3804
|
+
if (activeTab.value === "guests") return false;
|
|
3805
|
+
if (activeTab.value === "overnight-parking") {
|
|
3806
|
+
return ["registered", "unregistered"].includes(String(item?.status ?? ""));
|
|
3807
|
+
}
|
|
3808
|
+
return true;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/**
|
|
3812
|
+
* Check Out, in iSecure's order: a held pass or key goes to the return
|
|
3813
|
+
* statuses first, an NFC card to the card return, and only a visit holding
|
|
3814
|
+
* neither checks out straight away. Each path is an update of the visit.
|
|
3815
|
+
*/
|
|
3816
|
+
function handleCheckout(userId: string, visitorObj?: Record<string, any>) {
|
|
2897
3817
|
if (!userId) {
|
|
2898
3818
|
showMessage("Invalid userId", "error");
|
|
2899
3819
|
return;
|
|
2900
3820
|
}
|
|
3821
|
+
if (!props.canCheckoutVisitor) {
|
|
3822
|
+
showMessage("You do not have permission to update visitors.", "error");
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
2901
3825
|
selectedVisitorId.value = userId;
|
|
2902
3826
|
|
|
2903
|
-
const visitor =
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
status: p.status ?? "In Use",
|
|
2913
|
-
remarks: "",
|
|
2914
|
-
prefixAndName: p.prefixAndName,
|
|
2915
|
-
})
|
|
2916
|
-
);
|
|
2917
|
-
keyReturnStatuses.value = ((visitor?.passKeys as any[]) || []).map(
|
|
2918
|
-
(k: any) => ({
|
|
2919
|
-
keyId: k.keyId,
|
|
2920
|
-
status: k.status ?? "In Use",
|
|
2921
|
-
remarks: "",
|
|
2922
|
-
prefixAndName: k.prefixAndName,
|
|
2923
|
-
})
|
|
2924
|
-
);
|
|
2925
|
-
dialog.returnPassesKeys = true;
|
|
3827
|
+
const visitor =
|
|
3828
|
+
visitorObj ??
|
|
3829
|
+
items.value.find((x: any) => x?._id === userId) ??
|
|
3830
|
+
(visitorDataFromScannedQRCode.value?._id === userId
|
|
3831
|
+
? visitorDataFromScannedQRCode.value
|
|
3832
|
+
: null);
|
|
3833
|
+
|
|
3834
|
+
if (visitor && holdsPassOrKeys(visitor)) {
|
|
3835
|
+
openReturnDialog(visitor);
|
|
2926
3836
|
return;
|
|
2927
3837
|
}
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
visitor?.accessCards?.type === "NFC" && !!visitor?.accessCards?.cardNo;
|
|
2931
|
-
if (hasNfc) {
|
|
2932
|
-
const allCards: any[] = visitor.cards?.flat() ?? [];
|
|
2933
|
-
nfcCardReturnStatuses.value = allCards.map((card: any) => ({
|
|
2934
|
-
cardId: card._id,
|
|
2935
|
-
cardNo: nfcCardLabel(card, visitor),
|
|
2936
|
-
status: "In Use",
|
|
2937
|
-
remarks: "",
|
|
2938
|
-
}));
|
|
2939
|
-
if (nfcCardReturnStatuses.value.length === 0) {
|
|
2940
|
-
nfcCardReturnStatuses.value = [
|
|
2941
|
-
{
|
|
2942
|
-
cardId: visitor.accessCards._id,
|
|
2943
|
-
cardNo: visitor.accessCards.cardNo,
|
|
2944
|
-
status: "In Use",
|
|
2945
|
-
remarks: "",
|
|
2946
|
-
},
|
|
2947
|
-
];
|
|
2948
|
-
}
|
|
2949
|
-
loadNfcCardNos(visitor);
|
|
2950
|
-
dialog.returnNfcCard = true;
|
|
3838
|
+
if (visitor && holdsNfcCards(visitor)) {
|
|
3839
|
+
openNfcDialog(visitor);
|
|
2951
3840
|
return;
|
|
2952
3841
|
}
|
|
2953
|
-
|
|
2954
3842
|
proceedCheckout();
|
|
2955
3843
|
}
|
|
2956
3844
|
|
|
3845
|
+
/** A visit holding nothing: just the check-out. */
|
|
2957
3846
|
async function proceedCheckout() {
|
|
2958
3847
|
const userId = selectedVisitorId.value;
|
|
2959
3848
|
if (!userId) return;
|
|
2960
3849
|
|
|
2961
3850
|
try {
|
|
2962
3851
|
loading.checkingOut = true;
|
|
2963
|
-
|
|
2964
|
-
const passReturnStatusesPayload = passReturnStatuses.value.map((p) => ({
|
|
2965
|
-
keyId: p.keyId,
|
|
2966
|
-
status: p.status,
|
|
2967
|
-
remarks: p.remarks,
|
|
2968
|
-
}));
|
|
2969
|
-
const keyReturnStatusesPayload = keyReturnStatuses.value.map((k) => ({
|
|
2970
|
-
keyId: k.keyId,
|
|
2971
|
-
status: k.status,
|
|
2972
|
-
remarks: k.remarks,
|
|
2973
|
-
}));
|
|
2974
|
-
|
|
2975
3852
|
const res = await updateVisitor(userId as string, {
|
|
2976
3853
|
checkOut: new Date().toISOString(),
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
: {}),
|
|
2980
|
-
...(keyReturnStatuses.value.length > 0
|
|
2981
|
-
? { passKeys: keyReturnStatusesPayload }
|
|
2982
|
-
: {}),
|
|
2983
|
-
});
|
|
3854
|
+
updatedBy: currentUser.value?._id,
|
|
3855
|
+
} as any);
|
|
2984
3856
|
if (res) {
|
|
2985
|
-
showMessage("Visitor successfully checked
|
|
3857
|
+
showMessage("Visitor successfully checked out.", "success");
|
|
2986
3858
|
await getVisitorRefresh();
|
|
2987
3859
|
dialog.viewVisitor = false;
|
|
2988
|
-
dialog.returnPassesKeys = false;
|
|
2989
3860
|
}
|
|
2990
3861
|
} catch (error: any) {
|
|
2991
|
-
const errorMessage = error?.response?._data?.message;
|
|
2992
3862
|
console.error("[ERROR]", error);
|
|
2993
3863
|
showMessage(
|
|
2994
|
-
|
|
3864
|
+
errorConverter(error) || "Something went wrong. Please try again later.",
|
|
2995
3865
|
"error"
|
|
2996
3866
|
);
|
|
2997
3867
|
} finally {
|
|
@@ -3002,6 +3872,7 @@ async function proceedCheckout() {
|
|
|
3002
3872
|
async function handleNfcCheckout() {
|
|
3003
3873
|
const userId = selectedVisitorId.value;
|
|
3004
3874
|
if (!userId) return;
|
|
3875
|
+
nfcDialogError.value = "";
|
|
3005
3876
|
try {
|
|
3006
3877
|
loading.checkingOut = true;
|
|
3007
3878
|
const res = await visitorCheckout({
|
|
@@ -3015,18 +3886,18 @@ async function handleNfcCheckout() {
|
|
|
3015
3886
|
if (res) {
|
|
3016
3887
|
await updateVisitor(userId as string, {
|
|
3017
3888
|
checkOut: new Date().toISOString(),
|
|
3018
|
-
|
|
3019
|
-
|
|
3889
|
+
updatedBy: currentUser.value?._id,
|
|
3890
|
+
} as any);
|
|
3891
|
+
showMessage("Visitor successfully checked out.", "success");
|
|
3020
3892
|
await getVisitorRefresh();
|
|
3021
3893
|
dialog.returnNfcCard = false;
|
|
3022
3894
|
dialog.viewVisitor = false;
|
|
3023
3895
|
}
|
|
3024
3896
|
} catch (error: any) {
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
);
|
|
3897
|
+
// Stays open with the reason.
|
|
3898
|
+
const reason = errorConverter(error) || "Failed to checkout visitor.";
|
|
3899
|
+
nfcDialogError.value = reason;
|
|
3900
|
+
showMessage(reason, "error");
|
|
3030
3901
|
} finally {
|
|
3031
3902
|
loading.checkingOut = false;
|
|
3032
3903
|
}
|
|
@@ -3036,17 +3907,13 @@ function buildReportParams(): any {
|
|
|
3036
3907
|
const params: any = {
|
|
3037
3908
|
site: siteId,
|
|
3038
3909
|
search: searchQuery.value,
|
|
3039
|
-
dateTo: dateTo.value
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
filterTypes.value.length === 0 && activeTab.value === "registered"
|
|
3047
|
-
? "contractor,delivery,walk-in,pick-up,drop-off,guest"
|
|
3048
|
-
: filterTypes.value.filter(Boolean).join(","),
|
|
3049
|
-
checkedOut: displayNotCheckedOut.value ? false : undefined,
|
|
3910
|
+
dateTo: sgDayEnd(dateTo.value),
|
|
3911
|
+
dateFrom: sgDayStart(dateFrom.value),
|
|
3912
|
+
type: typeParamForTab() ?? "",
|
|
3913
|
+
checkedOut:
|
|
3914
|
+
displayNotCheckedOut.value && activeTab.value === "registered"
|
|
3915
|
+
? false
|
|
3916
|
+
: undefined,
|
|
3050
3917
|
limit: 100,
|
|
3051
3918
|
};
|
|
3052
3919
|
|
|
@@ -3054,7 +3921,6 @@ function buildReportParams(): any {
|
|
|
3054
3921
|
// Kept in sync with the Guests tab's own list query above - otherwise a
|
|
3055
3922
|
// report generated from this tab would silently omit contractors and
|
|
3056
3923
|
// pending rows the screen is actually showing.
|
|
3057
|
-
params.type = "guest,contractor";
|
|
3058
3924
|
params.status = "approved,pending,rejected";
|
|
3059
3925
|
} else if (activeTab.value === "resident-transactions") {
|
|
3060
3926
|
params.status = "registered";
|
|
@@ -3065,10 +3931,10 @@ function buildReportParams(): any {
|
|
|
3065
3931
|
params.status = activeTab.value;
|
|
3066
3932
|
}
|
|
3067
3933
|
|
|
3068
|
-
if (activeTab.value !== "registered") {
|
|
3934
|
+
if (activeTab.value !== "registered" && activeTab.value !== "guests") {
|
|
3069
3935
|
delete params.type;
|
|
3070
|
-
delete params.checkedOut;
|
|
3071
3936
|
}
|
|
3937
|
+
if (activeTab.value !== "registered") delete params.checkedOut;
|
|
3072
3938
|
|
|
3073
3939
|
return params;
|
|
3074
3940
|
}
|
|
@@ -3248,8 +4114,20 @@ watch(
|
|
|
3248
4114
|
},
|
|
3249
4115
|
{ deep: true }
|
|
3250
4116
|
);
|
|
3251
|
-
|
|
3252
|
-
|
|
4117
|
+
// iSecure: any filter change starts again at page 1.
|
|
4118
|
+
watch(
|
|
4119
|
+
[searchQuery, dateFrom, dateTo, filterTypes, activeTab, displayNotCheckedOut],
|
|
4120
|
+
() => {
|
|
4121
|
+
page.value = 1;
|
|
4122
|
+
}
|
|
4123
|
+
);
|
|
4124
|
+
|
|
4125
|
+
// iSecure: the dates cannot be cleared - a cleared one goes back to its default.
|
|
4126
|
+
watch(dateFrom, (value) => {
|
|
4127
|
+
if (!value) dateFrom.value = defaultDateFrom();
|
|
4128
|
+
});
|
|
4129
|
+
watch(dateTo, (value) => {
|
|
4130
|
+
if (!value) dateTo.value = defaultDateTo();
|
|
3253
4131
|
});
|
|
3254
4132
|
|
|
3255
4133
|
const syncVisitorFiltersFromRoute = () => {
|
|
@@ -3257,7 +4135,7 @@ const syncVisitorFiltersFromRoute = () => {
|
|
|
3257
4135
|
const nextTab =
|
|
3258
4136
|
// Until the switches are read, keep a requested Unregistered tab (the plate
|
|
3259
4137
|
// toast links here) instead of bouncing it to Registered.
|
|
3260
|
-
(
|
|
4138
|
+
(unregisteredTabKnown.value &&
|
|
3261
4139
|
!enableUnregisteredTab.value &&
|
|
3262
4140
|
requestedTab === "unregistered") ||
|
|
3263
4141
|
(!showResidentTransactionsTab.value && requestedTab === "resident-transactions")
|
|
@@ -3291,14 +4169,17 @@ const syncVisitorFiltersFromRoute = () => {
|
|
|
3291
4169
|
}
|
|
3292
4170
|
}
|
|
3293
4171
|
|
|
3294
|
-
if (activeTab.value === "registered") {
|
|
4172
|
+
if (activeTab.value === "registered" || activeTab.value === "guests") {
|
|
4173
|
+
const allowed = typeFilterOptions.value.map((option) => option.value);
|
|
3295
4174
|
const nextFilterTypes = (
|
|
3296
4175
|
(route.query.type as string)?.split(",") || []
|
|
3297
|
-
).filter(Boolean)
|
|
4176
|
+
).filter((value) => Boolean(value) && allowed.includes(value));
|
|
3298
4177
|
if (filterTypes.value.join(",") !== nextFilterTypes.join(",")) {
|
|
3299
4178
|
filterTypes.value = nextFilterTypes;
|
|
3300
4179
|
}
|
|
4180
|
+
}
|
|
3301
4181
|
|
|
4182
|
+
if (activeTab.value === "registered") {
|
|
3302
4183
|
const nextDisplayNotCheckedOut =
|
|
3303
4184
|
(route.query.checkedOut as string) == "true" || false;
|
|
3304
4185
|
if (displayNotCheckedOut.value !== nextDisplayNotCheckedOut) {
|
|
@@ -3318,13 +4199,13 @@ onBeforeUnmount(() => {
|
|
|
3318
4199
|
|
|
3319
4200
|
watch(() => route.query, syncVisitorFiltersFromRoute, { deep: true });
|
|
3320
4201
|
|
|
3321
|
-
const { socketUnregisteredVisitorTrigger, visitorSocketData } =
|
|
4202
|
+
const { socketUnregisteredVisitorTrigger, visitorSocketData, registerVisitorFormData } =
|
|
3322
4203
|
useVisitorSocket();
|
|
3323
4204
|
|
|
3324
4205
|
watch(
|
|
3325
|
-
[enableUnregisteredTab,
|
|
4206
|
+
[enableUnregisteredTab, unregisteredTabKnown],
|
|
3326
4207
|
([enabled]) => {
|
|
3327
|
-
if (!enabled &&
|
|
4208
|
+
if (!enabled && unregisteredTabKnown.value && activeTab.value === "unregistered") {
|
|
3328
4209
|
activeTab.value = "registered";
|
|
3329
4210
|
}
|
|
3330
4211
|
},
|
|
@@ -3332,6 +4213,8 @@ watch(
|
|
|
3332
4213
|
);
|
|
3333
4214
|
|
|
3334
4215
|
watchEffect(async () => {
|
|
4216
|
+
// A reload only refreshes the list. It no longer cancels a pending Register
|
|
4217
|
+
// (security mobile app: nothing but a new arrival replaces the stored one).
|
|
3335
4218
|
if (
|
|
3336
4219
|
visitorSocketData.value &&
|
|
3337
4220
|
visitorSocketData.value.reload &&
|
|
@@ -3339,33 +4222,114 @@ watchEffect(async () => {
|
|
|
3339
4222
|
) {
|
|
3340
4223
|
await getVisitorRefresh();
|
|
3341
4224
|
visitorSocketData.value = null;
|
|
3342
|
-
socketUnregisteredVisitorTrigger.value = false;
|
|
3343
|
-
}
|
|
3344
|
-
|
|
3345
|
-
if (
|
|
3346
|
-
visitorSocketData.value &&
|
|
3347
|
-
visitorSocketData.value.plateNumber &&
|
|
3348
|
-
socketUnregisteredVisitorTrigger.value &&
|
|
3349
|
-
activeTab.value === "unregistered" &&
|
|
3350
|
-
dialog.showSelection === false
|
|
3351
|
-
) {
|
|
3352
|
-
// await getVisitorRefresh();
|
|
3353
|
-
// visitorSocketData.value = null;
|
|
3354
|
-
// socketUnregisteredVisitorTrigger.value = false;
|
|
3355
|
-
setTimeout(async () => {
|
|
3356
|
-
const { _id } = visitorSocketData.value;
|
|
3357
|
-
await handleRegistrationUnregisteredVisitor({ _id });
|
|
3358
|
-
socketUnregisteredVisitorTrigger.value = false;
|
|
3359
|
-
|
|
3360
|
-
setTimeout(() => {
|
|
3361
|
-
visitorSocketData.value = null;
|
|
3362
|
-
}, 1000);
|
|
3363
|
-
}, 1000);
|
|
3364
4225
|
}
|
|
3365
4226
|
});
|
|
4227
|
+
|
|
4228
|
+
/*
|
|
4229
|
+
* The plate notice's Register / "Go to Unregistered" (security mobile app,
|
|
4230
|
+
* \`(visitors)/index.tsx\`): once the Unregistered tab is showing, open the
|
|
4231
|
+
* visitor type picker for the record the camera read. Skipped without the
|
|
4232
|
+
* Update right (the trigger is left as it is), and the stored arrival is used
|
|
4233
|
+
* once - a later tap must not reopen the same vehicle.
|
|
4234
|
+
*/
|
|
4235
|
+
watch(
|
|
4236
|
+
[
|
|
4237
|
+
socketUnregisteredVisitorTrigger,
|
|
4238
|
+
registerVisitorFormData,
|
|
4239
|
+
activeTab,
|
|
4240
|
+
() => dialog.showSelection,
|
|
4241
|
+
() => dialog.showForm,
|
|
4242
|
+
],
|
|
4243
|
+
() => {
|
|
4244
|
+
const record = registerVisitorFormData.value;
|
|
4245
|
+
if (!socketUnregisteredVisitorTrigger.value || !record) return;
|
|
4246
|
+
if (activeTab.value !== "unregistered") return;
|
|
4247
|
+
if (!props.canUpdateVisitor) return;
|
|
4248
|
+
if (dialog.showSelection || dialog.showForm) return;
|
|
4249
|
+
|
|
4250
|
+
socketUnregisteredVisitorTrigger.value = false;
|
|
4251
|
+
registerVisitorFormData.value = null;
|
|
4252
|
+
if (!record._id) return;
|
|
4253
|
+
|
|
4254
|
+
// Straight away: the id and plate are all the form needs.
|
|
4255
|
+
handleRegistrationUnregisteredVisitor({
|
|
4256
|
+
_id: record._id,
|
|
4257
|
+
plateNumber: record.plateNumber,
|
|
4258
|
+
});
|
|
4259
|
+
},
|
|
4260
|
+
{ immediate: true }
|
|
4261
|
+
);
|
|
3366
4262
|
</script>
|
|
3367
4263
|
|
|
3368
4264
|
<style scoped>
|
|
4265
|
+
/*
|
|
4266
|
+
* Pass / key / Assign / NFC / QR chips in the Check In/Out column: dark text
|
|
4267
|
+
* and border on the card surface (theme colours, so dark mode reads white on
|
|
4268
|
+
* the dark card), bold, default size - readable for older eyes. The icon
|
|
4269
|
+
* keeps its colour so a pass and a key are told apart at a glance.
|
|
4270
|
+
*/
|
|
4271
|
+
/* Whose passes and keys these are, above the scanner. */
|
|
4272
|
+
.visitor-return__who {
|
|
4273
|
+
display: flex;
|
|
4274
|
+
flex-direction: column;
|
|
4275
|
+
gap: 4px;
|
|
4276
|
+
padding: 10px 12px;
|
|
4277
|
+
margin-bottom: 12px;
|
|
4278
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
4279
|
+
border-radius: 8px;
|
|
4280
|
+
font-size: 0.9375rem;
|
|
4281
|
+
color: rgb(var(--v-theme-on-surface));
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
.visitor-return__who-line {
|
|
4285
|
+
display: flex;
|
|
4286
|
+
align-items: center;
|
|
4287
|
+
gap: 8px;
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
/* The section headings in the return dialogs: readable at a glance. */
|
|
4291
|
+
.visitor-return__label {
|
|
4292
|
+
margin: 6px 0 8px;
|
|
4293
|
+
font-size: 1rem;
|
|
4294
|
+
font-weight: 600;
|
|
4295
|
+
color: rgb(var(--v-theme-on-surface));
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
.visitor-pass-chip.v-chip {
|
|
4299
|
+
/* Between Vuetify's small (24px) and default (32px): readable without
|
|
4300
|
+
making every row much taller. */
|
|
4301
|
+
--v-chip-height: 28px;
|
|
4302
|
+
font-size: 0.8125rem;
|
|
4303
|
+
padding: 0 10px;
|
|
4304
|
+
background: rgb(var(--v-theme-surface));
|
|
4305
|
+
color: rgb(var(--v-theme-on-surface));
|
|
4306
|
+
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.87);
|
|
4307
|
+
letter-spacing: 0;
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
/* In the return dialogs: the same height as the status dropdown beside it. */
|
|
4311
|
+
.visitor-pass-chip--field.v-chip {
|
|
4312
|
+
--v-chip-height: 40px;
|
|
4313
|
+
font-size: 0.875rem;
|
|
4314
|
+
padding: 0 14px;
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
.visitor-pass-chip--pass :deep(.v-chip__prepend .v-icon) {
|
|
4318
|
+
color: #1565c0;
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
.visitor-pass-chip--key :deep(.v-chip__prepend .v-icon) {
|
|
4322
|
+
color: #e65100;
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
.visitor-pass-chip--nfc :deep(.v-chip__prepend .v-icon) {
|
|
4326
|
+
color: #7b1fa2;
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
.visitor-pass-chip--qr :deep(.v-chip__prepend .v-icon) {
|
|
4330
|
+
color: #00796b;
|
|
4331
|
+
}
|
|
4332
|
+
|
|
3369
4333
|
/*
|
|
3370
4334
|
* THE PREVIEW DIALOG'S FIELD LIST - LABEL BESIDE VALUE.
|
|
3371
4335
|
*
|
|
@@ -3428,6 +4392,43 @@ watchEffect(async () => {
|
|
|
3428
4392
|
display: block;
|
|
3429
4393
|
}
|
|
3430
4394
|
|
|
4395
|
+
.visitor-preview__chips {
|
|
4396
|
+
display: flex;
|
|
4397
|
+
flex-wrap: wrap;
|
|
4398
|
+
gap: 6px;
|
|
4399
|
+
min-width: 0;
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
.visitor-preview__chips .visitor-pass-chip.v-chip {
|
|
4403
|
+
max-width: 100%;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
.visitor-preview__photos {
|
|
4407
|
+
border-radius: 12px;
|
|
4408
|
+
overflow: hidden;
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
.visitor-photos-full {
|
|
4412
|
+
background: #000 !important;
|
|
4413
|
+
color: #fff;
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
.visitor-photos-full__head {
|
|
4417
|
+
display: flex;
|
|
4418
|
+
align-items: center;
|
|
4419
|
+
justify-content: space-between;
|
|
4420
|
+
height: 64px;
|
|
4421
|
+
padding: 0 8px 0 20px;
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
.visitor-preview__lead {
|
|
4425
|
+
width: 100%;
|
|
4426
|
+
text-align: left;
|
|
4427
|
+
cursor: pointer;
|
|
4428
|
+
font: inherit;
|
|
4429
|
+
color: inherit;
|
|
4430
|
+
}
|
|
4431
|
+
|
|
3431
4432
|
.visitor-preview__members {
|
|
3432
4433
|
display: flex;
|
|
3433
4434
|
flex-direction: column;
|