@7365admin1/layer-common 4.146.0 → 4.147.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -4
- package/components/AddPassKeyToVisitor.vue +332 -54
- package/components/FileInput.vue +4 -0
- package/components/MemberInformation.vue +580 -281
- package/components/NricScanDialog.vue +512 -0
- package/components/PassInformation.vue +11 -2
- package/components/ScheduleTaskForm.vue +5 -3
- package/components/ScheduleTaskMain.vue +5 -3
- package/components/VisitorForm.vue +1796 -642
- package/components/VisitorFormSelection.vue +51 -18
- package/components/VisitorManagement.vue +1641 -640
- package/components/VisitorPassKeyQRScanner.vue +1 -1
- package/components/VisitorSocketPopUp.vue +72 -23
- package/composables/usePeople.ts +54 -0
- package/composables/useVisitor.ts +4 -2
- package/composables/useVisitorSocket.ts +19 -0
- package/package.json +2 -1
- package/utils/nric-card.ts +278 -0
- package/utils/schedule-task.ts +2 -1
- package/utils/visitor-people.ts +262 -0
|
@@ -1,116 +1,158 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The contractor's crew, as the security mobile app (isecure365
|
|
4
|
+
`contractor-members.tsx` + `add.tsx` addMember): a "Members" heading with
|
|
5
|
+
"Add Member", the members added so far as cards (each with Remove), and the
|
|
6
|
+
member input (NRIC, phone, name, pass) in a dialog that "Add Member" opens.
|
|
7
|
+
The dialog's "Add" is where a member is checked; "Cancel" empties the input
|
|
8
|
+
and closes it, so nothing half-entered is left behind.
|
|
9
|
+
-->
|
|
2
10
|
<v-row no-gutters class="w-100">
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
:clearable="clearable" @click="fetchPasses">
|
|
60
|
-
<template v-slot:no-data>
|
|
61
|
-
<v-list-item density="compact">
|
|
62
|
-
<v-list-item-title v-if="passInput">
|
|
63
|
-
No results matching "<strong>{{ passInput }}</strong>". This value will be added as new option.
|
|
64
|
-
</v-list-item-title>
|
|
65
|
-
<v-list-item v-else>No data available</v-list-item>
|
|
66
|
-
</v-list-item>
|
|
67
|
-
</template>
|
|
68
|
-
|
|
69
|
-
<template v-slot:chip="{ props, item }">
|
|
70
|
-
<v-chip v-if="selectedPass" v-bind="props" prepend-icon="mdi-card-bulleted-outline"
|
|
71
|
-
:text="item.raw?.prefixAndName"></v-chip>
|
|
72
|
-
</template>
|
|
73
|
-
</v-autocomplete>
|
|
74
|
-
</v-col>
|
|
75
|
-
|
|
76
|
-
<v-col v-if="props.unitId" cols="12">
|
|
77
|
-
<EntryPassInformation v-model="memberPassType" v-model:quantity="memberPassQuantity"
|
|
78
|
-
v-model:cards="memberPassCards" :settings="props.settings" :loading="props.settingsLoading"
|
|
79
|
-
:site-id="props.site" :unit-id="props.unitId" :visitor-type="props.type"
|
|
80
|
-
:hid-qr-code-enabled="props.hidQrCodeEnabled" :hid-qr-printer-configured="props.hidQrPrinterConfigured"
|
|
81
|
-
:hid-reader-name="props.hidReaderName" :hid-portal-name="props.hidPortalName"
|
|
82
|
-
:excluded-card-ids="props.selectedNfcCards.map((c) => c._id)" />
|
|
83
|
-
</v-col>
|
|
84
|
-
|
|
85
|
-
<v-row class="pt-3" justify="space-between">
|
|
86
|
-
<v-col cols="6">
|
|
87
|
-
<!-- The primitive rather than a class on a v-btn: these two
|
|
88
|
-
need nothing Vuetify owns, and AppButton is the only way
|
|
89
|
-
they draw at the handoff's 40px / 38px. -->
|
|
90
|
-
<AppButton icon="mdi-plus" :disabled="!validForm" @click="handleAddMember">
|
|
91
|
-
Add Member
|
|
92
|
-
</AppButton>
|
|
11
|
+
<div class="w-100 d-flex align-center justify-space-between ga-2">
|
|
12
|
+
<InputLabel class="text-capitalize" :title="`Members (${committedMembers.length})`" />
|
|
13
|
+
<AppButton icon="mdi-plus" data-test="member-add-open"
|
|
14
|
+
:disabled="committedMembers.length >= props.max" @click="openAddMember">
|
|
15
|
+
Add Member
|
|
16
|
+
</AppButton>
|
|
17
|
+
</div>
|
|
18
|
+
<p v-if="committedMembers.length === 0" class="text-caption text-medium-emphasis mt-1 w-100">
|
|
19
|
+
Add anyone entering with this contractor. Skip if they are alone.
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
<v-row v-if="committedMembers.length > 0" no-gutters class="w-100 mt-3 ga-3">
|
|
23
|
+
<template v-for="(member, index) in membersDisplayed" :key="(member.nric || '') + index">
|
|
24
|
+
<CardMemberInfoSummary :member="member" @remove="handleRemoveMember(index)" />
|
|
25
|
+
</template>
|
|
26
|
+
</v-row>
|
|
27
|
+
|
|
28
|
+
<v-dialog v-model="dialog.addMember" max-width="520" persistent>
|
|
29
|
+
<v-card class="screen-modal" :loading="adding">
|
|
30
|
+
<div class="member-dialog__head">
|
|
31
|
+
<v-card-title>Add Member</v-card-title>
|
|
32
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close" @click="cancelAddMember" />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="screen-modal__body member-dialog__body filter-field">
|
|
36
|
+
<v-form ref="formRef" :disabled="adding" @submit.prevent>
|
|
37
|
+
<v-row no-gutters class="pt-2 ga-2">
|
|
38
|
+
<v-col cols="12">
|
|
39
|
+
<!-- Eye and Scan, as the security mobile app's member NRIC. -->
|
|
40
|
+
<InputLabel class="text-capitalize" title="NRIC / Passport / ID No. (Required)" required />
|
|
41
|
+
<div class="scan-row">
|
|
42
|
+
<div class="scan-row__field">
|
|
43
|
+
<v-menu v-model="memberNRICSearchMenu" location="bottom" offset-y :close-on-content-click="false">
|
|
44
|
+
<template #activator="{ props: menuProps }">
|
|
45
|
+
<InputNRICNumber v-bind="menuProps" v-model.trim="memberForm.nric" density="compact" hide-details
|
|
46
|
+
masked :key="memberCurrentAutofillSource + 'member-nric-key'"
|
|
47
|
+
@focus="isMemberNRICFieldFocused = true" @blur="isMemberNRICFieldFocused = false"
|
|
48
|
+
@update:model-value="handleUpdateMemberNRIC" />
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<v-list v-if="memberNRICSearchResults.length" class="pa-1 phone-search-results">
|
|
52
|
+
<v-list-item v-for="(item, index) in memberNRICSearchResults" :key="item._id || index"
|
|
53
|
+
class="cursor-pointer nric-search-item" @click="memberSelectAutofillSearchResult(item, 'nric')">
|
|
54
|
+
<VisitorSearchResultSummary :item="item" />
|
|
55
|
+
<template #append>
|
|
56
|
+
<v-btn icon="mdi-delete-outline" variant="text" size="small" color="error"
|
|
57
|
+
:aria-label="`Delete ${item.name || ''}`" @click.stop="askDeletePerson(item)" />
|
|
58
|
+
</template>
|
|
59
|
+
</v-list-item>
|
|
60
|
+
</v-list>
|
|
61
|
+
</v-menu>
|
|
62
|
+
</div>
|
|
63
|
+
<v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn"
|
|
64
|
+
prepend-icon="mdi-card-account-details-outline" data-test="member-nric-scan"
|
|
65
|
+
:disabled="adding" @click="memberNricScanOpen = true">Scan</v-btn>
|
|
66
|
+
</div>
|
|
93
67
|
</v-col>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
68
|
+
|
|
69
|
+
<v-col cols="12">
|
|
70
|
+
<InputLabel class="text-capitalize" title="Phone Number (Required)" required />
|
|
71
|
+
<v-menu v-model="memberPhoneSearchMenu" location="bottom" offset-y :close-on-content-click="false">
|
|
72
|
+
<template #activator="{ props: menuProps }">
|
|
73
|
+
<InputPhoneNumberV2 v-bind="menuProps" v-model="memberForm.contact" density="compact"
|
|
74
|
+
default-country="SG" hide-details :key="memberCurrentAutofillSource + 'member-phone-key'"
|
|
75
|
+
@focus="isMemberPhoneFieldFocused = true" @blur="isMemberPhoneFieldFocused = false"
|
|
76
|
+
@update:model-value="handleUpdateMemberPhoneNumber" />
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<v-list v-if="memberPhoneSearchResults.length" class="pa-1 phone-search-results">
|
|
80
|
+
<v-list-item v-for="(item, index) in memberPhoneSearchResults" :key="item._id || index"
|
|
81
|
+
class="cursor-pointer nric-search-item" @click="memberSelectAutofillSearchResult(item, 'contact')">
|
|
82
|
+
<VisitorSearchResultSummary :item="item" />
|
|
83
|
+
<template #append>
|
|
84
|
+
<v-btn icon="mdi-delete-outline" variant="text" size="small" color="error"
|
|
85
|
+
:aria-label="`Delete ${item.name || ''}`" @click.stop="askDeletePerson(item)" />
|
|
86
|
+
</template>
|
|
87
|
+
</v-list-item>
|
|
88
|
+
</v-list>
|
|
89
|
+
</v-menu>
|
|
90
|
+
</v-col>
|
|
91
|
+
|
|
92
|
+
<v-col cols="12">
|
|
93
|
+
<InputLabel class="text-capitalize" title="Name (Required)" required />
|
|
94
|
+
<v-text-field v-model.trim="memberForm.name" density="compact" hide-details />
|
|
95
|
+
</v-col>
|
|
96
|
+
|
|
97
|
+
<!-- The member's Visitor Pass: only with See All Passes And Keys AND
|
|
98
|
+
Update Passes And Keys, as the security mobile app. -->
|
|
99
|
+
<v-col v-if="props.canAssignPassKeys" cols="12">
|
|
100
|
+
<InputLabel class="text-capitalize mt-2" title="Visitor Pass (Optional)" />
|
|
101
|
+
<p v-if="props.passDisabledMessage" class="text-caption text-medium-emphasis mt-1">
|
|
102
|
+
{{ props.passDisabledMessage }}
|
|
103
|
+
</p>
|
|
104
|
+
<div v-else class="scan-row">
|
|
105
|
+
<div class="scan-row__field">
|
|
106
|
+
<v-autocomplete v-model="selectedPass" v-model:search="passInput" :hide-no-data="false"
|
|
107
|
+
:items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id"
|
|
108
|
+
variant="outlined" density="compact" :error-messages="errorScanPassMessage" small-chips
|
|
109
|
+
:clearable="clearable" :loading="isPassListLoading" @click="fetchPasses">
|
|
110
|
+
<template v-slot:no-data>
|
|
111
|
+
<v-list-item density="compact">
|
|
112
|
+
<v-list-item-title>No passes available</v-list-item-title>
|
|
113
|
+
</v-list-item>
|
|
114
|
+
</template>
|
|
115
|
+
<template v-slot:chip="{ props: chipProps, item }">
|
|
116
|
+
<v-chip v-if="selectedPass" v-bind="chipProps" prepend-icon="mdi-card-bulleted-outline"
|
|
117
|
+
:text="item.raw?.prefixAndName"></v-chip>
|
|
118
|
+
</template>
|
|
119
|
+
</v-autocomplete>
|
|
120
|
+
</div>
|
|
121
|
+
<v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn" prepend-icon="mdi-qrcode"
|
|
122
|
+
:disabled="adding" @click="handleScanQRPass">Scan Pass</v-btn>
|
|
123
|
+
</div>
|
|
124
|
+
</v-col>
|
|
125
|
+
|
|
126
|
+
<!-- Web-only: a member's own entry pass. -->
|
|
127
|
+
<v-col v-if="props.unitId && props.showEntryPass" cols="12">
|
|
128
|
+
<EntryPassInformation v-model="memberPassType" v-model:quantity="memberPassQuantity"
|
|
129
|
+
v-model:cards="memberPassCards" :settings="props.settings" :loading="props.settingsLoading"
|
|
130
|
+
:site-id="props.site" :unit-id="props.unitId" :visitor-type="props.type"
|
|
131
|
+
:hid-qr-code-enabled="props.hidQrCodeEnabled" :hid-qr-printer-configured="props.hidQrPrinterConfigured"
|
|
132
|
+
:hid-reader-name="props.hidReaderName" :hid-portal-name="props.hidPortalName"
|
|
133
|
+
:excluded-card-ids="props.selectedNfcCards.map((c) => c._id)" />
|
|
98
134
|
</v-col>
|
|
99
135
|
</v-row>
|
|
100
|
-
</v-
|
|
101
|
-
</
|
|
102
|
-
|
|
136
|
+
</v-form>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<p v-if="memberError" class="member-dialog__error" data-test="member-error">{{ memberError }}</p>
|
|
140
|
+
|
|
141
|
+
<div class="member-dialog__footer">
|
|
142
|
+
<AppButton variant="ghost" @click="cancelAddMember">Cancel</AppButton>
|
|
143
|
+
<AppButton data-test="member-add" :disabled="adding" @click="addMember">Add</AppButton>
|
|
144
|
+
</div>
|
|
145
|
+
</v-card>
|
|
146
|
+
</v-dialog>
|
|
147
|
+
|
|
103
148
|
<visitor-pass-key-q-r-scanner :dialog="dialog.scanQRCode" v-model:scannedValue="scannedPassValue"
|
|
104
149
|
@close-dialog="dialog.scanQRCode = false" />
|
|
150
|
+
<NricScanDialog v-model="memberNricScanOpen" @read="handleMemberNricScanRead" />
|
|
151
|
+
|
|
152
|
+
<!-- The number chooser (no title, Back / Fill In), as the security mobile app. -->
|
|
105
153
|
<v-dialog v-model="dialog.memberAutofillOptions" max-width="430">
|
|
106
154
|
<v-card v-if="memberSelectedSearchResult" class="nric-options-card screen-modal">
|
|
107
|
-
<v-card-
|
|
108
|
-
<v-btn icon="mdi-close" variant="text" size="small" @click="memberCloseAutofillOptions" />
|
|
109
|
-
</v-card-actions>
|
|
110
|
-
|
|
111
|
-
<v-card-text class="pa-4 pt-1">
|
|
112
|
-
<h2 class="nric-options-title font-weight-bold mb-4">Select</h2>
|
|
113
|
-
|
|
155
|
+
<v-card-text class="pa-4">
|
|
114
156
|
<div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
|
|
115
157
|
<span>{{ memberSelectedSearchResult.name || "Unknown" }}</span>
|
|
116
158
|
<span class="text-no-wrap">
|
|
@@ -118,39 +160,39 @@
|
|
|
118
160
|
</span>
|
|
119
161
|
</div>
|
|
120
162
|
|
|
121
|
-
<template v-if="
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
memberSelectedContactOptions.length >= 1
|
|
125
|
-
">
|
|
126
|
-
<h3 class="nric-options-section-title font-weight-bold mb-2">
|
|
127
|
-
Contact
|
|
128
|
-
</h3>
|
|
129
|
-
<v-radio-group v-model="memberSelectedContact" hide-details color="success" class="nric-option-group">
|
|
163
|
+
<template v-if="memberSelectedContactOptions.length >= 1">
|
|
164
|
+
<h3 class="nric-options-section-title font-weight-bold mb-2">Contact</h3>
|
|
165
|
+
<v-radio-group v-model="memberSelectedContact" hide-details color="accent" class="nric-option-group">
|
|
130
166
|
<v-radio v-for="contact in memberSelectedContactOptions" :key="contact" :label="contact" :value="contact"
|
|
131
167
|
class="nric-option-radio" />
|
|
132
168
|
</v-radio-group>
|
|
133
169
|
</template>
|
|
134
170
|
</v-card-text>
|
|
135
171
|
|
|
136
|
-
<
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<v-btn variant="flat" class="screen-btn-primary px-5" size="small"
|
|
141
|
-
@click="memberConfirmAutofillOptions">
|
|
142
|
-
Select
|
|
143
|
-
</v-btn>
|
|
144
|
-
</v-card-actions>
|
|
172
|
+
<div class="member-dialog__footer">
|
|
173
|
+
<AppButton variant="ghost" @click="memberChooserBack">Back</AppButton>
|
|
174
|
+
<AppButton @click="memberConfirmAutofillOptions">Fill In</AppButton>
|
|
175
|
+
</div>
|
|
145
176
|
</v-card>
|
|
146
177
|
</v-dialog>
|
|
147
|
-
|
|
148
|
-
<v-
|
|
149
|
-
<
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
178
|
+
|
|
179
|
+
<v-dialog v-model="dialog.deletePerson" max-width="430">
|
|
180
|
+
<v-card class="screen-modal">
|
|
181
|
+
<div class="member-dialog__head">
|
|
182
|
+
<v-card-title>{{ DELETE_PERSON_TITLE }}</v-card-title>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="screen-modal__body visitor-form__dialog-body">
|
|
185
|
+
<p class="text-body-2">{{ deletePersonMessage(personToDelete?.name || "This person") }}</p>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="member-dialog__footer">
|
|
188
|
+
<AppButton variant="ghost" @click="dialog.deletePerson = false">Cancel</AppButton>
|
|
189
|
+
<AppButton variant="err" icon="mdi-delete-outline" :disabled="deletingPerson"
|
|
190
|
+
@click="deletePersonConfirmed">Delete</AppButton>
|
|
191
|
+
</div>
|
|
192
|
+
</v-card>
|
|
193
|
+
</v-dialog>
|
|
194
|
+
|
|
195
|
+
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
154
196
|
</v-row>
|
|
155
197
|
</template>
|
|
156
198
|
|
|
@@ -158,6 +200,17 @@
|
|
|
158
200
|
import type { PropType } from "vue";
|
|
159
201
|
import usePassKey from "../composables/usePassKey";
|
|
160
202
|
import useUtils from "../composables/useUtils";
|
|
203
|
+
import usePeople from "../composables/usePeople";
|
|
204
|
+
import { errorConverter } from "../utils/data";
|
|
205
|
+
import {
|
|
206
|
+
DELETE_PERSON_TITLE,
|
|
207
|
+
PERSON_DELETED_MESSAGE,
|
|
208
|
+
deletePersonMessage,
|
|
209
|
+
personKey,
|
|
210
|
+
recordIdsOf,
|
|
211
|
+
summaryContacts,
|
|
212
|
+
type VisitorPerson,
|
|
213
|
+
} from "../utils/visitor-people";
|
|
161
214
|
|
|
162
215
|
const props = defineProps({
|
|
163
216
|
type: {
|
|
@@ -192,6 +245,10 @@ const props = defineProps({
|
|
|
192
245
|
type: String as PropType<string | null>,
|
|
193
246
|
default: null,
|
|
194
247
|
},
|
|
248
|
+
showEntryPass: {
|
|
249
|
+
type: Boolean,
|
|
250
|
+
default: false,
|
|
251
|
+
},
|
|
195
252
|
hidQrCodeEnabled: {
|
|
196
253
|
type: Boolean,
|
|
197
254
|
default: false,
|
|
@@ -212,25 +269,57 @@ const props = defineProps({
|
|
|
212
269
|
type: Array as PropType<{ _id: string; cardNo: string }[]>,
|
|
213
270
|
default: () => [],
|
|
214
271
|
},
|
|
272
|
+
/** See All Passes And Keys AND Update Passes And Keys. */
|
|
273
|
+
canAssignPassKeys: {
|
|
274
|
+
type: Boolean,
|
|
275
|
+
default: false,
|
|
276
|
+
},
|
|
277
|
+
/** Set: the member's pass picker is locked and shows this. */
|
|
278
|
+
passDisabledMessage: {
|
|
279
|
+
type: String,
|
|
280
|
+
default: "",
|
|
281
|
+
},
|
|
282
|
+
/** The lead contractor's NRIC and phone (E.164), for the duplicate checks. */
|
|
283
|
+
leadNric: {
|
|
284
|
+
type: String,
|
|
285
|
+
default: "",
|
|
286
|
+
},
|
|
287
|
+
leadContact: {
|
|
288
|
+
type: String,
|
|
289
|
+
default: "",
|
|
290
|
+
},
|
|
291
|
+
/**
|
|
292
|
+
* Any visit of this member still open? Resolves true when clear. The form
|
|
293
|
+
* owns the "already inside" dialog (check-out, pass / key return), so it is
|
|
294
|
+
* asked here and, once the member's visits are checked out, calls
|
|
295
|
+
* `addMember()` again.
|
|
296
|
+
*/
|
|
297
|
+
checkOpenVisits: {
|
|
298
|
+
type: Function as PropType<(member: { nric: string; contact: string }) => Promise<boolean>>,
|
|
299
|
+
default: null,
|
|
300
|
+
},
|
|
301
|
+
max: {
|
|
302
|
+
type: Number,
|
|
303
|
+
default: 10,
|
|
304
|
+
},
|
|
215
305
|
});
|
|
216
306
|
|
|
217
|
-
const {
|
|
307
|
+
const { maskNRIC } = useUtils();
|
|
218
308
|
const { getPassKeysByPageSearch } = usePassKey();
|
|
219
|
-
const {
|
|
220
|
-
findPersonByNRICMultipleResult,
|
|
221
|
-
findPersonByPhoneNumberMultipleResult,
|
|
222
|
-
} = usePeople();
|
|
309
|
+
const { findVisitorPeopleAnyType, deletePersonRecords } = usePeople();
|
|
223
310
|
|
|
224
|
-
const
|
|
311
|
+
const emptyMember = (): TMemberInfo => ({
|
|
225
312
|
name: "",
|
|
226
313
|
nric: "",
|
|
227
314
|
visitorPass: [] as { keyId: string }[],
|
|
228
315
|
contact: "",
|
|
229
316
|
});
|
|
230
317
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
const
|
|
318
|
+
const memberForm = reactive<TMemberInfo>(emptyMember());
|
|
319
|
+
|
|
320
|
+
const formRef = ref<any>();
|
|
321
|
+
const adding = ref(false);
|
|
322
|
+
const memberError = ref("");
|
|
234
323
|
const errorScanPassMessage = ref("");
|
|
235
324
|
|
|
236
325
|
const message = ref("");
|
|
@@ -249,8 +338,10 @@ const passItems = ref<TPassKey[]>([]);
|
|
|
249
338
|
const scannedPassValue = ref<string>("");
|
|
250
339
|
|
|
251
340
|
const dialog = reactive({
|
|
341
|
+
addMember: false,
|
|
252
342
|
scanQRCode: false,
|
|
253
343
|
memberAutofillOptions: false,
|
|
344
|
+
deletePerson: false,
|
|
254
345
|
});
|
|
255
346
|
|
|
256
347
|
const selectedPass = ref<string>("");
|
|
@@ -259,37 +350,33 @@ const memberPassQuantity = ref<number | null>(1);
|
|
|
259
350
|
const memberPassCards = ref<{ _id: string; cardNo: string }[]>([]);
|
|
260
351
|
|
|
261
352
|
const members = defineModel<TMemberInfo[]>({ required: true, default: [] });
|
|
262
|
-
const committedMembers = ref<TMemberInfo[]>([]);
|
|
353
|
+
const committedMembers = ref<TMemberInfo[]>([...(members.value ?? [])]);
|
|
354
|
+
|
|
355
|
+
// The model is the members added - never a half-typed input.
|
|
356
|
+
watch(
|
|
357
|
+
committedMembers,
|
|
358
|
+
(list) => {
|
|
359
|
+
members.value = [...list];
|
|
360
|
+
},
|
|
361
|
+
{ deep: true }
|
|
362
|
+
);
|
|
263
363
|
|
|
264
364
|
type AutofillSource = "nric" | "contact" | null;
|
|
265
|
-
type SearchPerson = Omit<Partial<TPeople>, "contact"> & {
|
|
266
|
-
contact?: string | string[];
|
|
267
|
-
contacts?: string | string[];
|
|
268
|
-
};
|
|
269
365
|
|
|
270
366
|
const memberPhoneSearchMenu = ref(false);
|
|
271
367
|
const memberNRICSearchMenu = ref(false);
|
|
272
368
|
const isMemberPhoneFieldFocused = ref(false);
|
|
273
369
|
const isMemberNRICFieldFocused = ref(false);
|
|
274
|
-
const memberPhoneSearchResults = ref<
|
|
275
|
-
const memberNRICSearchResults = ref<
|
|
370
|
+
const memberPhoneSearchResults = ref<VisitorPerson[]>([]);
|
|
371
|
+
const memberNRICSearchResults = ref<VisitorPerson[]>([]);
|
|
276
372
|
const memberCurrentAutofillSource = ref<AutofillSource>(null);
|
|
277
|
-
const memberSelectedSearchResult = ref<
|
|
373
|
+
const memberSelectedSearchResult = ref<VisitorPerson | null>(null);
|
|
278
374
|
const memberSelectedContact = ref("");
|
|
375
|
+
const memberChooserResults = ref<VisitorPerson[]>([]);
|
|
279
376
|
const skipMemberNRICFetch = ref(false);
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
() => memberForm,
|
|
284
|
-
(newForm) => {
|
|
285
|
-
if (memberForm.name && memberForm.nric && memberForm.contact) {
|
|
286
|
-
members.value = [...committedMembers.value, { ...newForm }];
|
|
287
|
-
} else {
|
|
288
|
-
members.value = [...committedMembers.value];
|
|
289
|
-
}
|
|
290
|
-
},
|
|
291
|
-
{ deep: true }
|
|
292
|
-
);
|
|
377
|
+
// The person each field already filled in, so editing the field afterwards
|
|
378
|
+
// does not put them back on every keystroke.
|
|
379
|
+
const autoFilled: Record<string, string> = {};
|
|
293
380
|
|
|
294
381
|
const passTypesComputed = computed(() => {
|
|
295
382
|
if (props.type === "contractor") {
|
|
@@ -306,8 +393,6 @@ const passTypesComputed = computed(() => {
|
|
|
306
393
|
const isPassListLoading = ref(false);
|
|
307
394
|
|
|
308
395
|
async function fetchPasses() {
|
|
309
|
-
// load pass list
|
|
310
|
-
passItems.value = [];
|
|
311
396
|
isPassListLoading.value = true;
|
|
312
397
|
try {
|
|
313
398
|
const result = await getPassKeysByPageSearch({
|
|
@@ -318,7 +403,7 @@ async function fetchPasses() {
|
|
|
318
403
|
statuses: ["Available"],
|
|
319
404
|
sortBy: { column: "prefixAndName", order: "1" },
|
|
320
405
|
});
|
|
321
|
-
passItems.value = result?.items;
|
|
406
|
+
passItems.value = result?.items ?? [];
|
|
322
407
|
} catch (error) {
|
|
323
408
|
showMessage(errorConverter(error as string), "error");
|
|
324
409
|
} finally {
|
|
@@ -326,6 +411,7 @@ async function fetchPasses() {
|
|
|
326
411
|
}
|
|
327
412
|
}
|
|
328
413
|
|
|
414
|
+
// Passes taken by the lead or another member are not offered.
|
|
329
415
|
const passItemsFilteredFinal = computed(() => {
|
|
330
416
|
return passItems.value.filter((item: TPassKey) => {
|
|
331
417
|
return (
|
|
@@ -339,41 +425,53 @@ const passItemsFilteredFinal = computed(() => {
|
|
|
339
425
|
});
|
|
340
426
|
});
|
|
341
427
|
|
|
428
|
+
const passNames = ref<Record<string, string>>({});
|
|
429
|
+
watch(passItems, (items) => {
|
|
430
|
+
for (const item of items ?? []) {
|
|
431
|
+
if (item?._id) passNames.value[item._id] = item.prefixAndName ?? "";
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Cards: name, phone, masked NRIC, and the pass only when one is held.
|
|
342
436
|
const membersDisplayed = computed(() => {
|
|
343
437
|
return committedMembers.value.map((member: TMemberInfo) => {
|
|
438
|
+
const passId = member.visitorPass?.[0]?.keyId ?? "";
|
|
344
439
|
return {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
};
|
|
440
|
+
name: member.name,
|
|
441
|
+
contact: member.contact,
|
|
442
|
+
nric: maskNRIC(member.nric) || member.nric,
|
|
443
|
+
visitorPass: passId ? passNames.value[passId] || "" : "",
|
|
444
|
+
} as unknown as TMemberInfo;
|
|
351
445
|
});
|
|
352
446
|
});
|
|
353
447
|
|
|
354
448
|
function handleScanQRPass() {
|
|
355
449
|
errorScanPassMessage.value = "";
|
|
356
450
|
dialog.scanQRCode = true;
|
|
451
|
+
if (passItems.value.length === 0) void fetchPasses();
|
|
357
452
|
}
|
|
358
453
|
|
|
359
454
|
watch(selectedPass, (newVal) => {
|
|
360
455
|
memberForm.visitorPass = [...(newVal ? [{ keyId: newVal }] : [])];
|
|
361
456
|
});
|
|
362
457
|
|
|
363
|
-
watch(scannedPassValue, (newVal) => {
|
|
458
|
+
watch(scannedPassValue, async (newVal) => {
|
|
364
459
|
if (!newVal) return;
|
|
460
|
+
const scanned = String(newVal).trim().toLowerCase();
|
|
461
|
+
if (passItems.value.length === 0) await fetchPasses();
|
|
365
462
|
|
|
366
463
|
const matchedPass = passItemsFilteredFinal.value.find(
|
|
367
|
-
(p) => p.prefixAndName ===
|
|
464
|
+
(p) => (p.prefixAndName ?? "").toLowerCase() === scanned
|
|
368
465
|
);
|
|
369
466
|
|
|
370
467
|
if (matchedPass) {
|
|
371
468
|
selectedPass.value = matchedPass._id ?? "";
|
|
469
|
+
showMessage(`Pass ${matchedPass.prefixAndName} selected.`, "success");
|
|
372
470
|
return;
|
|
373
471
|
}
|
|
374
472
|
|
|
375
|
-
errorScanPassMessage.value = "
|
|
376
|
-
showMessage(
|
|
473
|
+
errorScanPassMessage.value = `Pass "${newVal}" is not available.`;
|
|
474
|
+
showMessage(errorScanPassMessage.value, "error");
|
|
377
475
|
});
|
|
378
476
|
|
|
379
477
|
function resetAccessCard() {
|
|
@@ -382,25 +480,6 @@ function resetAccessCard() {
|
|
|
382
480
|
memberPassCards.value = [];
|
|
383
481
|
}
|
|
384
482
|
|
|
385
|
-
function getPeopleSearchItems(res: any): Partial<TPeople>[] {
|
|
386
|
-
if (Array.isArray(res?.items)) return res.items;
|
|
387
|
-
if (Array.isArray(res?.data)) return res.data;
|
|
388
|
-
if (Array.isArray(res?.data?.items)) return res.data.items;
|
|
389
|
-
if (res?.data) return [res.data];
|
|
390
|
-
if (res && !res?.message) return [res];
|
|
391
|
-
return [];
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function toUniqueStringArray(value: unknown) {
|
|
395
|
-
const rawValues = Array.isArray(value) ? value : value ? [value] : [];
|
|
396
|
-
return [...new Set(rawValues.map((v) => String(v).trim()).filter(Boolean))];
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function getPersonContactOptions(item: Partial<TPeople>) {
|
|
400
|
-
const person = item as SearchPerson;
|
|
401
|
-
return toUniqueStringArray(person.contacts || person.contact);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
483
|
function findMatchingContactOption(options: string[], value?: string | null) {
|
|
405
484
|
const normalizedValue = value?.trim();
|
|
406
485
|
if (!normalizedValue) return "";
|
|
@@ -419,90 +498,92 @@ function findMatchingContactOption(options: string[], value?: string | null) {
|
|
|
419
498
|
|
|
420
499
|
const memberSelectedContactOptions = computed(() => {
|
|
421
500
|
return memberSelectedSearchResult.value
|
|
422
|
-
?
|
|
501
|
+
? summaryContacts(memberSelectedSearchResult.value)
|
|
423
502
|
: [];
|
|
424
503
|
});
|
|
425
504
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const contact = memberForm.contact?.trim() || "";
|
|
431
|
-
if (contact.length < 4) return Promise.resolve(null);
|
|
432
|
-
const type = props.type === "walk-in" ? "guest" : props.type;
|
|
433
|
-
return findPersonByPhoneNumberMultipleResult(contact, props.site, type);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
watch(fetchMemberByPhoneNumber, (obj) => {
|
|
437
|
-
memberPhoneSearchResults.value = getPeopleSearchItems(obj);
|
|
438
|
-
memberPhoneSearchMenu.value = memberPhoneSearchResults.value.length > 0;
|
|
439
|
-
});
|
|
505
|
+
// Member NRIC / phone lookups (isecure365 `scheduleMemberLookup`): 4
|
|
506
|
+
// characters or more, 500 ms after the last keystroke, across every visitor
|
|
507
|
+
// type. One person is filled in straight away (or the chooser opens).
|
|
508
|
+
const lookupTimers: Record<string, ReturnType<typeof setTimeout> | undefined> = {};
|
|
440
509
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
);
|
|
445
|
-
|
|
446
|
-
function handleUpdateMemberPhoneNumber() {
|
|
447
|
-
if (!isMemberPhoneFieldFocused.value) return;
|
|
510
|
+
function memberTerm(by: "nric" | "contact") {
|
|
511
|
+
return by === "nric" ? memberForm.nric?.trim() || "" : memberForm.contact?.trim() || "";
|
|
512
|
+
}
|
|
448
513
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
514
|
+
function scheduleMemberLookup(by: "nric" | "contact") {
|
|
515
|
+
clearTimeout(lookupTimers[by]);
|
|
516
|
+
if (memberTerm(by).length < 4) {
|
|
517
|
+
if (by === "nric") {
|
|
518
|
+
memberNRICSearchResults.value = [];
|
|
519
|
+
memberNRICSearchMenu.value = false;
|
|
520
|
+
} else {
|
|
521
|
+
memberPhoneSearchResults.value = [];
|
|
522
|
+
memberPhoneSearchMenu.value = false;
|
|
523
|
+
}
|
|
524
|
+
delete autoFilled[by];
|
|
453
525
|
return;
|
|
454
526
|
}
|
|
455
|
-
|
|
456
|
-
if (props.type === "drop-off" || props.type === "pick-up") return;
|
|
457
|
-
debounceFetchMemberPhoneNumber();
|
|
527
|
+
lookupTimers[by] = setTimeout(() => void runMemberLookup(by), 500);
|
|
458
528
|
}
|
|
459
529
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
530
|
+
async function runMemberLookup(by: "nric" | "contact") {
|
|
531
|
+
const term = memberTerm(by);
|
|
532
|
+
if (term.length < 4) return;
|
|
533
|
+
try {
|
|
534
|
+
const results = await findVisitorPeopleAnyType(by, term, props.site, props.type);
|
|
535
|
+
if (memberTerm(by) !== term) return;
|
|
536
|
+
const only = results.length === 1 ? results[0] : null;
|
|
537
|
+
const onlyKey = only ? personKey(only) : "";
|
|
538
|
+
if (only && autoFilled[by] !== onlyKey) {
|
|
539
|
+
autoFilled[by] = onlyKey;
|
|
540
|
+
memberSelectAutofillSearchResult(only, by, [only]);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (by === "nric") {
|
|
544
|
+
memberNRICSearchResults.value = results;
|
|
545
|
+
memberNRICSearchMenu.value = results.length > 0;
|
|
546
|
+
} else {
|
|
547
|
+
memberPhoneSearchResults.value = results;
|
|
548
|
+
memberPhoneSearchMenu.value = results.length > 0;
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
// Autofill never stands in the guard's way.
|
|
552
|
+
}
|
|
553
|
+
}
|
|
474
554
|
|
|
475
|
-
|
|
555
|
+
// NRIC Scan for the member being added (security mobile app `onNricRead`,
|
|
556
|
+
// member): the NRIC - and the name, when read - filled in as if typed, and the
|
|
557
|
+
// member lookup run for it.
|
|
558
|
+
const memberNricScanOpen = ref(false);
|
|
559
|
+
|
|
560
|
+
function handleMemberNricScanRead({ nric, name }: { nric: string; name: string | null }) {
|
|
561
|
+
memberNricScanOpen.value = false;
|
|
562
|
+
const next = String(nric || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
563
|
+
if (!next) return;
|
|
564
|
+
memberForm.nric = next;
|
|
565
|
+
if (name) memberForm.name = name;
|
|
566
|
+
if (next.length >= 4) void runMemberLookup("nric");
|
|
567
|
+
}
|
|
476
568
|
|
|
477
569
|
function handleUpdateMemberNRIC() {
|
|
478
570
|
if (skipMemberNRICFetch.value) return;
|
|
479
571
|
if (!isMemberNRICFieldFocused.value) return;
|
|
480
|
-
|
|
481
|
-
const search = memberForm.nric?.trim() || "";
|
|
482
|
-
if (search.length < 4) {
|
|
483
|
-
memberNRICSearchResults.value = [];
|
|
484
|
-
memberNRICSearchMenu.value = false;
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (props.type === "drop-off" || props.type === "pick-up") return;
|
|
489
|
-
debounceFetchMemberNRIC();
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function selectMemberPhoneNumberSearchResult(item: Partial<TPeople>) {
|
|
493
|
-
memberSelectAutofillSearchResult(item, "contact");
|
|
572
|
+
scheduleMemberLookup("nric");
|
|
494
573
|
}
|
|
495
574
|
|
|
496
|
-
function
|
|
497
|
-
|
|
575
|
+
function handleUpdateMemberPhoneNumber() {
|
|
576
|
+
if (!isMemberPhoneFieldFocused.value) return;
|
|
577
|
+
scheduleMemberLookup("contact");
|
|
498
578
|
}
|
|
499
579
|
|
|
500
580
|
function memberSelectAutofillSearchResult(
|
|
501
|
-
item:
|
|
502
|
-
source: Exclude<AutofillSource, null
|
|
581
|
+
item: VisitorPerson,
|
|
582
|
+
source: Exclude<AutofillSource, null>,
|
|
583
|
+
results?: VisitorPerson[]
|
|
503
584
|
) {
|
|
504
585
|
memberCurrentAutofillSource.value = source;
|
|
505
|
-
const contactOptions =
|
|
586
|
+
const contactOptions = summaryContacts(item);
|
|
506
587
|
const selectedContact =
|
|
507
588
|
findMatchingContactOption(contactOptions, memberForm.contact) ||
|
|
508
589
|
contactOptions[0] ||
|
|
@@ -513,6 +594,8 @@ function memberSelectAutofillSearchResult(
|
|
|
513
594
|
contactOptions.length > 1;
|
|
514
595
|
|
|
515
596
|
if (needsContactChoice) {
|
|
597
|
+
memberChooserResults.value =
|
|
598
|
+
results ?? (source === "nric" ? memberNRICSearchResults.value : memberPhoneSearchResults.value);
|
|
516
599
|
memberPhoneSearchMenu.value = false;
|
|
517
600
|
memberNRICSearchMenu.value = false;
|
|
518
601
|
memberSelectedSearchResult.value = item;
|
|
@@ -528,7 +611,7 @@ function memberSelectAutofillSearchResult(
|
|
|
528
611
|
}
|
|
529
612
|
|
|
530
613
|
function applyMemberSearchResult(
|
|
531
|
-
item:
|
|
614
|
+
item: VisitorPerson,
|
|
532
615
|
options: {
|
|
533
616
|
contacts?: string;
|
|
534
617
|
source?: Exclude<AutofillSource, null>;
|
|
@@ -541,27 +624,24 @@ function applyMemberSearchResult(
|
|
|
541
624
|
skipMemberNRICFetch.value = true;
|
|
542
625
|
memberCurrentAutofillSource.value = options.source || null;
|
|
543
626
|
|
|
544
|
-
|
|
545
|
-
if (!fieldSource) return false;
|
|
546
|
-
return memberCurrentAutofillSource.value === fieldSource;
|
|
547
|
-
};
|
|
627
|
+
// Only EMPTY fields are filled, except the one searched by.
|
|
548
628
|
const getAutofillValue = (
|
|
549
629
|
currentValue: string | undefined,
|
|
550
630
|
nextValue: string | undefined,
|
|
551
631
|
fieldSource?: Exclude<AutofillSource, null>
|
|
552
632
|
) => {
|
|
553
633
|
if (!nextValue) return currentValue;
|
|
554
|
-
if (currentValue && !
|
|
634
|
+
if (currentValue && (!fieldSource || options.source !== fieldSource)) return currentValue;
|
|
555
635
|
return nextValue;
|
|
556
636
|
};
|
|
557
637
|
|
|
558
638
|
memberForm.nric =
|
|
559
|
-
getAutofillValue(memberForm.nric, item.nric, "nric") ?? "";
|
|
639
|
+
(getAutofillValue(memberForm.nric, item.nric, "nric") ?? "").replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
560
640
|
memberForm.name = getAutofillValue(memberForm.name, item.name) ?? "";
|
|
561
641
|
memberForm.contact =
|
|
562
642
|
getAutofillValue(
|
|
563
643
|
memberForm.contact,
|
|
564
|
-
options.contacts ||
|
|
644
|
+
options.contacts || summaryContacts(item)[0],
|
|
565
645
|
"contact"
|
|
566
646
|
) ?? "";
|
|
567
647
|
|
|
@@ -577,6 +657,21 @@ function memberCloseAutofillOptions() {
|
|
|
577
657
|
memberSelectedContact.value = "";
|
|
578
658
|
}
|
|
579
659
|
|
|
660
|
+
// Back: the chooser closes and the result list it came from shows again.
|
|
661
|
+
function memberChooserBack() {
|
|
662
|
+
const source = memberCurrentAutofillSource.value;
|
|
663
|
+
const results = memberChooserResults.value;
|
|
664
|
+
memberCloseAutofillOptions();
|
|
665
|
+
if (!results.length) return;
|
|
666
|
+
if (source === "contact") {
|
|
667
|
+
memberPhoneSearchResults.value = results;
|
|
668
|
+
memberPhoneSearchMenu.value = true;
|
|
669
|
+
} else {
|
|
670
|
+
memberNRICSearchResults.value = results;
|
|
671
|
+
memberNRICSearchMenu.value = true;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
580
675
|
function memberConfirmAutofillOptions() {
|
|
581
676
|
if (!memberSelectedSearchResult.value) return;
|
|
582
677
|
|
|
@@ -587,8 +682,45 @@ function memberConfirmAutofillOptions() {
|
|
|
587
682
|
memberCloseAutofillOptions();
|
|
588
683
|
}
|
|
589
684
|
|
|
590
|
-
|
|
591
|
-
|
|
685
|
+
// Delete on a match card: for a person saved with a wrong NRIC.
|
|
686
|
+
const personToDelete = ref<VisitorPerson | null>(null);
|
|
687
|
+
const deletingPerson = ref(false);
|
|
688
|
+
|
|
689
|
+
function askDeletePerson(item: VisitorPerson) {
|
|
690
|
+
personToDelete.value = item;
|
|
691
|
+
dialog.deletePerson = true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function deletePersonConfirmed() {
|
|
695
|
+
const person = personToDelete.value;
|
|
696
|
+
if (!person) return;
|
|
697
|
+
deletingPerson.value = true;
|
|
698
|
+
try {
|
|
699
|
+
await deletePersonRecords(person);
|
|
700
|
+
const ids = new Set(recordIdsOf(person));
|
|
701
|
+
const keep = (list: VisitorPerson[]) =>
|
|
702
|
+
list.filter((p) => !recordIdsOf(p).some((id) => ids.has(id)));
|
|
703
|
+
memberNRICSearchResults.value = keep(memberNRICSearchResults.value);
|
|
704
|
+
memberPhoneSearchResults.value = keep(memberPhoneSearchResults.value);
|
|
705
|
+
memberNRICSearchMenu.value = memberNRICSearchResults.value.length > 0;
|
|
706
|
+
memberPhoneSearchMenu.value = memberPhoneSearchResults.value.length > 0;
|
|
707
|
+
showMessage(PERSON_DELETED_MESSAGE, "success");
|
|
708
|
+
dialog.deletePerson = false;
|
|
709
|
+
personToDelete.value = null;
|
|
710
|
+
} catch (error) {
|
|
711
|
+
showMessage(errorConverter(error) || "Unable to delete the saved details.", "error");
|
|
712
|
+
} finally {
|
|
713
|
+
deletingPerson.value = false;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function clearMemberDraft() {
|
|
718
|
+
delete autoFilled.nric;
|
|
719
|
+
delete autoFilled.contact;
|
|
720
|
+
Object.assign(memberForm, emptyMember());
|
|
721
|
+
selectedPass.value = "";
|
|
722
|
+
errorScanPassMessage.value = "";
|
|
723
|
+
memberError.value = "";
|
|
592
724
|
resetAccessCard();
|
|
593
725
|
memberNRICSearchMenu.value = false;
|
|
594
726
|
memberPhoneSearchMenu.value = false;
|
|
@@ -597,25 +729,169 @@ function handleClearForm() {
|
|
|
597
729
|
memberCloseAutofillOptions();
|
|
598
730
|
}
|
|
599
731
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
732
|
+
function openAddMember() {
|
|
733
|
+
memberError.value = "";
|
|
734
|
+
dialog.addMember = true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function cancelAddMember() {
|
|
738
|
+
clearMemberDraft();
|
|
739
|
+
dialog.addMember = false;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const sameText = (a?: string, b?: string) =>
|
|
743
|
+
Boolean(a?.trim()) && a!.trim().toLowerCase() === (b ?? "").trim().toLowerCase();
|
|
744
|
+
|
|
745
|
+
/** The member as typed joins the list, and the input empties. */
|
|
746
|
+
function commitDraft() {
|
|
747
|
+
committedMembers.value = [
|
|
748
|
+
...committedMembers.value,
|
|
749
|
+
{
|
|
750
|
+
...memberForm,
|
|
751
|
+
name: memberForm.name.trim(),
|
|
752
|
+
visitorPass: [...(memberForm.visitorPass ?? [])],
|
|
753
|
+
...(memberPassType.value
|
|
754
|
+
? {
|
|
755
|
+
passType: memberPassType.value,
|
|
756
|
+
passQuantity: memberPassQuantity.value,
|
|
757
|
+
passCards: memberPassCards.value,
|
|
758
|
+
}
|
|
759
|
+
: {}),
|
|
760
|
+
} as any,
|
|
761
|
+
];
|
|
762
|
+
clearMemberDraft();
|
|
763
|
+
dialog.addMember = false;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Anything typed in the member input? */
|
|
767
|
+
function hasDraft() {
|
|
768
|
+
return Boolean(
|
|
769
|
+
memberForm.name.trim() ||
|
|
770
|
+
memberForm.contact.trim() ||
|
|
771
|
+
memberForm.nric.trim() ||
|
|
772
|
+
selectedPass.value
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* "Add" (isecure365 `addMember`): required fields, the lead's and the other
|
|
778
|
+
* members' phone / NRIC (messages verbatim), then any visit of theirs still
|
|
779
|
+
* open. Clear, the member joins the list; a problem keeps the dialog open.
|
|
780
|
+
*/
|
|
781
|
+
async function addMember() {
|
|
782
|
+
const contact = memberForm.contact?.trim() || "";
|
|
783
|
+
const nric = memberForm.nric?.trim() || "";
|
|
784
|
+
const added = committedMembers.value;
|
|
785
|
+
|
|
786
|
+
let problem = "";
|
|
787
|
+
if (!memberForm.name.trim()) problem = "Name is required.";
|
|
788
|
+
else if (!contact) problem = "Phone number is required.";
|
|
789
|
+
else if (!(await phoneIsValid())) problem = "Please enter a valid member phone number.";
|
|
790
|
+
else if (sameText(contact, props.leadContact))
|
|
791
|
+
problem = "Phone Number is already used by the lead contractor.";
|
|
792
|
+
else if (added.some((other) => sameText(other.contact, contact)))
|
|
793
|
+
problem = "Phone Number is already used by one of the added member(s).";
|
|
794
|
+
else if (!nric) problem = "NRIC is required.";
|
|
795
|
+
else if (sameText(nric, props.leadNric))
|
|
796
|
+
problem = "NRIC is already used by the lead contractor.";
|
|
797
|
+
else if (added.some((other) => sameText(other.nric, nric)))
|
|
798
|
+
problem = "NRIC is already used in one of the added member(s).";
|
|
799
|
+
|
|
800
|
+
memberError.value = problem;
|
|
801
|
+
if (problem) {
|
|
802
|
+
showMessage(problem, "error");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (props.checkOpenVisits) {
|
|
807
|
+
adding.value = true;
|
|
808
|
+
try {
|
|
809
|
+
const clear = await props.checkOpenVisits({ nric, contact });
|
|
810
|
+
if (!clear) return;
|
|
811
|
+
} finally {
|
|
812
|
+
adding.value = false;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
commitDraft();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// The phone field's own country rule.
|
|
820
|
+
async function phoneIsValid() {
|
|
821
|
+
try {
|
|
822
|
+
const result = await formRef.value?.validate?.();
|
|
823
|
+
if (!result) return true;
|
|
824
|
+
return result.valid !== false;
|
|
825
|
+
} catch {
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
611
828
|
}
|
|
612
829
|
|
|
613
830
|
function handleRemoveMember(index: number) {
|
|
614
831
|
committedMembers.value = committedMembers.value.filter((_, i) => i !== index);
|
|
615
832
|
}
|
|
833
|
+
|
|
834
|
+
/** Passes the server refused / a contractor type change made unusable. */
|
|
835
|
+
function clearPasses(ids?: Set<string>) {
|
|
836
|
+
committedMembers.value = committedMembers.value.map((member) => ({
|
|
837
|
+
...member,
|
|
838
|
+
visitorPass: (member.visitorPass ?? []).filter((p) => ids && !ids.has(p.keyId)),
|
|
839
|
+
}));
|
|
840
|
+
if (!ids || ids.has(selectedPass.value)) selectedPass.value = "";
|
|
841
|
+
if (ids) passItems.value = passItems.value.filter((item) => !ids.has(item._id ?? ""));
|
|
842
|
+
else passItems.value = [];
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Pass ids picked on members and the member input. */
|
|
846
|
+
function pickedPassIds() {
|
|
847
|
+
return [
|
|
848
|
+
...committedMembers.value.flatMap((m) => (m.visitorPass ?? []).map((p) => p.keyId)),
|
|
849
|
+
selectedPass.value,
|
|
850
|
+
].filter(Boolean);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Every pass id → name seen so far, for refused-pass lookups. */
|
|
854
|
+
function passIdsNamed(name: string) {
|
|
855
|
+
const wanted = name.trim().toLowerCase();
|
|
856
|
+
return Object.entries(passNames.value)
|
|
857
|
+
.filter(([, label]) => label.trim().toLowerCase() === wanted)
|
|
858
|
+
.map(([id]) => id);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Clears everything (a matched unregistered record does not carry members). */
|
|
862
|
+
function reset() {
|
|
863
|
+
committedMembers.value = [];
|
|
864
|
+
clearMemberDraft();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
watch(
|
|
868
|
+
() => props.contractorType,
|
|
869
|
+
() => {
|
|
870
|
+
passItems.value = [];
|
|
871
|
+
}
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
defineExpose({ addMember, commitDraft, hasDraft, clearPasses, pickedPassIds, passIdsNamed, reset });
|
|
616
875
|
</script>
|
|
617
876
|
|
|
618
877
|
<style scoped>
|
|
878
|
+
/* A Scan button beside its input, the same height (both 40px), like the
|
|
879
|
+
country code beside the phone number - not on the label's line. */
|
|
880
|
+
.scan-row {
|
|
881
|
+
display: flex;
|
|
882
|
+
align-items: flex-start;
|
|
883
|
+
gap: 8px;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
.scan-row__field {
|
|
887
|
+
flex: 1 1 auto;
|
|
888
|
+
min-width: 0;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.scan-row__btn {
|
|
892
|
+
flex: 0 0 auto;
|
|
893
|
+
}
|
|
894
|
+
|
|
619
895
|
.nric-search-item {
|
|
620
896
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
621
897
|
border-radius: 4px;
|
|
@@ -632,12 +908,6 @@ function handleRemoveMember(index: number) {
|
|
|
632
908
|
border-radius: 16px !important;
|
|
633
909
|
}
|
|
634
910
|
|
|
635
|
-
.nric-options-title {
|
|
636
|
-
color: var(--text);
|
|
637
|
-
font-size: 1.45rem;
|
|
638
|
-
line-height: 1.2;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
911
|
.nric-options-section-title {
|
|
642
912
|
color: var(--text);
|
|
643
913
|
font-size: 1.05rem;
|
|
@@ -656,4 +926,33 @@ function handleRemoveMember(index: number) {
|
|
|
656
926
|
.nric-option-group {
|
|
657
927
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
658
928
|
}
|
|
929
|
+
|
|
930
|
+
.member-dialog__head {
|
|
931
|
+
display: flex;
|
|
932
|
+
align-items: center;
|
|
933
|
+
justify-content: space-between;
|
|
934
|
+
padding-right: 12px;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
.member-dialog__body {
|
|
938
|
+
padding: 4px 20px 8px;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* Small confirm / edit dialogs: the body lines up with the title. */
|
|
942
|
+
.visitor-form__dialog-body {
|
|
943
|
+
padding: 4px 16px 0;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
.member-dialog__footer {
|
|
947
|
+
display: flex;
|
|
948
|
+
justify-content: flex-end;
|
|
949
|
+
gap: 8px;
|
|
950
|
+
padding: 12px 20px 16px;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.member-dialog__error {
|
|
954
|
+
color: rgb(var(--v-theme-error));
|
|
955
|
+
font-size: 0.85rem;
|
|
956
|
+
padding: 0 20px;
|
|
957
|
+
}
|
|
659
958
|
</style>
|