@7365admin1/layer-common 4.146.0 → 4.147.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -4
- package/components/AddPassKeyToVisitor.vue +332 -54
- package/components/FileInput.vue +4 -0
- package/components/MemberInformation.vue +580 -281
- package/components/NricScanDialog.vue +512 -0
- package/components/PassInformation.vue +11 -2
- package/components/ScheduleTaskForm.vue +5 -3
- package/components/ScheduleTaskMain.vue +5 -3
- package/components/VisitorForm.vue +1796 -642
- package/components/VisitorFormSelection.vue +51 -18
- package/components/VisitorManagement.vue +1641 -640
- package/components/VisitorPassKeyQRScanner.vue +1 -1
- package/components/VisitorSocketPopUp.vue +72 -23
- package/composables/usePeople.ts +54 -0
- package/composables/useVisitor.ts +4 -2
- package/composables/useVisitorSocket.ts +19 -0
- package/package.json +2 -1
- package/utils/nric-card.ts +278 -0
- package/utils/schedule-task.ts +2 -1
- package/utils/visitor-people.ts +262 -0
|
@@ -4,30 +4,26 @@
|
|
|
4
4
|
<v-card-title class="text-capitalize">
|
|
5
5
|
{{ formMode }} {{ formatVisitorType(type) }}
|
|
6
6
|
</v-card-title>
|
|
7
|
-
<
|
|
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
|
|
127
|
-
autocomplete="off" :hide-no-data="false"
|
|
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
|
-
<!--
|
|
161
|
-
|
|
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="
|
|
170
|
-
<v-radio label="Parcel" value="
|
|
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="
|
|
192
|
-
<v-combobox v-model="deliveryCompany" v-model:search="deliveryCompanyInput" ref="
|
|
193
|
-
:rules="[requiredRule]"
|
|
194
|
-
autocomplete="off" :hide-no-data="false" :items="deliveryCompanyList"
|
|
195
|
-
:loading="
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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-
|
|
263
|
-
<MemberInformation v-model="visitor.members" :type="prop.type"
|
|
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" :
|
|
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'
|
|
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
|
|
297
|
-
|
|
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
|
-
<
|
|
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="
|
|
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="
|
|
340
|
-
<AppButton @click="confirmNricAutofillOptions">
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
363
|
-
|
|
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-
|
|
459
|
-
|
|
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
|
|
555
|
+
:disabled="!canConfirmNfcReturnCheckout" @click="handleNfcReturnCheckout">Confirm & 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
|
-
<
|
|
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="
|
|
744
|
+
<AppButton variant="ghost" @click="cancelUnregisteredAndAddNew">Cancel & 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
|
-
<
|
|
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 {
|
|
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
|
-
|
|
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<"
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
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<
|
|
1257
|
+
const nricSearchResults = ref<VisitorPerson[]>([]);
|
|
996
1258
|
const skipNricFetch = ref(false);
|
|
997
1259
|
const phoneSearchMenu = ref(false);
|
|
998
|
-
const phoneSearchResults = ref<
|
|
1260
|
+
const phoneSearchResults = ref<VisitorPerson[]>([]);
|
|
999
1261
|
const vehicleSearchMenu = ref(false);
|
|
1000
|
-
const vehicleSearchResults = ref<
|
|
1001
|
-
const selectedNricSearchResult = ref<
|
|
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
|
-
|
|
1006
|
-
|
|
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
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
|
1035
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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:
|
|
1153
|
-
|
|
1154
|
-
return toUniqueStringArray(person.contacts || person.contact);
|
|
1526
|
+
function getPersonContactOptions(item: VisitorPerson) {
|
|
1527
|
+
return summaryContacts(item);
|
|
1155
1528
|
}
|
|
1156
1529
|
|
|
1157
|
-
function getPersonPlateOptions(item:
|
|
1158
|
-
|
|
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:
|
|
1205
|
-
options: {
|
|
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?:
|
|
1598
|
+
fieldSource?: LookupField
|
|
1225
1599
|
) => {
|
|
1226
1600
|
if (!nextValue) return currentValue;
|
|
1227
|
-
if (currentValue && !
|
|
1601
|
+
if (currentValue?.trim() && (!fieldSource || fieldSource !== searchedBy)) return currentValue;
|
|
1228
1602
|
return nextValue;
|
|
1229
1603
|
};
|
|
1230
1604
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
-
//
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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:
|
|
1661
|
+
function selectNricSearchResult(item: VisitorPerson) {
|
|
1270
1662
|
selectAutofillSearchResult(item, "nric");
|
|
1271
1663
|
}
|
|
1272
|
-
function selectPhoneNumberSearchResult(item:
|
|
1664
|
+
function selectPhoneNumberSearchResult(item: VisitorPerson) {
|
|
1273
1665
|
selectAutofillSearchResult(item, "contact");
|
|
1274
1666
|
}
|
|
1275
|
-
function selectPlateNumberSearchResult(item:
|
|
1667
|
+
function selectPlateNumberSearchResult(item: VisitorPerson) {
|
|
1276
1668
|
selectAutofillSearchResult(item, "vehicleNumber");
|
|
1277
1669
|
}
|
|
1278
1670
|
|
|
1279
|
-
|
|
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 =
|
|
1288
|
-
|
|
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:
|
|
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
|
-
|
|
1334
|
-
|
|
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
|
|
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
|
|
1438
|
-
(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1517
|
-
visitor.level = item.level ?? "";
|
|
1518
|
-
visitor.unit = item.unit ?? "";
|
|
2134
|
+
}
|
|
1519
2135
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1828
|
-
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
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: "
|
|
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 === "
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
-
|
|
2752
|
+
function clearDeliveryPhotos() {
|
|
2753
|
+
deliveryPhotos.value = [];
|
|
2754
|
+
deliveryPhotoIndex.value = 0;
|
|
1970
2755
|
}
|
|
1971
2756
|
|
|
1972
|
-
async function
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
-
|
|
1991
|
-
|
|
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 (
|
|
1995
|
-
|
|
1996
|
-
|
|
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
|
-
|
|
2003
|
-
|
|
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
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
if (
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
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
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
payload
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
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 ${
|
|
2197
|
-
|
|
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;
|