@7365admin1/layer-common 3.1.3-staging.40 → 3.1.3-staging.42

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.
@@ -48,7 +48,7 @@
48
48
  <v-list-item class="cursor-pointer" @click="openMediaViewer(item._id, item.mimeType)">
49
49
  <template #prepend>
50
50
  <v-avatar size="36" rounded="sm" class="mr-3">
51
- <v-img :src="getFileUrl(item._id)" cover>
51
+ <v-img :src="imageCover(item._id)" cover>
52
52
  <template #error>
53
53
  <v-icon color="green" size="20">mdi-image</v-icon>
54
54
  </template>
@@ -97,7 +97,7 @@
97
97
  </v-row>
98
98
 
99
99
  <!-- Media Carousel (images + videos) -->
100
- <ImageCarousel v-model="showCarousel" :active-file-id="activeFileId" :files="carouselFiles" />
100
+ <ImageCarousel v-model="showCarousel" :active-file-id="activeFileId" :files="carouselFiles" :anpr="anpr" :site="site" />
101
101
 
102
102
  <!-- Document Viewer -->
103
103
  <DocumentViewer v-model="showDocumentViewer" :file-id="activeFileId" :file-name="activeFileName"
@@ -123,10 +123,18 @@ const props = defineProps({
123
123
  showFileTypeTitle: {
124
124
  type: Boolean,
125
125
  default: false,
126
+ },
127
+ anpr: {
128
+ type: Boolean,
129
+ default: false
130
+ },
131
+ site: {
132
+ type: String,
133
+ default: ""
126
134
  }
127
135
  });
128
136
 
129
- const { getFileUrl, urlToFile, getFileById } = useFile();
137
+ const { getFileUrl, urlToFile, getFileById, getFileUrlAnpr } = useFile();
130
138
 
131
139
  const rawFileArray = ref<{ file: File, _id: string, preview: boolean, mimeType: string }[]>([]);
132
140
  const loading = ref(true);
@@ -159,6 +167,12 @@ function openDocumentViewer(id: string, name: string, mimeType: string) {
159
167
  showDocumentViewer.value = true;
160
168
  }
161
169
 
