@7365admin1/layer-common 4.144.0 → 4.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,30 +313,46 @@
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"
253
- v-model:cards="passCards" :settings="entryPassSettings"
254
- :loading="entryPassSettingsPending || siteDataPending" :site-id="prop.site"
255
- :unit-id="visitor.unit || null" :visitor-type="prop.type" :hid-qr-code-enabled="hidQrCodePassAllowed"
256
- :hid-qr-printer-configured="hasHidQrPrinterConfig"
257
- :hid-reader-name="hidQrReader?.name || ''"
258
- :hid-portal-name="hidQrReader?.portalName || ''"
259
- @update:available-qr-count="(val) => availableQrCount = val" />
327
+ :passKeys="visitor.passKeys" :clearable="true" :exclude-ids="memberPassIds" />
260
328
  </v-col>
261
329
 
262
- <v-col v-show="withStep3 && contractorStep === 3" cols="12">
263
- <MemberInformation v-model="visitor.members" :type="prop.type" :contractor-type="visitor.contractorType"
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"
264
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"
265
337
  :settings="entryPassSettings" :settings-loading="entryPassSettingsPending || siteDataPending"
266
- :unit-id="visitor.unit || null" :hid-qr-code-enabled="hidQrCodePassAllowed"
338
+ :unit-id="visitor.unit || null" :show-entry-pass="showEntryPassInformation"
339
+ :hid-qr-code-enabled="hidQrCodePassAllowed"
267
340
  :hid-qr-printer-configured="hasHidQrPrinterConfig"
268
341
  :hid-reader-name="hidQrReader?.name || ''" :hid-portal-name="hidQrReader?.portalName || ''"
269
342
  :selected-nfc-cards="passType === 'NFC' ? passCards : []" />
270
343
  </v-col>
271
344
 
345
+ <v-col v-if="showEntryPassInformation" cols="12" class="mt-2">
346
+ <EntryPassInformation v-model="passType" v-model:quantity="passQuantity"
347
+ v-model:cards="passCards" :settings="entryPassSettings"
348
+ :loading="entryPassSettingsPending || siteDataPending" :site-id="prop.site"
349
+ :unit-id="visitor.unit || null" :visitor-type="prop.type" :hid-qr-code-enabled="hidQrCodePassAllowed"
350
+ :hid-qr-printer-configured="hasHidQrPrinterConfig"
351
+ :hid-reader-name="hidQrReader?.name || ''"
352
+ :hid-portal-name="hidQrReader?.portalName || ''"
353
+ @update:available-qr-count="(val) => availableQrCount = val" />
354
+ </v-col>
355
+
272
356
  <v-col v-if="formMode === 'add'" cols="12" class="mt-2">
273
357
  <v-checkbox v-model="createMore" density="comfortable" hide-details>
274
358
  <template #label>
@@ -287,32 +371,21 @@
287
371
  </p>
288
372
 
289
373
  <div class="visitor-form__footer">
290
- <AppButton v-if="formMode === 'add' && (withStep2 || withStep3) && contractorStep > 1" variant="ghost"
291
- @click="handleGoToPreviousPage">Back</AppButton>
292
- <AppButton v-else-if="formMode === 'add' || formMode === 'register'" variant="ghost" @click="backToSelection">
374
+ <AppButton v-if="formMode === 'add' || formMode === 'register'" variant="ghost" @click="backToSelection">
293
375
  Back to Selection</AppButton>
294
376
  <AppButton v-else variant="ghost" @click="emit('close:all')">Close</AppButton>
295
377
 
296
- <AppButton v-if="showNextButton" :disabled="processing" @click="handleNextPage">Next</AppButton>
297
- <AppButton v-else :disabled="!validForm || processing || membersFieldIncomplete" @click="submit">
298
- {{ formMode == 'add' ? 'Submit' : 'Update' }}</AppButton>
378
+ <AppButton data-test="visitor-submit" :disabled="processing" @click="submit()">
379
+ {{ isSavingOntoRecord ? 'Update' : 'Submit' }}</AppButton>
299
380
  </div>
300
381
 
301
- <v-dialog v-model="dialog.vehicleNumberUsersList" v-if="vehicleNumberUserItems.length > 0" persistent
302
- max-width="600">
303
- <SearchVehicleNumberUser :vehicle-number="visitor.plateNumber ?? ''"
304
- :vehicle-number-users-list="vehicleNumberUserItems" @close="dialog.vehicleNumberUsersList = false"
305
- @update:people="handleAutofillDataViaVehicleNumber" />
306
- </v-dialog>
382
+ <NricScanDialog v-model="nricScanOpen" @read="handleNricScanRead" />
307
383
 
308
384
  <v-dialog v-model="dialog.nricAutofillOptions" max-width="430">
385
+ <!-- The chooser: no title, Back (to the result list) / Fill In, as the
386
+ security mobile app. -->
309
387
  <v-card v-if="selectedNricSearchResult" class="screen-modal nric-options-card">
310
- <div class="visitor-form__head">
311
- <v-card-title>Select</v-card-title>
312
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeNricAutofillOptions" />
313
- </div>
314
-
315
- <div class="screen-modal__body nric-options-body">
388
+ <div class="screen-modal__body nric-options-body pt-4">
316
389
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
317
390
  <span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
318
391
  <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ maskNRIC(selectedNricSearchResult.nric) || "N/A" }}</span>
@@ -326,7 +399,7 @@
326
399
  </v-radio-group>
327
400
  </template>
328
401
 
329
- <template v-if="(!visitor.plateNumber || currentAutofillSource === 'vehicleNumber') && selectedNricPlateOptions.length >= 1">
402
+ <template v-if="prop.type !== 'walk-in' && (!visitor.plateNumber || currentAutofillSource === 'vehicleNumber') && selectedNricPlateOptions.length >= 1">
330
403
  <h3 class="nric-options-section-title mb-2 mt-5">Vehicle</h3>
331
404
  <v-radio-group v-model="selectedNricPlate" hide-details color="accent" class="nric-option-group">
332
405
  <v-radio v-for="plate in selectedNricPlateOptions" :key="plate" :label="plate" :value="plate"
@@ -336,8 +409,8 @@
336
409
  </div>
337
410
 
338
411
  <div class="visitor-form__footer">
339
- <AppButton variant="ghost" @click="closeNricAutofillOptions">Cancel</AppButton>
340
- <AppButton @click="confirmNricAutofillOptions">Select</AppButton>
412
+ <AppButton variant="ghost" @click="chooserBack">Back</AppButton>
413
+ <AppButton @click="confirmNricAutofillOptions">Fill In</AppButton>
341
414
  </div>
342
415
  </v-card>
343
416
  </v-dialog>
@@ -349,18 +422,25 @@
349
422
  is not this batch's call. The design's modal frame does draw a
350
423
  title, so that gap is raised on the PR as a copy decision. -->
351
424
  <div class="visitor-form__head visitor-form__head--bare">
352
- <AppButton variant="icon" icon="mdi-close" aria-label="Close"
353
- @click="dialog.showNonCheckedOutDialog = false" />
425
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="dialog.showNonCheckedOutDialog = false" />
354
426
  </div>
355
427
 
356
428
  <div class="screen-modal__body checkout-dialog__body">
357
429
  <p class="checkout-dialog__lead">
358
- The following list needs to check-out before proceeding to add related to the NRIC/Phone Number below:
430
+ {{ nonCheckedOutFor.kind === "member"
431
+ ? "The following list needs to check-out before proceeding to add member related to the NRIC/Phone Number below:"
432
+ : "The following list needs to check-out before proceeding to add related to the NRIC/Phone Number below:" }}
359
433
  </p>
360
434
 
361
435
  <div class="checkout-dialog__subject">
362
- <div v-if="!skipNonCheckedOutNric">NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}</div>
363
- <div>Phone Number: {{ visitor.contact || "N/A" }}</div>
436
+ <template v-if="nonCheckedOutFor.kind === 'member'">
437
+ <div>NRIC: {{ maskNRIC(nonCheckedOutFor.nric) || "N/A" }}</div>
438
+ <div>Phone Number: {{ nonCheckedOutFor.contact || "N/A" }}</div>
439
+ </template>
440
+ <template v-else>
441
+ <div>NRIC: {{ (!skipNonCheckedOutNric && maskNRIC(visitor.nric)) || "N/A" }}</div>
442
+ <div>Phone Number: {{ visitor.contact || "N/A" }}</div>
443
+ </template>
364
444
  </div>
365
445
 
366
446
  <v-data-table class="table-card" :headers="nonCheckedOutVisitorHeaders"
@@ -423,10 +503,10 @@
423
503
  </v-data-table>
424
504
  </div>
425
505
 
426
- <div class="visitor-form__footer" v-if="!skipNonCheckedOutNric">
506
+ <div class="visitor-form__footer" v-if="nonCheckedOutFor.kind === 'member' ? !!nonCheckedOutFor.nric : (!skipNonCheckedOutNric && !!visitor.nric)">
427
507
  <v-btn class="screen-btn-primary" variant="flat" :loading="loading.searchingNonCheckedOut"
428
- @click="handleSkipNricNonCheckedOut">
429
- Skip NRIC: {{ maskNRIC(visitor.nric) || "N/A" }}
508
+ @click="nonCheckedOutFor.kind === 'member' ? handleSkipMemberNric() : handleSkipNricNonCheckedOut()">
509
+ {{ nonCheckedOutFor.kind === "member" ? "SKIP NRIC" : "Skip NRIC" }}: {{ maskNRIC(nonCheckedOutFor.kind === "member" ? nonCheckedOutFor.nric : visitor.nric) || "N/A" }}
430
510
  </v-btn>
431
511
  </div>
432
512
  </v-card>
@@ -436,7 +516,7 @@
436
516
  <v-card>
437
517
  <v-toolbar density="compact" color="">
438
518
  <v-row no-gutters class="d-flex fill-height justify-space-between align-center px-4">
439
- <span class="font-weight-bold">Return NFC Card</span>
519
+ <span class="font-weight-bold">Return NFC Card(s)</span>
440
520
  <v-btn icon="mdi-close" variant="text" @click="dialog.returnNfcCard = false" />
441
521
  </v-row>
442
522
  </v-toolbar>
@@ -455,8 +535,8 @@
455
535
  </div>
456
536
  <div v-for="(card, idx) in nfcCardReturnStatuses" :key="card.cardId" class="mb-4">
457
537
  <div class="d-flex align-center ga-2 mb-2">
458
- <v-icon size="18" icon="mdi-credit-card-outline" color="purple" />
459
- <v-chip size="small" variant="tonal" color="purple">{{ card.cardNo }}</v-chip>
538
+ <v-chip prepend-icon="mdi-credit-card-outline" variant="outlined"
539
+ class="font-weight-bold visitor-pass-chip visitor-pass-chip--field visitor-pass-chip--nfc">{{ card.cardNo }}</v-chip>
460
540
  </div>
461
541
  <v-select v-model="nfcCardReturnStatuses[idx].status" :items="nfcPassStatusOptions" item-title="label"
462
542
  item-value="value" density="comfortable" hide-details />
@@ -472,18 +552,157 @@
472
552
  </v-col>
473
553
  <v-col cols="6">
474
554
  <v-btn color="red" variant="flat" height="48" rounded="0" block :loading="loading.checkingOut"
