@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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
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
|
-
|
|
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="
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
:
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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;
|