@7365admin1/layer-common 4.97.1-staging.587 → 4.97.1-staging.588

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.
@@ -4,30 +4,26 @@
4
4
  <v-card-title class="text-capitalize">
5
5
  {{ formMode }} {{ formatVisitorType(type) }}
6
6
  </v-card-title>
7
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="emit('close:all')" />
7
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="emit('close:all')" />
8
8
  </div>
9
9
 
10
+ <!-- One page for every visitor type, only Submit (Update when saving onto
11
+ an existing record), as the security mobile app (isecure365). -->
10
12
  <div class="screen-modal__body visitor-form__body filter-field">
11
- <div class="visitor-form__section-row">
12
- <span class="visitor-form__section">{{ formTitle }}</span>
13
- <span v-if="withStep2 || withStep3" class="visitor-form__step">Step
14
- <span class="visitor-form__step-now">{{ contractorStep }}</span>/{{ withStep3 ? 3 : withStep2 ? 2 : 1 }}</span>
15
- </div>
16
13
  <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorMessage = ''">
17
14
  <v-row no-gutters class="pt-4">
18
- <v-col v-if="shouldShowField('attachments')" class="w-100">
19
- <InputLabel class="text-capitalize" title="Attach Image (Optional)" />
20
- <InputFileV2 v-model="visitor.attachments" multiple :max-length="3" />
21
- </v-col>
22
-
23
15
  <v-col v-if="shouldShowField('nric')" cols="12">
24
16
  <v-row>
25
17
  <v-col cols="12">
18
+ <!-- As the security mobile app: an eye to reveal the NRIC, and
19
+ Scan to read the NRIC and name off the card. -->
26
20
  <InputLabel class="text-capitalize" :title="requireNRIC ? 'NRIC (Required)' : 'NRIC (Optional)'" :required="requireNRIC" />
21
+ <div class="scan-row">
22
+ <div class="scan-row__field">
27
23
  <v-menu v-model="nricSearchMenu" location="bottom" offset-y :close-on-content-click="false"
28
24
  content-class="visitor-form__menu">
29
25
  <template #activator="{ props }">
30
- <InputNRICNumber v-bind="props" v-model="visitor.nric" density="comfortable"
26
+ <InputNRICNumber v-bind="props" v-model="visitor.nric" density="comfortable" masked
31
27
  @focus="isNricFieldFocused = true" @blur="isNricFieldFocused = false"
32
28
  :rules="[requireNRIC ? requiredRule : () => true]" :key="currentAutofillSource + 'nric-key'"
33
29
  @update:model-value="handleUpdateNRIC" :loading="fetchPersonByNRICPending" />
@@ -38,6 +34,10 @@
38
34
  <v-list-item v-for="(item, index) in nricSearchResults" :key="item._id || index"
39
35
  @click="selectNricSearchResult(item)" class="cursor-pointer nric-search-item">
40
36
  <VisitorSearchResultSummary :item="item" />
37
+ <template #append>
38
+ <v-btn icon="mdi-delete-outline" variant="text" size="small" color="error"
39
+ :aria-label="`Delete ${item.name || ''}`" @click.stop="askDeletePerson(item)" />
40
+ </template>
41
41
  </v-list-item>
42
42
  </div>
43
43
  <div class="search-results-close pa-1">
@@ -48,6 +48,14 @@
48
48
  </div>
49
49
  </v-list>
50
50
  </v-menu>
51
+ </div>
52
+ <v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn"
53
+ prepend-icon="mdi-card-account-details-outline" data-test="visitor-nric-scan"
54
+ :disabled="processing" @click="nricScanOpen = true">Scan</v-btn>
55
+ </div>
56
+ <p v-if="nricScanNotice" class="text-caption text-success mt-1" data-test="visitor-nric-scan-notice">
57
+ {{ nricScanNotice }}
58
+ </p>
51
59
  <p v-if="visitorFieldSwitches.nric === 'last4'" class="text-caption text-medium-emphasis mt-1"
52
60
  data-test="visitor-nric-last4-hint">
53
61
  {{ NRIC_LAST4_HINT }}
@@ -73,6 +81,10 @@
73
81
  <v-list-item v-for="(item, index) in phoneSearchResults" :key="item._id || index"
74
82
  @click="selectPhoneNumberSearchResult(item)" class="cursor-pointer nric-search-item">
75
83
  <VisitorSearchResultSummary :item="item" />
84
+ <template #append>
85
+ <v-btn icon="mdi-delete-outline" variant="text" size="small" color="error"
86
+ :aria-label="`Delete ${item.name || ''}`" @click.stop="askDeletePerson(item)" />
87
+ </template>
76
88
  </v-list-item>
77
89
  </div>
78
90
  <div class="search-results-close pa-1">
@@ -107,6 +119,10 @@
107
119
  <v-list-item v-for="(item, index) in vehicleSearchResults" :key="item._id || index"
108
120
  @click="selectPlateNumberSearchResult(item)" class="cursor-pointer nric-search-item">
109
121
  <VisitorSearchResultSummary :item="item" />
122
+ <template #append>
123
+ <v-btn icon="mdi-delete-outline" variant="text" size="small" color="error"
124
+ :aria-label="`Delete ${item.name || ''}`" @click.stop="askDeletePerson(item)" />
125
+ </template>
110
126
  </v-list-item>
111
127
  </div>
112
128
  <div class="search-results-close pa-1">
@@ -121,10 +137,19 @@
121
137
  </v-row>
122
138
  </v-col>
123
139
 
140
+ <v-col v-if="shouldShowField('name')" cols="12">
141
+ <v-row>
142
+ <v-col cols="12">
143
+ <InputLabel class="text-capitalize" title="Name (Required)" required />
144
+ <v-text-field v-model.trim="visitor.name" density="comfortable" :rules="[requiredRule]" />
145
+ </v-col>
146
+ </v-row>
147
+ </v-col>
148
+
124
149
  <v-col v-if="shouldShowField('contractorType')" cols="12">
125
150
  <InputLabel class="text-capitalize" title="Contractor Type (Required)" required />
126
- <v-combobox v-model="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
127
- autocomplete="off" :hide-no-data="false" @update:focused="handleFocusedContractorType"
151
+ <v-combobox :model-value="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
152
+ autocomplete="off" :hide-no-data="false"
128
153
  :items="contractorTypeItems" :rules="[requiredRule]" item-value="value"
129
154
  @update:model-value="handleSelectContractorType" variant="outlined" density="comfortable" persistent-hint
130
155
  small-chips>
@@ -140,39 +165,49 @@
140
165
  </v-combobox>
141
166
  </v-col>
142
167
 
143
-
144
- <v-col v-if="shouldShowField('name')" cols="12">
145
- <v-row>
146
- <v-col cols="12">
147
- <InputLabel class="text-capitalize" title="Full Name (Required)" required />
148
- <v-text-field v-model.trim="visitor.name" density="comfortable" :rules="[requiredRule]" />
149
- </v-col>
150
- </v-row>
151
- </v-col>
152
-
153
-
154
-
155
-
156
-
157
168
  <v-col v-if="shouldShowField('deliveryType')" cols="12">
158
169
  <v-row>
159
170
  <v-col cols="12">
160
- <!-- The handoff never draws the delivery type, so this takes the
161
- form's OWN established pattern rather than inventing one: an
162
- `InputLabel` like every other field, and `accent` - the
163
- colour the converted NRIC picker's radios already use - in
164
- place of `primary`, which is the darker link tone. Without
165
- the label this was the only required field in the form with
166
- nothing naming it: just a bare Food/Parcel pair. -->
171
+ <!-- Saved as `food` / `parcel`, Food by default, as the security
172
+ mobile app. -->
167
173
  <InputLabel class="text-capitalize" title="Delivery Type (Required)" required />
168
174
  <v-radio-group v-model="visitor.deliveryType" inline color="accent" :rules="[requiredRule]">
169
- <v-radio label="Food" value="Food"></v-radio>
170
- <v-radio label="Parcel" value="Parcel"></v-radio>
175
+ <v-radio label="Food" value="food"></v-radio>
176
+ <v-radio label="Parcel" value="parcel"></v-radio>
171
177
  </v-radio-group>
172
178
  </v-col>
173
179
  </v-row>
174
180
  </v-col>
175
181
 
182
+ <v-col v-if="shouldShowField('block')" cols="12">
183
+ <InputLabel class="text-capitalize" title="Block (Required)" required />
184
+ <v-autocomplete v-model="visitor.block" :items="blocksArray" item-value="value" autocomplete="off"
185
+ item-title="title" :key="blockListDataPending ? 'block-list-pending' : 'block-list-ready'
186
+ " :loading="blockListDataPending" @update:model-value="handleChangeBlock" density="comfortable"
187
+ :rules="[requiredRule]" />
188
+ </v-col>
189
+
190
+ <v-col v-if="shouldShowField('level')" cols="12">
191
+ <InputLabel class="text-capitalize" title="Level (Required)" required />
192
+ <v-autocomplete v-model="visitor.level" :items="levelsArray" density="comfortable" autocomplete="off"
193
+ :disabled="!visitor.block" :key="levelsDataPending ? 'level-list-pending' : 'level-list-ready'
194
+ " :loading="levelsDataPending" @update:model-value="handleChangeLevel" :rules="[requiredRule]" />
195
+ </v-col>
196
+ <v-col v-if="shouldShowField('unit')" cols="12">
197
+ <InputLabel class="text-capitalize" title="Unit (Required)" required />
198
+ <v-autocomplete v-model="visitor.unit" :items="unitsArray" density="comfortable" autocomplete="off"
199
+ item-title="title" item-value="value" :disabled="!visitor.level"
200
+ :key="unitsDataPending ? 'unit-list-pending' : 'unit-list-ready'" :loading="unitsDataPending"
201
+ :rules="[requiredRule]" @update:model-value="handleUpdateUnit" />
202
+ </v-col>
203
+
204
+ <v-col v-if="shouldShowField('unit')" cols="12">
205
+ <InputLabel class="text-capitalize" title="Registered Company Name"
206
+ :key="'unit-key' + visitor.level" />
207
+ <v-text-field v-model.trim="registeredUnitCompanyName" density="comfortable"
208
+ :loading="buildingUnitDataPending" readonly class="no-pointer" />
209
+ </v-col>
210
+
176
211
  <template v-if="shouldShowField('company')">
177
212
  <v-col cols="12">
178
213
  <InputLabel class="text-capitalize" :title="requireCompany ? 'Company Name (Required)' : 'Company Name (Optional)'" :required="requireCompany" />
@@ -185,14 +220,31 @@
185
220
  </v-col>
186
221
  </template>
187
222
 
188
-
223
+ <!-- Delivery: pick from the site's delivery company list, or type one
224
+ that is not listed - it is added to the list once the visitor is
225
+ saved (isecure365 `delivery-company-field.tsx`). -->
189
226
  <template v-if="shouldShowField('delivery-company')">
190
227
  <v-col cols="12">
191
- <InputLabel class="text-capitalize" title="Delivery Company (Required)" required />
192
- <v-combobox v-model="deliveryCompany" v-model:search="deliveryCompanyInput" ref="companyCombo"
193
- :rules="[requiredRule]"
194
- autocomplete="off" :hide-no-data="false" :items="deliveryCompanyList" item-value="value"
195
- :loading="siteDataPending" variant="outlined" density="comfortable" persistent-hint small-chips>
228
+ <InputLabel class="text-capitalize" title="Company Name (Required)" required />
229
+ <v-combobox v-model="deliveryCompany" v-model:search="deliveryCompanyInput" ref="deliveryCompanyCombo"
230
+ :rules="[requiredRule]" :hint="deliveryCompanyHint" persistent-hint
231
+ autocomplete="off" :hide-no-data="false" :items="deliveryCompanyList"
232
+ :loading="deliveryCompaniesLoading" variant="outlined" density="comfortable" small-chips
233
+ @update:focused="handleDeliveryCompanyFocus" @blur="snapDeliveryCompany">
234
+ <!-- Each listed company with a pencil (rename) and a bin
235
+ (remove from the site's list), as the security mobile app. -->
236
+ <template #item="{ item, props: itemProps }">
237
+ <v-list-item v-bind="itemProps" :title="undefined" class="delivery-company__item">
238
+ <div class="d-flex align-center ga-2 w-100">
239
+ <span class="flex-grow-1 text-truncate">{{ item.title }}</span>
240
+ <v-btn icon="mdi-pencil" variant="text" size="small" :disabled="deliveryCompanySaving"
241
+ :aria-label="`Edit ${item.title}`" @click.stop="openEditDeliveryCompany(item.title)" />
242
+ <v-btn icon="mdi-delete" variant="text" size="small" color="error"
243
+ :disabled="deliveryCompanySaving" :aria-label="`Remove ${item.title}`"
244
+ @click.stop="deliveryCompanyToDelete = item.title" />
245
+ </div>
246
+ </v-list-item>
247
+ </template>
196
248
  <template v-slot:no-data>
197
249
  <v-list-item>
198
250
  <v-list-item-title v-if="deliveryCompanyInput" @click.stop="deliveryCompany = deliveryCompanyInput"
@@ -210,34 +262,50 @@
210
262
  </v-col>
211
263
  </template>
212
264
 
213
-
214
- <v-col v-if="shouldShowField('block')" cols="12">
215
- <InputLabel class="text-capitalize" title="Block (Required)" required />
216
- <v-autocomplete v-model="visitor.block" :items="blocksArray" item-value="value" autocomplete="off"
217
- item-title="title" :key="blockListDataPending ? 'block-list-pending' : 'block-list-ready'
218
- " :loading="blockListDataPending" @update:model-value="handleChangeBlock" density="comfortable"
219
- :rules="[requiredRule]" />
220
- </v-col>
221
-
222
- <v-col v-if="shouldShowField('level')" cols="12">
223
- <InputLabel class="text-capitalize" title="Level (Required)" required />
224
- <v-autocomplete v-model="visitor.level" :items="levelsArray" density="comfortable" autocomplete="off"
225
- :disabled="!visitor.block" :key="levelsDataPending ? 'level-list-pending' : 'level-list-ready'
226
- " :loading="levelsDataPending" @update:model-value="handleChangeLevel" :rules="[requiredRule]" />
227
- </v-col>
228
- <v-col v-if="shouldShowField('unit')" cols="12">
229
- <InputLabel class="text-capitalize" title="Unit (Required)" required />
230
- <v-autocomplete v-model="visitor.unit" :items="unitsArray" density="comfortable" autocomplete="off"
231
- item-title="title" item-value="value" :disabled="!visitor.level"
232
- :key="unitsDataPending ? 'unit-list-pending' : 'unit-list-ready'" :loading="unitsDataPending"
233
- :rules="[requiredRule]" @update:model-value="handleUpdateUnit" />
234
- </v-col>
235
-
236
- <v-col v-if="shouldShowField('unit')" cols="12">
237
- <InputLabel class="text-capitalize" title="Registered Unit Company Name"
238
- :key="'unit-key' + visitor.level" />
239
- <v-text-field v-model.trim="registeredUnitCompanyName" density="comfortable"
240
- :loading="buildingUnitDataPending" readonly class="no-pointer" />
265
+ <!-- Photos, as the security mobile app: the photos in a carousel with
266
+ Edit (draw on the photo) and Delete on each - shown only once
267
+ there is one - then the picker. Up to 5; uploaded on Submit. -->
268
+ <v-col v-if="shouldShowField('attachments')" class="w-100" cols="12">
269
+ <InputLabel class="text-capitalize" title="Photos (Optional)" />
270
+ <template v-if="deliveryPhotos.length">
271
+ <v-carousel v-model="deliveryPhotoIndex" height="220" hide-delimiters
272
+ :show-arrows="deliveryPhotos.length > 1 ? 'hover' : false" class="visitor-form__photos">
273
+ <v-carousel-item v-for="photo in deliveryPhotos" :key="photo.url">
274
+ <v-img :src="photo.url" cover height="100%" />
275
+ </v-carousel-item>
276
+ </v-carousel>
277
+ <!-- Edit / Delete act on the photo shown, under it (not over it). -->
278
+ <div class="d-flex align-center justify-space-between flex-wrap ga-2 mt-2">
279
+ <span class="text-caption text-medium-emphasis">
280
+ Photo {{ deliveryPhotoIndex + 1 }} of {{ deliveryPhotos.length }}
281
+ </span>
282
+ <div class="d-flex ga-2">
283
+ <AppButton variant="ghost" icon="mdi-pencil" @click="editDeliveryPhoto">Edit</AppButton>
284
+ <AppButton variant="err" icon="mdi-delete-outline" @click="removeDeliveryPhotoAt(deliveryPhotoIndex)">
285
+ Delete</AppButton>
286
+ </div>
287
+ </div>
288
+ </template>
289
+ <div class="d-flex align-center flex-wrap ga-2 mt-2">
290
+ <v-btn variant="flat" class="text-none screen-btn-primary" prepend-icon="mdi-image-multiple-outline"
291
+ :disabled="deliveryPhotos.length >= MAX_DELIVERY_PHOTOS" @click="deliveryGalleryInput?.click()">
292
+ Gallery</v-btn>
293
+ <v-btn variant="flat" class="text-none screen-btn-primary" prepend-icon="mdi-camera-outline"
294
+ :disabled="deliveryPhotos.length >= MAX_DELIVERY_PHOTOS" @click="deliveryCamera?.openCameraDialog()">
295
+ Camera</v-btn>
296
+ <span class="text-caption text-medium-emphasis">
297
+ {{ deliveryPhotos.length }} of {{ MAX_DELIVERY_PHOTOS }} photos
298
+ </span>
299
+ </div>
300
+ <input ref="deliveryGalleryInput" type="file" accept="image/*" multiple class="d-none"
301
+ @change="onDeliveryGalleryPicked" />
302
+ <!-- Only its camera dialog is used (the Camera button above). -->
303
+ <div class="d-none">
304
+ <FileInput ref="deliveryCamera" :init-files="[]" @update:files="addDeliveryPhotos" />
305
+ </div>
306
+ <DrawImage v-if="deliveryPhotoEdit.open" :is-show-dialog="deliveryPhotoEdit.open"
307
+ :image-url="deliveryPhotoEdit.url" :image-idx="deliveryPhotoEdit.idx"
308
+ @on-submit="onDeliveryPhotoEdited" @on-close-dialog="deliveryPhotoEdit.open = false" />
241
309
  </v-col>
242
310
 
243
311
  <v-col v-if="shouldShowField('remarks')" cols="12">
@@ -245,11 +313,37 @@
245
313
  <v-textarea v-model="visitor.remarks" density="comfortable" :rows="3" no-resize />
246
314
  </v-col>
247
315
 
248
- <v-col v-show="(withStep2 || withStep3) && contractorStep === 2" cols="12">
249
- <PassInformation v-model:pass="visitor.visitorPass" v-model:keys="visitor.passKeys" :site="prop.site"
316
+ <!-- Pass & Keys under Remarks (Drive-In, Walk-In, Contractor), only
317
+ with See All Passes And Keys AND Update Passes And Keys, as the
318
+ security mobile app. Keys are a contractor thing. -->
319
+ <v-col v-if="showPassSection" cols="12">
320
+ <InputLabel class="text-capitalize" title="Pass & Keys (Optional)" />
321
+ <p v-if="passLockedMessage" class="text-caption text-medium-emphasis mb-2"
322
+ data-test="visitor-pass-locked">
323
+ {{ passLockedMessage }}
324
+ </p>
325
+ <PassInformation v-else :key="passInfoKey" v-model:pass="visitor.visitorPass" v-model:keys="visitor.passKeys" :site="prop.site"
250
326
  :type="prop.type" :contractor-type="visitor.contractorType" :hide-keys="!showShowKeysField"
251
- :passKeys="visitor.passKeys" :clearable="true" />
252
- <EntryPassInformation v-if="showEntryPassInformation" v-model="passType" v-model:quantity="passQuantity"
327
+ :passKeys="visitor.passKeys" :clearable="true" :exclude-ids="memberPassIds" />
328
+ </v-col>
329
+
330
+ <v-col v-if="prop.type === 'contractor'" cols="12" class="mt-2">
331
+ <MemberInformation ref="memberInfoRef" :key="membersKey" v-model="visitor.members" :type="prop.type"
332
+ :contractor-type="visitor.contractorType"
333
+ :site="prop.site" :selected-visitor-pass="visitor.visitorPass" :clearable="true"
334
+ :can-assign-pass-keys="prop.canAssignPassKeys" :pass-disabled-message="passLockedMessage"
335
+ :lead-nric="visitor.nric || ''" :lead-contact="visitor.contact || ''"
336
+ :check-open-visits="checkMemberOpenVisits"
337
+ :settings="entryPassSettings" :settings-loading="entryPassSettingsPending || siteDataPending"
338
+ :unit-id="visitor.unit || null" :show-entry-pass="showEntryPassInformation"
339
+ :hid-qr-code-enabled="hidQrCodePassAllowed"
340
+ :hid-qr-printer-configured="hasHidQrPrinterConfig"
341
+ :hid-reader-name="hidQrReader?.name || ''" :hid-portal-name="hidQrReader?.portalName || ''"
342
+ :selected-nfc-cards="passType === 'NFC' ? passCards : []" />
343
+ </v-col>
344
+
345
+ <v-col v-if="showEntryPassInformation" cols="12" class="mt-2">
346
+ <EntryPassInformation v-model="passType" v-model:quantity="passQuantity"
253
347
  v-model:cards="passCards" :settings="entryPassSettings"
254
348
  :loading="entryPassSettingsPending || siteDataPending" :site-id="prop.site"
255
349
  :unit-id="visitor.unit || null" :visitor-type="prop.type" :hid-qr-code-enabled="hidQrCodePassAllowed"
@@ -260,16 +354,6 @@
260
354
  @update:available-qr-count="(val) => availableQrCount = val" />
261
355
  </v-col>
262
356
 
263
- <v-col v-show="withStep3 && contractorStep === 3" cols="12">
264
- <MemberInformation v-model="visitor.members" :type="prop.type" :contractor-type="visitor.contractorType"
265
- :site="prop.site" :selected-visitor-pass="visitor.visitorPass" :clearable="true"
266
- :settings="entryPassSettings" :settings-loading="entryPassSettingsPending || siteDataPending"
267
- :unit-id="visitor.unit || null" :hid-qr-code-enabled="hidQrCodePassAllowed"
268
- :hid-qr-printer-configured="hasHidQrPrinterConfig"
269
- :hid-reader-name="hidQrReader?.name || ''" :hid-portal-name="hidQrReader?.portalName || ''"
270
- :selected-nfc-cards="passType === 'NFC' ? passCards : []" />
271
- </v-col>
272
-
273
357
  <v-col v-if="formMode === 'add'" cols="12" class="mt-2">
274
358
  <v-checkbox v-model="createMore" density="comfortable" hide-details>
275
359
  <template #label>
@@ -288,32 +372,21 @@
288
372
  </p>
289
373
 
290
374
  <div class="visitor-form__footer">
291
- <AppButton v-if="formMode === 'add' && (withStep2 || withStep3) && contractorStep > 1" variant="ghost"
292
- @click="handleGoToPreviousPage">Back</AppButton>
293
- <AppButton v-else-if="formMode === 'add' || formMode === 'register'" variant="ghost" @click="backToSelection">
375
+ <AppButton v-if="formMode === 'add' || formMode === 'register'" variant="ghost" @click="backToSelection">
294
376
  Back to Selection</AppButton>
295
377
  <AppButton v-else variant="ghost" @click="emit('close:all')">Close</AppButton>
296
378
 
297
- <AppButton v-if="showNextButton" :disabled="processing" @click="handleNextPage">Next</AppButton>
298
- <AppButton v-else :disabled="!validForm || processing || membersFieldIncomplete" @click="submit">
299
- {{ formMode == 'add' ? 'Submit' : 'Update' }}</AppButton>
379
+ <AppButton data-test="visitor-submit" :disabled="processing" @click="submit()">
380
+ {{ isSavingOntoRecord ? 'Update' : 'Submit' }}</AppButton>
300
381
  </div>
301
382
 
302
- <v-dialog v-model="dialog.vehicleNumberUsersList" v-if="vehicleNumberUserItems.length > 0" persistent
303
- max-width="600">
304
- <SearchVehicleNumberUser :vehicle-number="visitor.plateNumber ?? ''"
305
- :vehicle-number-users-list="vehicleNumberUserItems" @close="dialog.vehicleNumberUsersList = false"
306
- @update:people="handleAutofillDataViaVehicleNumber" />
307
- </v-dialog>
383
+ <NricScanDialog v-model="nricScanOpen" @read="handleNricScanRead" />
308
384
 
309
385
  <v-dialog v-model="dialog.nricAutofillOptions" max-width="430">
386
+ <!-- The chooser: no title, Back (to the result list) / Fill In, as the
387
+ security mobile app. -->
310
388
  <v-card v-if="selectedNricSearchResult" class="screen-modal nric-options-card">
311
- <div class="visitor-form__head">
312
- <v-card-title>Select</v-card-title>
313
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeNricAutofillOptions" />
314
- </div>
315
-
316
- <div class="screen-modal__body nric-options-body">
389
+ <div class="screen-modal__body nric-options-body pt-4">
317
390
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
318
391
  <span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
319
392
  <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ maskNRIC(selectedNricSearchResult.nric) || "N/A" }}</span>