170
+ const imageCover = (id: string) => {
171
+ if(props.anpr && props.site){
172
+ return getFileUrlAnpr(`${props.site}/${id}`)
173
+ } else return getFileUrl(id)
174
+ }
175
+
162
176
  function getFileIcon(mimeType: string): string {
163
177
  if (mimeType.startsWith('image/')) return 'mdi-image';
164
178
  if (mimeType.startsWith('video/')) return 'mdi-video';
@@ -206,7 +220,11 @@ watchEffect(async () => {
206
220
 
207
221
  try {
208
222
  // Try to get the actual file with proper MIME type detection from blob
209
- const fileUrl = getFileUrl(item.id);
223
+ // const fileUrl = getFileUrl(item.id);
224
+ let fileUrl = ""
225
+ if(props.anpr && props.site){
226
+ fileUrl = getFileUrlAnpr(`${props.site}/${item.id}`)
227
+ } else fileUrl = getFileUrl(item.id);
210
228
  file = await urlToFile(fileUrl, item.name);
211
229
  mimeType = file.type || 'application/octet-stream';
212
230
 
@@ -7,7 +7,7 @@
7
7
  <template v-if="fileTypes?.[x] === 'image'">
8
8
  <v-carousel-item height="100%" width="100%" rounded="lg">
9
9
  <v-row no-gutters class="w-100 h-100" align="center">
10
- <v-img :lazy-src="getFileUrl(x)" :src="getFileUrl(x)" width="70%" height="70%"
10
+ <v-img :lazy-src="getFinalUrl(x)" :src="getFinalUrl(x)" width="70%" height="70%"
11
11
  :alt="'Image Viewer Card -' + index"></v-img>
12
12
  </v-row>
13
13
  <template v-slot:placeholder>
@@ -21,7 +21,7 @@
21
21
  <v-carousel-item>
22
22
  <v-row no-gutters class="h-100 w-100" align="center" justify="center">
23
23
  <video width="80%" height="80%" controls>
24
- <source :src="getFileUrl(x)" />
24
+ <source :src="getFinalUrl(x)" />
25
25
  </video>
26
26
  </v-row>
27
27
  <template v-slot:placeholder>
@@ -78,11 +78,19 @@ const props = defineProps({
78
78
  files: {
79
79
  type: Array as PropType<string[]>,
80
80
  default: []
81
+ },
82
+ site: {
83
+ type: String,
84
+ default: ""
85
+ },
86
+ anpr: {
87
+ type: Boolean,
88
+ default: false
81
89
  }
82
90
  });
83
91
 
84
92
 
85
- const { getFileUrl, urlToFile } = useFile()
93
+ const { getFileUrl, urlToFile, getFileUrlAnpr } = useFile()
86
94
  const overlay = defineModel({ required: true, default: false });
87
95
  const activeIndex = ref(0);
88
96
  const fileTypes = ref<Record<string, "image" | "video" | "other">>({});
@@ -107,10 +115,13 @@ async function resolveFileTypes() {
107
115
 
108
116
  for (const x of props.files) {
109
117
  try {
110
- const url = getFileUrl(x);
118
+ let url = ""
119
+ if (props.anpr && props.site) {
120
+ url = getFileUrlAnpr(`${props.site}/${x}`)
121
+ } else url = getFileUrl(x);
111
122
  const file = await urlToFile(url, x);
112
123
  const type = file?.type;
113
-
124
+
114
125
 
115
126
  if (type?.startsWith("video")) fileTypes.value[x] = "video";
116
127
  else if (type?.startsWith("image")) fileTypes.value[x] = "image";
@@ -129,6 +140,13 @@ watch(
129
140
  );
130
141
 
131
142
 
143
+ const getFinalUrl = (id: string) => {
144
+ if (props.anpr && props.site) {
145
+ return getFileUrlAnpr(`${props.site}/${id}`)
146
+ } else return getFileUrl(id)
147
+ }
148
+
149
+
132
150
  </script>
133
151
 
134
152
  <style scoped>
@@ -2,152 +2,144 @@
2
2
  <v-row no-gutters class="w-100">
3
3
  <v-card class="w-100">
4
4
  <v-card-text>
5
- <v-btn
6
- block
7
- color="primary-button"
8
- :height="40"
9
- text="Scan QR Code"
10
- class="text-capitalize"
11
- prepend-icon="mdi-qrcode"
12
- @click="handleScanQRPass"
13
- />
14
- <v-form
15
- ref="formRef"
16
- v-model="validForm"
17
- :disabled="processing"
18
- @click="errorScanPassMessage = ''"
19
- >
5
+ <v-btn block color="primary-button" :height="40" text="Scan QR Code" class="text-capitalize"
6
+ prepend-icon="mdi-qrcode" @click="handleScanQRPass" />
7
+ <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorScanPassMessage = ''">
20
8
  <v-row no-gutters class="pt-5 ga-2">
21
9
  <v-col cols="12">
22
10
  <InputLabel class="text-capitalize" title="Full Name" required />
23
- <v-text-field
24
- v-model.trim="memberForm.name"
25
- density="compact"
26
- hide-details
27
- :rules="[requiredRule]"
28
- />
11
+ <v-text-field v-model.trim="memberForm.name" density="compact" hide-details :rules="[requiredRule]" />
29
12
  </v-col>
30
13
 
31
14
  <v-col cols="12">
32
15
  <InputLabel class="text-capitalize" title="NRIC" required />
33
- <InputNRICNumber
34
- v-model.trim="memberForm.nric"
35
- density="compact"
36
- hide-details
37
- :rules="[requiredRule]"
38
- />
16
+ <v-menu v-model="memberNRICSearchMenu" location="bottom" offset-y :close-on-content-click="false">
17
+ <template #activator="{ props: menuProps }">
18
+ <InputNRICNumber v-bind="menuProps" v-model.trim="memberForm.nric" density="compact" hide-details
19
+ :rules="[requiredRule]" :key="memberCurrentAutofillSource + 'member-nric-key'"
20
+ @focus="isMemberNRICFieldFocused = true" @blur="isMemberNRICFieldFocused = false"
21
+ @update:model-value="handleUpdateMemberNRIC" />
22
+ </template>
23
+
24
+ <v-list v-if="memberNRICSearchResults.length" class="pa-1 phone-search-results">
25
+ <v-list-item v-for="(item, index) in memberNRICSearchResults" :key="item._id || index"
26
+ class="cursor-pointer nric-search-item" @click="selectMemberNRICSearchResult(item)">
27
+ <VisitorSearchResultSummary :item="item" />
28
+ </v-list-item>
29
+ </v-list>
30
+ </v-menu>
31
+ </v-col>
32
+
33
+ <v-col cols="12">
34
+ <InputLabel class="text-capitalize" title="Phone Number" required />
35
+ <v-menu v-model="memberPhoneSearchMenu" location="bottom" offset-y :close-on-content-click="false">
36
+ <template #activator="{ props: menuProps }">
37
+ <InputPhoneNumberV2 v-bind="menuProps" v-model="memberForm.contact" density="compact"
38
+ default-country="SG" hide-details :key="memberCurrentAutofillSource + 'member-phone-key'"
39
+ @focus="isMemberPhoneFieldFocused = true" @blur="isMemberPhoneFieldFocused = false"
40
+ @update:model-value="handleUpdateMemberPhoneNumber" />
41
+ </template>
42
+
43
+ <v-list v-if="memberPhoneSearchResults.length" class="pa-1 phone-search-results">
44
+ <v-list-item v-for="(item, index) in memberPhoneSearchResults" :key="item._id || index"
45
+ class="cursor-pointer nric-search-item" @click="selectMemberPhoneNumberSearchResult(item)">
46
+ <VisitorSearchResultSummary :item="item" />
47
+ </v-list-item>
48
+ </v-list>
49
+ </v-menu>
39
50
  </v-col>
40
51
 
41
52
  <v-col cols="12">
42
- <v-autocomplete
43
- v-model="selectedPass"
44
- v-model:search="passInput"
45
- :hide-no-data="false"
46
- class="mt-3"
47
- :items="passItemsFilteredFinal"
48
- item-title="prefixAndName"
49
- item-value="_id"
50
- label="Pass (optional)"
51
- variant="outlined"
52
- density="compact"
53
- :error-messages="errorScanPassMessage"
54
- persistent-hint
55
- small-chips
56
- :clearable="clearable"
57
- @click="fetchPasses"
58
- >
53
+ <v-autocomplete v-model="selectedPass" v-model:search="passInput" :hide-no-data="false" class="mt-3"
54
+ :items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id" label="Pass (optional)"
55
+ variant="outlined" density="compact" :error-messages="errorScanPassMessage" persistent-hint small-chips
56
+ :clearable="clearable" @click="fetchPasses">
59
57
  <template v-slot:no-data>
60
58
  <v-list-item density="compact">
61
59
  <v-list-item-title v-if="passInput">
62
- No results matching "<strong>{{ passInput }}</strong
63
- >". This value will be added as new option.
60
+ No results matching "<strong>{{ passInput }}</strong>". This value will be added as new option.
64
61
  </v-list-item-title>
65
62
  <v-list-item v-else>No data available</v-list-item>
66
63
  </v-list-item>
67
64
  </template>
68
65
 
69
66
  <template v-slot:chip="{ props, item }">
70
- <v-chip
71
- v-if="selectedPass"
72
- v-bind="props"
73
- prepend-icon="mdi-card-bulleted-outline"
74
- :text="item.raw?.prefixAndName"
75
- ></v-chip>
67
+ <v-chip v-if="selectedPass" v-bind="props" prepend-icon="mdi-card-bulleted-outline"
68
+ :text="item.raw?.prefixAndName"></v-chip>
76
69
  </template>
77
70
  </v-autocomplete>
78
71
  </v-col>
79
72
 
80
73
  <v-col v-if="props.unitId" cols="12">
81
- <EntryPassInformation
82
- v-model="memberPassType"
83
- v-model:quantity="memberPassQuantity"
84
- v-model:cards="memberPassCards"
85
- :settings="props.settings"
86
- :loading="props.settingsLoading"
87
- :site-id="props.site"
88
- :unit-id="props.unitId"
89
- :visitor-type="props.type"
90
- :hid-qr-code-enabled="props.hidQrCodeEnabled"
91
- :hid-qr-printer-configured="props.hidQrPrinterConfigured"
92
- :excluded-card-ids="props.selectedNfcCards.map((c) => c._id)"
93
- />
94
- </v-col>
95
-
96
- <v-col cols="12">
97
- <InputLabel
98
- class="text-capitalize"
99
- title="Phone Number"
100
- required
101
- />
102
- <InputPhoneNumberV2
103
- v-model="memberForm.contact"
104
- density="compact"
105
- default-country="SG"
106
- hide-details
107
- />
74
+ <EntryPassInformation v-model="memberPassType" v-model:quantity="memberPassQuantity"
75
+ v-model:cards="memberPassCards" :settings="props.settings" :loading="props.settingsLoading"
76
+ :site-id="props.site" :unit-id="props.unitId" :visitor-type="props.type"
77
+ :hid-qr-code-enabled="props.hidQrCodeEnabled" :hid-qr-printer-configured="props.hidQrPrinterConfigured"
78
+ :excluded-card-ids="props.selectedNfcCards.map((c) => c._id)" />
108
79
  </v-col>
109
80
 
110
81
  <v-row class="pt-3" justify="space-between">
111
82
  <v-col cols="6">
112
- <v-btn
113
- text="Add Member"
114
- :disabled="!validForm"
115
- prepend-icon="mdi-plus"
116
- color="primary-button"
117
- class="text-capitalize"
118
- @click="handleAddMember"
119
- />
83
+ <v-btn text="Add Member" :disabled="!validForm" prepend-icon="mdi-plus" color="primary-button"
84
+ class="text-capitalize" @click="handleAddMember" />
120
85
  </v-col>
121
86
  <v-col cols="6" align="end">
122
- <v-btn
123
- text="Clear Form"
124
- prepend-icon="mdi-refresh"
125
- color="primary-button"
126
- class="text-capitalize"
127
- @click.stop="handleClearForm"
128
- />
87
+ <v-btn text="Clear Form" prepend-icon="mdi-refresh" color="primary-button" class="text-capitalize"
88
+ @click.stop="handleClearForm" />
129
89
  </v-col>
130
90
  </v-row>
131
91
  </v-row>
132
92
  </v-form>
133
93
  </v-card-text>
134
94
  </v-card>
135
- <visitor-pass-key-q-r-scanner
136
- :dialog="dialog.scanQRCode"
137
- v-model:scannedValue="scannedPassValue"
138
- @close-dialog="dialog.scanQRCode = false"
139
- />
95
+ <visitor-pass-key-q-r-scanner :dialog="dialog.scanQRCode" v-model:scannedValue="scannedPassValue"
96
+ @close-dialog="dialog.scanQRCode = false" />
97
+ <v-dialog v-model="dialog.memberAutofillOptions" max-width="430">
98
+ <v-card v-if="memberSelectedSearchResult" class="nric-options-card">
99
+ <v-card-actions class="justify-end pa-1">
100
+ <v-btn icon="mdi-close" variant="text" size="small" @click="memberCloseAutofillOptions" />
101
+ </v-card-actions>
102
+
103
+ <v-card-text class="pa-4 pt-1">
104
+ <h2 class="nric-options-title font-weight-bold mb-4">Select</h2>
105
+
106
+ <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
107
+ <span>{{ memberSelectedSearchResult.name || "Unknown" }}</span>
108
+ <span class="text-no-wrap">
109
+ NRIC: {{ memberSelectedSearchResult.nric || "N/A" }}
110
+ </span>
111
+ </div>
112
+
113
+ <template v-if="
114
+ (!memberForm.contact ||
115
+ memberCurrentAutofillSource === 'contact') &&
116
+ memberSelectedContactOptions.length >= 1
117
+ ">
118
+ <h3 class="nric-options-section-title font-weight-bold mb-2">
119
+ Contact
120
+ </h3>
121
+ <v-radio-group v-model="memberSelectedContact" hide-details color="success" class="nric-option-group">
122
+ <v-radio v-for="contact in memberSelectedContactOptions" :key="contact" :label="contact" :value="contact"
123
+ class="nric-option-radio" />
124
+ </v-radio-group>
125
+ </template>
126
+ </v-card-text>
127
+
128
+ <v-card-actions class="justify-end px-4 pb-4">
129
+ <v-btn variant="text" class="text-none" size="small" @click="memberCloseAutofillOptions">
130
+ Cancel
131
+ </v-btn>
132
+ <v-btn color="primary-button" variant="flat" class="text-none px-5" size="small"
133
+ @click="memberConfirmAutofillOptions">
134
+ Select
135
+ </v-btn>
136
+ </v-card-actions>
137
+ </v-card>
138
+ </v-dialog>
140
139
  <v-divider class="my-3" />
141
- <v-row
142
- v-if="committedMembers.length > 0"
143
- no-gutters
144
- class="w-100 mt-5 ga-3"
145
- >
140
+ <v-row v-if="committedMembers.length > 0" no-gutters class="w-100 mt-5 ga-3">
146
141
  <template v-for="(member, index) in committedMembers" :key="member.nric">
147
- <CardMemberInfoSummary
148
- :member="membersDisplayed[index]"
149
- @remove="handleRemoveMember(index)"
150
- />
142
+ <CardMemberInfoSummary :member="membersDisplayed[index]" @remove="handleRemoveMember(index)" />
151
143
  </template>
152
144
  </v-row>
153
145
  <!-- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" /> -->
@@ -206,8 +198,12 @@ const props = defineProps({
206
198
  },
207
199
  });
208
200
 
209
- const { requiredRule } = useUtils();
201
+ const { requiredRule, debounce } = useUtils();
210
202
  const { getPassKeysByPageSearch } = usePassKey();
203
+ const {
204
+ findPersonByNRICMultipleResult,
205
+ findPersonByPhoneNumberMultipleResult,
206
+ } = usePeople();
211
207
 
212
208
  const memberForm = reactive<TMemberInfo>({
213
209
  name: "",
@@ -238,6 +234,7 @@ const scannedPassValue = ref<string>("");
238
234
 
239
235
  const dialog = reactive({
240
236
  scanQRCode: false,
237
+ memberAutofillOptions: false,
241
238
  });
242
239
 
243
240
  const selectedPass = ref<string>("");
@@ -248,6 +245,23 @@ const memberPassCards = ref<{ _id: string; cardNo: string }[]>([]);
248
245
  const members = defineModel<TMemberInfo[]>({ required: true, default: [] });
249
246
  const committedMembers = ref<TMemberInfo[]>([]);
250
247
 
248
+ type AutofillSource = "nric" | "contact" | null;
249
+ type SearchPerson = Omit<Partial<TPeople>, "contact"> & {
250
+ contact?: string | string[];
251
+ contacts?: string | string[];
252
+ };
253
+
254
+ const memberPhoneSearchMenu = ref(false);
255
+ const memberNRICSearchMenu = ref(false);
256
+ const isMemberPhoneFieldFocused = ref(false);
257
+ const isMemberNRICFieldFocused = ref(false);
258
+ const memberPhoneSearchResults = ref<Partial<TPeople>[]>([]);
259
+ const memberNRICSearchResults = ref<Partial<TPeople>[]>([]);
260
+ const memberCurrentAutofillSource = ref<AutofillSource>(null);
261
+ const memberSelectedSearchResult = ref<Partial<TPeople> | null>(null);
262
+ const memberSelectedContact = ref("");
263
+ const skipMemberNRICFetch = ref(false);
264
+
251
265
  // Always keep the model in sync: committed members + current form draft
252
266
  watch(
253
267
  () => memberForm,
@@ -352,9 +366,219 @@ function resetAccessCard() {
352
366
  memberPassCards.value = [];
353
367
  }
354
368
 
369
+ function getPeopleSearchItems(res: any): Partial<TPeople>[] {
370
+ if (Array.isArray(res?.items)) return res.items;
371
+ if (Array.isArray(res?.data)) return res.data;
372
+ if (Array.isArray(res?.data?.items)) return res.data.items;
373
+ if (res?.data) return [res.data];
374
+ if (res && !res?.message) return [res];
375
+ return [];
376
+ }
377
+
378
+ function toUniqueStringArray(value: unknown) {
379
+ const rawValues = Array.isArray(value) ? value : value ? [value] : [];
380
+ return [...new Set(rawValues.map((v) => String(v).trim()).filter(Boolean))];
381
+ }
382
+
383
+ function getPersonContactOptions(item: Partial<TPeople>) {
384
+ const person = item as SearchPerson;
385
+ return toUniqueStringArray(person.contacts || person.contact);
386
+ }
387
+
388
+ function findMatchingContactOption(options: string[], value?: string | null) {
389
+ const normalizedValue = value?.trim();
390
+ if (!normalizedValue) return "";
391
+ const compactValue = normalizedValue.replace(/\s/g, "");
392
+
393
+ return (
394
+ options.find((option) => option.trim() === normalizedValue) ||
395
+ options.find((option) => option.replace(/\s/g, "") === compactValue) ||
396
+ options.find((option) => option.trim().includes(normalizedValue)) ||
397
+ options.find((option) =>
398
+ option.replace(/\s/g, "").includes(compactValue)
399
+ ) ||
400
+ ""
401
+ );
402
+ }
403
+
404
+ const memberSelectedContactOptions = computed(() => {
405
+ return memberSelectedSearchResult.value
406
+ ? getPersonContactOptions(memberSelectedSearchResult.value)
407
+ : [];
408
+ });
409
+
410
+ const {
411
+ data: fetchMemberByPhoneNumber,
412
+ refresh: fetchMemberByPhoneNumberRefresh,
413
+ } = useLazyAsyncData(`fetch-member-phone-number-${props.site}`, () => {
414
+ const contact = memberForm.contact?.trim() || "";
415
+ if (contact.length < 4) return Promise.resolve(null);
416
+ const type = props.type === "walk-in" ? "guest" : props.type;
417
+ return findPersonByPhoneNumberMultipleResult(contact, props.site, type);
418
+ });
419
+
420
+ watch(fetchMemberByPhoneNumber, (obj) => {
421
+ memberPhoneSearchResults.value = getPeopleSearchItems(obj);
422
+ memberPhoneSearchMenu.value = memberPhoneSearchResults.value.length > 0;
423
+ });
424
+
425
+ const debounceFetchMemberPhoneNumber = debounce(
426
+ fetchMemberByPhoneNumberRefresh,
427
+ 500
428
+ );
429
+
430
+ function handleUpdateMemberPhoneNumber() {
431
+ if (!isMemberPhoneFieldFocused.value) return;
432
+
433
+ const search = memberForm.contact?.trim() || "";
434
+ if (search.length < 4) {
435
+ memberPhoneSearchResults.value = [];
436
+ memberPhoneSearchMenu.value = false;
437
+ return;
438
+ }
439
+
440
+ if (props.type === "drop-off" || props.type === "pick-up") return;
441
+ debounceFetchMemberPhoneNumber();
442
+ }
443
+
444
+ const {
445
+ data: fetchMemberByNRIC,
446
+ refresh: fetchMemberByNRICRefresh,
447
+ } = useLazyAsyncData(`fetch-member-nric-${props.site}`, () => {
448
+ const nric = memberForm.nric?.trim() || "";
449
+ if (nric.length < 4) return Promise.resolve(null);
450
+ const type = props.type === "walk-in" ? "guest" : props.type;
451
+ return findPersonByNRICMultipleResult(nric, props.site, type);
452
+ });
453
+
454
+ watch(fetchMemberByNRIC, (obj) => {
455
+ memberNRICSearchResults.value = getPeopleSearchItems(obj);
456
+ memberNRICSearchMenu.value = memberNRICSearchResults.value.length > 0;
457
+ });
458
+
459
+ const debounceFetchMemberNRIC = debounce(fetchMemberByNRICRefresh, 500);
460
+
461
+ function handleUpdateMemberNRIC() {
462
+ if (skipMemberNRICFetch.value) return;
463
+ if (!isMemberNRICFieldFocused.value) return;
464
+
465
+ const search = memberForm.nric?.trim() || "";
466
+ if (search.length < 4) {
467
+ memberNRICSearchResults.value = [];
468
+ memberNRICSearchMenu.value = false;
469
+ return;
470
+ }
471
+
472
+ if (props.type === "drop-off" || props.type === "pick-up") return;
473
+ debounceFetchMemberNRIC();
474
+ }
475
+
476
+ function selectMemberPhoneNumberSearchResult(item: Partial<TPeople>) {
477
+ memberSelectAutofillSearchResult(item, "contact");
478
+ }
479
+
480
+ function selectMemberNRICSearchResult(item: Partial<TPeople>) {
481
+ memberSelectAutofillSearchResult(item, "nric");
482
+ }
483
+
484
+ function memberSelectAutofillSearchResult(
485
+ item: Partial<TPeople>,
486
+ source: Exclude<AutofillSource, null>
487
+ ) {
488
+ memberCurrentAutofillSource.value = source;
489
+ const contactOptions = getPersonContactOptions(item);
490
+ const selectedContact =
491
+ findMatchingContactOption(contactOptions, memberForm.contact) ||
492
+ contactOptions[0] ||
493
+ "";
494
+
495
+ const needsContactChoice =
496
+ (!memberForm.contact?.trim() || source === "contact") &&
497
+ contactOptions.length > 1;
498
+
499
+ if (needsContactChoice) {
500
+ memberPhoneSearchMenu.value = false;
501
+ memberNRICSearchMenu.value = false;
502
+ memberSelectedSearchResult.value = item;
503
+ memberSelectedContact.value = selectedContact;
504
+ dialog.memberAutofillOptions = true;
505
+ return;
506
+ }
507
+
508
+ applyMemberSearchResult(item, {
509
+ contacts: selectedContact,
510
+ source,
511
+ });
512
+ }
513
+
514
+ function applyMemberSearchResult(
515
+ item: Partial<TPeople>,
516
+ options: {
517
+ contacts?: string;
518
+ source?: Exclude<AutofillSource, null>;
519
+ } = {}
520
+ ) {
521
+ memberNRICSearchMenu.value = false;
522
+ memberPhoneSearchMenu.value = false;
523
+ memberNRICSearchResults.value = [];
524
+ memberPhoneSearchResults.value = [];
525
+ skipMemberNRICFetch.value = true;
526
+ memberCurrentAutofillSource.value = options.source || null;
527
+
528
+ const shouldAutofillField = (fieldSource?: Exclude<AutofillSource, null>) => {
529
+ if (!fieldSource) return false;
530
+ return memberCurrentAutofillSource.value === fieldSource;
531
+ };
532
+ const getAutofillValue = (
533
+ currentValue: string | undefined,
534
+ nextValue: string | undefined,
535
+ fieldSource?: Exclude<AutofillSource, null>
536
+ ) => {
537
+ if (!nextValue) return currentValue;
538
+ if (currentValue && !shouldAutofillField(fieldSource)) return currentValue;
539
+ return nextValue;
540
+ };
541
+
542
+ memberForm.nric =
543
+ getAutofillValue(memberForm.nric, item.nric, "nric") ?? "";
544
+ memberForm.name = getAutofillValue(memberForm.name, item.name) ?? "";
545
+ memberForm.contact =
546
+ getAutofillValue(
547
+ memberForm.contact,
548
+ options.contacts || getPersonContactOptions(item)[0],
549
+ "contact"
550
+ ) ?? "";
551
+
552
+ setTimeout(() => {
553
+ memberCurrentAutofillSource.value = null;
554
+ skipMemberNRICFetch.value = false;
555
+ }, 1000);
556
+ }
557
+
558
+ function memberCloseAutofillOptions() {
559
+ dialog.memberAutofillOptions = false;
560
+ memberSelectedSearchResult.value = null;
561
+ memberSelectedContact.value = "";
562
+ }
563
+
564
+ function memberConfirmAutofillOptions() {
565
+ if (!memberSelectedSearchResult.value) return;
566
+
567
+ applyMemberSearchResult(memberSelectedSearchResult.value, {
568
+ contacts: memberSelectedContact.value,
569
+ source: memberCurrentAutofillSource.value || "nric",
570
+ });
571
+ memberCloseAutofillOptions();
572
+ }
573
+
355
574
  function handleClearForm() {
356
575
  formRef.value?.reset();
357
576
  resetAccessCard();
577
+ memberNRICSearchMenu.value = false;
578
+ memberPhoneSearchMenu.value = false;
579
+ memberNRICSearchResults.value = [];
580
+ memberPhoneSearchResults.value = [];
581
+ memberCloseAutofillOptions();
358
582
  }
359
583
 
360
584
  async function handleAddMember() {
@@ -375,4 +599,45 @@ function handleRemoveMember(index: number) {
375
599
  }
376
600
  </script>
377
601
 
378
- <style scoped></style>
602
+ <style scoped>
603
+ .nric-search-item {
604
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
605
+ border-radius: 4px;
606
+ margin: 4px;
607
+ min-height: 72px;
608
+ }
609
+
610
+ .phone-search-results {
611
+ max-height: 400px;
612
+ overflow-y: auto;
613
+ }
614
+
615
+ .nric-options-card {
616
+ border-radius: 16px !important;
617
+ }
618
+
619
+ .nric-options-title {
620
+ color: #06253b;
621
+ font-size: 1.45rem;
622
+ line-height: 1.2;
623
+ }
624
+
625
+ .nric-options-section-title {
626
+ color: #06253b;
627
+ font-size: 1.05rem;
628
+ line-height: 1.25;
629
+ }
630
+
631
+ .nric-selected-person {
632
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
633
+ border-radius: 12px;
634
+ color: #06253b;
635
+ font-size: 0.95rem;
636
+ min-height: 52px;
637
+ padding: 10px 14px;
638
+ }
639
+
640
+ .nric-option-group {
641
+ border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
642
+ }
643
+ </style>
@@ -675,6 +675,24 @@
675
675
  />
676
676
  </div>
677
677
  </span>
678
+ <span
679
+ v-else-if="
680
+ key === 'snapshotEntryImage' || key === 'snapshotExitImage'
681
+ "
682
+ class="d-flex flex-column"
683
+ >
684
+ <strong class="w-full">{{ label }}:</strong>
685
+ <div class="d-flex flex-wrap ga-2">
686
+ <AttachmentsViewer
687
+ :files="
688
+ mappedSnapshots([selectedVisitorObject.snapshotEntryImage])
689
+ "
690
+ anpr
691
+ :site="siteId"
692
+ title="Snapshot"
693
+ />
694
+ </div>
695
+ </span>
678
696
 
679
697
  <span
680
698
  v-else-if="selectedVisitorObject[key]"
@@ -1438,6 +1456,15 @@ function mappedAttachments(attachments: string[]) {
1438
1456
  });
1439
1457
  }
1440
1458
 
1459
+ function mappedSnapshots(attachments: string[]) {
1460
+ return attachments.map((x, index) => {
1461
+ return {
1462
+ id: x,
1463
+ name: `Snapshot - ${x}`,
1464
+ };
1465
+ });
1466
+ }
1467
+
1441
1468
  function normalizeDateOnly(value: string) {
1442
1469
  if (!value) return "";
1443
1470
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.1.3-staging.40",
5
+ "version": "3.1.3-staging.42",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {