@7365admin1/layer-common 4.146.0 → 4.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 Mgmt">
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="activeTab == 'registered'"
115
+ v-if="typeFilterOptions.length"
113
116
  v-model="filterTypes"
114
- label="Filter by types"
117
+ label="Filter by type"
115
118
  item-title="label"
116
119
  item-value="value"
117
- :items="visitorSelection"
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
- >( +{{ item?.members?.length }} members)</span
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
- <template v-slot:item.contact-vehicleNumber="{ item }">
192
- <span class="d-flex align-center ga-2">
193
- <v-icon icon="mdi-phone" size="15" />
194
- <span class="text-capitalize">{{ item?.contact || "N/A" }}</span>
195
- </span>
196
- <span class="d-flex align-center ga-2">
197
- <v-icon icon="mdi-car-back" size="15" />
198
- <span class="text-capitalize">{{ item?.plateNumber || "N/A" }}</span>
199
- </span>
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
- <span class="text-capitalize">{{
211
- UTCToLocalTIme(item.checkIn) || "-"
212
- }}</span>
213
- <span>
214
- <v-icon
215
- v-if="item?.snapshotEntryImage"
216
- size="17"
217
- icon="mdi-image"
218
- @click.stop="handleViewImage(item.snapshotEntryImage)"
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?.snapshotExitImage"
262
- size="17"
350
+ v-if="item.checkIn && item?.snapshotEntryImage"
351
+ size="20"
263
352
  icon="mdi-image"
264
- @click.stop="handleViewImage(item.snapshotExitImage)"
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
- <div
279
- v-if="showAddPassKeyButton(item)"
280
- class="d-flex flex-wrap ga-1 mt-1"
281
- v-bind="menuProps"
282
- style="cursor: pointer"
283
- >
284
- <v-chip
285
- size="x-small"
286
- variant="tonal"
287
- prepend-icon="mdi-key"
288
- @click.stop="handleOpenAddPassKey(item)"
289
- >Add Pass/Key</v-chip
290
- >
291
- </div>
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="x-small"
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="x-small"
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
- <v-btn
479
- v-if="activeTab === 'unregistered' && canAddVisitor"
480
- size="small"
481
- color="primary"
482
- text="Register"
483
- @click.stop="handleRegistrationUnregisteredVisitor(item)"
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-icon icon="mdi-magnify-close" size="32" />
621
- <span>{{ visitorSearchEmptyText(searchQuery) }}</span>
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--tall"
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="d-flex flex-wrap ga-2">
815
- <AttachmentsViewer
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
- Two fixes the redesign is not allowed to leave behind. The
825
- branch had no value check, so "Entry Image" and "Exit Image"
826
- both printed as bare labels over an AttachmentsViewer holding
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 === 'snapshotEntryImage' ||
836
- key === 'snapshotExitImage') &&
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="d-flex flex-wrap ga-2">
843
- <AttachmentsViewer
844
- :files="mappedSnapshots([selectedVisitorObject[key]])"
845
- anpr
846
- :site="siteId"
847
- title="Snapshot"
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="x-small"
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>Snapshot</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">Return Passes &amp; Keys</span>
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
- <p class="text-body-2 mb-3">
1094
- Please ensure all passes and keys are returned before checking out.
1095
- </p>
1096
-
1097
- <div>
1098
- <InputLabel class="text-capitalize" title="Full Name" />
1099
- <v-text-field
1100
- v-model="selectedVisitorObject.name"
1101
- density="comfortable"
1102
- readonly
1103
- />
1104
- </div>
1105
-
1106
- <div>
1107
- <InputLabel class="text-capitalize" title="Location" />
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
- <div v-if="passReturnStatuses.length > 0" class="mb-2">
1116
- <p class="text-caption text-medium-emphasis mb-1">Passes</p>
1117
- <v-row
1118
- no-gutters
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="(pass as any)._id ?? (pass as any).keyId"
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
- size="small"
1126
- variant="tonal"
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
- {{ (pass as any)?.prefixAndName }}
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="passStatusOptions"
1357
+ :items="returnStatusItems(pass)"
1136
1358
  item-title="label"
1137
1359
  item-value="value"
1138
1360
  v-model="pass.status"
1139
- :disabled="pass.status == 'Removed'"
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="keyReturnStatuses.length > 0" class="mb-2">
1152
- <p class="text-caption text-medium-emphasis mb-1">Keys</p>
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="(key as any)._id ?? (key as any).keyId"
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
- size="small"
1162
- variant="tonal"
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
- {{ (key as any)?.prefixAndName }}
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="passStatusOptions"
1397
+ :items="returnStatusItems(key)"
1172
1398
  item-title="label"
1173
1399
  item-value="value"
1174
1400
  v-model="key.status"
1175
- :disabled="key.status == 'Removed'"
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-row no-gutters class="my-5">
1189
- <v-btn
1190
- variant="flat"
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
- variant="text"
1424
+ color="blue"
1425
+ variant="flat"
1426
+ height="48"
1427
+ rounded="0"
1206
1428
  block
1207
- @click="dialog.returnPassesKeys = false"
1429
+ :loading="loading.updatingPassKeys"
1430
+ :disabled="!returnStatusesChanged || loading.checkingOut"
1431
+ @click="handleUpdatePassKeys"
1208
1432
  >
1209
- Close
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
- !canConfirmCheckout || selectedVisitorObject.checkOut
1222
- "
1223
- @click="proceedCheckout"
1444
+ :disabled="returnCheckOutBlocked || loading.updatingPassKeys"
1445
+ @click="confirmReturnCheckout"
1224
1446
  >
