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