475
- :disabled="!canConfirmNfcReturnCheckout" @click="handleNfcReturnCheckout">Confirm Checkout</v-btn>
555
+ :disabled="!canConfirmNfcReturnCheckout" @click="handleNfcReturnCheckout">Confirm &amp; Check Out</v-btn>
556
+ </v-col>
557
+ </v-row>
558
+ </v-toolbar>
559
+ </v-card>
560
+ </v-dialog>
561
+
562
+ <!-- A still-inside visitor holding a pass or key: what came back is
563
+ recorded before the check-out (isecure365 pass-key-return-dialog). -->
564
+ <v-dialog v-model="dialog.returnPassKeys" max-width="450" persistent>
565
+ <v-card>
566
+ <v-toolbar density="compact" color="">
567
+ <v-row no-gutters class="d-flex fill-height justify-space-between align-center px-4">
568
+ <span class="font-weight-bold">Update Pass or Key(s)</span>
569
+ <v-btn icon="mdi-close" variant="text" @click="dialog.returnPassKeys = false" />
570
+ </v-row>
571
+ </v-toolbar>
572
+ <v-card-text class="px-4 pb-2">
573
+ <div class="visitor-return__who">
574
+ <div class="visitor-return__who-line font-weight-bold">
575
+ <v-icon icon="mdi-account-outline" size="18" />
576
+ {{ passReturnVisitor?.name || "N/A" }}
577
+ </div>
578
+ <div class="visitor-return__who-line">
579
+ <v-icon icon="mdi-storefront-outline" size="18" />
580
+ {{ passReturnVisitor ? formatVisitorLocation(passReturnVisitor as TVisitor) : "N/A" }}
581
+ </div>
582
+ </div>
583
+
584
+ <v-btn block variant="flat" class="text-capitalize mb-3 screen-btn-primary" prepend-icon="mdi-qrcode-scan"
585
+ @click="passReturnScan.open = true">
586
+ Scan QR Code
587
+ </v-btn>
588
+
589
+ <div v-for="section in passReturnSections" :key="section.title" class="mb-2">
590
+ <template v-if="section.show">
591
+ <p class="visitor-return__label">{{ section.title }} ({{ section.items.length }})</p>
592
+ <p v-if="section.items.length === 0" class="text-body-2 mb-2">{{ section.empty }}</p>
593
+ <v-row v-for="entry in section.items" :key="entry.keyId" no-gutters
594
+ class="d-flex flex-wrap justify-space-between align-center ga-5 mb-2">
595
+ <v-chip :prepend-icon="section.icon" variant="outlined"
596
+ :class="['font-weight-bold', 'visitor-pass-chip', 'visitor-pass-chip--field', section.color === 'blue' ? 'visitor-pass-chip--pass' : 'visitor-pass-chip--key']">
597
+ {{ entry.prefixAndName || passKeyNames[entry.keyId] || section.fallback }}
598
+ </v-chip>
599
+ <v-select v-model="entry.status" hide-details max-width="200px" density="compact"
600
+ :items="entry.locked ? ['Removed'] : PASS_RETURN_STATUSES" :disabled="entry.locked" />
601
+ <v-textarea v-if="entry.status === 'Damaged' || entry.status === 'Lost'" v-model="entry.remarks"
602
+ no-resize rows="3" class="w-100" density="compact" label="Remarks" placeholder="Enter remarks" />
603
+ </v-row>
604
+ </template>
605
+ </div>
606
+
607
+ <p v-if="passReturnError" class="text-error text-body-2 mb-2">{{ passReturnError }}</p>
608
+ </v-card-text>
609
+ <v-toolbar class="pa-0" density="compact">
610
+ <v-row no-gutters>
611
+ <v-col cols="6">
612
+ <v-btn color="blue" variant="flat" height="48" rounded="0" block :loading="loading.updatingPassKeys"
613
+ :disabled="!passReturnChanged || loading.checkingOut" @click="handlePassKeyReturnUpdate">
614
+ Update
615
+ </v-btn>
616
+ </v-col>
617
+ <v-col cols="6">
618
+ <v-btn color="red" variant="flat" height="48" rounded="0" block :loading="loading.checkingOut"
619
+ :disabled="!canCheckOutAfterReturn || loading.updatingPassKeys" @click="handlePassKeyReturnCheckout">
620
+ Check Out
621
+ </v-btn>
476
622
  </v-col>
477
623
  </v-row>
478
624
  </v-toolbar>
479
625
  </v-card>
480
626
  </v-dialog>
481
627
 
628
+ <VisitorPassKeyQRScanner v-if="passReturnScan.open" :dialog="passReturnScan.open"
629
+ v-model:scanned-value="passReturnScan.value" @close-dialog="passReturnScan.open = false" />
630
+
631
+ <v-dialog v-model="dialog.deletePerson" max-width="430">
632
+ <v-card class="screen-modal">
633
+ <div class="visitor-form__head">
634
+ <v-card-title>{{ DELETE_PERSON_TITLE }}</v-card-title>
635
+ </div>
636
+ <div class="screen-modal__body visitor-form__dialog-body">
637
+ <p class="text-body-2">{{ deletePersonMessage(personToDelete?.name || "This person") }}</p>
638
+ </div>
639
+ <div class="visitor-form__footer">
640
+ <AppButton variant="ghost" @click="dialog.deletePerson = false">Cancel</AppButton>
641
+ <AppButton variant="err" icon="mdi-delete-outline" :disabled="deletingPerson"
642
+ @click="deletePersonConfirmed">Delete</AppButton>
643
+ </div>
644
+ </v-card>
645
+ </v-dialog>
646
+
647
+ <!-- Changing Contractor Type when the passes picked take another pass type. -->
648
+ <v-dialog v-model="dialog.changeContractorType" max-width="430">
649
+ <v-card class="screen-modal">
650
+ <div class="visitor-form__head">
651
+ <v-card-title>Change Contractor Type?</v-card-title>
652
+ </div>
653
+ <div class="screen-modal__body visitor-form__dialog-body">
654
+ <p class="text-body-2">{{ contractorTypeChangeMessage }}</p>
655
+ </div>
656
+ <div class="visitor-form__footer">
657
+ <AppButton variant="ghost" @click="cancelContractorTypeChange">Cancel</AppButton>
658
+ <AppButton @click="confirmContractorTypeChange">Change</AppButton>
659
+ </div>
660
+ </v-card>
661
+ </v-dialog>
662
+
663
+ <v-dialog :model-value="!!deliveryCompanyEdit" max-width="430" persistent>
664
+ <v-card class="screen-modal">
665
+ <div class="visitor-form__head">
666
+ <v-card-title>Edit Company</v-card-title>
667
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="deliveryCompanyEdit = null" />
668
+ </div>
669
+ <div class="screen-modal__body visitor-form__dialog-body filter-field">
670
+ <InputLabel class="text-capitalize" title="Company Name" />
671
+ <v-text-field v-if="deliveryCompanyEdit" v-model="deliveryCompanyEdit.value" density="comfortable"
672
+ :error-messages="deliveryCompanyEdit.error ? [deliveryCompanyEdit.error] : []" autofocus
673
+ @update:model-value="deliveryCompanyEdit && (deliveryCompanyEdit.error = '')"
674
+ @keyup.enter="saveEditDeliveryCompany" />
675
+ </div>
676
+ <div class="visitor-form__footer">
677
+ <AppButton variant="ghost" @click="deliveryCompanyEdit = null">Cancel</AppButton>
678
+ <AppButton :disabled="deliveryCompanySaving" @click="saveEditDeliveryCompany">Save</AppButton>
679
+ </div>
680
+ </v-card>
681
+ </v-dialog>
682
+
683
+ <v-dialog :model-value="!!deliveryCompanyToDelete" max-width="430" persistent>
684
+ <v-card class="screen-modal">
685
+ <div class="visitor-form__head">
686
+ <v-card-title>Delete Company</v-card-title>
687
+ </div>
688
+ <div class="screen-modal__body visitor-form__dialog-body">
689
+ <p class="text-body-2">Remove "{{ deliveryCompanyToDelete }}" from the delivery company list?</p>
690
+ </div>
691
+ <div class="visitor-form__footer">
692
+ <AppButton variant="ghost" @click="deliveryCompanyToDelete = null">Cancel</AppButton>
693
+ <AppButton variant="err" icon="mdi-delete-outline" :disabled="deliveryCompanySaving"
694
+ @click="confirmDeleteDeliveryCompany">Delete</AppButton>
695
+ </div>
696
+ </v-card>
697
+ </v-dialog>
698
+
699
+ <Snackbar v-model="snackbar.show" :text="snackbar.text" :color="snackbar.color" />
700
+
482
701
  <v-dialog v-if="enableUnregistered" v-model="dialog.showUnregisteredPlateDialog" max-width="900" persistent>
483
702
  <v-card class="screen-modal">
484
703
  <!-- No heading in the product; see the note on the check-out dialog. -->
485
704
  <div class="visitor-form__head visitor-form__head--bare">
486
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeUnregisteredPlateDialog" />
705
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeUnregisteredPlateDialog" />
487
706
  </div>
488
707
 
489
708
  <div class="screen-modal__body unreg-dialog__body">
@@ -522,7 +741,7 @@
522
741
  </div>
523
742
 
524
743
  <div class="visitor-form__footer">
525
- <AppButton variant="ghost" @click="closeUnregisteredPlateDialog">Cancel &amp; Add New</AppButton>
744
+ <AppButton variant="ghost" @click="cancelUnregisteredAndAddNew">Cancel &amp; Add New</AppButton>
526
745
  </div>
527
746
  </v-card>
528
747
  </v-dialog>
@@ -531,7 +750,7 @@
531
750
  <v-card class="screen-modal hid-qr-preview-card">
532
751
  <div class="visitor-form__head">
533
752
  <v-card-title>Visitor QR Code</v-card-title>
534
- <AppButton variant="icon" icon="mdi-close" aria-label="Close" @click="closeHidQrPreview" />
753
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeHidQrPreview" />
535
754
  </div>
536
755
 
537
756
  <div class="screen-modal__body hid-qr__body">
@@ -622,6 +841,29 @@ import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
622
841
  import useAccessManagement from "../composables/useAccessManagement";
623
842
  import usePeople from "../composables/usePeople";
624
843
  import useHidAmico from "../composables/useHidAmico";
844
+ import useFile from "../composables/useFile";
845
+ import usePassKey from "../composables/usePassKey";
846
+ import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
847
+ import {
848
+ DELETE_PERSON_TITLE,
849
+ PERSON_DELETED_MESSAGE,
850
+ companiesForType,
851
+ deletePersonMessage,
852
+ isListedCompany,
853
+ listedName,
854
+ normalizeCompany,
855
+ personKey,
856
+ recordIdsOf,
857
+ refusedPassKeyName,
858
+ renameProblem,
859
+ renamedCompany,
860
+ sameCompany,
861
+ summaryContacts,
862
+ summaryPlates,
863
+ withCompany,
864
+ withoutCompany,
865
+ type VisitorPerson,
866
+ } from "../utils/visitor-people";
625
867
 