@@ -327,7 +400,7 @@
327
400
  </v-radio-group>
328
401
  </template>
329
402
 
330
- <template v-if="(!visitor.plateNumber || currentAutofillSource === 'vehicleNumber') && selectedNricPlateOptions.length >= 1">
403
+ <template v-if="prop.type !== 'walk-in' && (!visitor.plateNumber || currentAutofillSource === 'vehicleNumber') && selectedNricPlateOptions.length >= 1">
331
404
  <h3 class="nric-options-section-title mb-2 mt-5">Vehicle</h3>
332
405
  <v-radio-group v-model="selectedNricPlate" hide-details color="accent" class="nric-option-group">
333
406
  <v-radio v-for="plate in selectedNricPlateOptions" :key="plate" :label="plate" :value="plate"
@@ -337,8 +410,8 @@
337
410
  </div>
338
411
 
339
412
  <div class="visitor-form__footer">
340
- <AppButton variant="ghost" @click="closeNricAutofillOptions">Cancel</AppButton>
341
- <AppButton @click="confirmNricAutofillOptions">Select</AppButton>
413
+ <AppButton variant="ghost" @click="chooserBack">Back</AppButton>
414
+ <AppButton @click="confirmNricAutofillOptions">Fill In</AppButton>
342
415
  </div>
343
416
  </v-card>
344
417
  </v-dialog>
@@ -350,18 +423,25 @@
350
423
  is not this batch's call. The design's modal frame does draw a
351
424
  title, so that gap is raised on the PR as a copy decision. -->
352
425
  <div class="visitor-form__head visitor-form__head--bare">
353
- <AppButton variant="icon" icon="mdi-close" aria-label="Close"
354
- @click="dialog.showNonCheckedOutDialog = false" />
426
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="dialog.showNonCheckedOutDialog = false" />
355
427
  </div>
356
428
 
357
429
  <div class="screen-modal__body checkout-dialog__body">
358
430
  <p class="checkout-dialog__lead">
359
- The following list needs to check-out before proceeding to add related to the NRIC/Phone Number below:
431
+ {{ nonCheckedOutFor.kind === "member"
432
+ ? "The following list needs to check-out before proceeding to add member related to the NRIC/Phone Number below:"
433
+ : "The following list needs to check-out before proceeding to add related to the NRIC/Phone Number below:" }}
360
434
  </p>
361
435
 
362
436
  <div class="checkout-dialog__subject">
363
- <div v-if="!skipNonCheckedOutNric">NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}</div>
364
- <div>Phone Number: {{ visitor.contact || "N/A" }}</div>
437
+ <template v-if="nonCheckedOutFor.kind === 'member'">
438
+ <div>NRIC: {{ maskNRIC(nonCheckedOutFor.nric) || "N/A" }}</div>
439
+ <div>Phone Number: {{ nonCheckedOutFor.contact || "N/A" }}</div>
440
+ </template>
441
+ <template v-else>
442
+ <div>NRIC: {{ (!skipNonCheckedOutNric && maskNRIC(visitor.nric)) || "N/A" }}</div>
443
+ <div>Phone Number: {{ visitor.contact || "N/A" }}</div>
444
+ </template>
365
445
  </div>
366
446
 
367
447
  <v-data-table class="table-card" :headers="nonCheckedOutVisitorHeaders"
@@ -424,10 +504,10 @@
424
504
  </v-data-table>
425
505
  </div>
426
506
 
427
- <div class="visitor-form__footer" v-if="!skipNonCheckedOutNric">
507
+ <div class="visitor-form__footer" v-if="nonCheckedOutFor.kind === 'member' ? !!nonCheckedOutFor.nric : (!skipNonCheckedOutNric && !!visitor.nric)">
428
508
  <v-btn class="screen-btn-primary" variant="flat" :loading="loading.searchingNonCheckedOut"
429
- @click="handleSkipNricNonCheckedOut">
430
- Skip NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}
509
+ @click="nonCheckedOutFor.kind === 'member' ? handleSkipMemberNric() : handleSkipNricNonCheckedOut()">
510
+ {{ nonCheckedOutFor.kind === "member" ? "SKIP NRIC" : "Skip NRIC" }}: {{ maskNRIC(nonCheckedOutFor.kind === "member" ? nonCheckedOutFor.nric : visitor.nric) || "N/A" }}
431
511
  </v-btn>
432
512
  </div>
433
513
  </v-card>
@@ -437,7 +517,7 @@
437
517
  <v-card>
438
518
  <v-toolbar density="compact" color="">
439
519
  <v-row no-gutters class="d-flex fill-height justify-space-between align-center px-4">
440
- <span class="font-weight-bold">Return NFC Card</span>
520
+ <span class="font-weight-bold">Return NFC Card(s)</span>
441
521
  <v-btn icon="mdi-close" variant="text" @click="dialog.returnNfcCard = false" />
442
522
  </v-row>
443
523
  </v-toolbar>
@@ -456,8 +536,8 @@
456
536
  </div>
457
537
  <div v-for="(card, idx) in nfcCardReturnStatuses" :key="card.cardId" class="mb-4">
458
538
  <div class="d-flex align-center ga-2 mb-2">
459
- <v-icon size="18" icon="mdi-credit-card-outline" color="purple" />
460
- <v-chip size="small" variant="tonal" color="purple">{{ card.cardNo }}</v-chip>
539
+ <v-chip prepend-icon="mdi-credit-card-outline" variant="outlined"
540
+ class="font-weight-bold visitor-pass-chip visitor-pass-chip--field visitor-pass-chip--nfc">{{ card.cardNo }}</v-chip>
461
541
  </div>
462
542
  <v-select v-model="nfcCardReturnStatuses[idx].status" :items="nfcPassStatusOptions" item-title="label"
463
543
  item-value="value" density="comfortable" hide-details />
@@ -473,18 +553,157 @@
473
553
  </v-col>
474
554
  <v-col cols="6">
475
555
  <v-btn color="red" variant="flat" height="48" rounded="0" block :loading="loading.checkingOut"
476
- :disabled="!canConfirmNfcReturnCheckout" @click="handleNfcReturnCheckout">Confirm Checkout</v-btn>
556
+ :disabled="!canConfirmNfcReturnCheckout" @click="handleNfcReturnCheckout">Confirm &amp; Check Out</v-btn>
557
+ </v-col>
558
+ </v-row>
559
+ </v-toolbar>
560
+ </v-card>
561
+ </v-dialog>
562
+
563
+ <!-- A still-inside visitor holding a pass or key: what came back is
564
+ recorded before the check-out (isecure365 pass-key-return-dialog). -->
565
+ <v-dialog v-model="dialog.returnPassKeys" max-width="450" persistent>
566
+ <v-card>
567
+ <v-toolbar density="compact" color="">
568
+ <v-row no-gutters class="d-flex fill-height justify-space-between align-center px-4">
569
+ <span class="font-weight-bold">Update Pass or Key(s)</span>
570
+ <v-btn icon="mdi-close" variant="text" @click="dialog.returnPassKeys = false" />
571
+ </v-row>
572
+ </v-toolbar>
573
+ <v-card-text class="px-4 pb-2">
574
+ <div class="visitor-return__who">
575
+ <div class="visitor-return__who-line font-weight-bold">
576
+ <v-icon icon="mdi-account-outline" size="18" />
577
+ {{ passReturnVisitor?.name || "N/A" }}
578
+ </div>
579
+ <div class="visitor-return__who-line">
580
+ <v-icon icon="mdi-storefront-outline" size="18" />
581
+ {{ passReturnVisitor ? formatVisitorLocation(passReturnVisitor as TVisitor) : "N/A" }}
582
+ </div>
583
+ </div>
584
+
585
+ <v-btn block variant="flat" class="text-capitalize mb-3 screen-btn-primary" prepend-icon="mdi-qrcode-scan"
586
+ @click="passReturnScan.open = true">
587
+ Scan QR Code
588
+ </v-btn>
589
+
590
+ <div v-for="section in passReturnSections" :key="section.title" class="mb-2">
591
+ <template v-if="section.show">
592
+ <p class="visitor-return__label">{{ section.title }} ({{ section.items.length }})</p>
593
+ <p v-if="section.items.length === 0" class="text-body-2 mb-2">{{ section.empty }}</p>
594
+ <v-row v-for="entry in section.items" :key="entry.keyId" no-gutters
595
+ class="d-flex flex-wrap justify-space-between align-center ga-5 mb-2">
596
+ <v-chip :prepend-icon="section.icon" variant="outlined"
597
+ :class="['font-weight-bold', 'visitor-pass-chip', 'visitor-pass-chip--field', section.color === 'blue' ? 'visitor-pass-chip--pass' : 'visitor-pass-chip--key']">
598
+ {{ entry.prefixAndName || passKeyNames[entry.keyId] || section.fallback }}
599
+ </v-chip>
600
+ <v-select v-model="entry.status" hide-details max-width="200px" density="compact"
601
+ :items="entry.locked ? ['Removed'] : PASS_RETURN_STATUSES" :disabled="entry.locked" />
602
+ <v-textarea v-if="entry.status === 'Damaged' || entry.status === 'Lost'" v-model="entry.remarks"
603
+ no-resize rows="3" class="w-100" density="compact" label="Remarks" placeholder="Enter remarks" />
604
+ </v-row>
605
+ </template>
606
+ </div>
607
+
608
+ <p v-if="passReturnError" class="text-error text-body-2 mb-2">{{ passReturnError }}</p>
609
+ </v-card-text>
610
+ <v-toolbar class="pa-0" density="compact">
611
+ <v-row no-gutters>
612
+ <v-col cols="6">
613
+ <v-btn color="blue" variant="flat" height="48" rounded="0" block :loading="loading.updatingPassKeys"
614
+ :disabled="!passReturnChanged || loading.checkingOut" @click="handlePassKeyReturnUpdate">
615
+ Update
616
+ </v-btn>
617
+ </v-col>
618
+ <v-col cols="6">
619
+ <v-btn color="red" variant="flat" height="48" rounded="0" block :loading="loading.checkingOut"
620
+ :disabled="!canCheckOutAfterReturn || loading.updatingPassKeys" @click="handlePassKeyReturnCheckout">
621
+ Check Out
622
+ </v-btn>
477
623
  </v-col>
478
624
  </v-row>
479
625
  </v-toolbar>
480
626
  </v-card>
481
627
  </v-dialog>
482
628
 
629
+ <VisitorPassKeyQRScanner v-if="passReturnScan.open" :dialog="passReturnScan.open"
630
+ v-model:scanned-value="passReturnScan.value" @close-dialog="passReturnScan.open = false" />
631
+
632
+ <v-dialog v-model="dialog.deletePerson" max-width="430">
633
+ <v-card class="screen-modal">
634
+ <div class="visitor-form__head">
635
+ <v-card-title>{{ DELETE_PERSON_TITLE }}</v-card-title>
636
+ </div>
637
+ <div class="screen-modal__body visitor-form__dialog-body">
638
+ <p class="text-body-2">{{ deletePersonMessage(personToDelete?.name || "This person") }}</p>
639
+ </div>
640
+ <div class="visitor-form__footer">
641
+ <AppButton variant="ghost" @click="dialog.deletePerson = false">Cancel</AppButton>
642
+ <AppButton variant="err" icon="mdi-delete-outline" :disabled="deletingPerson"
643
+ @click="deletePersonConfirmed">Delete</AppButton>
644
+ </div>
645
+ </v-card>
646
+ </v-dialog>
647
+
648
+ <!-- Changing Contractor Type when the passes picked take another pass type. -->
649
+ <v-dialog v-model="dialog.changeContractorType" max-width="430">
650
+ <v-card class="screen-modal">
651
+ <div class="visitor-form__head">
652
+ <v-card-title>Change Contractor Type?</v-card-title>
653
+ </div>
654
+ <div class="screen-modal__body visitor-form__dialog-body">
655
+ <p class="text-body-2">{{ contractorTypeChangeMessage }}</p>
656
+ </div>
657
+ <div class="visitor-form__footer">
658
+ <AppButton variant="ghost" @click="cancelContractorTypeChange">Cancel</AppButton>
659
+ <AppButton @click="confirmContractorTypeChange">Change</AppButton>
660
+ </div>
661
+ </v-card>
662
+ </v-dialog>
663
+
664
+ <v-dialog :model-value="!!deliveryCompanyEdit" max-width="430" persistent>
665
+ <v-card class="screen-modal">
666
+ <div class="visitor-form__head">
667
+ <v-card-title>Edit Company</v-card-title>
668
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="deliveryCompanyEdit = null" />
669
+ </div>
670
+ <div class="screen-modal__body visitor-form__dialog-body filter-field">
671
+ <InputLabel class="text-capitalize" title="Company Name" />
672
+ <v-text-field v-if="deliveryCompanyEdit" v-model="deliveryCompanyEdit.value" density="comfortable"
673
+ :error-messages="deliveryCompanyEdit.error ? [deliveryCompanyEdit.error] : []" autofocus
674
+ @update:model-value="deliveryCompanyEdit && (deliveryCompanyEdit.error = '')"
675
+ @keyup.enter="saveEditDeliveryCompany" />
676
+ </div>
677
+ <div class="visitor-form__footer">
678
+ <AppButton variant="ghost" @click="deliveryCompanyEdit = null">Cancel</AppButton>
679
+ <AppButton :disabled="deliveryCompanySaving" @click="saveEditDeliveryCompany">Save</AppButton>
680
+ </div>
681
+ </v-card>
682
+ </v-dialog>
683
+
684
+ <v-dialog :model-value="!!deliveryCompanyToDelete" max-width="430" persistent>
685
+ <v-card class="screen-modal">
686
+ <div class="visitor-form__head">
687
+ <v-card-title>Delete Company</v-card-title>
688
+ </div>
689
+ <div class="screen-modal__body visitor-form__dialog-body">
690
+ <p class="text-body-2">Remove "{{ deliveryCompanyToDelete }}" from the delivery company list?</p>
691
+ </div>
692
+ <div class="visitor-form__footer">
693
+ <AppButton variant="ghost" @click="deliveryCompanyToDelete = null">Cancel</AppButton>
694
+ <AppButton variant="err" icon="mdi-delete-outline" :disabled="deliveryCompanySaving"
695
+ @click="confirmDeleteDeliveryCompany">Delete</AppButton>
696
+ </div>
697
+ </v-card>
698
+ </v-dialog>
699
+
700
+ <Snackbar v-model="snackbar.show" :text="snackbar.text" :color="snackbar.color" />
701
+
483
702
  <v-dialog v-if="enableUnregistered" v-model="dialog.showUnregisteredPlateDialog" max-width="900" persistent>
