@7365admin1/layer-common 4.97.1-staging.586 → 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.
@@ -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
- <AppCard class="w-100 pa-4">
4
- <!-- GHOST, not primary: the handoff names "Scan QR Code" in its
5
- ghost/secondary list, alongside Bulk Upload, Download Report and
6
- Export. Stays a `v-btn` because `block` has no AppButton
7
- equivalent, so the design's button is applied as a class. -->
8
- <v-btn block :height="38" text="Scan QR Code" variant="outlined"
9
- class="screen-btn-ghost" prepend-icon="mdi-qrcode" @click="handleScanQRPass" />
10
- <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorScanPassMessage = ''">
11
- <v-row no-gutters class="pt-5 ga-2">
12
- <v-col cols="12">
13
- <InputLabel class="text-capitalize" title="Full Name (Required)" required />
14
- <v-text-field v-model.trim="memberForm.name" density="compact" hide-details :rules="[requiredRule]" />
15
- </v-col>
16
-
17
- <v-col cols="12">
18
- <InputLabel class="text-capitalize" title="NRIC (Required)" required />
19
- <v-menu v-model="memberNRICSearchMenu" location="bottom" offset-y :close-on-content-click="false">
20
- <template #activator="{ props: menuProps }">
21
- <InputNRICNumber v-bind="menuProps" v-model.trim="memberForm.nric" density="compact" hide-details
22
- :rules="[requiredRule]" :key="memberCurrentAutofillSource + 'member-nric-key'"
23
- @focus="isMemberNRICFieldFocused = true" @blur="isMemberNRICFieldFocused = false"
24
- @update:model-value="handleUpdateMemberNRIC" />
25
- </template>
26
-
27
- <v-list v-if="memberNRICSearchResults.length" class="pa-1 phone-search-results">
28
- <v-list-item v-for="(item, index) in memberNRICSearchResults" :key="item._id || index"
29
- class="cursor-pointer nric-search-item" @click="selectMemberNRICSearchResult(item)">
30
- <VisitorSearchResultSummary :item="item" />
31
- </v-list-item>
32
- </v-list>
33
- </v-menu>
34
- </v-col>
35
-
36
- <v-col cols="12">
37
- <InputLabel class="text-capitalize" title="Phone Number (Required)" required />
38
- <v-menu v-model="memberPhoneSearchMenu" location="bottom" offset-y :close-on-content-click="false">
39
- <template #activator="{ props: menuProps }">
40
- <InputPhoneNumberV2 v-bind="menuProps" v-model="memberForm.contact" density="compact"
41
- default-country="SG" hide-details :key="memberCurrentAutofillSource + 'member-phone-key'"
42
- @focus="isMemberPhoneFieldFocused = true" @blur="isMemberPhoneFieldFocused = false"
43
- @update:model-value="handleUpdateMemberPhoneNumber" />
44
- </template>
45
-
46
- <v-list v-if="memberPhoneSearchResults.length" class="pa-1 phone-search-results">
47
- <v-list-item v-for="(item, index) in memberPhoneSearchResults" :key="item._id || index"
48
- class="cursor-pointer nric-search-item" @click="selectMemberPhoneNumberSearchResult(item)">
49
- <VisitorSearchResultSummary :item="item" />
50
- </v-list-item>
51
- </v-list>
52
- </v-menu>
53
- </v-col>
54
-
55
- <v-col cols="12">
56
- <v-autocomplete v-model="selectedPass" v-model:search="passInput" :hide-no-data="false" class="mt-3"
57
- :items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id" label="Pass (Optional)"
58
- variant="outlined" density="compact" :error-messages="errorScanPassMessage" persistent-hint small-chips
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
- <v-col cols="6" align="end">
95
- <AppButton variant="ghost" icon="mdi-refresh" @click.stop="handleClearForm">
96
- Clear Form
97
- </AppButton>
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-row>
101
- </v-form>
102
- </AppCard>
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-actions class="justify-end pa-1">
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
- (!memberForm.contact ||
123
- memberCurrentAutofillSource === 'contact') &&
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
- <v-card-actions class="justify-end px-4 pb-4">
137
- <v-btn variant="outlined" class="screen-btn-ghost" size="small" @click="memberCloseAutofillOptions">
138
- Cancel
139
- </v-btn>
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
- <v-divider class="my-3" />
148
- <v-row v-if="committedMembers.length > 0" no-gutters class="w-100 mt-5 ga-3">
149
- <template v-for="(member, index) in committedMembers" :key="member.nric">
150
- <CardMemberInfoSummary :member="membersDisplayed[index]" @remove="handleRemoveMember(index)" />
151
- </template>
152
- </v-row>
153
- <!-- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" /> -->
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 { requiredRule, debounce, maskNRIC } = useUtils();
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 memberForm = reactive<TMemberInfo>({
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 validForm = ref(false);
232
- const formRef = ref<HTMLFormElement>();
233
- const processing = ref(false);
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<Partial<TPeople>[]>([]);
275
- const memberNRICSearchResults = ref<Partial<TPeople>[]>([]);
370
+ const memberPhoneSearchResults = ref<VisitorPerson[]>([]);
371
+ const memberNRICSearchResults = ref<VisitorPerson[]>([]);
276
372
  const memberCurrentAutofillSource = ref<AutofillSource>(null);
277
- const memberSelectedSearchResult = ref<Partial<TPeople> | null>(null);
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
- // Always keep the model in sync: committed members + current form draft
282
- watch(
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
- ...member,
346
- visitorPass:
347
- passItems.value.find(
348
- (item: TPassKey) => item._id === member.visitorPass?.[0]?.keyId
349
- )?.prefixAndName || "",
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 === newVal
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 = "Scanned pass not available";
376
- showMessage("Scanned pass not available", "error");
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
- ? getPersonContactOptions(memberSelectedSearchResult.value)
501
+ ? summaryContacts(memberSelectedSearchResult.value)
423
502
  : [];
424
503
  });
425
504
 
426
- const {
427
- data: fetchMemberByPhoneNumber,
428
- refresh: fetchMemberByPhoneNumberRefresh,
429
- } = useLazyAsyncData(`fetch-member-phone-number-${props.site}`, () => {
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
- const debounceFetchMemberPhoneNumber = debounce(
442
- fetchMemberByPhoneNumberRefresh,
443
- 500
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
- const search = memberForm.contact?.trim() || "";
450
- if (search.length < 4) {
451
- memberPhoneSearchResults.value = [];
452
- memberPhoneSearchMenu.value = false;
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
- const {
461
- data: fetchMemberByNRIC,
462
- refresh: fetchMemberByNRICRefresh,
463
- } = useLazyAsyncData(`fetch-member-nric-${props.site}`, () => {
464
- const nric = memberForm.nric?.trim() || "";
465
- if (nric.length < 4) return Promise.resolve(null);
466
- const type = props.type === "walk-in" ? "guest" : props.type;
467
- return findPersonByNRICMultipleResult(nric, props.site, type);
468
- });
469
-
470
- watch(fetchMemberByNRIC, (obj) => {
471
- memberNRICSearchResults.value = getPeopleSearchItems(obj);
472
- memberNRICSearchMenu.value = memberNRICSearchResults.value.length > 0;
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
- const debounceFetchMemberNRIC = debounce(fetchMemberByNRICRefresh, 500);
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 selectMemberNRICSearchResult(item: Partial<TPeople>) {
497
- memberSelectAutofillSearchResult(item, "nric");
575
+ function handleUpdateMemberPhoneNumber() {
576
+ if (!isMemberPhoneFieldFocused.value) return;
577
+ scheduleMemberLookup("contact");
498
578
  }
499
579
 
500
580
  function memberSelectAutofillSearchResult(
501
- item: Partial<TPeople>,
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 = getPersonContactOptions(item);
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: Partial<TPeople>,
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
- const shouldAutofillField = (fieldSource?: Exclude<AutofillSource, null>) => {
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 && !shouldAutofillField(fieldSource)) return 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 || getPersonContactOptions(item)[0],
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
- function handleClearForm() {
591
- formRef.value?.reset();
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
- async function handleAddMember() {
601
- committedMembers.value.push({
602
- ...memberForm,
603
- ...(memberPassType.value ? {
604
- passType: memberPassType.value,
605
- passQuantity: memberPassQuantity.value,
606
- passCards: memberPassCards.value,
607
- } : {}),
608
- } as any);
609
- resetAccessCard();
610
- handleClearForm();
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>