1225
- Confirm Checkout
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
- Please indicate the status of the NFC card before checking out.
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-icon size="18" icon="mdi-credit-card-outline" color="purple" />
1275
- <v-chip size="small" variant="tonal" color="purple">{{
1276
- nfcCardNos[card.cardId] || card.cardNo
1277
- }}</v-chip>
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 Checkout</v-btn
1538
+ >Confirm &amp; 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
- mode="add"
1326
- :visitor="selectedVisitorDataObject"
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
- const today = new Date();
1895
- const oneMonthAgo = new Date();
1896
- oneMonthAgo.setMonth(today.getMonth() - 1);
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
- const dateTo = ref(normalizeDateOnly(today.toISOString()));
1899
- const dateFrom = ref(normalizeDateOnly(oneMonthAgo.toISOString()));
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
- const filterTypes = ref<TVisitorType[]>([]);
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. Define logic for all other tabs
2054
- const list = [{ title: "Name", value: "name" }];
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
- // Standard info for non-parking tabs
2057
- list.push(
2058
- { title: "Type/Company", value: "type-company" },
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
- { title: "Contact/Vehicle No.", value: "contact-vehicleNumber" }
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: "Registered", value: "registered" },
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 items.value.find((x: any) => x?._id === selectedVisitorId.value) || {};
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
- snapshotExitImage: "Exit Image",
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
- const passStatusOptions = [
2430
- { label: "Returned", value: "Returned" },
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
- const nfcPassStatusOptions = [
2437
- { label: "Returned", value: "Returned" },
2438
- { label: "Not Returned", value: "In Use" },
2439
- { label: "Damaged", value: "Damage" },
2440
- { label: "Lost", value: "Lost" },
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
- ? moment(dateTo.value).endOf("day").toISOString()
2468
- : undefined,
2469
- dateFrom: dateFrom.value
2470
- ? moment(dateFrom.value).startOf("day").toISOString()
2471
- : undefined,
2472
- type:
2473
- filterTypes.value.length === 0 && activeTab.value === "registered"
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
- // Self-service Contractor submissions land here too, not just Visitor
2481
- // (type: "guest") - the "Registered" tab hard-filters status="registered",
2482
- // which a pending/approved self-service contractor never has, so without
2483
- // this it had no tab that would ever show it.
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) throw error;
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, visitorPass } = scannedData;
2620
- const hasPasses = (visitorPass?.length ?? 0) > 0;
3148
+ const { checkIn, checkOut, _id } = scannedData;
2621
3149
 
2622
- // 1. If they are checked in and HAVE passes/keys, go to the Return screen
2623
- if (checkIn && !checkOut && hasPasses) {
2624
- handleCheckout(_id);
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
- function handleRowClick(data: any) {
2700
- selectedVisitorId.value = data?.item?._id;
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
- function showAddPassKeyButton(item: any) {
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
- const isTypeWithPassKey = ["contractor", "guest", "walk-in"].includes(
2713
- item?.type
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
- !hasPasses &&
2718
- !hasKeys &&
2719
- isTypeWithPassKey &&
2720
- item?.status === "registered" &&
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
- function handleViewImage(imageId: string) {
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
- if (!selectedVisitorId.value) return;
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
- const payload: any = {};
2827
- if (passReturnStatuses.value.length > 0) {
2828
- const passReturnStatusesPayload = passReturnStatuses.value.map((p) => ({
2829
- keyId: p.keyId,
2830
- status: p.status,
2831
- remarks: p.remarks,
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
- const errorMessage = error?.response?._data?.message;
2848
- showMessage(
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
- const passReturnStatuses = ref<
2858
- { keyId: string; status: string; prefixAndName: string; remarks: string }[]
2859
- >([]);
2860
- const keyReturnStatuses = ref<
2861
- { keyId: string; status: string; prefixAndName: string; remarks: string }[]
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
- function handleCheckout(userId: string) {
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 = items.value.find((x: any) => x?._id === userId);
2904
- const hasPasses = (visitor?.visitorPass?.length ?? 0) > 0;
2905
- const hasKeys = (visitor?.passKeys?.length ?? 0) > 0;
2906
-
2907
- if (hasPasses || hasKeys) {
2908
- const visitor = items.value.find((x: any) => x?._id === userId);
2909
- passReturnStatuses.value = ((visitor?.visitorPass as any[]) || []).map(
2910
- (p: any) => ({
2911
- keyId: p.keyId,
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
- const hasNfc =
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
- ...(passReturnStatuses.value.length > 0
2978
- ? { visitorPass: passReturnStatusesPayload }
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-out!", "info");
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
- errorMessage || "Something went wrong. Please try again later.",
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
- showMessage("Visitor successfully checked-out!", "info");
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
- const errorMessage = error?.response?._data?.message;
3026
- showMessage(
3027
- errorMessage || "Something went wrong. Please try again later.",
3028
- "error"
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
- ? moment(dateTo.value).endOf("day").toISOString()
3041
- : undefined,
3042
- dateFrom: dateFrom.value
3043
- ? moment(dateFrom.value).startOf("day").toISOString()
3044
- : undefined,
3045
- type:
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
- watch([searchQuery, dateFrom, dateTo, filterTypes, activeTab], () => {
3252
- page.value = 1;
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
- (anprSettingsLoaded.value &&
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) as TVisitorType[];
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, anprSettingsLoaded],
4206
+ [enableUnregisteredTab, unregisteredTabKnown],
3326
4207
  ([enabled]) => {
3327
- if (!enabled && anprSettingsLoaded.value && activeTab.value === "unregistered") {
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;