484
703
  <v-card class="screen-modal">
485
704
  <!-- No heading in the product; see the note on the check-out dialog. -->
486
705
  <div class="visitor-form__head visitor-form__head--bare">
487
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeUnregisteredPlateDialog" />
706
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeUnregisteredPlateDialog" />
488
707
  </div>
489
708
 
490
709
  <div class="screen-modal__body unreg-dialog__body">
@@ -523,7 +742,7 @@
523
742
  </div>
524
743
 
525
744
  <div class="visitor-form__footer">
526
- <AppButton variant="ghost" @click="closeUnregisteredPlateDialog">Cancel &amp; Add New</AppButton>
745
+ <AppButton variant="ghost" @click="cancelUnregisteredAndAddNew">Cancel &amp; Add New</AppButton>
527
746
  </div>
528
747
  </v-card>
529
748
  </v-dialog>
@@ -532,7 +751,7 @@
532
751
  <v-card class="screen-modal hid-qr-preview-card">
533
752
  <div class="visitor-form__head">
534
753
  <v-card-title>Visitor QR Code</v-card-title>
535
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeHidQrPreview" />
754
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeHidQrPreview" />
536
755
  </div>
537
756
 
538
757
  <div class="screen-modal__body hid-qr__body">
@@ -624,6 +843,29 @@ import useAccessManagement from "../composables/useAccessManagement";
624
843
  import usePeople from "../composables/usePeople";
625
844
  import useHidAmico from "../composables/useHidAmico";
626
845
  import { hidQrUsageFromSettings, minutesUntilEndOfDay } from "../utils/hid-qr-validity";
846
+ import useFile from "../composables/useFile";
847
+ import usePassKey from "../composables/usePassKey";
848
+ import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
849
+ import {
850
+ DELETE_PERSON_TITLE,
851
+ PERSON_DELETED_MESSAGE,
852
+ companiesForType,
853
+ deletePersonMessage,
854
+ isListedCompany,
855
+ listedName,
856
+ normalizeCompany,
857
+ personKey,
858
+ recordIdsOf,
859
+ refusedPassKeyName,
860
+ renameProblem,
861
+ renamedCompany,
862
+ sameCompany,
863
+ summaryContacts,
864
+ summaryPlates,
865
+ withCompany,
866
+ withoutCompany,
867
+ type VisitorPerson,
868
+ } from "../utils/visitor-people";
627
869
 