626
868
  const prop = defineProps({
627
869
  type: {
@@ -651,14 +893,24 @@ const prop = defineProps({
651
893
  enableUnregistered: {
652
894
  type: Boolean,
653
895
  default: false,
654
- }
896
+ },
897
+ /**
898
+ * Passes And Keys Management > See All Passes And Keys AND Update Passes And
899
+ * Keys: either one unticked hides every pass / key picker on the form (the
900
+ * visitor's and each member's), as the security mobile app.
901
+ */
902
+ canAssignPassKeys: {
903
+ type: Boolean,
904
+ default: false,
905
+ },
655
906
  });
656
907
 
657
908
  type AutofillSource = "nric" | "contact" | "vehicleNumber" | null;
658
909
  const currentAutofillSource = ref<AutofillSource>(null);
659
910
 
660
- const { requiredRule, debounce, UTCToLocalTIme, maskNRIC } = useUtils();
661
- const { getSiteById } = useSiteSettings();
911
+ const { requiredRule, debounce, UTCToLocalTIme, maskNRIC, getImage } = useUtils();
912
+ const { addFile } = useFile();
913
+ const { getSiteById, updateSitebyId } = useSiteSettings();
662
914
  const { currentUser } = useLocalAuth();
663
915
  const { getBlocksSelection, getLevelsSelection, getUnitSelection } =
664
916
  useBuilding();
@@ -675,12 +927,8 @@ const { getBySiteId: getEntryPassSettingsBySiteId } =
675
927
  const { createVisitorPass, signQr, visitorCheckout } = useAccessManagement();
676
928
  const { getReaders, issueVisitorQr } = useHidAmico();
677
929
  const { testConnection } = useWebUsb();
678
- const {
679
- findPersonByNRIC,
680
- findPersonByNRICMultipleResult,
681
- findPersonByPhoneNumberMultipleResult,
682
- findPersonByPlateNumberMultipleResult,
683
- } = usePeople();
930
+ const { findVisitorPeopleAnyType, deletePersonRecords } = usePeople();
931
+ const { getPassKeysByPageSearch } = usePassKey();
684
932
  const { getById: getUnitDataById } = useBuildingUnit();
685
933
 
686
934
  const emit = defineEmits([
@@ -758,14 +1006,25 @@ const registeredUnitCompanyName = ref("N/A");
758
1006
 
759
1007
  const dialog = reactive({
760
1008
  nricAutofillOptions: false,
761
- vehicleNumberUsersList: false,
762
1009
  showNonCheckedOutDialog: false,
763
1010
  showUnregisteredPlateDialog: false,
764
1011
  returnNfcCard: false,
1012
+ returnPassKeys: false,
1013
+ deletePerson: false,
1014
+ changeContractorType: false,
765
1015
  });
766
1016
 
1017
+ // Success / info messages (errors stay in the line above the footer).
1018
+ const snackbar = reactive({ show: false, text: "", color: "success" });
1019
+ function showSnackbar(text: string, color = "success") {
1020
+ snackbar.text = text;
1021
+ snackbar.color = color;
1022
+ snackbar.show = true;
1023
+ }
1024
+
767
1025
  const loading = reactive({
768
1026
  checkingOut: false,
1027
+ updatingPassKeys: false,
769
1028
  checkingUnregisteredPlate: false,
770
1029
  searchingNonCheckedOut: false,
771
1030
  });
@@ -779,7 +1038,6 @@ const createMore = ref(false);
779
1038
 
780
1039
  const contractorTypeInput = ref("");
781
1040
  const contractorTypeObj = ref<TDefaultOptionObj | null>(null);
782
- const contractorStep = ref(1);
783
1041
 
784
1042
  const companyNameInput = ref("");
785
1043
  const companyNameObj = ref<string | null>(null);
@@ -799,11 +1057,12 @@ const skipNonCheckedOutNric = ref(false);
799
1057
  const nfcCardReturnStatuses = ref<
800
1058
  { cardId: string; cardNo: string; status: string; remarks: string }[]
801
1059
  >([]);
1060
+ // The card statuses as stored (isecure365 card-return-dialog).
802
1061
  const nfcPassStatusOptions = [
1062
+ { label: "In Use", value: "In Use" },
803
1063
  { label: "Returned", value: "Returned" },
804
- { label: "Not Returned", value: "In Use" },
805
- { label: "Damaged", value: "Damage" },
806
1064
  { label: "Lost", value: "Lost" },
1065
+ { label: "Damage", value: "Damage" },
807
1066
  ];
808
1067
  const selectedCheckoutVisitor = computed(() =>
809
1068
  (matchingNonCheckedOutVisitors.value as any[]).find(
@@ -830,9 +1089,10 @@ const unregisteredPlateMatches = ref<TVisitor[]>([]);
830
1089
  const unregisteredPlateSearchText = ref("");
831
1090
  const selectedUnregisteredVisitor = ref<TVisitor | null>(null);
832
1091
  const skipUnregisteredPlateCheck = ref(false);
833
- const pendingUnregisteredAction = ref<"next" | "submit" | null>(null);
834
-
835
- const vehicleNumberUserItems = ref<TPeople[]>([]);
1092
+ const pendingUnregisteredAction = ref<"submit" | null>(null);
1093
+ // The plate "Cancel & Add New" was pressed for: not checked against the
1094
+ // Unregistered list again (a changed plate is).
1095
+ const unregisteredSkippedPlate = ref("");
836
1096
 
837
1097
  const formMode = computed<"add" | "edit" | "register">(() => {
838
1098
  return selectedUnregisteredVisitor.value ? "register" : prop.mode;
@@ -866,36 +1126,35 @@ const isFormField = (
866
1126
 
867
1127
  const shouldShowField = (
868
1128
  fieldKey: keyof TVisitorPayload | "delivery-company"
869
- ) => {
870
- if ((!withStep2.value && !withStep3.value) || contractorStep.value === 1) {
871
- return isFormField(fieldKey);
872
- }
873
- };
1129
+ ) => isFormField(fieldKey);
874
1130
 
875
- const withStep2 = computed(() => {
876
- const step2Types = ["guest", "walk-in"];
877
- return step2Types.includes(prop.type);
878
- });
1131
+ // Saving onto an existing record (registering a camera record, or editing).
1132
+ const isSavingOntoRecord = computed(
1133
+ () => formMode.value === "register" || prop.mode === "edit"
1134
+ );
879
1135
 
880
- const withStep3 = computed(() => {
881
- const step3Types = ["contractor"];
882
- return step3Types.includes(prop.type);
883
- });
1136
+ // Pass & Keys: Drive-In, Walk-In and Contractor, with both pass & key rights.
1137
+ const showPassSection = computed(
1138
+ () =>
1139
+ prop.canAssignPassKeys &&
1140
+ ["guest", "walk-in", "contractor"].includes(prop.type)
1141
+ );
884
1142
 
885
- const showNextButton = computed(() => {
886
- if (withStep3.value) {
887
- return contractorStep.value === 1 || contractorStep.value === 2;
888
- } else if (withStep2.value) {
889
- return contractorStep.value === 1;
890
- } else return false;
891
- });
1143
+ // A contractor's pass type depends on the Contractor Type.
1144
+ const passLockedMessage = computed(() =>
1145
+ prop.type === "contractor" && !visitor.contractorType
1146
+ ? "Please select the Contractor Type first. The pass allowed depends on it."
1147
+ : ""
1148
+ );
892
1149
 
893
1150
  const enableUnregistered = computed(() => {
894
1151
  return prop.mode === "add" && prop.enableUnregistered
895
1152
  });
896
1153
 
1154
+ // Keys are a contractor thing: the security mobile app (isecure365) offers no
1155
+ // keys to a Drive-In / Walk-In and saves them with \`passKeys: []\`.
897
1156
  const showShowKeysField = computed(() => {
898
- return ["guest", "contractor", "walk-in"].includes(prop.type);
1157
+ return prop.type === "contractor";
899
1158
  });
900
1159
 
901
1160
  // Required / optional per type - kept in line with the security mobile app.
@@ -983,78 +1242,112 @@ const hidQrCodePassAllowed = computed(() => {
983
1242
  );
984
1243
  });
985
1244
 
1245
+ // The entry pass is a contractor's, with the site's NFC pass switch on, as
1246
+ // the security mobile app (the web-only HID QR pass, switched off above, keeps
1247
+ // its own types).
986
1248
  const showEntryPassInformation = computed(() => {
987
1249
  return Boolean(
988
- entryPassSettings.value?.data?.settings?.nfcPass ||
1250
+ (prop.type === "contractor" && entryPassSettings.value?.data?.settings?.nfcPass) ||
989
1251
  hidQrCodePassAllowed.value
990
1252
  );
991
1253
  });
992
1254
 
993
1255
  const companyNames = ref<string[]>([]);
994
1256
  const nricSearchMenu = ref(false);
995
- const nricSearchResults = ref<Partial<TPeople>[]>([]);
1257
+ const nricSearchResults = ref<VisitorPerson[]>([]);
996
1258
  const skipNricFetch = ref(false);
997
1259
  const phoneSearchMenu = ref(false);
998
- const phoneSearchResults = ref<Partial<TPeople>[]>([]);
1260
+ const phoneSearchResults = ref<VisitorPerson[]>([]);
999
1261
  const vehicleSearchMenu = ref(false);
1000
- const vehicleSearchResults = ref<Partial<TPeople>[]>([]);
1001
- 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[]>([]);
1002
1266
  const selectedNricContact = ref("");
1003
1267
  const selectedNricPlate = ref("");
1004
1268
 
1005
- type SearchPerson = Omit<Partial<TPeople>, "contact"> & {
1006
- contact?: string | string[];
1007
- contacts?: string | string[];
1008
- plateNumbers?: string | string[];
1009
- plateNumber?: string | string[];
1010
- plates?: { plateNumber?: string }[];
1011
- };
1012
-
1013
- function getPeopleSearchItems(res: any): Partial<TPeople>[] {
1014
- if (Array.isArray(res?.items)) return res.items;
1015
- if (Array.isArray(res?.data)) return res.data;
1016
- if (Array.isArray(res?.data?.items)) return res.data.items;
1017
- if (res?.data) return [res.data];
1018
- if (res && !res?.message) return [res];
1019
- return [];
1269
+ function contractorPassTypeOf(contractorType?: string) {
1270
+ return contractorType === "property-agent" ? "agent-pass" : "contractor-pass";
1020
1271
  }
1021
1272
 
1022
- const formTitle = computed(() => {
1023
- const isContractorForm = prop.type === "contractor";
1024
- const isDriveInForm = prop.type === "guest";
1025
- const isWalkInForm = prop.type === "walk-in";
1026
- const step = contractorStep.value;
1027
- if ((isContractorForm || isDriveInForm || isWalkInForm) && step === 2) {
1028
- return "Pass & Keys Information";
1029
- } else if (isContractorForm && step === 3) {
1030
- return "Members Information";
1031
- } 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.`;
1032
1284
  });
1033
1285
 
1034
- function handleSelectContractorType(obj: { title: string; value: string }) {
1035
- 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 ?? "";
1036
1289
  // Managing agent staff (site switch): fill in the MA's company if empty.
1037
1290
  if (obj?.value === MA_STAFF_CONTRACTOR_TYPE && !visitor.company) {
1038
1291
  visitor.company = maStaffRegistration.value.company;
1039
1292
  }
1040
1293
  }
1041
1294
 
1042
- function handleFocusedContractorType() {
1043
- // const obj = contractorTypeObj.value;
1044
- // const matched = contractorTypes.some(
1045
- // (x) => x.value === obj?.value && x.title === obj?.title
1046
- // );
1047
- // if (!matched) {
1048
- // contractorTypeObj.value = null;
1049
- // contractorTypeInput.value = ""
1050
- // visitor.contractorType = "";
1051
- // }
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);
1052
1343
  }
1053
1344
 
1054
1345
  const contractorTypeCombo = ref(null);
1346
+ const memberInfoRef = ref<any>(null);
1347
+ const membersKey = ref(0);
1055
1348
 
1056
1349
  async function handleAddNewContractorType() {
1057
- visitor.contractorType = contractorTypeInput.value;
1350
+ handleSelectContractorType({ title: contractorTypeInput.value, value: contractorTypeInput.value });
1058
1351
 
1059
1352
  const combo = contractorTypeCombo.value as any;
1060
1353
  await nextTick();
@@ -1069,45 +1362,126 @@ async function handleSelectCompanyName(company: string) {
1069
1362
 
1070
1363
  const companyCombo = ref(null);
1071
1364
 
1072
- const {
1073
- data: fetchPersonByNRICReq,
1074
- refresh: fetchPersonByNRICRefresh,
1075
- pending: fetchPersonByNRICPending,
1076
- } = useLazyAsyncData(`fetch-person`, () => {
1077
- const NRIC = visitor.nric;
1078
- if (!NRIC || NRIC.length < 1) return Promise.resolve(null);
1079
-
1080
- const type = prop.type === 'walk-in' ? 'guest' : prop.type
1081
- 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,
1082
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
+ }
1083
1408
 
1084
- watch(fetchPersonByNRICReq, (obj) => {
1085
- nricSearchResults.value = getPeopleSearchItems(obj);
1086
- nricSearchMenu.value = nricSearchResults.value.length > 0;
1087
-
1088
- // Mock data for testing NRIC autofill options.
1089
- // Uncomment this block and type at least 4 characters in NRIC to test
1090
- // both the multi-option dialog and immediate single-option autofill.
1091
- // nricSearchResults.value = [
1092
- // {
1093
- // _id: "mock-person-multiple-options",
1094
- // name: "James Smith",
1095
- // nric: "1323232499",
1096
- // contacts: ["653939394", "81234567"],
1097
- // plateNumbers: ["34566", "SMA1234A"],
1098
- // companyName: ["Mock Company Pte Ltd"],
1099
- // } as Partial<SearchPerson>,
1100
- // {
1101
- // _id: "mock-person-single-option",
1102
- // name: "Jane Tan",
1103
- // nric: "S1234567A",
1104
- // contacts: ["91234567"],
1105
- // plateNumbers: ["SGB5678B"],
1106
- // companyName: ["Single Option Company"],
1107
- // } as Partial<SearchPerson>,
1108
- // ];
1109
- // nricSearchMenu.value = true;
1110
- });
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
+ }
1111
1485
 
1112
1486
  watch(
1113
1487
  () => visitor.nric,
@@ -1149,22 +1523,12 @@ function toUniqueStringArray(value: unknown) {
1149
1523
  return [...new Set(rawValues.map((v) => String(v).trim()).filter(Boolean))];
1150
1524
  }
1151
1525
 
1152
- function getPersonContactOptions(item: Partial<TPeople>) {
1153
- const person = item as SearchPerson;
1154
- return toUniqueStringArray(person.contacts || person.contact);
1526
+ function getPersonContactOptions(item: VisitorPerson) {
1527
+ return summaryContacts(item);
1155
1528
  }
1156
1529
 
1157
- function getPersonPlateOptions(item: Partial<TPeople>) {
1158
- const person = item as SearchPerson;
1159
- const visitorPlateNumbers = toUniqueStringArray(person.plateNumbers);
1160
- if (visitorPlateNumbers.length) return visitorPlateNumbers;
1161
-
1162
- const plateNumbers = toUniqueStringArray(person.plateNumber);
1163
- const plates = Array.isArray(person.plates)
1164
- ? person.plates.map((plate) => plate?.plateNumber)
1165
- : [];
1166
-
1167
- return toUniqueStringArray([...plateNumbers, ...plates]);
1530
+ function getPersonPlateOptions(item: VisitorPerson) {
1531
+ return summaryPlates(item).map((plate) => plate.toUpperCase());
1168
1532
  }
1169
1533
 
1170
1534
  function findMatchingContactOption(options: string[], value?: string | null) {
@@ -1200,9 +1564,22 @@ function findMatchingPlateOption(options: string[], value?: string | null) {
1200
1564
  );
1201
1565
  }
1202
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
+ */
1203
1575
  function applyNricSearchResult(
1204
- item: Partial<TPeople>,
1205
- 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
+ } = {}
1206
1583
  ) {
1207
1584
  nricSearchMenu.value = false;
1208
1585
  phoneSearchMenu.value = false;
@@ -1213,52 +1590,67 @@ function applyNricSearchResult(
1213
1590
  skipNricFetch.value = true;
1214
1591
 
1215
1592
  currentAutofillSource.value = options.source || "nric";
1593
+ const searchedBy = options.emptyOnly ? null : options.source || "nric";
1216
1594
 
1217
- const shouldAutofillField = (fieldSource?: Exclude<AutofillSource, null>) => {
1218
- if (!fieldSource) return false;
1219
- return currentAutofillSource.value === fieldSource;
1220
- };
1221
1595
  const getAutofillValue = (
1222
1596
  currentValue: string | undefined,
1223
1597
  nextValue: string | undefined,
1224
- fieldSource?: Exclude<AutofillSource, null>
1598
+ fieldSource?: LookupField
1225
1599
  ) => {
1226
1600
  if (!nextValue) return currentValue;
1227
- if (currentValue && !shouldAutofillField(fieldSource)) return currentValue;
1601
+ if (currentValue?.trim() && (!fieldSource || fieldSource !== searchedBy)) return currentValue;
1228
1602
  return nextValue;
1229
1603
  };
1230
1604
 
1231
- visitor.nric = getAutofillValue(visitor.nric, item.nric, "nric");
1232
- visitor.name = getAutofillValue(visitor.name, item.name);
1233
- visitor.contact = getAutofillValue(
1234
- visitor.contact,
1235
- options.contacts || getPersonContactOptions(item)[0],
1236
- "contact"
1237
- );
1238
-
1239
- if(prop.type !== 'walk-in'){
1240
- visitor.plateNumber = getAutofillValue(
1241
- visitor.plateNumber,
1242
- options.plateNumber || getPersonPlateOptions(item)[0],
1243
- "vehicleNumber"
1244
- );
1605
+ if (isFormField("nric")) {
1606
+ const nric = getAutofillValue(visitor.nric, item.nric, "nric");
1607
+ visitor.nric = (nric ?? "").replace(/[^A-Za-z0-9]/g, "").toUpperCase();
1245
1608
  }
1246
-
1247
- companyNames.value = Array.isArray(item.companyName) ? item.companyName : [];
1248
-
1249
- if (!visitor.company && item.companyName && item.companyName.length === 1) {
1250
- 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
+ ) ?? "";
1251
1617
  }
1252
1618
 
1253
- if (!visitor.company && item.companyName && item.companyName.length > 1) {
1254
- 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();
1255
1627
  }
1256
1628
 
1257
-
1258
- // visitor.block = (item.block as any) ?? (visitor.block as any) ?? "";
1259
- // visitor.level = (item.level as any) ?? (visitor.level as any) ?? "";
1260
- // visitor.unit = item.unit ?? visitor.unit ?? "";
1261
- // 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
+ }
1262
1654
 
1263
1655
  setTimeout(() => {
1264
1656
  currentAutofillSource.value = null;
@@ -1266,28 +1658,47 @@ function applyNricSearchResult(
1266
1658
  }, 1000);
1267
1659
  }
1268
1660
 
1269
- function selectNricSearchResult(item: Partial<TPeople>) {
1661
+ function selectNricSearchResult(item: VisitorPerson) {
1270
1662
  selectAutofillSearchResult(item, "nric");
1271
1663
  }
1272
- function selectPhoneNumberSearchResult(item: Partial<TPeople>) {
1664
+ function selectPhoneNumberSearchResult(item: VisitorPerson) {
1273
1665
  selectAutofillSearchResult(item, "contact");
1274
1666
  }
1275
- function selectPlateNumberSearchResult(item: Partial<TPeople>) {
1667
+ function selectPlateNumberSearchResult(item: VisitorPerson) {
1276
1668
  selectAutofillSearchResult(item, "vehicleNumber");
1277
1669
  }
1278
1670
 
1279
- 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
+ ) {
1280
1677
  const contactOptions = getPersonContactOptions(item);
1281
- const plateOptions = getPersonPlateOptions(item);
1678
+ const plateOptions = prop.type === "walk-in" ? [] : getPersonPlateOptions(item);
1282
1679
  const selectedContact =
1283
1680
  findMatchingContactOption(contactOptions, visitor.contact) || contactOptions[0] || "";
1284
1681
  const selectedPlate =
1285
1682
  findMatchingPlateOption(plateOptions, visitor.plateNumber) || plateOptions[0] || "";
1286
1683
 
1287
- const needsContactChoice = (!visitor.contact?.trim() || source === 'contact') && contactOptions.length > 1;
1288
- 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;
1289
1693
 
1290
1694
  if (needsContactChoice || needsPlateChoice) {
1695
+ chooserResults.value =
1696
+ results ??
1697
+ (source === "nric"
1698
+ ? nricSearchResults.value
1699
+ : source === "contact"
1700
+ ? phoneSearchResults.value
1701
+ : vehicleSearchResults.value);
1291
1702
  nricSearchMenu.value = false;
1292
1703
  phoneSearchMenu.value = false;
1293
1704
  vehicleSearchMenu.value = false;
@@ -1306,7 +1717,7 @@ function selectAutofillSearchResult(item: Partial<TPeople>, source: Exclude<Auto
1306
1717
  });
1307
1718
  }
1308
1719
 
1309
- function closeAutofillSearchResults(source: Exclude<AutofillSource, null>) {
1720
+ function closeAutofillSearchResults(source: LookupField) {
1310
1721
  if (source === "nric") {
1311
1722
  nricSearchMenu.value = false;
1312
1723
  nricSearchResults.value = [];
@@ -1330,9 +1741,18 @@ function closeNricAutofillOptions() {
1330
1741
  selectedNricPlate.value = "";
1331
1742
  }
1332
1743
 
1333
- function confirmNricAutofillOptions() {
1334
- if (!selectedNricSearchResult.value) return;
1335
-
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
+
1336
1756
  applyNricSearchResult(selectedNricSearchResult.value, {
1337
1757
  contacts: selectedNricContact.value,
1338
1758
  plateNumber: selectedNricPlate.value,
@@ -1341,6 +1761,40 @@ function confirmNricAutofillOptions() {
1341
1761
  closeNricAutofillOptions();
1342
1762
  }
1343
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
+
1344
1798
  function getVisitorSearchItems(res: any): TVisitor[] {
1345
1799
  const items =
1346
1800
  res?.items ||
@@ -1376,12 +1830,36 @@ function formatNonCheckedOutDate(date?: string) {
1376
1830
  return date ? UTCToLocalTIme(date) : "N/A";
1377
1831
  }
1378
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
+
1379
1857
  async function checkNonCheckedOutVisitorsBeforeSubmit(
1380
1858
  options: { skipNric?: boolean; showDialog?: boolean } = {}
1381
1859
  ) {
1382
1860
  const { skipNric = skipNonCheckedOutNric.value, showDialog = true } = options;
1383
- const nric = skipNric ? null : visitor.nric || null;
1384
- const contact = visitor.contact || null;
1861
+ const nric = skipNric || !isFormField("nric") ? null : visitor.nric || null;
1862
+ const contact = isFormField("contact") ? visitor.contact || null : null;
1385
1863
 
1386
1864
  if (!nric && !contact) {
1387
1865
  matchingNonCheckedOutVisitors.value = [];
@@ -1391,18 +1869,13 @@ async function checkNonCheckedOutVisitorsBeforeSubmit(
1391
1869
 
1392
1870
  try {
1393
1871
  loading.searchingNonCheckedOut = true;
1394
- const res = await searchVisitorObject({
1395
- page: 1,
1396
- limit: 1000,
1397
- site: prop.site,
1398
- searchObject: {
1399
- nric,
1400
- contact,
1401
- },
1402
- notCheckedOut: true,
1403
- });
1404
- const items = getVisitorSearchItems(res);
1872
+ const items = await findOpenVisits(nric, contact);
1405
1873
  matchingNonCheckedOutVisitors.value = items;
1874
+ if (items.length) {
1875
+ nonCheckedOutFor.kind = "lead";
1876
+ nonCheckedOutFor.nric = "";
1877
+ nonCheckedOutFor.contact = "";
1878
+ }
1406
1879
  dialog.showNonCheckedOutDialog = showDialog && items.length > 0;
1407
1880
  return items.length === 0;
1408
1881
  } catch (error: any) {
@@ -1416,6 +1889,26 @@ async function checkNonCheckedOutVisitorsBeforeSubmit(
1416
1889
  }
1417
1890
  }
1418
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.
1419
1912
  async function handleSkipNricNonCheckedOut() {
1420
1913
  skipNonCheckedOutNric.value = true;
1421
1914
 
@@ -1424,6 +1917,56 @@ async function handleSkipNricNonCheckedOut() {
1424
1917
  });
1425
1918
  if (!canSubmit) return;
1426
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
+ );
1427
1970
  }
1428
1971
 
1429
1972
  function handleCheckout(visitorId: string) {
@@ -1434,12 +1977,10 @@ function handleCheckout(visitorId: string) {
1434
1977
 
1435
1978
  selectedCheckoutVisitorId.value = visitorId;
1436
1979
 
1437
- const visitor = (matchingNonCheckedOutVisitors.value as any[]).find(
1438
- (v) => v?._id === visitorId
1439
- );
1440
- const nfcCards = ((visitor?.cards as any[]) || []).filter(
1441
- (card) => card?.type === "NFC"
1980
+ const v = (matchingNonCheckedOutVisitors.value as any[]).find(
1981
+ (item) => item?._id === visitorId
1442
1982
  );
1983
+ const nfcCards = heldNfcCards(v);
1443
1984
 
1444
1985
  if (nfcCards.length > 0) {
1445
1986
  nfcCardReturnStatuses.value = nfcCards.map((card: any) => ({
@@ -1452,21 +1993,34 @@ function handleCheckout(visitorId: string) {
1452
1993
  return;
1453
1994
  }
1454
1995
 
1996
+ if (holdsPassOrKeys(v)) {
1997
+ openPassKeyReturn(v);
1998
+ return;
1999
+ }
2000
+
1455
2001
  proceedNonCheckedOutCheckout(visitorId);
1456
2002
  }
1457
2003
 
1458
- async function proceedNonCheckedOutCheckout(visitorId: string) {
2004
+ async function proceedNonCheckedOutCheckout(
2005
+ visitorId: string,
2006
+ extra: Record<string, unknown> = {}
2007
+ ) {
1459
2008
  try {
1460
2009
  loading.checkingOut = true;
1461
2010
  const res = await updateVisitor(visitorId as string, {
1462
2011
  checkOut: new Date().toISOString(),
1463
- });
2012
+ ...(currentUser.value?._id ? { updatedBy: currentUser.value._id } : {}),
2013
+ ...extra,
2014
+ } as Partial<TVisitorPayload>);
1464
2015
  if (res) {
1465
2016
  dialog.returnNfcCard = false;
1466
- await checkNonCheckedOutVisitorsBeforeSubmit();
2017
+ dialog.returnPassKeys = false;
2018
+ selectedCheckoutVisitorId.value = null;
2019
+ loading.checkingOut = false;
2020
+ await refreshOpenVisitsAfterCheckout();
1467
2021
  }
1468
2022
  } catch (error: any) {
1469
- const checkoutErrorMessage = error?.response?._data?.message;
2023
+ const checkoutErrorMessage = error?.data?.message || error?.response?._data?.message;
1470
2024
  console.error("[ERROR]", error);
1471
2025
  errorMessage.value =
1472
2026
  checkoutErrorMessage || "An error occurred while checking out the vehicle.";
@@ -1491,35 +2045,194 @@ async function handleNfcReturnCheckout() {
1491
2045
  })),
1492
2046
  });
1493
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;
1494
2059
  await proceedNonCheckedOutCheckout(visitorId);
1495
2060
  }
1496
2061
  } catch (error: any) {
1497
- const checkoutErrorMessage = error?.response?._data?.message;
2062
+ const checkoutErrorMessage = error?.data?.message || error?.response?._data?.message;
1498
2063
  console.error("[ERROR]", error);
1499
- errorMessage.value =
1500
- checkoutErrorMessage || "An error occurred while returning the NFC card.";
2064
+ errorMessage.value = checkoutErrorMessage || "Failed to checkout visitor.";
1501
2065
  } finally {
1502
2066
  loading.checkingOut = false;
1503
2067
  }
1504
2068
  }
1505
2069
 
1506
- function handleAutofillDataViaVehicleNumber(item: TPeople) {
1507
- 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
+ }
1508
2118
 
1509
- visitor.name = item.name;
1510
- visitor.nric = item.nric;
1511
- visitor.contact = item.contact;
1512
- companyNames.value = item.companyName ?? [];
1513
- if (!visitor.company) {
1514
- 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".
1515
2133
  }
1516
- visitor.block = item.block ?? "";
1517
- visitor.level = item.level ?? "";
1518
- visitor.unit = item.unit ?? "";
2134
+ }
1519
2135
 
1520
- setTimeout(() => {
1521
- currentAutofillSource.value = null;
1522
- }, 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
+ });
1523
2236
  }
1524
2237
 
1525
2238
  const { data: entryPassSettings, pending: entryPassSettingsPending } =
@@ -1636,20 +2349,43 @@ const {
1636
2349
  { watch: [() => visitor.level], deep: true }
1637
2350
  );
1638
2351
 
1639
- watch(
1640
- siteData,
1641
- (newVal) => {
1642
- const siteDataValue = newVal as TSite;
1643
- if (siteDataValue) {
1644
- deliveryCompanyList.value = Array.isArray(
1645
- siteDataValue?.deliveryCompanyList
1646
- )
1647
- ? siteDataValue.deliveryCompanyList
2352
+ /*
2353
+ * The delivery company list is read when the Company Name input is clicked,
2354
+ * not when the form opens - each click reads the site's latest list. It is
2355
+ * also read (once) when something needs it before then: an autofill company,
2356
+ * or the save's listed-spelling check.
2357
+ */
2358
+ const deliveryCompaniesLoaded = ref(false);
2359
+ const deliveryCompaniesLoading = ref(false);
2360
+ let deliveryCompaniesRequest: Promise<void> | null = null;
2361
+
2362
+ function loadDeliveryCompanies(): Promise<void> {
2363
+ if (deliveryCompaniesRequest) return deliveryCompaniesRequest;
2364
+ deliveryCompaniesLoading.value = true;
2365
+ deliveryCompaniesRequest = (async () => {
2366
+ try {
2367
+ const site = (await getSiteById(prop.site)) as any;
2368
+ deliveryCompanyList.value = Array.isArray(site?.deliveryCompanyList)
2369
+ ? site.deliveryCompanyList
1648
2370
  : [];
2371
+ deliveryCompaniesLoaded.value = true;
2372
+ } catch {
2373
+ // The list stays as it was; typing a company still works.
2374
+ } finally {
2375
+ deliveryCompaniesLoading.value = false;
2376
+ deliveryCompaniesRequest = null;
1649
2377
  }
1650
- },
1651
- { immediate: true }
1652
- );
2378
+ })();
2379
+ return deliveryCompaniesRequest;
2380
+ }
2381
+
2382
+ async function ensureDeliveryCompanies() {
2383
+ if (!deliveryCompaniesLoaded.value) await loadDeliveryCompanies();
2384
+ }
2385
+
2386
+ function handleDeliveryCompanyFocus(focused: boolean) {
2387
+ if (focused) void loadDeliveryCompanies();
2388
+ }
1653
2389
 
1654
2390
  watch(
1655
2391
  [blocksListData],
@@ -1760,87 +2496,51 @@ function handleUpdateUnit(value: any) {
1760
2496
  visitor.unitName = selectedUnit?.title || "";
1761
2497
  }
1762
2498
 
1763
- const debounceFetchNRIC = debounce(fetchPersonByNRICRefresh, 500);
2499
+ // NRIC Scan (security mobile app `onNricRead`): the NRIC - and the name, when
2500
+ // it was read - filled in as if typed, so the people lookup and the "Skip
2501
+ // NRIC" reset run as they do for a typed NRIC.
2502
+ const nricScanOpen = ref(false);
2503
+ const nricScanNotice = ref("");
2504
+
2505
+ function handleNricScanRead({ nric, name }: { nric: string; name: string | null }) {
2506
+ nricScanOpen.value = false;
2507
+ const next = String(nric || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase();
2508
+ if (!next) return;
2509
+ visitor.nric = next;
2510
+ const fillName = Boolean(name) && isFormField("name");
2511
+ if (fillName) visitor.name = name ?? "";
2512
+ nricScanNotice.value = fillName
2513
+ ? "NRIC and name filled in from the card. Please check them."
2514
+ : "NRIC filled in from the card. Please type the name.";
2515
+ if (next.length >= 4) void lookup("nric");
2516
+ }
1764
2517
 
1765
2518
  function handleUpdateNRIC() {
2519
+ nricScanNotice.value = "";
1766
2520
  if (skipNricFetch.value) return;
1767
2521
  if (!isNricFieldFocused.value) return;
1768
- if (visitor.nric && visitor.nric?.length < 4) return;
1769
- if(prop.type === 'walk-in' || prop.type === 'drop-off') return;
1770
- debounceFetchNRIC();
2522
+ scheduleLookup("nric");
1771
2523
  }
1772
2524
 
1773
-
1774
- const {
1775
- data: fetchPersonByPhoneNumber,
1776
- refresh: fetchPersonByPhoneNumberRefresh,
1777
- pending: fetchPersonByPhoneNumberPending,
1778
- } = useLazyAsyncData(`fetch-person-phone-number`, () => {
1779
- const contact = visitor.contact;
1780
- if (!contact || contact.length < 4) return Promise.resolve(null);
1781
- const type = prop.type === 'walk-in' ? 'guest' : prop.type
1782
- return findPersonByPhoneNumberMultipleResult(contact, prop.site, type);
1783
- });
1784
-
1785
- watch(fetchPersonByPhoneNumber, (obj) => {
1786
- phoneSearchResults.value = getPeopleSearchItems(obj);
1787
- phoneSearchMenu.value = phoneSearchResults.value.length > 0;
1788
- });
1789
-
1790
- const debounceFetchPhoneNumber = debounce(fetchPersonByPhoneNumberRefresh, 500);
1791
-
1792
2525
  function handleUpdatePhoneNumber() {
2526
+ if (skipNricFetch.value) return;
1793
2527
  if (!isPhoneFieldFocused.value) return;
1794
-
1795
- const search = visitor.contact?.trim() || "";
1796
- if (search.length < 4) {
1797
- phoneSearchResults.value = [];
1798
- phoneSearchMenu.value = false;
1799
- return;
1800
- }
1801
- if(prop.type === 'walk-in' || prop.type === 'drop-off') return;
1802
-
1803
- debounceFetchPhoneNumber();
2528
+ scheduleLookup("contact");
1804
2529
  }
1805
2530
 
1806
- const {
1807
- data: fetchPersonByPlateNumber,
1808
- refresh: fetchPersonByPlateNumberRefresh,
1809
- pending: fetchPersonByPlateNumberPending,
1810
- } = useLazyAsyncData(`fetch-person-plate-number`, () => {
1811
- const plateNumber = visitor.plateNumber?.trim()?.toUpperCase() || "";
1812
- if (plateNumber.length < 4) return Promise.resolve(null);
1813
- const type = prop.type === 'walk-in' ? 'guest' : prop.type
1814
- return findPersonByPlateNumberMultipleResult(plateNumber, prop.site, type);
1815
- });
1816
-
1817
- watch(fetchPersonByPlateNumber, (obj) => {
1818
- vehicleSearchResults.value = getPeopleSearchItems(obj);
1819
- vehicleSearchMenu.value = vehicleSearchResults.value.length > 0;
1820
- });
1821
-
1822
- const debounceFetchPlateNumber = debounce(fetchPersonByPlateNumberRefresh, 500);
1823
-
1824
2531
  function handleUpdateVehicleNumber() {
2532
+ if (skipNricFetch.value) return;
1825
2533
  if (!isPlateNumberFieldFocused.value) return;
1826
-
1827
- const search = visitor.plateNumber?.trim()?.toUpperCase() || "";
1828
- if (search.length < 4) {
1829
- vehicleSearchResults.value = [];
1830
- vehicleSearchMenu.value = false;
1831
- return;
1832
- }
1833
-
1834
- if(prop.type === 'walk-in' || prop.type === 'drop-off') return;
1835
-
1836
- debounceFetchPlateNumber();
2534
+ // A walk-in has no plate field.
2535
+ if (prop.type === "walk-in") return;
2536
+ scheduleLookup("vehicleNumber");
1837
2537
  }
1838
2538
 
1839
2539
  const handleFocusPlateNumber = () => {
1840
2540
  isPlateNumberFieldFocused.value = true;
1841
- if (prop.mode === 'register') {
1842
-
1843
- handleUpdateVehicleNumber();
2541
+ // Registering a camera record: focusing its plate looks it up again.
2542
+ if (formMode.value === "register" && !vehicleSearchResults.value.length) {
2543
+ void lookup("vehicleNumber", { prefilled: true });
1844
2544
  }
1845
2545
  };
1846
2546
 
@@ -1850,12 +2550,6 @@ function backToSelection() {
1850
2550
  message.value = "";
1851
2551
  }
1852
2552
 
1853
- function handleGoToPreviousPage() {
1854
- if (contractorStep.value > 1) {
1855
- contractorStep.value--;
1856
- }
1857
- }
1858
-
1859
2553
  function close() {
1860
2554
  emit("close");
1861
2555
  message.value = "";
@@ -1880,7 +2574,7 @@ function formatVisitorType(type: TVisitorType): string {
1880
2574
  }
1881
2575
  }
1882
2576
 
1883
- async function checkUnregisteredPlateBeforeContinue(action: "next" | "submit") {
2577
+ async function checkUnregisteredPlateBeforeContinue(action: "submit") {
1884
2578
  if (
1885
2579
  !enableUnregistered.value ||
1886
2580
  skipUnregisteredPlateCheck.value ||
@@ -1934,11 +2628,7 @@ function formatUnregisteredCheckIn(checkIn?: string) {
1934
2628
  async function continuePendingUnregisteredAction() {
1935
2629
  const action = pendingUnregisteredAction.value;
1936
2630
  pendingUnregisteredAction.value = null;
1937
- if (action === "next") {
1938
- await handleNextPage();
1939
- } else if (action === "submit") {
1940
- await submit();
1941
- }
2631
+ if (action === "submit") await submit();
1942
2632
  }
1943
2633
 
1944
2634
  async function closeUnregisteredPlateDialog() {
@@ -1947,259 +2637,634 @@ async function closeUnregisteredPlateDialog() {
1947
2637
  skipUnregisteredPlateCheck.value = true;
1948
2638
  }
1949
2639
 
2640
+ // "Cancel & Add New" (as the security mobile app): none of the listed records
2641
+ // is this visitor, so the paused Submit carries on straight away as a NEW
2642
+ // visitor - this plate is not checked against the Unregistered list again (a
2643
+ // changed plate is, see the plate watcher), so the list cannot keep reopening.
2644
+ async function cancelUnregisteredAndAddNew() {
2645
+ await closeUnregisteredPlateDialog();
2646
+ await continuePendingUnregisteredAction();
2647
+ }
2648
+
2649
+ /**
2650
+ * "Update" on an unregistered camera record (isecure365 `registerMatch`):
2651
+ * nothing is saved yet. The form switches to registering THAT record - its
2652
+ * plate, what was typed kept - and the guard submits. New photos, the pass /
2653
+ * keys and the members picked so far are not carried over, and a delivery
2654
+ * takes the record's delivery type, or Food.
2655
+ */
1950
2656
  async function selectUnregisteredPlateMatch(match: TVisitor) {
2657
+ if (!match?._id) {
2658
+ errorMessage.value = "Invalid unregistered vehicle selected.";
2659
+ return;
2660
+ }
1951
2661
  selectedUnregisteredVisitor.value = match;
1952
2662
  skipUnregisteredPlateCheck.value = true;
1953
2663
  dialog.showUnregisteredPlateDialog = false;
1954
2664
  unregisteredPlateMatches.value = [];
2665
+ pendingUnregisteredAction.value = null;
2666
+
2667
+ visitor.plateNumber = (match.plateNumber || "").toUpperCase();
2668
+ if (prop.type === "delivery") {
2669
+ visitor.deliveryType = (match as any).deliveryType || "food";
2670
+ }
2671
+ visitor.attachments = [];
2672
+ clearDeliveryPhotos();
2673
+ visitor.visitorPass = [];
2674
+ visitor.passKeys = [];
2675
+ visitor.members = [];
2676
+ passInfoKey.value++;
2677
+ memberInfoRef.value?.reset?.();
2678
+ membersKey.value++;
2679
+ }
1955
2680
 
1956
- visitor.plateNumber = match.plateNumber;
1957
- visitor.attachments = match.attachments || visitor.attachments;
1958
- visitor.block = match.block || visitor.block;
1959
- visitor.level = match.level || visitor.level;
1960
- visitor.unit = match.unit || visitor.unit;
1961
- visitor.company = match.company || visitor.company;
1962
- visitor.contractorType = match.contractorType || visitor.contractorType;
1963
- visitor.name = match.name || visitor.name;
1964
- visitor.contact = match.contact || visitor.contact;
1965
- visitor.nric = match.nric || visitor.nric;
1966
- visitor.company = match.company || visitor.company;
1967
- visitor.remarks = match.remarks || visitor.remarks;
2681
+ /*
2682
+ * Delivery photos (isecure365 `PhotoCarouselField`): kept as files until
2683
+ * Submit, then uploaded and sent as `attachments` (file ids). A photo already
2684
+ * uploaded by an earlier Submit keeps its id, so a retry does not upload it
2685
+ * twice.
2686
+ */
2687
+ type TDeliveryPhoto = { name: string; data: File; url: string; id?: string };
2688
+ const MAX_DELIVERY_PHOTOS = 5;
2689
+ const deliveryPhotos = ref<TDeliveryPhoto[]>([]);
2690
+ const deliveryPhotoIndex = ref(0);
2691
+ const deliveryGalleryInput = ref<HTMLInputElement | null>(null);
2692
+ const deliveryCamera = ref<{ openCameraDialog: () => void } | null>(null);
2693
+ const deliveryPhotoEdit = reactive({ open: false, url: "", idx: 0 });
2694
+
2695
+ function onDeliveryGalleryPicked(event: Event) {
2696
+ const input = event.target as HTMLInputElement;
2697
+ const files = Array.from(input.files ?? []).map((file) => ({
2698
+ name: file.name,
2699
+ data: file,
2700
+ url: URL.createObjectURL(file),
2701
+ }));
2702
+ addDeliveryPhotos(files);
2703
+ input.value = "";
2704
+ }
2705
+
2706
+ function editDeliveryPhoto() {
2707
+ const photo = deliveryPhotos.value[deliveryPhotoIndex.value];
2708
+ if (!photo) return;
2709
+ deliveryPhotoEdit.url = photo.url;
2710
+ deliveryPhotoEdit.idx = deliveryPhotoIndex.value;
2711
+ deliveryPhotoEdit.open = true;
2712
+ }
2713
+
2714
+ function removeDeliveryPhotoAt(idx: number) {
2715
+ const photo = deliveryPhotos.value[idx];
2716
+ if (!photo) return;
2717
+ deliveryPhotos.value = deliveryPhotos.value.filter((_, i) => i !== idx);
2718
+ deliveryPhotoIndex.value = Math.max(0, Math.min(deliveryPhotoIndex.value, deliveryPhotos.value.length - 1));
2719
+ }
2720
+
2721
+ const samePhotoFile = (a: File, b: File) =>
2722
+ a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
2723
+
2724
+ function addDeliveryPhotos(files: Array<{ name: string; data: File; url: string }>) {
2725
+ let skipped = false;
2726
+ for (const file of files ?? []) {
2727
+ if (deliveryPhotos.value.some((photo) => samePhotoFile(photo.data, file.data))) continue;
2728
+ if (deliveryPhotos.value.length >= MAX_DELIVERY_PHOTOS) {
2729
+ skipped = true;
2730
+ continue;
2731
+ }
2732
+ deliveryPhotos.value.push({ name: file.name, data: file.data, url: file.url });
2733
+ deliveryPhotoIndex.value = deliveryPhotos.value.length - 1;
2734
+ }
2735
+ if (skipped) showSnackbar(`You can add up to ${MAX_DELIVERY_PHOTOS} photos.`, "error");
2736
+ }
2737
+
2738
+ // Edit: the drawn-on photo replaces the original (it is a new file).
2739
+ async function onDeliveryPhotoEdited(url: string, idx: number) {
2740
+ const photo = deliveryPhotos.value[idx];
2741
+ if (!photo) return;
2742
+ deliveryPhotoEdit.open = false;
2743
+ const blob = await getImage(url);
2744
+ if (!blob) return;
2745
+ deliveryPhotos.value[idx] = {
2746
+ name: photo.name,
2747
+ data: new File([blob], photo.data.name, { type: photo.data.type }),
2748
+ url,
2749
+ };
2750
+ }
1968
2751
 
1969
- pendingUnregisteredAction.value = null;
2752
+ function clearDeliveryPhotos() {
2753
+ deliveryPhotos.value = [];
2754
+ deliveryPhotoIndex.value = 0;
1970
2755
  }
1971
2756
 
1972
- async function handleNextPage() {
1973
- errorMessage.value = "";
1974
- formRef.value!.validate();
1975
- if (!validForm.value) {
1976
- errorMessage.value = "Please complete all required fields *";
2757
+ async function uploadDeliveryPhotos(): Promise<string[]> {
2758
+ const ids: string[] = [];
2759
+ for (const photo of deliveryPhotos.value) {
2760
+ if (!photo.id) {
2761
+ const res: any = await addFile(photo.data);
2762
+ const id = res?.id ?? res?._id ?? res?.data?.id;
2763
+ if (!id) throw new Error("upload");
2764
+ photo.id = String(id);
2765
+ }
2766
+ ids.push(photo.id);
2767
+ }
2768
+ return ids;
2769
+ }
2770
+
2771
+ // PassInformation reads its pass only when mounted; bumped to clear it.
2772
+ const passInfoKey = ref(0);
2773
+
2774
+ // Pass ids held by members: not offered for the contractor's own pass.
2775
+ const memberPassIds = computed(() =>
2776
+ (visitor.members ?? []).flatMap((m) => (m.visitorPass ?? []).map((p) => p.keyId)).filter(Boolean)
2777
+ );
2778
+
2779
+ /*
2780
+ * The save - the same process as the security mobile app (isecure365
2781
+ * `add.tsx` buildPayload), for every visitor type. Only the screens differ.
2782
+ *
2783
+ * The fields an update always sends, so emptying one on the form empties it on
2784
+ * the record (each only when this visitor type's form has it).
2785
+ */
2786
+ const CLEARABLE_ON_UPDATE = [
2787
+ "name",
2788
+ "nric",
2789
+ "contact",
2790
+ "company",
2791
+ "contractorType",
2792
+ "deliveryType",
2793
+ "block",
2794
+ "level",
2795
+ "unit",
2796
+ "plateNumber",
2797
+ "remarks",
2798
+ "attachments",
2799
+ ];
2800
+ const OBJECT_ID = /^[a-f0-9]{24}$/i;
2801
+ // Users who record a visit they are not witnessing: their visit opens with no
2802
+ // check-in / check-out (isecure365 `backOffice`).
2803
+ const BACK_OFFICE_USER_TYPES = /^(service-provider|service-provider-member|site)$/i;
2804
+
2805
+ // The site's delivery company list (and the typed name snapped to the
2806
+ // listed spelling, "grabfood" -> "GrabFood").
2807
+ const deliveryCompanyHint = computed(() => {
2808
+ const typed = (deliveryCompany.value || "").trim();
2809
+ if (!typed || isListedCompany(deliveryCompanyList.value, typed)) return "";
2810
+ return "New company - it will be added to the list on submit.";
2811
+ });
2812
+
2813
+ function snapDeliveryCompany() {
2814
+ const typed = deliveryCompany.value || deliveryCompanyInput.value || "";
2815
+ if (!typed.trim()) return;
2816
+ deliveryCompany.value = listedName(deliveryCompanyList.value, typed);
2817
+ }
2818
+
2819
+ /**
2820
+ * After the visitor is saved: a typed delivery company that is not listed yet
2821
+ * is added to the site's list (the latest list is read first, so a company
2822
+ * added elsewhere meanwhile is not lost). Never throws.
2823
+ */
2824
+ /*
2825
+ * Rename / remove a listed delivery company (isecure365
2826
+ * `delivery-company-field.tsx`, `useDeliveryCompanies().saveList`): the site's
2827
+ * latest list is read first and the change applied to it, so a company someone
2828
+ * added elsewhere meanwhile is not lost; then the whole list is saved. The
2829
+ * server refuses a caller without the right to change the site.
2830
+ */
2831
+ const deliveryCompanySaving = ref(false);
2832
+ const deliveryCompanyEdit = ref<{ original: string; value: string; error: string } | null>(null);
2833
+ const deliveryCompanyToDelete = ref<string | null>(null);
2834
+
2835
+ async function saveDeliveryCompanyList(change: (latest: string[]) => string[]) {
2836
+ let latest = [...deliveryCompanyList.value];
2837
+ try {
2838
+ const site = (await getSiteById(prop.site)) as any;
2839
+ if (Array.isArray(site?.deliveryCompanyList)) latest = site.deliveryCompanyList;
2840
+ } catch {
2841
+ // Fall back to the list on screen.
2842
+ }
2843
+ const next = change([...latest]);
2844
+ await updateSitebyId(prop.site, { deliveryCompanyList: next } as any);
2845
+ deliveryCompanyList.value = next;
2846
+ return next;
2847
+ }
2848
+
2849
+ function openEditDeliveryCompany(company: string) {
2850
+ deliveryCompanyEdit.value = { original: company, value: company, error: "" };
2851
+ }
2852
+
2853
+ async function saveEditDeliveryCompany() {
2854
+ const editing = deliveryCompanyEdit.value;
2855
+ if (!editing) return;
2856
+ const problem = renameProblem(deliveryCompanyList.value, editing.original, editing.value);
2857
+ if (problem) {
2858
+ editing.error = problem;
1977
2859
  return;
1978
2860
  }
1979
- const step = contractorStep.value;
1980
- if (step === 2 && passType.value === "QR") {
1981
- if (availableQrCount.value === null) {
1982
- errorMessage.value = "Please wait while QR passes are being generated...";
1983
- return;
2861
+ const name = normalizeCompany(editing.value);
2862
+ deliveryCompanySaving.value = true;
2863
+ try {
2864
+ await saveDeliveryCompanyList((latest) => renamedCompany(latest, editing.original, name));
2865
+ if (sameCompany(deliveryCompany.value || "", editing.original)) deliveryCompany.value = name;
2866
+ deliveryCompanyEdit.value = null;
2867
+ showSnackbar("Company updated.");
2868
+ } catch (error: any) {
2869
+ showSnackbar(error?.data?.message || "Unable to update the company. Please try again.", "error");
2870
+ } finally {
2871
+ deliveryCompanySaving.value = false;
2872
+ }
2873
+ }
2874
+
2875
+ async function confirmDeleteDeliveryCompany() {
2876
+ const company = deliveryCompanyToDelete.value;
2877
+ if (!company) return;
2878
+ deliveryCompanySaving.value = true;
2879
+ try {
2880
+ await saveDeliveryCompanyList((latest) => withoutCompany(latest, company));
2881
+ if (sameCompany(deliveryCompany.value || "", company)) deliveryCompany.value = "";
2882
+ deliveryCompanyToDelete.value = null;
2883
+ showSnackbar("Company deleted.");
2884
+ } catch (error: any) {
2885
+ showSnackbar(error?.data?.message || "Unable to delete the company. Please try again.", "error");
2886
+ } finally {
2887
+ deliveryCompanySaving.value = false;
2888
+ }
2889
+ }
2890
+
2891
+ async function ensureDeliveryCompanyInList(name: string) {
2892
+ if (!name || isListedCompany(deliveryCompanyList.value, name)) return;
2893
+ try {
2894
+ let latest = deliveryCompanyList.value;
2895
+ try {
2896
+ const site = (await getSiteById(prop.site)) as any;
2897
+ if (Array.isArray(site?.deliveryCompanyList)) latest = site.deliveryCompanyList;
2898
+ } catch {
2899
+ // Fall back to the list on screen.
1984
2900
  }
1985
- if (availableQrCount.value === 0) {
1986
- errorMessage.value = "No QR passes available. Add a non-physical card to this unit in Access Management first.";
1987
- return;
2901
+ const next = withCompany(latest, name);
2902
+ await updateSitebyId(prop.site, { deliveryCompanyList: next } as any);
2903
+ deliveryCompanyList.value = next;
2904
+ } catch (error: any) {
2905
+ showSnackbar(
2906
+ `The visitor was saved, but "${name}" could not be added to the company list. ${error?.data?.message ?? ""}`.trim(),
2907
+ "error"
2908
+ );
2909
+ }
2910
+ }
2911
+
2912
+ function buildPayload(isUpdate: boolean): Record<string, unknown> {
2913
+ const payload: Record<string, unknown> = { type: prop.type, site: prop.site };
2914
+
2915
+ // Only what this form has (a Walk-In has no plate, so none is sent - not even
2916
+ // on a register, where the camera record keeps its own). Text is trimmed and
2917
+ // an empty field is left out of a new visit; on an update it is sent as null
2918
+ // so the record is cleared too.
2919
+ for (const key of typeFieldMap[prop.type] as (keyof TVisitorPayload | "delivery-company")[]) {
2920
+ if (!isFormField(key)) continue;
2921
+ if (key === "delivery-company") continue;
2922
+ const raw = visitor[key as keyof TVisitorPayload];
2923
+ const value = typeof raw === "string" ? raw.trim() : raw;
2924
+ const empty =
2925
+ value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
2926
+ if (!empty) {
2927
+ payload[key] = value;
2928
+ } else if (isUpdate && CLEARABLE_ON_UPDATE.includes(key)) {
2929
+ payload[key] = null;
1988
2930
  }
1989
2931
  }
1990
- if (step === 2 && passType.value === "NFC" && passCards.value.length === 0) {
1991
- errorMessage.value = "Please select at least one NFC card before proceeding.";
2932
+
2933
+ // Delivery: the company is the delivery company, in the listed spelling.
2934
+ if (prop.type === "delivery" && isFormField("delivery-company")) {
2935
+ const company = listedName(deliveryCompanyList.value, deliveryCompany.value);
2936
+ if (company) payload.company = company;
2937
+ else if (isUpdate) payload.company = null;
2938
+ }
2939
+
2940
+ // Pick-up and drop-off carry no person, so the type itself becomes the
2941
+ // remark when it is left blank.
2942
+ if ((prop.type === "pick-up" || prop.type === "drop-off") && !payload.remarks) {
2943
+ payload.remarks = prop.type;
2944
+ }
2945
+
2946
+ // Passes: one visitor pass (Drive-In, Walk-In, Contractor), keys for a
2947
+ // contractor only; each entry `add: true` on an existing record.
2948
+ if (["guest", "walk-in", "contractor"].includes(prop.type)) {
2949
+ const entry = (keyId: string) => (isUpdate ? { keyId, add: true } : { keyId });
2950
+ const passId = String(visitor.visitorPass?.[0]?.keyId ?? "");
2951
+ payload.visitorPass = OBJECT_ID.test(passId) ? [entry(passId)] : [];
2952
+ payload.passKeys =
2953
+ prop.type === "contractor"
2954
+ ? (visitor.passKeys ?? [])
2955
+ .map((k) => String(k?.keyId ?? ""))
2956
+ .filter((id) => OBJECT_ID.test(id))
2957
+ .map(entry)
2958
+ : [];
2959
+ }
2960
+
2961
+ // Only contractors bring a crew.
2962
+ if (prop.type === "contractor" && visitor.members?.length) {
2963
+ payload.members = visitor.members.map((member: any) => {
2964
+ const memberPass = String(member.visitorPass?.[0]?.keyId ?? "");
2965
+ return {
2966
+ name: String(member.name ?? "").trim(),
2967
+ nric: member.nric,
2968
+ contact: member.contact,
2969
+ visitorPass: OBJECT_ID.test(memberPass) ? [{ keyId: memberPass }] : [],
2970
+ // Web-only: the member's own entry pass.
2971
+ ...(member.passType
2972
+ ? { passType: member.passType, passQuantity: member.passQuantity, passCards: member.passCards }
2973
+ : {}),
2974
+ };
2975
+ });
2976
+ }
2977
+
2978
+ const userId = currentUser.value?._id;
2979
+ if (isUpdate) {
2980
+ // Registering a camera record promotes it; no unit name on an update.
2981
+ if (formMode.value === "register") payload.status = "registered";
2982
+ delete payload.unitName;
2983
+ if (userId) payload.updatedBy = userId;
2984
+ return payload;
2985
+ }
2986
+
2987
+ payload.org = prop.org;
2988
+ if (userId) payload.createdBy = userId;
2989
+ // Registering a visitor at the guardhouse IS their arrival: checked in as it
2990
+ // is created. A back-office user's visit opens with no times at all.
2991
+ const userType = currentUser.value?.type ?? "";
2992
+ if (BACK_OFFICE_USER_TYPES.test(userType)) {
2993
+ payload.clientUserType = userType;
2994
+ payload.checkIn = null;
2995
+ payload.checkOut = null;
2996
+ } else {
2997
+ payload.checkIn = new Date().toISOString();
2998
+ }
2999
+ return payload;
3000
+ }
3001
+
3002
+ // The ban / white list is answered as a \`code\` in a 200 response: nothing was
3003
+ // saved (isecure365 \`assertNotRejected\`), for every visitor type.
3004
+ function rejectedMessage(res: unknown): string {
3005
+ const code =
3006
+ typeof res === "number" ? res : (res as { code?: number } | null)?.code;
3007
+ if (code === 900)
3008
+ return "Unable to save. The visitor's plate number is on the ban list.";
3009
+ if (code === 901)
3010
+ return "Unable to save. The visitor's plate number is on the white list.";
3011
+ return "";
3012
+ }
3013
+
3014
+ /**
3015
+ * The lead contractor and every member must each have their own phone number,
3016
+ * NRIC and pass - checked again at Submit, as the lead's own fields can change
3017
+ * after members were added.
3018
+ */
3019
+ function crewDuplicateProblem(): string {
3020
+ if (prop.type !== "contractor") return "";
3021
+ const contacts = new Set<string>();
3022
+ const nrics = new Set<string>();
3023
+ const passes = new Set<string>();
3024
+ const leadContact = visitor.contact?.trim() || "";
3025
+ const leadNric = visitor.nric?.trim().toLowerCase() || "";
3026
+ const leadPass = String(visitor.visitorPass?.[0]?.keyId ?? "");
3027
+ if (leadContact) contacts.add(leadContact);
3028
+ if (leadNric) nrics.add(leadNric);
3029
+ if (leadPass) passes.add(leadPass);
3030
+ for (const member of visitor.members ?? []) {
3031
+ const who = member.name?.trim() || "A member";
3032
+ const contact = member.contact?.trim() || "";
3033
+ const nric = member.nric?.trim().toLowerCase() || "";
3034
+ const pass = String(member.visitorPass?.[0]?.keyId ?? "");
3035
+ if (contact && contacts.has(contact))
3036
+ return `${who}: Phone Number is already used by the lead contractor or another member.`;
3037
+ if (nric && nrics.has(nric))
3038
+ return `${who}: NRIC is already used by the lead contractor or another member.`;
3039
+ if (pass && passes.has(pass))
3040
+ return `${who}: Pass is already used by the lead contractor or another member.`;
3041
+ if (contact) contacts.add(contact);
3042
+ if (nric) nrics.add(nric);
3043
+ if (pass) passes.add(pass);
3044
+ }
3045
+ return "";
3046
+ }
3047
+
3048
+ /** The server refused a pass or key ("Pass VP01 is In Use"): unselect it wherever it is picked. */
3049
+ function releaseRefusedPassKey(message: string) {
3050
+ const name = refusedPassKeyName(message);
3051
+ if (!name) return;
3052
+ // A member's pass (known by name in the member picker), else the visitor's
3053
+ // own pass - or keys, for a refused key. The pickers re-read what is
3054
+ // Available the next time they are opened.
3055
+ const ids = new Set<string>(memberInfoRef.value?.passIdsNamed?.(name) ?? []);
3056
+ if (ids.size) {
3057
+ memberInfoRef.value?.clearPasses?.(ids);
1992
3058
  return;
1993
3059
  }
1994
- if (!(await checkUnregisteredPlateBeforeContinue("next"))) return;
1995
- if (step < 3) contractorStep.value++;
1996
- else await submit();
3060
+ if (/^\s*Key\b/i.test(message)) visitor.passKeys = [];
3061
+ else visitor.visitorPass = [];
3062
+ passInfoKey.value++;
1997
3063
  }
1998
3064
 
1999
3065
  async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
2000
- formRef.value!.validate();
2001
3066
  errorMessage.value = "";
2002
- if (!validForm.value) {
2003
- errorMessage.value = "Please complete all required fields *";
3067
+ const result = await formRef.value?.validate?.();
3068
+ const valid = result ? result.valid !== false : validForm.value;
3069
+ if (!valid) {
3070
+ errorMessage.value = "Please check input fields in red.";
2004
3071
  return;
2005
3072
  }
2006
3073
 
2007
3074
  if (passType.value === "HID_QR" && !validateHidQrReaderConfig()) return;
2008
- if (
2009
- prop.mode === "add" &&
2010
- !options.skipNonCheckedOutCheck &&
2011
- !(await checkNonCheckedOutVisitorsBeforeSubmit())
2012
- ) {
3075
+
3076
+ const duplicate = crewDuplicateProblem();
3077
+ if (duplicate) {
3078
+ errorMessage.value = duplicate;
2013
3079
  return;
2014
3080
  }
2015
- if (!(await checkUnregisteredPlateBeforeContinue("submit"))) return;
2016
3081
 
2017
- processing.value = true;
2018
-
2019
- let payload: Partial<TVisitorPayload> = {
2020
- type: prop.type,
2021
- org: prop.org,
2022
- site: prop.site,
2023
- createdBy: prop.currentUser,
2024
- };
2025
-
2026
- if (formMode.value === "add" || formMode.value === "register") {
2027
- const allowedFields = typeFieldMap[prop.type];
2028
- for (const key of allowedFields as (keyof TVisitorPayload)[]) {
2029
- // only what the form has (see isFormField)
2030
- if (isFormField(key) && visitor[key] !== undefined) {
2031
- payload = {
2032
- ...payload,
2033
- [key]: visitor[key],
2034
- };
2035
- }
3082
+ // The entry pass (isecure365 `onSubmit`), messages verbatim.
3083
+ if (prop.type === "contractor" && showEntryPassInformation.value) {
3084
+ if (passType.value === "NFC" && passCards.value.length === 0) {
3085
+ errorMessage.value = "Please select at least one NFC card before proceeding.";
3086
+ return;
2036
3087
  }
2037
-
2038
- const isGuest = prop.type === "guest";
2039
- const isWalkIn = prop.type === "walk-in";
2040
- const isContractor = prop.type === "contractor";
2041
- if (isGuest || isWalkIn || isContractor) {
2042
- payload.visitorPass = visitor.visitorPass;
2043
- payload.passKeys = visitor.passKeys;
3088
+ if (passType.value === "QR" && availableQrCount.value === null) {
3089
+ errorMessage.value = "Please wait while QR passes are being generated...";
3090
+ return;
3091
+ }
3092
+ if (passType.value === "QR" && availableQrCount.value === 0) {
3093
+ errorMessage.value =
3094
+ "No QR passes available for this unit. Please add non-physical cards in Access Management.";
3095
+ return;
3096
+ }
3097
+ if (passType.value === "QR" && !((passQuantity.value ?? 0) > 0)) {
3098
+ errorMessage.value = "QR pass quantity must be at least 1.";
3099
+ return;
2044
3100
  }
2045
-
2046
- delete payload.createdBy;
2047
3101
  }
2048
3102
 
2049
- if (prop.type === "contractor") {
2050
- // contractor type logic payload
2051
- payload = {
2052
- ...payload,
2053
- members: visitor.members,
2054
- };
3103
+ // The checks between a valid form and the save, in the app's order: the
3104
+ // unregistered-plate match (not when registering), then the visitor's own
3105
+ // open visits (only when the form has an NRIC or phone). A plain edit is not
3106
+ // checked: the record itself is one of the open visits.
3107
+ const plainEdit = prop.mode === "edit";
3108
+ if (!plainEdit) {
3109
+ if (
3110
+ formMode.value !== "register" &&
3111
+ isFormField("plateNumber") &&
3112
+ !(await checkUnregisteredPlateBeforeContinue("submit"))
3113
+ ) {
3114
+ return;
3115
+ }
3116
+ if (
3117
+ (isFormField("nric") || isFormField("contact")) &&
3118
+ !options.skipNonCheckedOutCheck &&
3119
+ !(await checkNonCheckedOutVisitorsBeforeSubmit())
3120
+ ) {
3121
+ return;
3122
+ }
2055
3123
  }
2056
3124
 
2057
- if (prop.type === "delivery") {
2058
- payload = {
2059
- ...payload,
2060
- company: deliveryCompany.value,
2061
- };
2062
- }
2063
-
2064
- // add register status when registering unregistered visitor
2065
- if (formMode.value === "register") {
2066
- payload.status = "registered";
2067
- const currentUserId = currentUser.value?._id;
2068
- if (currentUserId) {
2069
- payload.updatedBy = currentUserId;
3125
+ processing.value = true;
3126
+
3127
+ const recordId = selectedUnregisteredVisitor.value?._id || prop.visitorData?._id || "";
3128
+ const isUpdate = isSavingOntoRecord.value && Boolean(recordId);
3129
+ // The listed spelling of the delivery company needs the site's list.
3130
+ if (prop.type === "delivery") await ensureDeliveryCompanies();
3131
+ if (isFormField("attachments")) {
3132
+ try {
3133
+ visitor.attachments = deliveryPhotos.value.length ? await uploadDeliveryPhotos() : [];
3134
+ } catch {
3135
+ errorMessage.value = "Unable to upload the photos. Please try again.";
3136
+ processing.value = false;
3137
+ return;
2070
3138
  }
2071
3139
  }
3140
+ const payload = buildPayload(isUpdate);
2072
3141
 
2073
3142
  try {
2074
- let res;
2075
- if (prop.mode === "add" && selectedUnregisteredVisitor.value?._id) {
2076
- payload.status = "registered";
2077
- const currentUserId = currentUser.value?._id;
2078
- if (currentUserId) {
2079
- payload.updatedBy = currentUserId;
2080
- }
2081
- const { unitName, ...rest } = payload;
2082
- res = await updateVisitor(
2083
- selectedUnregisteredVisitor.value._id,
2084
- rest as TVisitorPayload
2085
- );
2086
- } else if (formMode.value === "add") {
2087
- res = await createVisitor(payload as TVisitorPayload);
2088
- } else if (
2089
- (prop.mode === "edit" || prop.mode === "register") &&
2090
- prop.visitorData?._id
2091
- ) {
2092
- const { unitName, ...rest } = payload;
2093
- res = await updateVisitor(prop.visitorData._id, rest as TVisitorPayload);
3143
+ const res = isUpdate
3144
+ ? await updateVisitor(recordId, payload as Partial<TVisitorPayload>)
3145
+ : await createVisitor(payload as TVisitorPayload);
3146
+
3147
+ const rejected = rejectedMessage(res);
3148
+ if (rejected) {
3149
+ errorMessage.value = rejected;
3150
+ return;
2094
3151
  }
2095
3152
 
2096
- if (res) {
2097
- if (passType.value === "HID_QR") {
2098
- const previewReady = await prepareHidQrCodePreview(getVisitorIdFromResponse(res));
2099
- if (!previewReady) return;
2100
- hidQrPendingCompletion.value = createMore.value ? "done:more" : "done";
2101
- return;
2102
- }
3153
+ if (!res) return;
2103
3154
 
2104
- if (
2105
- prop.type === "contractor" &&
2106
- (passType.value === "QR" || passType.value === "NFC")
2107
- ) {
2108
- const visitorId = res as unknown as string;
2109
- const rawUrl = entryPassSettings.value?.data?.settings?.url;
2110
- const { encrypted: acmUrl } = await $fetch<{ encrypted: string }>(
2111
- `/api/encrypt-acm-url${rawUrl ? `?url=${encodeURIComponent(rawUrl)}` : ""
2112
- }`
2113
- );
2114
- if (visitorId && acmUrl) {
2115
- const passRes = await createVisitorPass({
2116
- site: prop.site,
2117
- unitId: visitor.unit!,
2118
- quantity: passQuantity.value ?? 1,
2119
- type: passType.value === "QR" ? "QRCODE" : passType.value,
2120
- nfcCards: passCards.value,
2121
- acm_url: acmUrl,
2122
- user: {
2123
- visitorId,
2124
- name: visitor.name,
2125
- nric: visitor.nric,
2126
- contact: visitor.contact,
2127
- company: visitor.company,
2128
- type: visitor.type,
2129
- plateNumber: visitor.plateNumber,
2130
- },
2131
- });
3155
+ // A delivery company typed in that is not on the site's list yet.
3156
+ if (prop.type === "delivery" && typeof payload.company === "string") {
3157
+ await ensureDeliveryCompanyInList(payload.company);
3158
+ }
2132
3159
 
2133
- if (passType.value === "QR") {
2134
- const accessCards: any[] =
2135
- (passRes as any)?.data?.accessCards ?? [];
2136
- const printer = entryPassSettings.value?.data?.settings?.printer;
2137
- if (!printer?.vendorId || !printer?.productId) {
2138
- console.warn("[QR Print] Aborted: no printer configured.");
2139
- } else if (!accessCards.length) {
2140
- console.warn("[QR Print] Aborted: accessCards is empty.");
2141
- } else {
2142
- const vendorId = parseInt(printer.vendorId);
2143
- const productId = parseInt(printer.productId);
2144
- const companyName = visitor.company || "";
2145
- const blockLabel =
2146
- blocksArray.value.find((b: any) => b.value === visitor.block)
2147
- ?.title || "";
2148
- const levelLabel =
2149
- levelsArray.value.find((l: any) => l.value === visitor.level)
2150
- ?.title || "";
2151
- const unitLabel =
2152
- unitsArray.value.find((u: any) => u.value === visitor.unit)
2153
- ?.title || "";
2154
- const address = [blockLabel, levelLabel, unitLabel]
2155
- .filter(Boolean)
2156
- .join("/");
2157
-
2158
- await nextTick();
2159
- for (const qrCode of accessCards) {
2160
- const signed = await signQr({
2161
- cardId: qrCode._id,
2162
- purpose: "vms",
2163
- });
2164
- const qrData = signed?.data;
2165
- if (!qrData) {
2166
- console.warn(
2167
- "[QR Print] Skipping card",
2168
- qrCode._id,
2169
- "— signQr returned no data."
2170
- );
2171
- continue;
2172
- }
2173
- const printResult = await testConnection(
2174
- vendorId,
2175
- productId,
2176
- true,
2177
- qrData,
2178
- qrCode.accessLevel,
2179
- qrCode.liftAccessLevel,
2180
- companyName,
2181
- address
2182
- );
2183
- }
2184
- }
2185
- }
2186
- }
2187
- }
2188
- if (createMore.value) {
2189
- emit("done:more");
2190
- } else emit("done");
3160
+ const visitorId = isUpdate ? recordId : getVisitorIdFromResponse(res);
3161
+
3162
+ if (passType.value === "HID_QR") {
3163
+ const previewReady = await prepareHidQrCodePreview(visitorId);
3164
+ if (!previewReady) return;
3165
+ hidQrPendingCompletion.value = createMore.value ? "done:more" : "done";
3166
+ return;
3167
+ }
3168
+
3169
+ // The contractor's entry pass, issued once the visitor exists - for a
3170
+ // register too, against the record being registered.
3171
+ if (
3172
+ prop.type === "contractor" &&
3173
+ showEntryPassInformation.value &&
3174
+ (passType.value === "QR" || passType.value === "NFC")
3175
+ ) {
3176
+ await issueEntryPass(visitorId, payload);
2191
3177
  }
3178
+
3179
+ if (createMore.value) {
3180
+ emit("done:more");
3181
+ } else emit("done");
2192
3182
  } catch (error: any) {
2193
- const err = error?.data?.message;
3183
+ const err = error?.data?.message || error?.response?._data?.message;
2194
3184
  errorMessage.value =
2195
3185
  err ||
2196
- `Failed to ${formMode.value === "add" ? "add" : "update"
2197
- } visitor. Please try again.`;
3186
+ `Failed to ${isUpdate ? "update" : "add"} visitor. Please try again.`;
3187
+ // "Pass VP01 is In Use": the pass is taken off the form so another (or
3188
+ // none) can be picked and submitted again (isecure365).
3189
+ if (err) releaseRefusedPassKey(err);
2198
3190
  } finally {
2199
3191
  processing.value = false;
2200
3192
  }
2201
3193
  }
2202
3194
 
3195
+ /** Never fails the save: the visitor is already saved. */
3196
+ async function issueEntryPass(visitorId: string, payload: Record<string, unknown>) {
3197
+ if (!visitorId) return;
3198
+ try {
3199
+ const rawUrl = entryPassSettings.value?.data?.settings?.url;
3200
+ const { encrypted: acmUrl } = await $fetch<{ encrypted: string }>(
3201
+ `/api/encrypt-acm-url${rawUrl ? `?url=${encodeURIComponent(rawUrl)}` : ""}`
3202
+ );
3203
+ if (!acmUrl) throw new Error("The entry pass address could not be read.");
3204
+ const passRes = await createVisitorPass({
3205
+ site: prop.site,
3206
+ unitId: visitor.unit!,
3207
+ quantity:
3208
+ passType.value === "NFC" ? passCards.value.length : passQuantity.value ?? 1,
3209
+ type: passType.value === "QR" ? "QRCODE" : passType.value,
3210
+ nfcCards: passType.value === "NFC" ? passCards.value : [],
3211
+ acm_url: acmUrl,
3212
+ user: {
3213
+ visitorId,
3214
+ name: payload.name,
3215
+ nric: payload.nric,
3216
+ contact: payload.contact,
3217
+ company: payload.company,
3218
+ type: payload.type,
3219
+ plateNumber: payload.plateNumber,
3220
+ },
3221
+ } as any);
3222
+
3223
+ if (passType.value === "QR") {
3224
+ const accessCards: any[] = (passRes as any)?.data?.accessCards ?? [];
3225
+ const printer = entryPassSettings.value?.data?.settings?.printer;
3226
+ if (!printer?.vendorId || !printer?.productId) {
3227
+ console.warn("[QR Print] Aborted: no printer configured.");
3228
+ } else if (!accessCards.length) {
3229
+ console.warn("[QR Print] Aborted: accessCards is empty.");
3230
+ } else {
3231
+ const vendorId = parseInt(printer.vendorId);
3232
+ const productId = parseInt(printer.productId);
3233
+ const companyName = String(payload.company ?? "");
3234
+ const address = buildVisitorAddress();
3235
+
3236
+ await nextTick();
3237
+ for (const qrCode of accessCards) {
3238
+ const signed = await signQr({
3239
+ cardId: qrCode._id,
3240
+ purpose: "vms",
3241
+ });
3242
+ const qrData = signed?.data;
3243
+ if (!qrData) {
3244
+ console.warn("[QR Print] Skipping card", qrCode._id, "— signQr returned no data.");
3245
+ continue;
3246
+ }
3247
+ await testConnection(
3248
+ vendorId,
3249
+ productId,
3250
+ true,
3251
+ qrData,
3252
+ qrCode.accessLevel,
3253
+ qrCode.liftAccessLevel,
3254
+ companyName,
3255
+ address
3256
+ );
3257
+ }
3258
+ }
3259
+ }
3260
+ } catch (error: any) {
3261
+ showSnackbar(
3262
+ `Visitor saved, pass not issued. The visitor was registered, but the entry pass could not be issued. ${error?.data?.message ?? error?.message ?? ""}`.trim(),
3263
+ "error"
3264
+ );
3265
+ }
3266
+ }
3267
+
2203
3268
  function parseUsbId(value: unknown) {
2204
3269
  if (typeof value === "number") return value;
2205
3270
  const text = String(value || "").trim();
@@ -2451,28 +3516,106 @@ watch(
2451
3516
  }
2452
3517
  );
2453
3518
 
2454
- const membersFieldIncomplete = computed(() => {
2455
- if (!visitor.members || visitor.members.length === 0) return false;
2456
-
2457
- return visitor.members.some((member) => {
2458
- const hasName = !!member.name;
2459
- const hasNric = !!member.nric;
2460
- const hasContact = !!member.contact;
2461
-
2462
- return !hasName || !hasNric || !hasContact;
2463
- });
2464
- });
2465
-
2466
3519
  onMounted(() => {
2467
- contractorStep.value = 1;
2468
3520
  currentAutofillSource.value = null;
2469
3521
 
3522
+ // A delivery is Food unless changed.
3523
+ if (prop.type === "delivery" && !visitor.deliveryType) visitor.deliveryType = "food";
3524
+
3525
+ // Registering a camera record: only its plate is filled in (editable); the
3526
+ // guard keys in the rest, and the save updates the record by its id.
2470
3527
  if (prop.mode === "register" && prop.visitorData) {
2471
- visitor.plateNumber = prop.visitorData?.plateNumber || "";
3528
+ visitor.plateNumber = (prop.visitorData?.plateNumber || "").toUpperCase();
3529
+
3530
+ // The camera's plate is looked up after the form has opened (security
3531
+ // mobile app `runPrefilledLookups`): one person with nothing to choose
3532
+ // fills only the empty fields, otherwise the matches are offered. Not for
3533
+ // pick-up / drop-off, which carry no person. Does not hold the form back.
3534
+ if ((visitor.plateNumber?.trim() || "").length >= 4) {
3535
+ void lookup("vehicleNumber", { prefilled: true });
3536
+ }
2472
3537
  }
2473
3538
  });
2474
3539
  </script>
2475
3540
  <style scoped>
3541
+ /*
3542
+ * Pass / key / NFC chips: dark bold text and border on the card surface
3543
+ * (theme colours), 28px, the icon keeping its colour - the same chips as the
3544
+ * visitor list's Check In/Out column.
3545
+ */
3546
+ /* Whose passes and keys these are, above the scanner. */
3547
+ .visitor-return__who {
3548
+ display: flex;
3549
+ flex-direction: column;
3550
+ gap: 4px;
3551
+ padding: 10px 12px;
3552
+ margin-bottom: 12px;
3553
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
3554
+ border-radius: 8px;
3555
+ font-size: 0.9375rem;
3556
+ color: rgb(var(--v-theme-on-surface));
3557
+ }
3558
+
3559
+ .visitor-return__who-line {
3560
+ display: flex;
3561
+ align-items: center;
3562
+ gap: 8px;
3563
+ }
3564
+
3565
+ /* The section headings in the return dialogs: readable at a glance. */
3566
+ .visitor-return__label {
3567
+ margin: 6px 0 8px;
3568
+ font-size: 1rem;
3569
+ font-weight: 600;
3570
+ color: rgb(var(--v-theme-on-surface));
3571
+ }
3572
+
3573
+ .visitor-pass-chip.v-chip {
3574
+ --v-chip-height: 28px;
3575
+ font-size: 0.8125rem;
3576
+ padding: 0 10px;
3577
+ background: rgb(var(--v-theme-surface));
3578
+ color: rgb(var(--v-theme-on-surface));
3579
+ border: 1.5px solid rgba(var(--v-theme-on-surface), 0.87);
3580
+ letter-spacing: 0;
3581
+ }
3582
+
3583
+ /* In the return dialogs: the same height as the status dropdown beside it. */
3584
+ .visitor-pass-chip--field.v-chip {
3585
+ --v-chip-height: 40px;
3586
+ font-size: 0.875rem;
3587
+ padding: 0 14px;
3588
+ }
3589
+
3590
+ .visitor-pass-chip--pass :deep(.v-chip__prepend .v-icon) {
3591
+ color: #1565c0;
3592
+ }
3593
+
3594
+ .visitor-pass-chip--key :deep(.v-chip__prepend .v-icon) {
3595
+ color: #e65100;
3596
+ }
3597
+
3598
+ .visitor-pass-chip--nfc :deep(.v-chip__prepend .v-icon) {
3599
+ color: #7b1fa2;
3600
+ }
3601
+
3602
+ /* A Scan button beside its input, the same height (both 40px), like the
3603
+ country code beside the phone number - not on the label's line. */
3604
+ .scan-row {
3605
+ display: flex;
3606
+ align-items: flex-start;
3607
+ gap: 8px;
3608
+ }
3609
+
3610
+ .scan-row__field {
3611
+ flex: 1 1 auto;
3612
+ min-width: 0;
3613
+ }
3614
+
3615
+ .scan-row__btn {
3616
+ flex: 0 0 auto;
3617
+ }
3618
+
2476
3619
  .button-outline-class {
2477
3620
  border: 1px solid rgba(var(--v-theme-primary));
2478
3621
  }
@@ -2538,6 +3681,17 @@ onMounted(() => {
2538
3681
  Deliberately NOT the shared `.screen-modal__footer` - that class is on the
2539
3682
  batch-D branch (#1150) and not on this base, and adding a second copy of it
2540
3683
  to `screens.css` here would collide with that PR. */
3684
+ /* The photo carousel: rounded, clipped to its frame. */
3685
+ .visitor-form__photos {
3686
+ border-radius: 16px;
3687
+ overflow: hidden;
3688
+ }
3689
+
3690
+ /* Small confirm / edit dialogs: the body lines up with the title. */
3691
+ .visitor-form__dialog-body {
3692
+ padding: 4px 16px 0;
3693
+ }
3694
+
2541
3695
  .visitor-form__footer {
2542
3696
  display: flex;
2543
3697
  justify-content: flex-end;