628
870
  const prop = defineProps({
629
871
  type: {
@@ -653,14 +895,24 @@ const prop = defineProps({
653
895
  enableUnregistered: {
654
896
  type: Boolean,
655
897
  default: false,
656
- }
898
+ },
899
+ /**
900
+ * Passes And Keys Management > See All Passes And Keys AND Update Passes And
901
+ * Keys: either one unticked hides every pass / key picker on the form (the
902
+ * visitor's and each member's), as the security mobile app.
903
+ */
904
+ canAssignPassKeys: {
905
+ type: Boolean,
906
+ default: false,
907
+ },
657
908
  });
658
909
 
659
910
  type AutofillSource = "nric" | "contact" | "vehicleNumber" | null;
660
911
  const currentAutofillSource = ref<AutofillSource>(null);
661
912
 
662
- const { requiredRule, debounce, UTCToLocalTIme, maskNRIC } = useUtils();
663
- const { getSiteById, getOvernightParkingAvailabilityV2 } = useSiteSettings();
913
+ const { requiredRule, debounce, UTCToLocalTIme, maskNRIC, getImage } = useUtils();
914
+ const { addFile } = useFile();
915
+ const { getSiteById, getOvernightParkingAvailabilityV2, updateSitebyId } = useSiteSettings();
664
916
  const { currentUser } = useLocalAuth();
665
917
  const { getBlocksSelection, getLevelsSelection, getUnitSelection } =
666
918
  useBuilding();
@@ -677,12 +929,8 @@ const { getBySiteId: getEntryPassSettingsBySiteId } =
677
929
  const { createVisitorPass, signQr, visitorCheckout } = useAccessManagement();
678
930
  const { getEnrollmentReaders, issueVisitorQr } = useHidAmico();
679
931
  const { testConnection } = useWebUsb();
680
- const {
681
- findPersonByNRIC,
682
- findPersonByNRICMultipleResult,
683
- findPersonByPhoneNumberMultipleResult,
684
- findPersonByPlateNumberMultipleResult,
685
- } = usePeople();
932
+ const { findVisitorPeopleAnyType, deletePersonRecords } = usePeople();
933
+ const { getPassKeysByPageSearch } = usePassKey();
686
934
  const { getById: getUnitDataById } = useBuildingUnit();
687
935
 
688
936
  const emit = defineEmits([
@@ -760,14 +1008,25 @@ const registeredUnitCompanyName = ref("N/A");
760
1008
 
761
1009
  const dialog = reactive({
762
1010
  nricAutofillOptions: false,
763
- vehicleNumberUsersList: false,
764
1011
  showNonCheckedOutDialog: false,
765
1012
  showUnregisteredPlateDialog: false,
766
1013
  returnNfcCard: false,
1014
+ returnPassKeys: false,
1015
+ deletePerson: false,
1016
+ changeContractorType: false,
767
1017
  });
768
1018
 
1019
+ // Success / info messages (errors stay in the line above the footer).
1020
+ const snackbar = reactive({ show: false, text: "", color: "success" });
1021
+ function showSnackbar(text: string, color = "success") {
1022
+ snackbar.text = text;
1023
+ snackbar.color = color;
1024
+ snackbar.show = true;
1025
+ }
1026
+
769
1027
  const loading = reactive({
770
1028
  checkingOut: false,
1029
+ updatingPassKeys: false,
771
1030
  checkingUnregisteredPlate: false,
772
1031
  searchingNonCheckedOut: false,
773
1032
  });
@@ -781,7 +1040,6 @@ const createMore = ref(false);
781
1040
 
782
1041
  const contractorTypeInput = ref("");
783
1042
  const contractorTypeObj = ref<TDefaultOptionObj | null>(null);
784
- const contractorStep = ref(1);
785
1043
 
786
1044
  const companyNameInput = ref("");
787
1045
  const companyNameObj = ref<string | null>(null);
@@ -801,11 +1059,12 @@ const skipNonCheckedOutNric = ref(false);
801
1059
  const nfcCardReturnStatuses = ref<
802
1060
  { cardId: string; cardNo: string; status: string; remarks: string }[]
803
1061
  >([]);
1062
+ // The card statuses as stored (isecure365 card-return-dialog).
804
1063
  const nfcPassStatusOptions = [
1064
+ { label: "In Use", value: "In Use" },
805
1065
  { label: "Returned", value: "Returned" },
806
- { label: "Not Returned", value: "In Use" },
807
- { label: "Damaged", value: "Damage" },
808
1066
  { label: "Lost", value: "Lost" },
1067
+ { label: "Damage", value: "Damage" },
809
1068
  ];
810
1069
  const selectedCheckoutVisitor = computed(() =>
811
1070
  (matchingNonCheckedOutVisitors.value as any[]).find(
@@ -832,9 +1091,10 @@ const unregisteredPlateMatches = ref<TVisitor[]>([]);
832
1091
  const unregisteredPlateSearchText = ref("");
833
1092
  const selectedUnregisteredVisitor = ref<TVisitor | null>(null);
834
1093
  const skipUnregisteredPlateCheck = ref(false);
835
- const pendingUnregisteredAction = ref<"next" | "submit" | null>(null);
836
-
837
- const vehicleNumberUserItems = ref<TPeople[]>([]);
1094
+ const pendingUnregisteredAction = ref<"submit" | null>(null);
1095
+ // The plate "Cancel & Add New" was pressed for: not checked against the
1096
+ // Unregistered list again (a changed plate is).
1097
+ const unregisteredSkippedPlate = ref("");
838
1098
 
839
1099
  const formMode = computed<"add" | "edit" | "register">(() => {
840
1100
  return selectedUnregisteredVisitor.value ? "register" : prop.mode;
@@ -868,36 +1128,35 @@ const isFormField = (
868
1128
 
869
1129
  const shouldShowField = (
870
1130
  fieldKey: keyof TVisitorPayload | "delivery-company"
871
- ) => {
872
- if ((!withStep2.value && !withStep3.value) || contractorStep.value === 1) {
873
- return isFormField(fieldKey);
874
- }
875
- };
1131
+ ) => isFormField(fieldKey);
876
1132
 
877
- const withStep2 = computed(() => {
878
- const step2Types = ["guest", "walk-in"];
879
- return step2Types.includes(prop.type);
880
- });
1133
+ // Saving onto an existing record (registering a camera record, or editing).
1134
+ const isSavingOntoRecord = computed(
1135
+ () => formMode.value === "register" || prop.mode === "edit"
1136
+ );
881
1137
 
882
- const withStep3 = computed(() => {
883
- const step3Types = ["contractor"];
884
- return step3Types.includes(prop.type);
885
- });
1138
+ // Pass & Keys: Drive-In, Walk-In and Contractor, with both pass & key rights.
1139
+ const showPassSection = computed(
1140
+ () =>
1141
+ prop.canAssignPassKeys &&
1142
+ ["guest", "walk-in", "contractor"].includes(prop.type)
1143
+ );
886
1144
 
887
- const showNextButton = computed(() => {
888
- if (withStep3.value) {
889
- return contractorStep.value === 1 || contractorStep.value === 2;
890
- } else if (withStep2.value) {
891
- return contractorStep.value === 1;
892
- } else return false;
893
- });
1145
+ // A contractor's pass type depends on the Contractor Type.
1146
+ const passLockedMessage = computed(() =>
1147
+ prop.type === "contractor" && !visitor.contractorType
1148
+ ? "Please select the Contractor Type first. The pass allowed depends on it."
1149
+ : ""
1150
+ );
894
1151
 
895
1152
  const enableUnregistered = computed(() => {
896
1153
  return prop.mode === "add" && prop.enableUnregistered
897
1154
  });
898
1155
 
1156
+ // Keys are a contractor thing: the security mobile app (isecure365) offers no
1157
+ // keys to a Drive-In / Walk-In and saves them with \`passKeys: []\`.
899
1158
  const showShowKeysField = computed(() => {
900
- return ["guest", "contractor", "walk-in"].includes(prop.type);
1159
+ return prop.type === "contractor";
901
1160
  });
902
1161
 
903
1162
  // Required / optional per type - kept in line with the security mobile app.
@@ -988,76 +1247,107 @@ const hidQrUsage = ref<"wholeDay" | "singleUse" | null>(null);
988
1247
 
989
1248
  const showEntryPassInformation = computed(() => {
990
1249
  return Boolean(
991
- entryPassSettings.value?.data?.settings?.nfcPass ||
1250
+ (prop.type === "contractor" && entryPassSettings.value?.data?.settings?.nfcPass) ||
992
1251
  hidQrCodePassAllowed.value
993
1252
  );
994
1253
  });
995
1254
 
996
1255
  const companyNames = ref<string[]>([]);
997
1256
  const nricSearchMenu = ref(false);
998
- const nricSearchResults = ref<Partial<TPeople>[]>([]);
1257
+ const nricSearchResults = ref<VisitorPerson[]>([]);
999
1258
  const skipNricFetch = ref(false);
1000
1259
  const phoneSearchMenu = ref(false);
1001
- const phoneSearchResults = ref<Partial<TPeople>[]>([]);
1260
+ const phoneSearchResults = ref<VisitorPerson[]>([]);
1002
1261
  const vehicleSearchMenu = ref(false);
1003
- const vehicleSearchResults = ref<Partial<TPeople>[]>([]);
1004
- const selectedNricSearchResult = ref<Partial<TPeople> | null>(null);
1262
+ const vehicleSearchResults = ref<VisitorPerson[]>([]);
1263
+ const selectedNricSearchResult = ref<VisitorPerson | null>(null);
1264
+ // The list Back on the chooser returns to.
1265
+ const chooserResults = ref<VisitorPerson[]>([]);
1005
1266
  const selectedNricContact = ref("");
1006
1267
  const selectedNricPlate = ref("");
1007
1268
 
1008
- type SearchPerson = Omit<Partial<TPeople>, "contact"> & {
1009
- contact?: string | string[];
1010
- contacts?: string | string[];
1011
- plateNumbers?: string | string[];
1012
- plateNumber?: string | string[];
1013
- plates?: { plateNumber?: string }[];
1014
- };
1015
-
1016
- function getPeopleSearchItems(res: any): Partial<TPeople>[] {
1017
- if (Array.isArray(res?.items)) return res.items;
1018
- if (Array.isArray(res?.data)) return res.data;
1019
- if (Array.isArray(res?.data?.items)) return res.data.items;
1020
- if (res?.data) return [res.data];
1021
- if (res && !res?.message) return [res];
1022
- return [];
1269
+ function contractorPassTypeOf(contractorType?: string) {
1270
+ return contractorType === "property-agent" ? "agent-pass" : "contractor-pass";
1023
1271
  }
1024
1272
 
1025
- const formTitle = computed(() => {
1026
- const isContractorForm = prop.type === "contractor";
1027
- const isDriveInForm = prop.type === "guest";
1028
- const isWalkInForm = prop.type === "walk-in";
1029
- const step = contractorStep.value;
1030
- if ((isContractorForm || isDriveInForm || isWalkInForm) && step === 2) {
1031
- return "Pass & Keys Information";
1032
- } else if (isContractorForm && step === 3) {
1033
- return "Members Information";
1034
- } else return "General Information";
1273
+ const PASS_TYPE_LABELS: Record<string, string> = {
1274
+ "contractor-pass": "Contractor Pass",
1275
+ "agent-pass": "Agent Pass",
1276
+ };
1277
+
1278
+ const pendingContractorType = ref<{ title: string; value: string } | null>(null);
1279
+ const contractorTypeChangeMessage = computed(() => {
1280
+ const next = pendingContractorType.value;
1281
+ if (!next) return "";
1282
+ const allowed = PASS_TYPE_LABELS[contractorPassTypeOf(next.value)] ?? "";
1283
+ return `${next.title} takes ${/^[AEIOU]/i.test(allowed) ? "an" : "a"} ${allowed}. The pass(es) already selected are not compatible and will be cleared.`;
1035
1284
  });
1036
1285
 
1037
- function handleSelectContractorType(obj: { title: string; value: string }) {
1038
- visitor.contractorType = obj.value ?? "";
1286
+ function applyContractorType(obj: { title: string; value: string } | null) {
1287
+ contractorTypeObj.value = obj as TDefaultOptionObj | null;
1288
+ visitor.contractorType = obj?.value ?? "";
1039
1289
  // Managing agent staff (site switch): fill in the MA's company if empty.
1040
1290
  if (obj?.value === MA_STAFF_CONTRACTOR_TYPE && !visitor.company) {
1041
1291
  visitor.company = maStaffRegistration.value.company;
1042
1292
  }
1043
1293
  }
1044
1294
 
1045
- function handleFocusedContractorType() {
1046
- // const obj = contractorTypeObj.value;
1047
- // const matched = contractorTypes.some(
1048
- // (x) => x.value === obj?.value && x.title === obj?.title
1049
- // );
1050
- // if (!matched) {
1051
- // contractorTypeObj.value = null;
1052
- // contractorTypeInput.value = ""
1053
- // visitor.contractorType = "";
1054
- // }
1295
+ /**
1296
+ * isecure365: a new Contractor Type that takes a different pass type makes the
1297
+ * passes picked so far (the contractor's and each member's) unusable - asked
1298
+ * first, then cleared. Same pass type: nothing to clear.
1299
+ */
1300
+ function handleSelectContractorType(obj: { title: string; value: string } | string | null) {
1301
+ const next =
1302
+ typeof obj === "string" ? { title: obj, value: obj } : obj ?? null;
1303
+ const value = next?.value ?? "";
1304
+ if (value === visitor.contractorType) {
1305
+ contractorTypeObj.value = next as TDefaultOptionObj | null;
1306
+ return;
1307
+ }
1308
+ const chosen = [
1309
+ ...(visitor.visitorPass ?? []).map((p) => p.keyId),
1310
+ ...(memberInfoRef.value?.pickedPassIds?.() ?? []),
1311
+ ].filter(Boolean);
1312
+ const incompatible =
1313
+ chosen.length > 0 &&
1314
+ Boolean(visitor.contractorType) &&
1315
+ Boolean(value) &&
1316
+ contractorPassTypeOf(visitor.contractorType) !== contractorPassTypeOf(value);
1317
+ if (!incompatible) {
1318
+ applyContractorType(next);
1319
+ return;
1320
+ }
1321
+ pendingContractorType.value = next;
1322
+ dialog.changeContractorType = true;
1323
+ }
1324
+
1325
+ function cancelContractorTypeChange() {
1326
+ pendingContractorType.value = null;
1327
+ dialog.changeContractorType = false;
1328
+ // The picker shows the type kept.
1329
+ const kept = contractorTypeItems.value.find((t) => t.value === visitor.contractorType);
1330
+ contractorTypeObj.value = (kept ?? (visitor.contractorType
1331
+ ? { title: visitor.contractorType, value: visitor.contractorType }
1332
+ : null)) as TDefaultOptionObj | null;
1333
+ }
1334
+
1335
+ function confirmContractorTypeChange() {
1336
+ const next = pendingContractorType.value;
1337
+ pendingContractorType.value = null;
1338
+ dialog.changeContractorType = false;
1339
+ visitor.visitorPass = [];
1340
+ passInfoKey.value++;
1341
+ memberInfoRef.value?.clearPasses?.();
1342
+ applyContractorType(next);
1055
1343
  }
1056
1344
 
1057
1345
  const contractorTypeCombo = ref(null);
1346
+ const memberInfoRef = ref<any>(null);
1347
+ const membersKey = ref(0);
1058
1348
 
1059
1349
  async function handleAddNewContractorType() {
1060
- visitor.contractorType = contractorTypeInput.value;
1350
+ handleSelectContractorType({ title: contractorTypeInput.value, value: contractorTypeInput.value });
1061
1351
 
1062
1352
  const combo = contractorTypeCombo.value as any;
1063
1353
  await nextTick();
@@ -1072,45 +1362,126 @@ async function handleSelectCompanyName(company: string) {
1072
1362
 
1073
1363
  const companyCombo = ref(null);
1074
1364
 
1075
- const {
1076
- data: fetchPersonByNRICReq,
1077
- refresh: fetchPersonByNRICRefresh,
1078
- pending: fetchPersonByNRICPending,
1079
- } = useLazyAsyncData(`fetch-person`, () => {
1080
- const NRIC = visitor.nric;
1081
- if (!NRIC || NRIC.length < 1) return Promise.resolve(null);
1082
-
1083
- const type = prop.type === 'walk-in' ? 'guest' : prop.type
1084
- return findPersonByNRICMultipleResult(NRIC, prop.site, type);
1365
+ /*
1366
+ * People lookup and autofill, as the security mobile app (isecure365 `add.tsx`
1367
+ * lookup / choosePerson / applyPerson):
1368
+ * - typing 4 characters or more of the NRIC, phone (with its country code) or
1369
+ * plate looks the person up, 500 ms after the last keystroke, across EVERY
1370
+ * visitor type (the type being registered first); not for pick-up /
1371
+ * drop-off, which carry no person;
1372
+ * - exactly one person is filled in straight away (or, with several phone
1373
+ * numbers / plates, the chooser opens) - not again for the person that
1374
+ * field already filled;
1375
+ * - on a form opened with a value (registering a camera record), one person
1376
+ * with nothing to choose only fills the EMPTY fields;
1377
+ * - block / level / unit are never filled.
1378
+ */
1379
+ type LookupField = Exclude<AutofillSource, null>;
1380
+ const lookupPending = reactive<Record<LookupField, boolean>>({
1381
+ nric: false,
1382
+ contact: false,
1383
+ vehicleNumber: false,
1085
1384
  });
1385
+ const fetchPersonByNRICPending = computed(() => lookupPending.nric);
1386
+ const autoFilled: Partial<Record<LookupField, string>> = {};
1387
+ const lookupSearched: Partial<Record<LookupField, string>> = {};
1388
+ const lookupTimers: Partial<Record<LookupField, ReturnType<typeof setTimeout>>> = {};
1389
+
1390
+ function lookupTerm(by: LookupField) {
1391
+ if (by === "nric") return visitor.nric?.trim() || "";
1392
+ if (by === "contact") return visitor.contact?.trim() || "";
1393
+ return visitor.plateNumber?.trim()?.toUpperCase() || "";
1394
+ }
1395
+
1396
+ function setLookupResults(by: LookupField, results: VisitorPerson[]) {
1397
+ if (by === "nric") {
1398
+ nricSearchResults.value = results;
1399
+ nricSearchMenu.value = results.length > 0;
1400
+ } else if (by === "contact") {
1401
+ phoneSearchResults.value = results;
1402
+ phoneSearchMenu.value = results.length > 0;
1403
+ } else {
1404
+ vehicleSearchResults.value = results;
1405
+ vehicleSearchMenu.value = results.length > 0;
1406
+ }
1407
+ }
1086
1408
 
1087
- watch(fetchPersonByNRICReq, (obj) => {
1088
- nricSearchResults.value = getPeopleSearchItems(obj);
1089
- nricSearchMenu.value = nricSearchResults.value.length > 0;
1090
-
1091
- // Mock data for testing NRIC autofill options.
1092
- // Uncomment this block and type at least 4 characters in NRIC to test
1093
- // both the multi-option dialog and immediate single-option autofill.
1094
- // nricSearchResults.value = [
1095
- // {
1096
- // _id: "mock-person-multiple-options",
1097
- // name: "James Smith",
1098
- // nric: "1323232499",
1099
- // contacts: ["653939394", "81234567"],
1100
- // plateNumbers: ["34566", "SMA1234A"],
1101
- // companyName: ["Mock Company Pte Ltd"],
1102
- // } as Partial<SearchPerson>,
1103
- // {
1104
- // _id: "mock-person-single-option",
1105
- // name: "Jane Tan",
1106
- // nric: "S1234567A",
1107
- // contacts: ["91234567"],
1108
- // plateNumbers: ["SGB5678B"],
1109
- // companyName: ["Single Option Company"],
1110
- // } as Partial<SearchPerson>,
1111
- // ];
1112
- // nricSearchMenu.value = true;
1113
- });
1409
+ function personLookupAllowed() {
1410
+ return prop.type !== "pick-up" && prop.type !== "drop-off";
1411
+ }
1412
+
1413
+ function scheduleLookup(by: LookupField) {
1414
+ clearTimeout(lookupTimers[by]);
1415
+ if (lookupTerm(by).length < 4) {
1416
+ setLookupResults(by, []);
1417
+ delete autoFilled[by];
1418
+ return;
1419
+ }
1420
+ if (!personLookupAllowed()) return;
1421
+ lookupTimers[by] = setTimeout(() => void lookup(by), 500);
1422
+ }
1423
+
1424
+ /** No choice to make: at most one phone number and one plate (where the form has them). */
1425
+ function fillsWithoutChoice(person: VisitorPerson) {
1426
+ const contactCount = isFormField("contact") ? summaryContacts(person).length : 0;
1427
+ const plateCount =
1428
+ isFormField("plateNumber") && prop.type !== "walk-in" ? summaryPlates(person).length : 0;
1429
+ return contactCount <= 1 && plateCount <= 1;
1430
+ }
1431
+
1432
+ async function lookup(by: LookupField, opts: { prefilled?: boolean } = {}): Promise<boolean> {
1433
+ if (!personLookupAllowed()) return false;
1434
+ const term = lookupTerm(by);
1435
+ if (term.length < 4) {
1436
+ delete autoFilled[by];
1437
+ return false;
1438
+ }
1439
+ lookupSearched[by] = term;
1440
+ try {
1441
+ lookupPending[by] = true;
1442
+ const results = await findVisitorPeopleAnyType(
1443
+ by === "vehicleNumber" ? "plateNumber" : by,
1444
+ term,
1445
+ prop.site,
1446
+ prop.type
1447
+ );
1448
+ // The field changed while this was searching.
1449
+ if (lookupTerm(by) !== lookupSearched[by]) return false;
1450
+ if (!results.length) {
1451
+ setLookupResults(by, []);
1452
+ return false;
1453
+ }
1454
+ const only = results.length === 1 ? results[0] : null;
1455
+ const onlyKey = only ? personKey(only) : "";
1456
+ if (only && autoFilled[by] !== onlyKey) {
1457
+ if (!opts.prefilled) {
1458
+ autoFilled[by] = onlyKey;
1459
+ selectAutofillSearchResult(only, by, [only]);
1460
+ return true;
1461
+ }
1462
+ if (fillsWithoutChoice(only)) {
1463
+ autoFilled[by] = onlyKey;
1464
+ const plates = summaryPlates(only);
1465
+ applyNricSearchResult(only, {
1466
+ contacts: summaryContacts(only)[0] ?? "",
1467
+ plateNumber: prop.type === "walk-in" ? "" : plates[0] ?? "",
1468
+ source: by,
1469
+ emptyOnly: true,
1470
+ });
1471
+ return true;
1472
+ }
1473
+ }
1474
+ setLookupResults(by, results);
1475
+ return true;
1476
+ } catch {
1477
+ // Autofill is a convenience: a failed lookup never blocks the guard from
1478
+ // typing the visitor in by hand, and nothing is shown.
1479
+ if (lookupTerm(by) === lookupSearched[by]) setLookupResults(by, []);
1480
+ return false;
1481
+ } finally {
1482
+ lookupPending[by] = false;
1483
+ }
1484
+ }
1114
1485
 
1115
1486
  watch(
1116
1487
  () => visitor.nric,
@@ -1152,22 +1523,12 @@ function toUniqueStringArray(value: unknown) {
1152
1523
  return [...new Set(rawValues.map((v) => String(v).trim()).filter(Boolean))];
1153
1524
  }
1154
1525
 
1155
- function getPersonContactOptions(item: Partial<TPeople>) {
1156
- const person = item as SearchPerson;
1157
- return toUniqueStringArray(person.contacts || person.contact);
1526
+ function getPersonContactOptions(item: VisitorPerson) {
1527
+ return summaryContacts(item);
1158
1528
  }
1159
1529
 
1160
- function getPersonPlateOptions(item: Partial<TPeople>) {
1161
- const person = item as SearchPerson;
1162
- const visitorPlateNumbers = toUniqueStringArray(person.plateNumbers);
1163
- if (visitorPlateNumbers.length) return visitorPlateNumbers;
1164
-
1165
- const plateNumbers = toUniqueStringArray(person.plateNumber);
1166
- const plates = Array.isArray(person.plates)
1167
- ? person.plates.map((plate) => plate?.plateNumber)
1168
- : [];
1169
-
1170
- return toUniqueStringArray([...plateNumbers, ...plates]);
1530
+ function getPersonPlateOptions(item: VisitorPerson) {
1531
+ return summaryPlates(item).map((plate) => plate.toUpperCase());
1171
1532
  }
1172
1533
 
1173
1534
  function findMatchingContactOption(options: string[], value?: string | null) {
@@ -1203,9 +1564,22 @@ function findMatchingPlateOption(options: string[], value?: string | null) {
1203
1564
  );
1204
1565
  }
1205
1566
 
1567
+ /**
1568
+ * isecure365 `applyPerson`: every field of this form the person record carries
1569
+ * is filled - name, NRIC, phone, plate and company. Only EMPTY fields are
1570
+ * filled, except the one searched by (none with `emptyOnly`); block / level /
1571
+ * unit are never filled; the person's companies saved for THIS visitor type
1572
+ * are offered on Company Name, and one is filled in only when there is exactly
1573
+ * one and the field is empty.
1574
+ */
1206
1575
  function applyNricSearchResult(
1207
- item: Partial<TPeople>,
1208
- options: { contacts?: string; plateNumber?: string; source?: Exclude<AutofillSource, null> } = {}
1576
+ item: VisitorPerson,
1577
+ options: {
1578
+ contacts?: string;
1579
+ plateNumber?: string;
1580
+ source?: LookupField;
1581
+ emptyOnly?: boolean;
1582
+ } = {}
1209
1583
  ) {
1210
1584
  nricSearchMenu.value = false;
1211
1585
  phoneSearchMenu.value = false;
@@ -1216,52 +1590,67 @@ function applyNricSearchResult(
1216
1590
  skipNricFetch.value = true;
1217
1591
 
1218
1592
  currentAutofillSource.value = options.source || "nric";
1593
+ const searchedBy = options.emptyOnly ? null : options.source || "nric";
1219
1594
 
1220
- const shouldAutofillField = (fieldSource?: Exclude<AutofillSource, null>) => {
1221
- if (!fieldSource) return false;
1222
- return currentAutofillSource.value === fieldSource;
1223
- };
1224
1595
  const getAutofillValue = (
1225
1596
  currentValue: string | undefined,
1226
1597
  nextValue: string | undefined,
1227
- fieldSource?: Exclude<AutofillSource, null>
1598
+ fieldSource?: LookupField
1228
1599
  ) => {
1229
1600
  if (!nextValue) return currentValue;
1230
- if (currentValue && !shouldAutofillField(fieldSource)) return currentValue;
1601
+ if (currentValue?.trim() && (!fieldSource || fieldSource !== searchedBy)) return currentValue;
1231
1602
  return nextValue;
1232
1603
  };
1233
1604
 
1234
- visitor.nric = getAutofillValue(visitor.nric, item.nric, "nric");
1235
- visitor.name = getAutofillValue(visitor.name, item.name);
1236
- visitor.contact = getAutofillValue(
1237
- visitor.contact,
1238
- options.contacts || getPersonContactOptions(item)[0],
1239
- "contact"
1240
- );
1241
-
1242
- if(prop.type !== 'walk-in'){
1243
- visitor.plateNumber = getAutofillValue(
1244
- visitor.plateNumber,
1245
- options.plateNumber || getPersonPlateOptions(item)[0],
1246
- "vehicleNumber"
1247
- );
1605
+ if (isFormField("nric")) {
1606
+ const nric = getAutofillValue(visitor.nric, item.nric, "nric");
1607
+ visitor.nric = (nric ?? "").replace(/[^A-Za-z0-9]/g, "").toUpperCase();
1248
1608
  }
1249
-
1250
- companyNames.value = Array.isArray(item.companyName) ? item.companyName : [];
1251
-
1252
- if (!visitor.company && item.companyName && item.companyName.length === 1) {
1253
- visitor.company = item.companyName?.[0] || visitor.company;
1609
+ if (isFormField("name")) visitor.name = getAutofillValue(visitor.name, item.name) ?? "";
1610
+ if (isFormField("contact")) {
1611
+ visitor.contact =
1612
+ getAutofillValue(
1613
+ visitor.contact,
1614
+ options.contacts || getPersonContactOptions(item)[0],
1615
+ "contact"
1616
+ ) ?? "";
1254
1617
  }
1255
1618
 
1256
- if (!visitor.company && item.companyName && item.companyName.length > 1) {
1257
- visitor.company = "";
1619
+ // A walk-in has no plate.
1620
+ if (prop.type !== "walk-in" && isFormField("plateNumber")) {
1621
+ visitor.plateNumber =
1622
+ (getAutofillValue(
1623
+ visitor.plateNumber,
1624
+ options.plateNumber || getPersonPlateOptions(item)[0],
1625
+ "vehicleNumber"
1626
+ ) ?? "").toUpperCase();
1258
1627
  }
1259
1628
 
1260
-
1261
- // visitor.block = (item.block as any) ?? (visitor.block as any) ?? "";
1262
- // visitor.level = (item.level as any) ?? (visitor.level as any) ?? "";
1263
- // visitor.unit = item.unit ?? visitor.unit ?? "";
1264
- // visitor.unitName = item.unitName ?? visitor.unitName;
1629
+ // Only the companies saved from registrations of THIS type. A delivery is
1630
+ // only filled with a company still on the site's delivery list.
1631
+ const companies = companiesForType(item, prop.type);
1632
+ if (prop.type === "delivery") {
1633
+ companyNames.value = [];
1634
+ // Needs the site's list; read now if the input was not clicked yet.
1635
+ void ensureDeliveryCompanies().then(() => {
1636
+ const fitting = [
1637
+ ...new Set(
1638
+ companies
1639
+ .filter((name) => isListedCompany(deliveryCompanyList.value, name))
1640
+ .map((name) => listedName(deliveryCompanyList.value, name))
1641
+ ),
1642
+ ];
1643
+ if (isFormField("delivery-company") && !deliveryCompany.value?.trim() && fitting.length === 1) {
1644
+ deliveryCompany.value = fitting[0];
1645
+ }
1646
+ });
1647
+ } else {
1648
+ const hasCompany = isFormField("company");
1649
+ companyNames.value = hasCompany ? [...new Set(companies)] : [];
1650
+ if (hasCompany && !visitor.company?.trim() && companies.length === 1) {
1651
+ visitor.company = companies[0];
1652
+ }
1653
+ }
1265
1654
 
1266
1655
  setTimeout(() => {
1267
1656
  currentAutofillSource.value = null;
@@ -1269,28 +1658,47 @@ function applyNricSearchResult(
1269
1658
  }, 1000);
1270
1659
  }
1271
1660
 
1272
- function selectNricSearchResult(item: Partial<TPeople>) {
1661
+ function selectNricSearchResult(item: VisitorPerson) {
1273
1662
  selectAutofillSearchResult(item, "nric");
1274
1663
  }
1275
- function selectPhoneNumberSearchResult(item: Partial<TPeople>) {
1664
+ function selectPhoneNumberSearchResult(item: VisitorPerson) {
1276
1665
  selectAutofillSearchResult(item, "contact");
1277
1666
  }
1278
- function selectPlateNumberSearchResult(item: Partial<TPeople>) {
1667
+ function selectPlateNumberSearchResult(item: VisitorPerson) {
1279
1668
  selectAutofillSearchResult(item, "vehicleNumber");
1280
1669
  }
1281
1670
 
1282
- function selectAutofillSearchResult(item: Partial<TPeople>, source: Exclude<AutofillSource, null>) {
1671
+ /** Fills the person in, or opens the chooser when there is a phone / plate to pick. */
1672
+ function selectAutofillSearchResult(
1673
+ item: VisitorPerson,
1674
+ source: LookupField,
1675
+ results?: VisitorPerson[]
1676
+ ) {
1283
1677
  const contactOptions = getPersonContactOptions(item);
1284
- const plateOptions = getPersonPlateOptions(item);
1678
+ const plateOptions = prop.type === "walk-in" ? [] : getPersonPlateOptions(item);
1285
1679
  const selectedContact =
1286
1680
  findMatchingContactOption(contactOptions, visitor.contact) || contactOptions[0] || "";
1287
1681
  const selectedPlate =
1288
1682
  findMatchingPlateOption(plateOptions, visitor.plateNumber) || plateOptions[0] || "";
1289
1683
 
1290
- const needsContactChoice = (!visitor.contact?.trim() || source === 'contact') && contactOptions.length > 1;
1291
- const needsPlateChoice = (!visitor.plateNumber?.trim() || source === 'vehicleNumber') && plateOptions.length > 1;
1684
+ const needsContactChoice =
1685
+ isFormField("contact") &&
1686
+ (!visitor.contact?.trim() || source === "contact") &&
1687
+ contactOptions.length > 1;
1688
+ const needsPlateChoice =
1689
+ prop.type !== "walk-in" &&
1690
+ isFormField("plateNumber") &&
1691
+ (!visitor.plateNumber?.trim() || source === "vehicleNumber") &&
1692
+ plateOptions.length > 1;
1292
1693
 
1293
1694
  if (needsContactChoice || needsPlateChoice) {
1695
+ chooserResults.value =
1696
+ results ??
1697
+ (source === "nric"
1698
+ ? nricSearchResults.value
1699
+ : source === "contact"
1700
+ ? phoneSearchResults.value
1701
+ : vehicleSearchResults.value);
1294
1702
  nricSearchMenu.value = false;
1295
1703
  phoneSearchMenu.value = false;
1296
1704
  vehicleSearchMenu.value = false;
@@ -1309,7 +1717,7 @@ function selectAutofillSearchResult(item: Partial<TPeople>, source: Exclude<Auto
1309
1717
  });
1310
1718
  }
1311
1719
 
1312
- function closeAutofillSearchResults(source: Exclude<AutofillSource, null>) {
1720
+ function closeAutofillSearchResults(source: LookupField) {
1313
1721
  if (source === "nric") {
1314
1722
  nricSearchMenu.value = false;
1315
1723
  nricSearchResults.value = [];
@@ -1333,9 +1741,18 @@ function closeNricAutofillOptions() {
1333
1741
  selectedNricPlate.value = "";
1334
1742
  }
1335
1743
 
1336
- function confirmNricAutofillOptions() {
1337
- if (!selectedNricSearchResult.value) return;
1338
-
1744
+ // Back: the chooser closes and the result list it came from shows again.
1745
+ function chooserBack() {
1746
+ const source = currentAutofillSource.value || "nric";
1747
+ const results = chooserResults.value;
1748
+ closeNricAutofillOptions();
1749
+ currentAutofillSource.value = null;
1750
+ if (results.length) setLookupResults(source, results);
1751
+ }
1752
+
1753
+ function confirmNricAutofillOptions() {
1754
+ if (!selectedNricSearchResult.value) return;
1755
+
1339
1756
  applyNricSearchResult(selectedNricSearchResult.value, {
1340
1757
  contacts: selectedNricContact.value,
1341
1758
  plateNumber: selectedNricPlate.value,
@@ -1344,6 +1761,40 @@ function confirmNricAutofillOptions() {
1344
1761
  closeNricAutofillOptions();
1345
1762
  }
1346
1763
 
1764
+ // Delete on a match card: for a person saved with a wrong NRIC.
1765
+ const personToDelete = ref<VisitorPerson | null>(null);
1766
+ const deletingPerson = ref(false);
1767
+
1768
+ function askDeletePerson(item: VisitorPerson) {
1769
+ personToDelete.value = item;
1770
+ dialog.deletePerson = true;
1771
+ }
1772
+
1773
+ async function deletePersonConfirmed() {
1774
+ const person = personToDelete.value;
1775
+ if (!person) return;
1776
+ deletingPerson.value = true;
1777
+ try {
1778
+ await deletePersonRecords(person);
1779
+ const ids = new Set(recordIdsOf(person));
1780
+ const keep = (list: VisitorPerson[]) =>
1781
+ list.filter((p) => !recordIdsOf(p).some((id) => ids.has(id)));
1782
+ setLookupResults("nric", keep(nricSearchResults.value));
1783
+ setLookupResults("contact", keep(phoneSearchResults.value));
1784
+ setLookupResults("vehicleNumber", keep(vehicleSearchResults.value));
1785
+ dialog.deletePerson = false;
1786
+ personToDelete.value = null;
1787
+ showSnackbar(PERSON_DELETED_MESSAGE);
1788
+ } catch (error: any) {
1789
+ showSnackbar(
1790
+ error?.data?.message || "Unable to delete the saved details. Please try again.",
1791
+ "error"
1792
+ );
1793
+ } finally {
1794
+ deletingPerson.value = false;
1795
+ }
1796
+ }
1797
+
1347
1798
  function getVisitorSearchItems(res: any): TVisitor[] {
1348
1799
  const items =
1349
1800
  res?.items ||
@@ -1379,12 +1830,36 @@ function formatNonCheckedOutDate(date?: string) {
1379
1830
  return date ? UTCToLocalTIme(date) : "N/A";
1380
1831
  }
1381
1832
 
1833
+ /*
1834
+ * "Already inside" (isecure365 `blockedByOpenVisit` / `addMember`): visitors
1835
+ * matching the NRIC or phone who never checked out. For the visitor being
1836
+ * saved, a match stops the save; once the list is clear (Skip NRIC, or each
1837
+ * one checked out here) the save carries on by itself. For a contractor
1838
+ * member, the member is added once the list is clear.
1839
+ */
1840
+ const nonCheckedOutFor = reactive<{ kind: "lead" | "member"; nric: string; contact: string }>({
1841
+ kind: "lead",
1842
+ nric: "",
1843
+ contact: "",
1844
+ });
1845
+
1846
+ async function findOpenVisits(nric: string | null, contact: string | null) {
1847
+ const res = await searchVisitorObject({
1848
+ page: 1,
1849
+ limit: 1000,
1850
+ site: prop.site,
1851
+ searchObject: { nric, contact },
1852
+ notCheckedOut: true,
1853
+ });
1854
+ return getVisitorSearchItems(res);
1855
+ }
1856
+
1382
1857
  async function checkNonCheckedOutVisitorsBeforeSubmit(
1383
1858
  options: { skipNric?: boolean; showDialog?: boolean } = {}
1384
1859
  ) {
1385
1860
  const { skipNric = skipNonCheckedOutNric.value, showDialog = true } = options;
1386
- const nric = skipNric ? null : visitor.nric || null;
1387
- const contact = visitor.contact || null;
1861
+ const nric = skipNric || !isFormField("nric") ? null : visitor.nric || null;
1862
+ const contact = isFormField("contact") ? visitor.contact || null : null;
1388
1863
 
1389
1864
  if (!nric && !contact) {
1390
1865
  matchingNonCheckedOutVisitors.value = [];
@@ -1394,18 +1869,13 @@ async function checkNonCheckedOutVisitorsBeforeSubmit(
1394
1869
 
1395
1870
  try {
1396
1871
  loading.searchingNonCheckedOut = true;
1397
- const res = await searchVisitorObject({
1398
- page: 1,
1399
- limit: 1000,
1400
- site: prop.site,
1401
- searchObject: {
1402
- nric,
1403
- contact,
1404
- },
1405
- notCheckedOut: true,
1406
- });
1407
- const items = getVisitorSearchItems(res);
1872
+ const items = await findOpenVisits(nric, contact);
1408
1873
  matchingNonCheckedOutVisitors.value = items;
1874
+ if (items.length) {
1875
+ nonCheckedOutFor.kind = "lead";
1876
+ nonCheckedOutFor.nric = "";
1877
+ nonCheckedOutFor.contact = "";
1878
+ }
1409
1879
  dialog.showNonCheckedOutDialog = showDialog && items.length > 0;
1410
1880
  return items.length === 0;
1411
1881
  } catch (error: any) {
@@ -1419,6 +1889,26 @@ async function checkNonCheckedOutVisitorsBeforeSubmit(
1419
1889
  }
1420
1890
  }
1421
1891
 
1892
+ /** Passed to MemberInformation: any open visit of the member being added? */
1893
+ async function checkMemberOpenVisits(member: { nric: string; contact: string }) {
1894
+ try {
1895
+ const items = await findOpenVisits(member.nric || null, member.contact || null);
1896
+ if (!items.length) return true;
1897
+ nonCheckedOutFor.kind = "member";
1898
+ nonCheckedOutFor.nric = member.nric;
1899
+ nonCheckedOutFor.contact = member.contact;
1900
+ matchingNonCheckedOutVisitors.value = items;
1901
+ dialog.showNonCheckedOutDialog = true;
1902
+ return false;
1903
+ } catch {
1904
+ // Reported, and with nothing listed the member is added (isecure365).
1905
+ showSnackbar("Unable to check unchecked-out visitors. Please try again.", "error");
1906
+ return true;
1907
+ }
1908
+ }
1909
+
1910
+ // "Skip NRIC": from now on the check matches the phone number only; when the
1911
+ // list is clear the dialog closes and the save carries on by itself.
1422
1912
  async function handleSkipNricNonCheckedOut() {
1423
1913
  skipNonCheckedOutNric.value = true;
1424
1914
 
@@ -1427,6 +1917,56 @@ async function handleSkipNricNonCheckedOut() {
1427
1917
  });
1428
1918
  if (!canSubmit) return;
1429
1919
  dialog.showNonCheckedOutDialog = false;
1920
+ await submit();
1921
+ }
1922
+
1923
+ // The member dialog's "SKIP NRIC": the member is added as entered and the
1924
+ // form submitted, the visitor's own check matching the phone number only.
1925
+ async function handleSkipMemberNric() {
1926
+ dialog.showNonCheckedOutDialog = false;
1927
+ matchingNonCheckedOutVisitors.value = [];
1928
+ memberInfoRef.value?.commitDraft?.();
1929
+ skipNonCheckedOutNric.value = true;
1930
+ await submit();
1931
+ }
1932
+
1933
+ // After a check-out from the list: read again; once clear, carry on.
1934
+ async function refreshOpenVisitsAfterCheckout() {
1935
+ if (nonCheckedOutFor.kind === "member") {
1936
+ dialog.showNonCheckedOutDialog = false;
1937
+ await memberInfoRef.value?.addMember?.();
1938
+ return;
1939
+ }
1940
+ const clear = await checkNonCheckedOutVisitorsBeforeSubmit();
1941
+ if (clear) {
1942
+ dialog.showNonCheckedOutDialog = false;
1943
+ await submit({ skipNonCheckedOutCheck: true });
1944
+ }
1945
+ }
1946
+
1947
+ function heldNfcCards(v: any) {
1948
+ const cards: { _id: string; cardNo: string }[] = [];
1949
+ if (v?.accessCards?.type === "NFC" && v?.accessCards?.cardNo) cards.push(v.accessCards);
1950
+ const primaryId = v?.accessCards?._id?.toString();
1951
+ for (const card of ((v?.cards as any[]) || []).flat()) {
1952
+ if (card?.type && card.type !== "NFC") continue;
1953
+ if (card?.cardNo && card?._id?.toString() !== primaryId) cards.push(card);
1954
+ }
1955
+ return cards;
1956
+ }
1957
+
1958
+ function passEntries(value: unknown): any[] {
1959
+ if (Array.isArray(value)) return value.filter((e) => e && typeof e === "object");
1960
+ return [];
1961
+ }
1962
+
1963
+ /** An active pass (not "Removed"), a pass id string, or any key not "Removed". */
1964
+ function holdsPassOrKeys(v: any) {
1965
+ return (
1966
+ passEntries(v?.visitorPass).some((p) => p.status !== "Removed") ||
1967
+ (typeof v?.visitorPass === "string" && Boolean(v.visitorPass)) ||
1968
+ passEntries(v?.passKeys).some((k) => k.status !== "Removed")
1969
+ );
1430
1970
  }
1431
1971
 
1432
1972
  function handleCheckout(visitorId: string) {
@@ -1437,12 +1977,10 @@ function handleCheckout(visitorId: string) {
1437
1977
 
1438
1978
  selectedCheckoutVisitorId.value = visitorId;
1439
1979
 
1440
- const visitor = (matchingNonCheckedOutVisitors.value as any[]).find(
1441
- (v) => v?._id === visitorId
1442
- );
1443
- const nfcCards = ((visitor?.cards as any[]) || []).filter(
1444
- (card) => card?.type === "NFC"
1980
+ const v = (matchingNonCheckedOutVisitors.value as any[]).find(
1981
+ (item) => item?._id === visitorId
1445
1982
  );
1983
+ const nfcCards = heldNfcCards(v);
1446
1984
 
1447
1985
  if (nfcCards.length > 0) {
1448
1986
  nfcCardReturnStatuses.value = nfcCards.map((card: any) => ({
@@ -1455,21 +1993,34 @@ function handleCheckout(visitorId: string) {
1455
1993
  return;
1456
1994
  }
1457
1995
 
1996
+ if (holdsPassOrKeys(v)) {
1997
+ openPassKeyReturn(v);
1998
+ return;
1999
+ }
2000
+
1458
2001
  proceedNonCheckedOutCheckout(visitorId);
1459
2002
  }
1460
2003
 
1461
- async function proceedNonCheckedOutCheckout(visitorId: string) {
2004
+ async function proceedNonCheckedOutCheckout(
2005
+ visitorId: string,
2006
+ extra: Record<string, unknown> = {}
2007
+ ) {
1462
2008
  try {
1463
2009
  loading.checkingOut = true;
1464
2010
  const res = await updateVisitor(visitorId as string, {
1465
2011
  checkOut: new Date().toISOString(),
1466
- });
2012
+ ...(currentUser.value?._id ? { updatedBy: currentUser.value._id } : {}),
2013
+ ...extra,
2014
+ } as Partial<TVisitorPayload>);
1467
2015
  if (res) {
1468
2016
  dialog.returnNfcCard = false;
1469
- await checkNonCheckedOutVisitorsBeforeSubmit();
2017
+ dialog.returnPassKeys = false;
2018
+ selectedCheckoutVisitorId.value = null;
2019
+ loading.checkingOut = false;
2020
+ await refreshOpenVisitsAfterCheckout();
1470
2021
  }
1471
2022
  } catch (error: any) {
1472
- const checkoutErrorMessage = error?.response?._data?.message;
2023
+ const checkoutErrorMessage = error?.data?.message || error?.response?._data?.message;
1473
2024
  console.error("[ERROR]", error);
1474
2025
  errorMessage.value =
1475
2026
  checkoutErrorMessage || "An error occurred while checking out the vehicle.";
@@ -1494,35 +2045,194 @@ async function handleNfcReturnCheckout() {
1494
2045
  })),
1495
2046
  });
1496
2047
  if (res) {
2048
+ dialog.returnNfcCard = false;
2049
+ const v = (matchingNonCheckedOutVisitors.value as any[]).find(
2050
+ (item) => item?._id === visitorId
2051
+ );
2052
+ // Cards back: a pass or key still held is recorded next.
2053
+ if (holdsPassOrKeys(v)) {
2054
+ loading.checkingOut = false;
2055
+ openPassKeyReturn(v);
2056
+ return;
2057
+ }
2058
+ loading.checkingOut = false;
1497
2059
  await proceedNonCheckedOutCheckout(visitorId);
1498
2060
  }
1499
2061
  } catch (error: any) {
1500
- const checkoutErrorMessage = error?.response?._data?.message;
2062
+ const checkoutErrorMessage = error?.data?.message || error?.response?._data?.message;
1501
2063
  console.error("[ERROR]", error);
1502
- errorMessage.value =
1503
- checkoutErrorMessage || "An error occurred while returning the NFC card.";
2064
+ errorMessage.value = checkoutErrorMessage || "Failed to checkout visitor.";
1504
2065
  } finally {
1505
2066
  loading.checkingOut = false;
1506
2067
  }
1507
2068
  }
1508
2069
 
1509
- function handleAutofillDataViaVehicleNumber(item: TPeople) {
1510
- currentAutofillSource.value = "vehicleNumber";
2070
+ // Pass & key return before the check-out (isecure365 `heldForReturn`,
2071
+ // `checkOutBlocked`, `buildReturnPayload`).
2072
+ const PASS_RETURN_STATUSES = ["Not Returned", "Returned", "Damaged", "Lost"];
2073
+ type PassReturnEntry = Record<string, any> & {
2074
+ keyId: string;
2075
+ status: string;
2076
+ remarks: string;
2077
+ locked?: boolean;
2078
+ };
2079
+ const passReturn = reactive<{ visitorId: string; passes: PassReturnEntry[]; keys: PassReturnEntry[] }>({
2080
+ visitorId: "",
2081
+ passes: [],
2082
+ keys: [],
2083
+ });
2084
+ const passReturnVisitor = ref<Record<string, any> | null>(null);
2085
+ const passReturnInitial = ref("");
2086
+ const passReturnError = ref("");
2087
+ const passReturnScan = reactive({ open: false, value: "" });
2088
+ // Names of passes / keys whose entry carries none (looked up by id).
2089
+ const passKeyNames = reactive<Record<string, string>>({});
2090
+
2091
+ // Keys are listed for a contractor only (every key still counts for Check Out).
2092
+ const passReturnSections = computed(() => [
2093
+ {
2094
+ title: "Pass",
2095
+ empty: "No current pass assigned.",
2096
+ icon: "mdi-card-bulleted-outline",
2097
+ color: "blue",
2098
+ fallback: "Pass",
2099
+ show: true,
2100
+ items: passReturn.passes,
2101
+ },
2102
+ {
2103
+ title: "Keys",
2104
+ empty: "No current key(s) assigned.",
2105
+ icon: "mdi-key",
2106
+ color: "orange",
2107
+ fallback: "Key",
2108
+ show: passReturnVisitor.value?.type === "contractor",
2109
+ items: passReturn.keys,
2110
+ },
2111
+ ]);
2112
+
2113
+ function passReturnSignature() {
2114
+ const sig = (entries: PassReturnEntry[]) =>
2115
+ entries.map((e) => `${e.keyId}|${e.status}|${String(e.remarks ?? "").trim()}`).join(",");
2116
+ return `${sig(passReturn.passes)}#${sig(passReturn.keys)}`;
2117
+ }
1511
2118
 
1512
- visitor.name = item.name;
1513
- visitor.nric = item.nric;
1514
- visitor.contact = item.contact;
1515
- companyNames.value = item.companyName ?? [];
1516
- if (!visitor.company) {
1517
- visitor.company = companyNames.value?.[0];
2119
+ const passReturnChanged = computed(() => passReturnSignature() !== passReturnInitial.value);
2120
+
2121
+ /** Never holds the dialog back: names fill in when they arrive. */
2122
+ async function loadPassKeyNames(ids: string[]) {
2123
+ const missing = ids.filter((id) => id && !passKeyNames[id]);
2124
+ if (!missing.length) return;
2125
+ try {
2126
+ const res: any = await getPassKeysByPageSearch({ _ids: missing, page: 1, limit: missing.length, sites: [prop.site] });
2127
+ const items: any[] = res?.items ?? res?.data?.items ?? [];
2128
+ for (const item of items) {
2129
+ if (item?._id && item?.prefixAndName) passKeyNames[item._id] = item.prefixAndName;
2130
+ }
2131
+ } catch {
2132
+ // The chip falls back to "Pass" / "Key".
1518
2133
  }
1519
- visitor.block = item.block ?? "";
1520
- visitor.level = item.level ?? "";
1521
- visitor.unit = item.unit ?? "";
2134
+ }
1522
2135
 
1523
- setTimeout(() => {
1524
- currentAutofillSource.value = null;
1525
- }, 1000);
2136
+ function openPassKeyReturn(v: any) {
2137
+ const start = (entry: any): PassReturnEntry => {
2138
+ const known = [...PASS_RETURN_STATUSES, "Removed"];
2139
+ const status =
2140
+ entry.status && entry.status !== "In Use" && known.includes(entry.status)
2141
+ ? entry.status
2142
+ : "Not Returned";
2143
+ return { ...entry, keyId: String(entry.keyId ?? ""), status, remarks: entry.remarks ?? "", locked: status === "Removed" };
2144
+ };
2145
+ passReturnVisitor.value = v ?? null;
2146
+ passReturn.visitorId = v?._id ?? "";
2147
+ passReturn.passes = passEntries(v?.visitorPass).filter((p) => Boolean(p.keyId)).map(start);
2148
+ passReturn.keys = passEntries(v?.passKeys).map(start);
2149
+ passReturnInitial.value = passReturnSignature();
2150
+ passReturnError.value = "";
2151
+ selectedCheckoutVisitorId.value = passReturn.visitorId;
2152
+ dialog.returnPassKeys = true;
2153
+ void loadPassKeyNames(
2154
+ [...passReturn.passes, ...passReturn.keys].filter((e) => !e.prefixAndName).map((e) => e.keyId)
2155
+ );
2156
+ }
2157
+
2158
+ // "Scan QR Code": the scanned pass or key is marked Returned.
2159
+ watch(
2160
+ () => passReturnScan.value,
2161
+ (value) => {
2162
+ const scanned = String(value ?? "").trim().toLowerCase();
2163
+ if (!scanned) return;
2164
+ passReturnScan.value = "";
2165
+ passReturnScan.open = false;
2166
+ const entry = [...passReturn.passes, ...passReturn.keys].find(
2167
+ (e) => !e.locked && String(e.prefixAndName || passKeyNames[e.keyId] || "").toLowerCase() === scanned
2168
+ );
2169
+ if (!entry) {
2170
+ passReturnError.value = `"${value}" is not a pass or key this visitor holds.`;
2171
+ return;
2172
+ }
2173
+ entry.status = "Returned";
2174
+ passReturnError.value = "";
2175
+ }
2176
+ );
2177
+
2178
+ function passReturnRemarksProblem() {
2179
+ return [...passReturn.passes, ...passReturn.keys].some(
2180
+ (e) => ["Damaged", "Lost"].includes(e.status) && !String(e.remarks ?? "").trim()
2181
+ )
2182
+ ? "Remarks cannot be empty"
2183
+ : "";
2184
+ }
2185
+
2186
+ // "Update": the statuses and remarks only, then the dialog closes.
2187
+ async function handlePassKeyReturnUpdate() {
2188
+ passReturnError.value = passReturnRemarksProblem();
2189
+ if (passReturnError.value || !passReturn.visitorId) return;
2190
+ try {
2191
+ loading.updatingPassKeys = true;
2192
+ await updateVisitor(passReturn.visitorId, {
2193
+ visitorPass: passReturn.passes.map(forServer),
2194
+ passKeys: passReturn.keys.map(forServer),
2195
+ ...(currentUser.value?._id ? { updatedBy: currentUser.value._id } : {}),
2196
+ } as Partial<TVisitorPayload>);
2197
+ const v = (matchingNonCheckedOutVisitors.value as any[]).find((item) => item?._id === passReturn.visitorId);
2198
+ if (v) {
2199
+ v.visitorPass = passReturn.passes.map(forServer);
2200
+ v.passKeys = passReturn.keys.map(forServer);
2201
+ }
2202
+ dialog.returnPassKeys = false;
2203
+ showSnackbar("Successfully updated.");
2204
+ } catch (error: any) {
2205
+ passReturnError.value = error?.data?.message || "Unable to update the pass or key(s). Please try again.";
2206
+ } finally {
2207
+ loading.updatingPassKeys = false;
2208
+ }
2209
+ }
2210
+
2211
+ const canCheckOutAfterReturn = computed(() => {
2212
+ const all = [...passReturn.passes, ...passReturn.keys];
2213
+ return all.every(
2214
+ (entry) =>
2215
+ ["Returned", "Damaged", "Lost", "Removed"].includes(entry.status) &&
2216
+ (!["Damaged", "Lost"].includes(entry.status) || Boolean(entry.remarks?.trim()))
2217
+ );
2218
+ });
2219
+
2220
+ function forServer(entry: PassReturnEntry) {
2221
+ const rest: Record<string, unknown> = { ...entry };
2222
+ for (const key of ["lastUpdate", "description", "templatePrefixPass", "prefixAndName", "add", "locked"]) {
2223
+ delete rest[key];
2224
+ }
2225
+ return rest;
2226
+ }
2227
+
2228
+ async function handlePassKeyReturnCheckout() {
2229
+ passReturnError.value = passReturnRemarksProblem();
2230
+ if (passReturnError.value) return;
2231
+ if (!passReturn.visitorId || !canCheckOutAfterReturn.value) return;
2232
+ await proceedNonCheckedOutCheckout(passReturn.visitorId, {
2233
+ visitorPass: passReturn.passes.map(forServer),
2234
+ passKeys: passReturn.keys.map(forServer),
2235
+ });
1526
2236
  }
1527
2237
 
1528
2238
  const { data: entryPassSettings, pending: entryPassSettingsPending } =
@@ -1662,20 +2372,43 @@ const {
1662
2372
  { watch: [() => visitor.level], deep: true }
1663
2373
  );
1664
2374
 
1665
- watch(
1666
- siteData,
1667
- (newVal) => {
1668
- const siteDataValue = newVal as TSite;
1669
- if (siteDataValue) {
1670
- deliveryCompanyList.value = Array.isArray(
1671
- siteDataValue?.deliveryCompanyList
1672
- )
1673
- ? siteDataValue.deliveryCompanyList
2375
+ /*
2376
+ * The delivery company list is read when the Company Name input is clicked,
2377
+ * not when the form opens - each click reads the site's latest list. It is
2378
+ * also read (once) when something needs it before then: an autofill company,
2379
+ * or the save's listed-spelling check.
2380
+ */
2381
+ const deliveryCompaniesLoaded = ref(false);
2382
+ const deliveryCompaniesLoading = ref(false);
2383
+ let deliveryCompaniesRequest: Promise<void> | null = null;
2384
+
2385
+ function loadDeliveryCompanies(): Promise<void> {
2386
+ if (deliveryCompaniesRequest) return deliveryCompaniesRequest;
2387
+ deliveryCompaniesLoading.value = true;
2388
+ deliveryCompaniesRequest = (async () => {
2389
+ try {
2390
+ const site = (await getSiteById(prop.site)) as any;
2391
+ deliveryCompanyList.value = Array.isArray(site?.deliveryCompanyList)
2392
+ ? site.deliveryCompanyList
1674
2393
  : [];
2394
+ deliveryCompaniesLoaded.value = true;
2395
+ } catch {
2396
+ // The list stays as it was; typing a company still works.
2397
+ } finally {
2398
+ deliveryCompaniesLoading.value = false;
2399
+ deliveryCompaniesRequest = null;
1675
2400
  }
1676
- },
1677
- { immediate: true }
1678
- );
2401
+ })();
2402
+ return deliveryCompaniesRequest;
2403
+ }
2404
+
2405
+ async function ensureDeliveryCompanies() {
2406
+ if (!deliveryCompaniesLoaded.value) await loadDeliveryCompanies();
2407
+ }
2408
+
2409
+ function handleDeliveryCompanyFocus(focused: boolean) {
2410
+ if (focused) void loadDeliveryCompanies();
2411
+ }
1679
2412
 
1680
2413
  watch(
1681
2414
  [blocksListData],
@@ -1786,87 +2519,51 @@ function handleUpdateUnit(value: any) {
1786
2519
  visitor.unitName = selectedUnit?.title || "";
1787
2520
  }
1788
2521
 
1789
- const debounceFetchNRIC = debounce(fetchPersonByNRICRefresh, 500);
2522
+ // NRIC Scan (security mobile app `onNricRead`): the NRIC - and the name, when
2523
+ // it was read - filled in as if typed, so the people lookup and the "Skip
2524
+ // NRIC" reset run as they do for a typed NRIC.
2525
+ const nricScanOpen = ref(false);
2526
+ const nricScanNotice = ref("");
2527
+
2528
+ function handleNricScanRead({ nric, name }: { nric: string; name: string | null }) {
2529
+ nricScanOpen.value = false;
2530
+ const next = String(nric || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2531
+ if (!next) return;
2532
+ visitor.nric = next;
2533
+ const fillName = Boolean(name) && isFormField("name");
2534
+ if (fillName) visitor.name = name ?? "";
2535
+ nricScanNotice.value = fillName
2536
+ ? "NRIC and name filled in from the card. Please check them."
2537
+ : "NRIC filled in from the card. Please type the name.";
2538
+ if (next.length >= 4) void lookup("nric");
2539
+ }
1790
2540
 
1791
2541
  function handleUpdateNRIC() {
2542
+ nricScanNotice.value = "";
1792
2543
  if (skipNricFetch.value) return;
1793
2544
  if (!isNricFieldFocused.value) return;
1794
- if (visitor.nric && visitor.nric?.length < 4) return;
1795
- if(prop.type === 'walk-in' || prop.type === 'drop-off') return;
1796
- debounceFetchNRIC();
2545
+ scheduleLookup("nric");
1797
2546
  }
1798
2547
 
1799
-
1800
- const {
1801
- data: fetchPersonByPhoneNumber,
1802
- refresh: fetchPersonByPhoneNumberRefresh,
1803
- pending: fetchPersonByPhoneNumberPending,
1804
- } = useLazyAsyncData(`fetch-person-phone-number`, () => {
1805
- const contact = visitor.contact;
1806
- if (!contact || contact.length < 4) return Promise.resolve(null);
1807
- const type = prop.type === 'walk-in' ? 'guest' : prop.type
1808
- return findPersonByPhoneNumberMultipleResult(contact, prop.site, type);
1809
- });
1810
-
1811
- watch(fetchPersonByPhoneNumber, (obj) => {
1812
- phoneSearchResults.value = getPeopleSearchItems(obj);
1813
- phoneSearchMenu.value = phoneSearchResults.value.length > 0;
1814
- });
1815
-
1816
- const debounceFetchPhoneNumber = debounce(fetchPersonByPhoneNumberRefresh, 500);
1817
-
1818
2548
  function handleUpdatePhoneNumber() {
2549
+ if (skipNricFetch.value) return;
1819
2550
  if (!isPhoneFieldFocused.value) return;
1820
-
1821
- const search = visitor.contact?.trim() || "";
1822
- if (search.length < 4) {
1823
- phoneSearchResults.value = [];
1824
- phoneSearchMenu.value = false;
1825
- return;
1826
- }
1827
- if(prop.type === 'walk-in' || prop.type === 'drop-off') return;
1828
-
1829
- debounceFetchPhoneNumber();
2551
+ scheduleLookup("contact");
1830
2552
  }
1831
2553
 
1832
- const {
1833
- data: fetchPersonByPlateNumber,
1834
- refresh: fetchPersonByPlateNumberRefresh,
1835
- pending: fetchPersonByPlateNumberPending,
1836
- } = useLazyAsyncData(`fetch-person-plate-number`, () => {
1837
- const plateNumber = visitor.plateNumber?.trim()?.toUpperCase() || "";
1838
- if (plateNumber.length < 4) return Promise.resolve(null);
1839
- const type = prop.type === 'walk-in' ? 'guest' : prop.type
1840
- return findPersonByPlateNumberMultipleResult(plateNumber, prop.site, type);
1841
- });
1842
-
1843
- watch(fetchPersonByPlateNumber, (obj) => {
1844
- vehicleSearchResults.value = getPeopleSearchItems(obj);
1845
- vehicleSearchMenu.value = vehicleSearchResults.value.length > 0;
1846
- });
1847
-
1848
- const debounceFetchPlateNumber = debounce(fetchPersonByPlateNumberRefresh, 500);
1849
-
1850
2554
  function handleUpdateVehicleNumber() {
2555
+ if (skipNricFetch.value) return;
1851
2556
  if (!isPlateNumberFieldFocused.value) return;
1852
-
1853
- const search = visitor.plateNumber?.trim()?.toUpperCase() || "";
1854
- if (search.length < 4) {
1855
- vehicleSearchResults.value = [];
1856
- vehicleSearchMenu.value = false;
1857
- return;
1858
- }
1859
-
1860
- if(prop.type === 'walk-in' || prop.type === 'drop-off') return;
1861
-
1862
- debounceFetchPlateNumber();
2557
+ // A walk-in has no plate field.
2558
+ if (prop.type === "walk-in") return;
2559
+ scheduleLookup("vehicleNumber");
1863
2560
  }
1864
2561
 
1865
2562
  const handleFocusPlateNumber = () => {
1866
2563
  isPlateNumberFieldFocused.value = true;
1867
- if (prop.mode === 'register') {
1868
-
1869
- handleUpdateVehicleNumber();
2564
+ // Registering a camera record: focusing its plate looks it up again.
2565
+ if (formMode.value === "register" && !vehicleSearchResults.value.length) {
2566
+ void lookup("vehicleNumber", { prefilled: true });
1870
2567
  }
1871
2568
  };
1872
2569
 
@@ -1876,12 +2573,6 @@ function backToSelection() {
1876
2573
  message.value = "";
1877
2574
  }
1878
2575
 
1879
- function handleGoToPreviousPage() {
1880
- if (contractorStep.value > 1) {
1881
- contractorStep.value--;
1882
- }
1883
- }
1884
-
1885
2576
  function close() {
1886
2577
  emit("close");
1887
2578
  message.value = "";
@@ -1906,7 +2597,7 @@ function formatVisitorType(type: TVisitorType): string {
1906
2597
  }
1907
2598
  }
1908
2599
 
1909
- async function checkUnregisteredPlateBeforeContinue(action: "next" | "submit") {
2600
+ async function checkUnregisteredPlateBeforeContinue(action: "submit") {
1910
2601
  if (
1911
2602
  !enableUnregistered.value ||
1912
2603
  skipUnregisteredPlateCheck.value ||
@@ -1960,11 +2651,7 @@ function formatUnregisteredCheckIn(checkIn?: string) {
1960
2651
  async function continuePendingUnregisteredAction() {
1961
2652
  const action = pendingUnregisteredAction.value;
1962
2653
  pendingUnregisteredAction.value = null;
1963
- if (action === "next") {
1964
- await handleNextPage();
1965
- } else if (action === "submit") {
1966
- await submit();
1967
- }
2654
+ if (action === "submit") await submit();
1968
2655
  }
1969
2656
 
1970
2657
  async function closeUnregisteredPlateDialog() {
@@ -1973,259 +2660,634 @@ async function closeUnregisteredPlateDialog() {
1973
2660
  skipUnregisteredPlateCheck.value = true;
1974
2661
  }
1975
2662
 
2663
+ // "Cancel & Add New" (as the security mobile app): none of the listed records
2664
+ // is this visitor, so the paused Submit carries on straight away as a NEW
2665
+ // visitor - this plate is not checked against the Unregistered list again (a
2666
+ // changed plate is, see the plate watcher), so the list cannot keep reopening.
2667
+ async function cancelUnregisteredAndAddNew() {
2668
+ await closeUnregisteredPlateDialog();
2669
+ await continuePendingUnregisteredAction();
2670
+ }
2671
+
2672
+ /**
2673
+ * "Update" on an unregistered camera record (isecure365 `registerMatch`):
2674
+ * nothing is saved yet. The form switches to registering THAT record - its
2675
+ * plate, what was typed kept - and the guard submits. New photos, the pass /
2676
+ * keys and the members picked so far are not carried over, and a delivery
2677
+ * takes the record's delivery type, or Food.
2678
+ */
1976
2679
  async function selectUnregisteredPlateMatch(match: TVisitor) {
2680
+ if (!match?._id) {
2681
+ errorMessage.value = "Invalid unregistered vehicle selected.";
2682
+ return;
2683
+ }
1977
2684
  selectedUnregisteredVisitor.value = match;
1978
2685
  skipUnregisteredPlateCheck.value = true;
1979
2686
  dialog.showUnregisteredPlateDialog = false;
1980
2687
  unregisteredPlateMatches.value = [];
2688
+ pendingUnregisteredAction.value = null;
2689
+
2690
+ visitor.plateNumber = (match.plateNumber || "").toUpperCase();
2691
+ if (prop.type === "delivery") {
2692
+ visitor.deliveryType = (match as any).deliveryType || "food";
2693
+ }
2694
+ visitor.attachments = [];
2695
+ clearDeliveryPhotos();
2696
+ visitor.visitorPass = [];
2697
+ visitor.passKeys = [];
2698
+ visitor.members = [];
2699
+ passInfoKey.value++;
2700
+ memberInfoRef.value?.reset?.();
2701
+ membersKey.value++;
2702
+ }
1981
2703
 
1982
- visitor.plateNumber = match.plateNumber;
1983
- visitor.attachments = match.attachments || visitor.attachments;
1984
- visitor.block = match.block || visitor.block;
1985
- visitor.level = match.level || visitor.level;
1986
- visitor.unit = match.unit || visitor.unit;
1987
- visitor.company = match.company || visitor.company;
1988
- visitor.contractorType = match.contractorType || visitor.contractorType;
1989
- visitor.name = match.name || visitor.name;
1990
- visitor.contact = match.contact || visitor.contact;
1991
- visitor.nric = match.nric || visitor.nric;
1992
- visitor.company = match.company || visitor.company;
1993
- visitor.remarks = match.remarks || visitor.remarks;
2704
+ /*
2705
+ * Delivery photos (isecure365 `PhotoCarouselField`): kept as files until
2706
+ * Submit, then uploaded and sent as `attachments` (file ids). A photo already
2707
+ * uploaded by an earlier Submit keeps its id, so a retry does not upload it
2708
+ * twice.
2709
+ */
2710
+ type TDeliveryPhoto = { name: string; data: File; url: string; id?: string };
2711
+ const MAX_DELIVERY_PHOTOS = 5;
2712
+ const deliveryPhotos = ref<TDeliveryPhoto[]>([]);
2713
+ const deliveryPhotoIndex = ref(0);
2714
+ const deliveryGalleryInput = ref<HTMLInputElement | null>(null);
2715
+ const deliveryCamera = ref<{ openCameraDialog: () => void } | null>(null);
2716
+ const deliveryPhotoEdit = reactive({ open: false, url: "", idx: 0 });
2717
+
2718
+ function onDeliveryGalleryPicked(event: Event) {
2719
+ const input = event.target as HTMLInputElement;
2720
+ const files = Array.from(input.files ?? []).map((file) => ({
2721
+ name: file.name,
2722
+ data: file,
2723
+ url: URL.createObjectURL(file),
2724
+ }));
2725
+ addDeliveryPhotos(files);
2726
+ input.value = "";
2727
+ }
2728
+
2729
+ function editDeliveryPhoto() {
2730
+ const photo = deliveryPhotos.value[deliveryPhotoIndex.value];
2731
+ if (!photo) return;
2732
+ deliveryPhotoEdit.url = photo.url;
2733
+ deliveryPhotoEdit.idx = deliveryPhotoIndex.value;
2734
+ deliveryPhotoEdit.open = true;
2735
+ }
2736
+
2737
+ function removeDeliveryPhotoAt(idx: number) {
2738
+ const photo = deliveryPhotos.value[idx];
2739
+ if (!photo) return;
2740
+ deliveryPhotos.value = deliveryPhotos.value.filter((_, i) => i !== idx);
2741
+ deliveryPhotoIndex.value = Math.max(0, Math.min(deliveryPhotoIndex.value, deliveryPhotos.value.length - 1));
2742
+ }
2743
+
2744
+ const samePhotoFile = (a: File, b: File) =>
2745
+ a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
2746
+
2747
+ function addDeliveryPhotos(files: Array<{ name: string; data: File; url: string }>) {
2748
+ let skipped = false;
2749
+ for (const file of files ?? []) {
2750
+ if (deliveryPhotos.value.some((photo) => samePhotoFile(photo.data, file.data))) continue;
2751
+ if (deliveryPhotos.value.length >= MAX_DELIVERY_PHOTOS) {
2752
+ skipped = true;
2753
+ continue;
2754
+ }
2755
+ deliveryPhotos.value.push({ name: file.name, data: file.data, url: file.url });
2756
+ deliveryPhotoIndex.value = deliveryPhotos.value.length - 1;
2757
+ }
2758
+ if (skipped) showSnackbar(`You can add up to ${MAX_DELIVERY_PHOTOS} photos.`, "error");
2759
+ }
2760
+
2761
+ // Edit: the drawn-on photo replaces the original (it is a new file).
2762
+ async function onDeliveryPhotoEdited(url: string, idx: number) {
2763
+ const photo = deliveryPhotos.value[idx];
2764
+ if (!photo) return;
2765
+ deliveryPhotoEdit.open = false;
2766
+ const blob = await getImage(url);
2767
+ if (!blob) return;
2768
+ deliveryPhotos.value[idx] = {
2769
+ name: photo.name,
2770
+ data: new File([blob], photo.data.name, { type: photo.data.type }),
2771
+ url,
2772
+ };
2773
+ }
1994
2774
 
1995
- pendingUnregisteredAction.value = null;
2775
+ function clearDeliveryPhotos() {
2776
+ deliveryPhotos.value = [];
2777
+ deliveryPhotoIndex.value = 0;
1996
2778
  }
1997
2779
 
1998
- async function handleNextPage() {
1999
- errorMessage.value = "";
2000
- formRef.value!.validate();
2001
- if (!validForm.value) {
2002
- errorMessage.value = "Please complete all required fields *";
2780
+ async function uploadDeliveryPhotos(): Promise<string[]> {
2781
+ const ids: string[] = [];
2782
+ for (const photo of deliveryPhotos.value) {
2783
+ if (!photo.id) {
2784
+ const res: any = await addFile(photo.data);
2785
+ const id = res?.id ?? res?._id ?? res?.data?.id;
2786
+ if (!id) throw new Error("upload");
2787
+ photo.id = String(id);
2788
+ }
2789
+ ids.push(photo.id);
2790
+ }
2791
+ return ids;
2792
+ }
2793
+
2794
+ // PassInformation reads its pass only when mounted; bumped to clear it.
2795
+ const passInfoKey = ref(0);
2796
+
2797
+ // Pass ids held by members: not offered for the contractor's own pass.
2798
+ const memberPassIds = computed(() =>
2799
+ (visitor.members ?? []).flatMap((m) => (m.visitorPass ?? []).map((p) => p.keyId)).filter(Boolean)
2800
+ );
2801
+
2802
+ /*
2803
+ * The save - the same process as the security mobile app (isecure365
2804
+ * `add.tsx` buildPayload), for every visitor type. Only the screens differ.
2805
+ *
2806
+ * The fields an update always sends, so emptying one on the form empties it on
2807
+ * the record (each only when this visitor type's form has it).
2808
+ */
2809
+ const CLEARABLE_ON_UPDATE = [
2810
+ "name",
2811
+ "nric",
2812
+ "contact",
2813
+ "company",
2814
+ "contractorType",
2815
+ "deliveryType",
2816
+ "block",
2817
+ "level",
2818
+ "unit",
2819
+ "plateNumber",
2820
+ "remarks",
2821
+ "attachments",
2822
+ ];
2823
+ const OBJECT_ID = /^[a-f0-9]{24}$/i;
2824
+ // Users who record a visit they are not witnessing: their visit opens with no
2825
+ // check-in / check-out (isecure365 `backOffice`).
2826
+ const BACK_OFFICE_USER_TYPES = /^(service-provider|service-provider-member|site)$/i;
2827
+
2828
+ // The site's delivery company list (and the typed name snapped to the
2829
+ // listed spelling, "grabfood" -> "GrabFood").
2830
+ const deliveryCompanyHint = computed(() => {
2831
+ const typed = (deliveryCompany.value || "").trim();
2832
+ if (!typed || isListedCompany(deliveryCompanyList.value, typed)) return "";
2833
+ return "New company - it will be added to the list on submit.";
2834
+ });
2835
+
2836
+ function snapDeliveryCompany() {
2837
+ const typed = deliveryCompany.value || deliveryCompanyInput.value || "";
2838
+ if (!typed.trim()) return;
2839
+ deliveryCompany.value = listedName(deliveryCompanyList.value, typed);
2840
+ }
2841
+
2842
+ /**
2843
+ * After the visitor is saved: a typed delivery company that is not listed yet
2844
+ * is added to the site's list (the latest list is read first, so a company
2845
+ * added elsewhere meanwhile is not lost). Never throws.
2846
+ */
2847
+ /*
2848
+ * Rename / remove a listed delivery company (isecure365
2849
+ * `delivery-company-field.tsx`, `useDeliveryCompanies().saveList`): the site's
2850
+ * latest list is read first and the change applied to it, so a company someone
2851
+ * added elsewhere meanwhile is not lost; then the whole list is saved. The
2852
+ * server refuses a caller without the right to change the site.
2853
+ */
2854
+ const deliveryCompanySaving = ref(false);
2855
+ const deliveryCompanyEdit = ref<{ original: string; value: string; error: string } | null>(null);
2856
+ const deliveryCompanyToDelete = ref<string | null>(null);
2857
+
2858
+ async function saveDeliveryCompanyList(change: (latest: string[]) => string[]) {
2859
+ let latest = [...deliveryCompanyList.value];
2860
+ try {
2861
+ const site = (await getSiteById(prop.site)) as any;
2862
+ if (Array.isArray(site?.deliveryCompanyList)) latest = site.deliveryCompanyList;
2863
+ } catch {
2864
+ // Fall back to the list on screen.
2865
+ }
2866
+ const next = change([...latest]);
2867
+ await updateSitebyId(prop.site, { deliveryCompanyList: next } as any);
2868
+ deliveryCompanyList.value = next;
2869
+ return next;
2870
+ }
2871
+
2872
+ function openEditDeliveryCompany(company: string) {
2873
+ deliveryCompanyEdit.value = { original: company, value: company, error: "" };
2874
+ }
2875
+
2876
+ async function saveEditDeliveryCompany() {
2877
+ const editing = deliveryCompanyEdit.value;
2878
+ if (!editing) return;
2879
+ const problem = renameProblem(deliveryCompanyList.value, editing.original, editing.value);
2880
+ if (problem) {
2881
+ editing.error = problem;
2003
2882
  return;
2004
2883
  }
2005
- const step = contractorStep.value;
2006
- if (step === 2 && passType.value === "QR") {
2007
- if (availableQrCount.value === null) {
2008
- errorMessage.value = "Please wait while QR passes are being generated...";
2009
- return;
2884
+ const name = normalizeCompany(editing.value);
2885
+ deliveryCompanySaving.value = true;
2886
+ try {
2887
+ await saveDeliveryCompanyList((latest) => renamedCompany(latest, editing.original, name));
2888
+ if (sameCompany(deliveryCompany.value || "", editing.original)) deliveryCompany.value = name;
2889
+ deliveryCompanyEdit.value = null;
2890
+ showSnackbar("Company updated.");
2891
+ } catch (error: any) {
2892
+ showSnackbar(error?.data?.message || "Unable to update the company. Please try again.", "error");
2893
+ } finally {
2894
+ deliveryCompanySaving.value = false;
2895
+ }
2896
+ }
2897
+
2898
+ async function confirmDeleteDeliveryCompany() {
2899
+ const company = deliveryCompanyToDelete.value;
2900
+ if (!company) return;
2901
+ deliveryCompanySaving.value = true;
2902
+ try {
2903
+ await saveDeliveryCompanyList((latest) => withoutCompany(latest, company));
2904
+ if (sameCompany(deliveryCompany.value || "", company)) deliveryCompany.value = "";
2905
+ deliveryCompanyToDelete.value = null;
2906
+ showSnackbar("Company deleted.");
2907
+ } catch (error: any) {
2908
+ showSnackbar(error?.data?.message || "Unable to delete the company. Please try again.", "error");
2909
+ } finally {
2910
+ deliveryCompanySaving.value = false;
2911
+ }
2912
+ }
2913
+
2914
+ async function ensureDeliveryCompanyInList(name: string) {
2915
+ if (!name || isListedCompany(deliveryCompanyList.value, name)) return;
2916
+ try {
2917
+ let latest = deliveryCompanyList.value;
2918
+ try {
2919
+ const site = (await getSiteById(prop.site)) as any;
2920
+ if (Array.isArray(site?.deliveryCompanyList)) latest = site.deliveryCompanyList;
2921
+ } catch {
2922
+ // Fall back to the list on screen.
2010
2923
  }
2011
- if (availableQrCount.value === 0) {
2012
- errorMessage.value = "No QR passes available. Add a non-physical card to this unit in Access Management first.";
2013
- return;
2924
+ const next = withCompany(latest, name);
2925
+ await updateSitebyId(prop.site, { deliveryCompanyList: next } as any);
2926
+ deliveryCompanyList.value = next;
2927
+ } catch (error: any) {
2928
+ showSnackbar(
2929
+ `The visitor was saved, but "${name}" could not be added to the company list. ${error?.data?.message ?? ""}`.trim(),
2930
+ "error"
2931
+ );
2932
+ }
2933
+ }
2934
+
2935
+ function buildPayload(isUpdate: boolean): Record<string, unknown> {
2936
+ const payload: Record<string, unknown> = { type: prop.type, site: prop.site };
2937
+
2938
+ // Only what this form has (a Walk-In has no plate, so none is sent - not even
2939
+ // on a register, where the camera record keeps its own). Text is trimmed and
2940
+ // an empty field is left out of a new visit; on an update it is sent as null
2941
+ // so the record is cleared too.
2942
+ for (const key of typeFieldMap[prop.type] as (keyof TVisitorPayload | "delivery-company")[]) {
2943
+ if (!isFormField(key)) continue;
2944
+ if (key === "delivery-company") continue;
2945
+ const raw = visitor[key as keyof TVisitorPayload];
2946
+ const value = typeof raw === "string" ? raw.trim() : raw;
2947
+ const empty =
2948
+ value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
2949
+ if (!empty) {
2950
+ payload[key] = value;
2951
+ } else if (isUpdate && CLEARABLE_ON_UPDATE.includes(key)) {
2952
+ payload[key] = null;
2014
2953
  }
2015
2954
  }
2016
- if (step === 2 && passType.value === "NFC" && passCards.value.length === 0) {
2017
- errorMessage.value = "Please select at least one NFC card before proceeding.";
2955
+
2956
+ // Delivery: the company is the delivery company, in the listed spelling.
2957
+ if (prop.type === "delivery" && isFormField("delivery-company")) {
2958
+ const company = listedName(deliveryCompanyList.value, deliveryCompany.value);
2959
+ if (company) payload.company = company;
2960
+ else if (isUpdate) payload.company = null;
2961
+ }
2962
+
2963
+ // Pick-up and drop-off carry no person, so the type itself becomes the
2964
+ // remark when it is left blank.
2965
+ if ((prop.type === "pick-up" || prop.type === "drop-off") && !payload.remarks) {
2966
+ payload.remarks = prop.type;
2967
+ }
2968
+
2969
+ // Passes: one visitor pass (Drive-In, Walk-In, Contractor), keys for a
2970
+ // contractor only; each entry `add: true` on an existing record.
2971
+ if (["guest", "walk-in", "contractor"].includes(prop.type)) {
2972
+ const entry = (keyId: string) => (isUpdate ? { keyId, add: true } : { keyId });
2973
+ const passId = String(visitor.visitorPass?.[0]?.keyId ?? "");
2974
+ payload.visitorPass = OBJECT_ID.test(passId) ? [entry(passId)] : [];
2975
+ payload.passKeys =
2976
+ prop.type === "contractor"
2977
+ ? (visitor.passKeys ?? [])
2978
+ .map((k) => String(k?.keyId ?? ""))
2979
+ .filter((id) => OBJECT_ID.test(id))
2980
+ .map(entry)
2981
+ : [];
2982
+ }
2983
+
2984
+ // Only contractors bring a crew.
2985
+ if (prop.type === "contractor" && visitor.members?.length) {
2986
+ payload.members = visitor.members.map((member: any) => {
2987
+ const memberPass = String(member.visitorPass?.[0]?.keyId ?? "");
2988
+ return {
2989
+ name: String(member.name ?? "").trim(),
2990
+ nric: member.nric,
2991
+ contact: member.contact,
2992
+ visitorPass: OBJECT_ID.test(memberPass) ? [{ keyId: memberPass }] : [],
2993
+ // Web-only: the member's own entry pass.
2994
+ ...(member.passType
2995
+ ? { passType: member.passType, passQuantity: member.passQuantity, passCards: member.passCards }
2996
+ : {}),
2997
+ };
2998
+ });
2999
+ }
3000
+
3001
+ const userId = currentUser.value?._id;
3002
+ if (isUpdate) {
3003
+ // Registering a camera record promotes it; no unit name on an update.
3004
+ if (formMode.value === "register") payload.status = "registered";
3005
+ delete payload.unitName;
3006
+ if (userId) payload.updatedBy = userId;
3007
+ return payload;
3008
+ }
3009
+
3010
+ payload.org = prop.org;
3011
+ if (userId) payload.createdBy = userId;
3012
+ // Registering a visitor at the guardhouse IS their arrival: checked in as it
3013
+ // is created. A back-office user's visit opens with no times at all.
3014
+ const userType = currentUser.value?.type ?? "";
3015
+ if (BACK_OFFICE_USER_TYPES.test(userType)) {
3016
+ payload.clientUserType = userType;
3017
+ payload.checkIn = null;
3018
+ payload.checkOut = null;
3019
+ } else {
3020
+ payload.checkIn = new Date().toISOString();
3021
+ }
3022
+ return payload;
3023
+ }
3024
+
3025
+ // The ban / white list is answered as a \`code\` in a 200 response: nothing was
3026
+ // saved (isecure365 \`assertNotRejected\`), for every visitor type.
3027
+ function rejectedMessage(res: unknown): string {
3028
+ const code =
3029
+ typeof res === "number" ? res : (res as { code?: number } | null)?.code;
3030
+ if (code === 900)
3031
+ return "Unable to save. The visitor's plate number is on the ban list.";
3032
+ if (code === 901)
3033
+ return "Unable to save. The visitor's plate number is on the white list.";
3034
+ return "";
3035
+ }
3036
+
3037
+ /**
3038
+ * The lead contractor and every member must each have their own phone number,
3039
+ * NRIC and pass - checked again at Submit, as the lead's own fields can change
3040
+ * after members were added.
3041
+ */
3042
+ function crewDuplicateProblem(): string {
3043
+ if (prop.type !== "contractor") return "";
3044
+ const contacts = new Set<string>();
3045
+ const nrics = new Set<string>();
3046
+ const passes = new Set<string>();
3047
+ const leadContact = visitor.contact?.trim() || "";
3048
+ const leadNric = visitor.nric?.trim().toLowerCase() || "";
3049
+ const leadPass = String(visitor.visitorPass?.[0]?.keyId ?? "");
3050
+ if (leadContact) contacts.add(leadContact);
3051
+ if (leadNric) nrics.add(leadNric);
3052
+ if (leadPass) passes.add(leadPass);
3053
+ for (const member of visitor.members ?? []) {
3054
+ const who = member.name?.trim() || "A member";
3055
+ const contact = member.contact?.trim() || "";
3056
+ const nric = member.nric?.trim().toLowerCase() || "";
3057
+ const pass = String(member.visitorPass?.[0]?.keyId ?? "");
3058
+ if (contact && contacts.has(contact))
3059
+ return `${who}: Phone Number is already used by the lead contractor or another member.`;
3060
+ if (nric && nrics.has(nric))
3061
+ return `${who}: NRIC is already used by the lead contractor or another member.`;
3062
+ if (pass && passes.has(pass))
3063
+ return `${who}: Pass is already used by the lead contractor or another member.`;
3064
+ if (contact) contacts.add(contact);
3065
+ if (nric) nrics.add(nric);
3066
+ if (pass) passes.add(pass);
3067
+ }
3068
+ return "";
3069
+ }
3070
+
3071
+ /** The server refused a pass or key ("Pass VP01 is In Use"): unselect it wherever it is picked. */
3072
+ function releaseRefusedPassKey(message: string) {
3073
+ const name = refusedPassKeyName(message);
3074
+ if (!name) return;
3075
+ // A member's pass (known by name in the member picker), else the visitor's
3076
+ // own pass - or keys, for a refused key. The pickers re-read what is
3077
+ // Available the next time they are opened.
3078
+ const ids = new Set<string>(memberInfoRef.value?.passIdsNamed?.(name) ?? []);
3079
+ if (ids.size) {
3080
+ memberInfoRef.value?.clearPasses?.(ids);
2018
3081
  return;
2019
3082
  }
2020
- if (!(await checkUnregisteredPlateBeforeContinue("next"))) return;
2021
- if (step < 3) contractorStep.value++;
2022
- else await submit();
3083
+ if (/^\s*Key\b/i.test(message)) visitor.passKeys = [];
3084
+ else visitor.visitorPass = [];
3085
+ passInfoKey.value++;
2023
3086
  }
2024
3087
 
2025
3088
  async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
2026
- formRef.value!.validate();
2027
3089
  errorMessage.value = "";
2028
- if (!validForm.value) {
2029
- errorMessage.value = "Please complete all required fields *";
3090
+ const result = await formRef.value?.validate?.();
3091
+ const valid = result ? result.valid !== false : validForm.value;
3092
+ if (!valid) {
3093
+ errorMessage.value = "Please check input fields in red.";
2030
3094
  return;
2031
3095
  }
2032
3096
 
2033
3097
  if (passType.value === "HID_QR" && !validateHidQrReaderConfig()) return;
2034
- if (
2035
- prop.mode === "add" &&
2036
- !options.skipNonCheckedOutCheck &&
2037
- !(await checkNonCheckedOutVisitorsBeforeSubmit())
2038
- ) {
3098
+
3099
+ const duplicate = crewDuplicateProblem();
3100
+ if (duplicate) {
3101
+ errorMessage.value = duplicate;
2039
3102
  return;
2040
3103
  }
2041
- if (!(await checkUnregisteredPlateBeforeContinue("submit"))) return;
2042
3104
 
2043
- processing.value = true;
2044
-
2045
- let payload: Partial<TVisitorPayload> = {
2046
- type: prop.type,
2047
- org: prop.org,
2048
- site: prop.site,
2049
- createdBy: prop.currentUser,
2050
- };
2051
-
2052
- if (formMode.value === "add" || formMode.value === "register") {
2053
- const allowedFields = typeFieldMap[prop.type];
2054
- for (const key of allowedFields as (keyof TVisitorPayload)[]) {
2055
- // only what the form has (see isFormField)
2056
- if (isFormField(key) && visitor[key] !== undefined) {
2057
- payload = {
2058
- ...payload,
2059
- [key]: visitor[key],
2060
- };
2061
- }
3105
+ // The entry pass (isecure365 `onSubmit`), messages verbatim.
3106
+ if (prop.type === "contractor" && showEntryPassInformation.value) {
3107
+ if (passType.value === "NFC" && passCards.value.length === 0) {
3108
+ errorMessage.value = "Please select at least one NFC card before proceeding.";
3109
+ return;
2062
3110
  }
2063
-
2064
- const isGuest = prop.type === "guest";
2065
- const isWalkIn = prop.type === "walk-in";
2066
- const isContractor = prop.type === "contractor";
2067
- if (isGuest || isWalkIn || isContractor) {
2068
- payload.visitorPass = visitor.visitorPass;
2069
- payload.passKeys = visitor.passKeys;
3111
+ if (passType.value === "QR" && availableQrCount.value === null) {
3112
+ errorMessage.value = "Please wait while QR passes are being generated...";
3113
+ return;
3114
+ }
3115
+ if (passType.value === "QR" && availableQrCount.value === 0) {
3116
+ errorMessage.value =
3117
+ "No QR passes available for this unit. Please add non-physical cards in Access Management.";
3118
+ return;
3119
+ }
3120
+ if (passType.value === "QR" && !((passQuantity.value ?? 0) > 0)) {
3121
+ errorMessage.value = "QR pass quantity must be at least 1.";
3122
+ return;
2070
3123
  }
2071
-
2072
- delete payload.createdBy;
2073
3124
  }
2074
3125
 
2075
- if (prop.type === "contractor") {
2076
- // contractor type logic payload
2077
- payload = {
2078
- ...payload,
2079
- members: visitor.members,
2080
- };
3126
+ // The checks between a valid form and the save, in the app's order: the
3127
+ // unregistered-plate match (not when registering), then the visitor's own
3128
+ // open visits (only when the form has an NRIC or phone). A plain edit is not
3129
+ // checked: the record itself is one of the open visits.
3130
+ const plainEdit = prop.mode === "edit";
3131
+ if (!plainEdit) {
3132
+ if (
3133
+ formMode.value !== "register" &&
3134
+ isFormField("plateNumber") &&
3135
+ !(await checkUnregisteredPlateBeforeContinue("submit"))
3136
+ ) {
3137
+ return;
3138
+ }
3139
+ if (
3140
+ (isFormField("nric") || isFormField("contact")) &&
3141
+ !options.skipNonCheckedOutCheck &&
3142
+ !(await checkNonCheckedOutVisitorsBeforeSubmit())
3143
+ ) {
3144
+ return;
3145
+ }
2081
3146
  }
2082
3147
 
2083
- if (prop.type === "delivery") {
2084
- payload = {
2085
- ...payload,
2086
- company: deliveryCompany.value,
2087
- };
2088
- }
2089
-
2090
- // add register status when registering unregistered visitor
2091
- if (formMode.value === "register") {
2092
- payload.status = "registered";
2093
- const currentUserId = currentUser.value?._id;
2094
- if (currentUserId) {
2095
- payload.updatedBy = currentUserId;
3148
+ processing.value = true;
3149
+
3150
+ const recordId = selectedUnregisteredVisitor.value?._id || prop.visitorData?._id || "";
3151
+ const isUpdate = isSavingOntoRecord.value && Boolean(recordId);
3152
+ // The listed spelling of the delivery company needs the site's list.
3153
+ if (prop.type === "delivery") await ensureDeliveryCompanies();
3154
+ if (isFormField("attachments")) {
3155
+ try {
3156
+ visitor.attachments = deliveryPhotos.value.length ? await uploadDeliveryPhotos() : [];
3157
+ } catch {
3158
+ errorMessage.value = "Unable to upload the photos. Please try again.";
3159
+ processing.value = false;
3160
+ return;
2096
3161
  }
2097
3162
  }
3163
+ const payload = buildPayload(isUpdate);
2098
3164
 
2099
3165
  try {
2100
- let res;
2101
- if (prop.mode === "add" && selectedUnregisteredVisitor.value?._id) {
2102
- payload.status = "registered";
2103
- const currentUserId = currentUser.value?._id;
2104
- if (currentUserId) {
2105
- payload.updatedBy = currentUserId;
2106
- }
2107
- const { unitName, ...rest } = payload;
2108
- res = await updateVisitor(
2109
- selectedUnregisteredVisitor.value._id,
2110
- rest as TVisitorPayload
2111
- );
2112
- } else if (formMode.value === "add") {
2113
- res = await createVisitor(payload as TVisitorPayload);
2114
- } else if (
2115
- (prop.mode === "edit" || prop.mode === "register") &&
2116
- prop.visitorData?._id
2117
- ) {
2118
- const { unitName, ...rest } = payload;
2119
- res = await updateVisitor(prop.visitorData._id, rest as TVisitorPayload);
3166
+ const res = isUpdate
3167
+ ? await updateVisitor(recordId, payload as Partial<TVisitorPayload>)
3168
+ : await createVisitor(payload as TVisitorPayload);
3169
+
3170
+ const rejected = rejectedMessage(res);
3171
+ if (rejected) {
3172
+ errorMessage.value = rejected;
3173
+ return;
2120
3174
  }
2121
3175
 
2122
- if (res) {
2123
- if (passType.value === "HID_QR") {
2124
- const previewReady = await prepareHidQrCodePreview(getVisitorIdFromResponse(res));
2125
- if (!previewReady) return;
2126
- hidQrPendingCompletion.value = createMore.value ? "done:more" : "done";
2127
- return;
2128
- }
3176
+ if (!res) return;
2129
3177
 
2130
- if (
2131
- prop.type === "contractor" &&
2132
- (passType.value === "QR" || passType.value === "NFC")
2133
- ) {
2134
- const visitorId = res as unknown as string;
2135
- const rawUrl = entryPassSettings.value?.data?.settings?.url;
2136
- const { encrypted: acmUrl } = await $fetch<{ encrypted: string }>(
2137
- `/api/encrypt-acm-url${rawUrl ? `?url=${encodeURIComponent(rawUrl)}` : ""
2138
- }`
2139
- );
2140
- if (visitorId && acmUrl) {
2141
- const passRes = await createVisitorPass({
2142
- site: prop.site,
2143
- unitId: visitor.unit!,
2144
- quantity: passQuantity.value ?? 1,
2145
- type: passType.value === "QR" ? "QRCODE" : passType.value,
2146
- nfcCards: passCards.value,
2147
- acm_url: acmUrl,
2148
- user: {
2149
- visitorId,
2150
- name: visitor.name,
2151
- nric: visitor.nric,
2152
- contact: visitor.contact,
2153
- company: visitor.company,
2154
- type: visitor.type,
2155
- plateNumber: visitor.plateNumber,
2156
- },
2157
- });
3178
+ // A delivery company typed in that is not on the site's list yet.
3179
+ if (prop.type === "delivery" && typeof payload.company === "string") {
3180
+ await ensureDeliveryCompanyInList(payload.company);
3181
+ }
2158
3182
 
2159
- if (passType.value === "QR") {
2160
- const accessCards: any[] =
2161
- (passRes as any)?.data?.accessCards ?? [];
2162
- const printer = entryPassSettings.value?.data?.settings?.printer;
2163
- if (!printer?.vendorId || !printer?.productId) {
2164
- console.warn("[QR Print] Aborted: no printer configured.");
2165
- } else if (!accessCards.length) {
2166
- console.warn("[QR Print] Aborted: accessCards is empty.");
2167
- } else {
2168
- const vendorId = parseInt(printer.vendorId);
2169
- const productId = parseInt(printer.productId);
2170
- const companyName = visitor.company || "";
2171
- const blockLabel =
2172
- blocksArray.value.find((b: any) => b.value === visitor.block)
2173
- ?.title || "";
2174
- const levelLabel =
2175
- levelsArray.value.find((l: any) => l.value === visitor.level)
2176
- ?.title || "";
2177
- const unitLabel =
2178
- unitsArray.value.find((u: any) => u.value === visitor.unit)
2179
- ?.title || "";
2180
- const address = [blockLabel, levelLabel, unitLabel]
2181
- .filter(Boolean)
2182
- .join("/");
2183
-
2184
- await nextTick();
2185
- for (const qrCode of accessCards) {
2186
- const signed = await signQr({
2187
- cardId: qrCode._id,
2188
- purpose: "vms",
2189
- });
2190
- const qrData = signed?.data;
2191
- if (!qrData) {
2192
- console.warn(
2193
- "[QR Print] Skipping card",
2194
- qrCode._id,
2195
- "— signQr returned no data."
2196
- );
2197
- continue;
2198
- }
2199
- const printResult = await testConnection(
2200
- vendorId,
2201
- productId,
2202
- true,
2203
- qrData,
2204
- qrCode.accessLevel,
2205
- qrCode.liftAccessLevel,
2206
- companyName,
2207
- address
2208
- );
2209
- }
2210
- }
2211
- }
2212
- }
2213
- }
2214
- if (createMore.value) {
2215
- emit("done:more");
2216
- } else emit("done");
3183
+ const visitorId = isUpdate ? recordId : getVisitorIdFromResponse(res);
3184
+
3185
+ if (passType.value === "HID_QR") {
3186
+ const previewReady = await prepareHidQrCodePreview(visitorId);
3187
+ if (!previewReady) return;
3188
+ hidQrPendingCompletion.value = createMore.value ? "done:more" : "done";
3189
+ return;
3190
+ }
3191
+
3192
+ // The contractor's entry pass, issued once the visitor exists - for a
3193
+ // register too, against the record being registered.
3194
+ if (
3195
+ prop.type === "contractor" &&
3196
+ showEntryPassInformation.value &&
3197
+ (passType.value === "QR" || passType.value === "NFC")
3198
+ ) {
3199
+ await issueEntryPass(visitorId, payload);
2217
3200
  }
3201
+
3202
+ if (createMore.value) {
3203
+ emit("done:more");
3204
+ } else emit("done");
2218
3205
  } catch (error: any) {
2219
- const err = error?.data?.message;
3206
+ const err = error?.data?.message || error?.response?._data?.message;
2220
3207
  errorMessage.value =
2221
3208
  err ||
2222
- `Failed to ${formMode.value === "add" ? "add" : "update"
2223
- } visitor. Please try again.`;
3209
+ `Failed to ${isUpdate ? "update" : "add"} visitor. Please try again.`;
3210
+ // "Pass VP01 is In Use": the pass is taken off the form so another (or
3211
+ // none) can be picked and submitted again (isecure365).
3212
+ if (err) releaseRefusedPassKey(err);
2224
3213
  } finally {
2225
3214
  processing.value = false;
2226
3215
  }
2227
3216
  }
2228
3217
 
3218
+ /** Never fails the save: the visitor is already saved. */
3219
+ async function issueEntryPass(visitorId: string, payload: Record<string, unknown>) {
3220
+ if (!visitorId) return;
3221
+ try {
3222
+ const rawUrl = entryPassSettings.value?.data?.settings?.url;
3223
+ const { encrypted: acmUrl } = await $fetch<{ encrypted: string }>(
3224
+ `/api/encrypt-acm-url${rawUrl ? `?url=${encodeURIComponent(rawUrl)}` : ""}`
3225
+ );
3226
+ if (!acmUrl) throw new Error("The entry pass address could not be read.");
3227
+ const passRes = await createVisitorPass({
3228
+ site: prop.site,
3229
+ unitId: visitor.unit!,
3230
+ quantity:
3231
+ passType.value === "NFC" ? passCards.value.length : passQuantity.value ?? 1,
3232
+ type: passType.value === "QR" ? "QRCODE" : passType.value,
3233
+ nfcCards: passType.value === "NFC" ? passCards.value : [],
3234
+ acm_url: acmUrl,
3235
+ user: {
3236
+ visitorId,
3237
+ name: payload.name,
3238
+ nric: payload.nric,
3239
+ contact: payload.contact,
3240
+ company: payload.company,
3241
+ type: payload.type,
3242
+ plateNumber: payload.plateNumber,
3243
+ },
3244
+ } as any);
3245
+
3246
+ if (passType.value === "QR") {
3247
+ const accessCards: any[] = (passRes as any)?.data?.accessCards ?? [];
3248
+ const printer = entryPassSettings.value?.data?.settings?.printer;
3249
+ if (!printer?.vendorId || !printer?.productId) {
3250
+ console.warn("[QR Print] Aborted: no printer configured.");
3251
+ } else if (!accessCards.length) {
3252
+ console.warn("[QR Print] Aborted: accessCards is empty.");
3253
+ } else {
3254
+ const vendorId = parseInt(printer.vendorId);
3255
+ const productId = parseInt(printer.productId);
3256
+ const companyName = String(payload.company ?? "");
3257
+ const address = buildVisitorAddress();
3258
+
3259
+ await nextTick();
3260
+ for (const qrCode of accessCards) {
3261
+ const signed = await signQr({
3262
+ cardId: qrCode._id,
3263
+ purpose: "vms",
3264
+ });
3265
+ const qrData = signed?.data;
3266
+ if (!qrData) {
3267
+ console.warn("[QR Print] Skipping card", qrCode._id, "— signQr returned no data.");
3268
+ continue;
3269
+ }
3270
+ await testConnection(
3271
+ vendorId,
3272
+ productId,
3273
+ true,
3274
+ qrData,
3275
+ qrCode.accessLevel,
3276
+ qrCode.liftAccessLevel,
3277
+ companyName,
3278
+ address
3279
+ );
3280
+ }
3281
+ }
3282
+ }
3283
+ } catch (error: any) {
3284
+ showSnackbar(
3285
+ `Visitor saved, pass not issued. The visitor was registered, but the entry pass could not be issued. ${error?.data?.message ?? error?.message ?? ""}`.trim(),
3286
+ "error"
3287
+ );
3288
+ }
3289
+ }
3290
+
2229
3291
  function parseUsbId(value: unknown) {
2230
3292
  if (typeof value === "number") return value;
2231
3293
  const text = String(value || "").trim();
@@ -2479,28 +3541,106 @@ watch(
2479
3541
  }
2480
3542
  );
2481
3543
 
2482
- const membersFieldIncomplete = computed(() => {
2483
- if (!visitor.members || visitor.members.length === 0) return false;
2484
-
2485
- return visitor.members.some((member) => {
2486
- const hasName = !!member.name;
2487
- const hasNric = !!member.nric;
2488
- const hasContact = !!member.contact;
2489
-
2490
- return !hasName || !hasNric || !hasContact;
2491
- });
2492
- });
2493
-
2494
3544
  onMounted(() => {
2495
- contractorStep.value = 1;
2496
3545
  currentAutofillSource.value = null;
2497
3546
 
3547
+ // A delivery is Food unless changed.
3548
+ if (prop.type === "delivery" && !visitor.deliveryType) visitor.deliveryType = "food";
3549
+
3550
+ // Registering a camera record: only its plate is filled in (editable); the
3551
+ // guard keys in the rest, and the save updates the record by its id.
2498
3552
  if (prop.mode === "register" && prop.visitorData) {
2499
- visitor.plateNumber = prop.visitorData?.plateNumber || "";
3553
+ visitor.plateNumber = (prop.visitorData?.plateNumber || "").toUpperCase();
3554
+
3555
+ // The camera's plate is looked up after the form has opened (security
3556
+ // mobile app `runPrefilledLookups`): one person with nothing to choose
3557
+ // fills only the empty fields, otherwise the matches are offered. Not for
3558
+ // pick-up / drop-off, which carry no person. Does not hold the form back.
3559
+ if ((visitor.plateNumber?.trim() || "").length >= 4) {
3560
+ void lookup("vehicleNumber", { prefilled: true });
3561
+ }
2500
3562
  }
2501
3563
  });
2502
3564
  </script>
2503
3565
  <style scoped>
3566
+ /*
3567
+ * Pass / key / NFC chips: dark bold text and border on the card surface
3568
+ * (theme colours), 28px, the icon keeping its colour - the same chips as the
3569
+ * visitor list's Check In/Out column.
3570
+ */
3571
+ /* Whose passes and keys these are, above the scanner. */
3572
+ .visitor-return__who {
3573
+ display: flex;
3574
+ flex-direction: column;
3575
+ gap: 4px;
3576
+ padding: 10px 12px;
3577
+ margin-bottom: 12px;
3578
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
3579
+ border-radius: 8px;
3580
+ font-size: 0.9375rem;
3581
+ color: rgb(var(--v-theme-on-surface));
3582
+ }
3583
+
3584
+ .visitor-return__who-line {
3585
+ display: flex;
3586
+ align-items: center;
3587
+ gap: 8px;
3588
+ }
3589
+
3590
+ /* The section headings in the return dialogs: readable at a glance. */
3591
+ .visitor-return__label {
3592
+ margin: 6px 0 8px;
3593
+ font-size: 1rem;
3594
+ font-weight: 600;
3595
+ color: rgb(var(--v-theme-on-surface));
3596
+ }
3597
+
3598
+ .visitor-pass-chip.v-chip {
3599
+ --v-chip-height: 28px;
3600
+ font-size: 0.8125rem;
3601
+ padding: 0 10px;
3602
+ background: rgb(var(--v-theme-surface));
3603
+ color: rgb(var(--v-theme-on-surface));
3604
+ border: 1.5px solid rgba(var(--v-theme-on-surface), 0.87);
3605
+ letter-spacing: 0;
3606
+ }
3607
+
3608
+ /* In the return dialogs: the same height as the status dropdown beside it. */
3609
+ .visitor-pass-chip--field.v-chip {
3610
+ --v-chip-height: 40px;
3611
+ font-size: 0.875rem;
3612
+ padding: 0 14px;
3613
+ }
3614
+
3615
+ .visitor-pass-chip--pass :deep(.v-chip__prepend .v-icon) {
3616
+ color: #1565c0;
3617
+ }
3618
+
3619
+ .visitor-pass-chip--key :deep(.v-chip__prepend .v-icon) {
3620
+ color: #e65100;
3621
+ }
3622
+
3623
+ .visitor-pass-chip--nfc :deep(.v-chip__prepend .v-icon) {
3624
+ color: #7b1fa2;
3625
+ }
3626
+
3627
+ /* A Scan button beside its input, the same height (both 40px), like the
3628
+ country code beside the phone number - not on the label's line. */
3629
+ .scan-row {
3630
+ display: flex;
3631
+ align-items: flex-start;
3632
+ gap: 8px;
3633
+ }
3634
+
3635
+ .scan-row__field {
3636
+ flex: 1 1 auto;
3637
+ min-width: 0;
3638
+ }
3639
+
3640
+ .scan-row__btn {
3641
+ flex: 0 0 auto;
3642
+ }
3643
+
2504
3644
  .button-outline-class {
2505
3645
  border: 1px solid rgba(var(--v-theme-primary));
2506
3646
  }
@@ -2566,6 +3706,17 @@ onMounted(() => {
2566
3706
  Deliberately NOT the shared `.screen-modal__footer` - that class is on the
2567
3707
  batch-D branch (#1150) and not on this base, and adding a second copy of it
2568
3708
  to `screens.css` here would collide with that PR. */
3709
+ /* The photo carousel: rounded, clipped to its frame. */
3710
+ .visitor-form__photos {
3711
+ border-radius: 16px;
3712
+ overflow: hidden;
3713
+ }
3714
+
3715
+ /* Small confirm / edit dialogs: the body lines up with the title. */
3716
+ .visitor-form__dialog-body {
3717
+ padding: 4px 16px 0;
3718
+ }
3719
+
2569
3720
  .visitor-form__footer {
2570
3721
  display: flex;
2571
3722
  justify-content: flex-end;