@7365admin1/layer-common 4.0.4-staging.241 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -4
- package/components/EntryPass/SelfServiceQrPreview.vue +79 -0
- package/components/EntryPassMain.vue +289 -0
- package/components/PublicOnboarding/ContractorRegistrationForm.vue +278 -0
- package/components/PublicOnboarding/LocationFields.vue +125 -0
- package/components/PublicOnboarding/TypeSelectCard.vue +34 -0
- package/components/PublicOnboarding/VisitorRegistrationForm.vue +273 -0
- package/components/VisitorManagement.vue +205 -14
- package/composables/useContactPicker.ts +36 -0
- package/composables/usePublicVisitorOnboarding.ts +73 -0
- package/composables/useWebUsb.ts +64 -1
- package/package.json +1 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-form v-model="formValid" @submit.prevent="submit">
|
|
3
|
+
<v-row no-gutters>
|
|
4
|
+
<v-col cols="12" class="mb-4">
|
|
5
|
+
<p class="text-caption text-medium-emphasis mb-2">Arrival Date</p>
|
|
6
|
+
<InputDatePicker v-model="visitor.expectedCheckIn" placeholder="DD/MM/YYYY" :rules="[requiredRule]" />
|
|
7
|
+
</v-col>
|
|
8
|
+
|
|
9
|
+
<v-col cols="12" class="mb-4">
|
|
10
|
+
<v-select
|
|
11
|
+
v-model="visitor.contractorType"
|
|
12
|
+
:items="contractorTypes"
|
|
13
|
+
item-title="title"
|
|
14
|
+
item-value="value"
|
|
15
|
+
label="Contractor Type"
|
|
16
|
+
density="comfortable"
|
|
17
|
+
variant="outlined"
|
|
18
|
+
hide-details
|
|
19
|
+
/>
|
|
20
|
+
</v-col>
|
|
21
|
+
|
|
22
|
+
<v-col cols="12" class="mb-4">
|
|
23
|
+
<v-text-field
|
|
24
|
+
v-model="visitor.name"
|
|
25
|
+
label="Name"
|
|
26
|
+
density="comfortable"
|
|
27
|
+
variant="outlined"
|
|
28
|
+
:rules="[requiredRule]"
|
|
29
|
+
/>
|
|
30
|
+
</v-col>
|
|
31
|
+
|
|
32
|
+
<v-col cols="12" class="mb-4">
|
|
33
|
+
<v-text-field
|
|
34
|
+
v-model="visitor.nric"
|
|
35
|
+
label="NRIC / Passport No. (Optional)"
|
|
36
|
+
density="comfortable"
|
|
37
|
+
variant="outlined"
|
|
38
|
+
hide-details
|
|
39
|
+
/>
|
|
40
|
+
</v-col>
|
|
41
|
+
|
|
42
|
+
<v-col cols="12" class="mb-4">
|
|
43
|
+
<v-text-field
|
|
44
|
+
v-model="visitor.email"
|
|
45
|
+
type="email"
|
|
46
|
+
label="Email"
|
|
47
|
+
density="comfortable"
|
|
48
|
+
variant="outlined"
|
|
49
|
+
hide-details
|
|
50
|
+
/>
|
|
51
|
+
</v-col>
|
|
52
|
+
|
|
53
|
+
<v-col cols="12" class="mb-1">
|
|
54
|
+
<InputPhoneNumberV2 v-model="visitor.contact" density="comfortable" :rules="[requiredRule]" />
|
|
55
|
+
</v-col>
|
|
56
|
+
<v-col v-if="isContactPickerSupported" cols="12" class="mb-4 text-right">
|
|
57
|
+
<a href="#" class="text-caption" @click.prevent="fillFromContactPicker">
|
|
58
|
+
or select contact in your phone book
|
|
59
|
+
</a>
|
|
60
|
+
</v-col>
|
|
61
|
+
|
|
62
|
+
<v-col cols="12" class="mb-4">
|
|
63
|
+
<PublicOnboardingLocationFields
|
|
64
|
+
:site="site"
|
|
65
|
+
v-model:block="visitor.block"
|
|
66
|
+
v-model:level="visitor.level"
|
|
67
|
+
v-model:unit="visitor.unit"
|
|
68
|
+
/>
|
|
69
|
+
</v-col>
|
|
70
|
+
|
|
71
|
+
<v-col cols="12" class="mb-4">
|
|
72
|
+
<v-text-field
|
|
73
|
+
v-model="visitor.company"
|
|
74
|
+
label="Company Name"
|
|
75
|
+
density="comfortable"
|
|
76
|
+
variant="outlined"
|
|
77
|
+
:rules="[requiredRule]"
|
|
78
|
+
/>
|
|
79
|
+
</v-col>
|
|
80
|
+
|
|
81
|
+
<v-col cols="12" class="mb-4">
|
|
82
|
+
<v-text-field
|
|
83
|
+
v-model="visitor.plateNumber"
|
|
84
|
+
label="Vehicle Number (Optional)"
|
|
85
|
+
density="comfortable"
|
|
86
|
+
variant="outlined"
|
|
87
|
+
hide-details
|
|
88
|
+
/>
|
|
89
|
+
</v-col>
|
|
90
|
+
|
|
91
|
+
<v-col cols="12" class="mb-4">
|
|
92
|
+
<v-btn variant="outlined" block class="justify-space-between" append-icon="mdi-pencil-outline" @click="showMembersDialog = true">
|
|
93
|
+
{{ members.length > 0 ? `${members.length} Member(s) Added` : "Add Member" }}
|
|
94
|
+
</v-btn>
|
|
95
|
+
</v-col>
|
|
96
|
+
|
|
97
|
+
<v-col cols="12" class="mb-4">
|
|
98
|
+
<v-textarea
|
|
99
|
+
v-model="visitor.remarks"
|
|
100
|
+
label="Remarks (optional)"
|
|
101
|
+
density="comfortable"
|
|
102
|
+
variant="outlined"
|
|
103
|
+
rows="3"
|
|
104
|
+
auto-grow
|
|
105
|
+
/>
|
|
106
|
+
</v-col>
|
|
107
|
+
|
|
108
|
+
<v-col v-if="submitError" cols="12" class="mb-4">
|
|
109
|
+
<v-alert type="error" variant="tonal" density="compact">{{ submitError }}</v-alert>
|
|
110
|
+
</v-col>
|
|
111
|
+
|
|
112
|
+
<v-col cols="12">
|
|
113
|
+
<v-btn color="primary" variant="flat" block size="large" :loading="submitting" :disabled="!formValid" type="submit">
|
|
114
|
+
Submit
|
|
115
|
+
</v-btn>
|
|
116
|
+
</v-col>
|
|
117
|
+
</v-row>
|
|
118
|
+
|
|
119
|
+
<v-dialog v-model="showMembersDialog" max-width="420" persistent>
|
|
120
|
+
<v-card class="pa-4">
|
|
121
|
+
<div class="d-flex align-center mb-4">
|
|
122
|
+
<v-btn icon variant="text" @click="showMembersDialog = false">
|
|
123
|
+
<v-icon>mdi-chevron-left</v-icon>
|
|
124
|
+
</v-btn>
|
|
125
|
+
<span class="text-subtitle-1 ml-2">Members</span>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div
|
|
129
|
+
v-for="(member, index) in draftMembers"
|
|
130
|
+
:key="index"
|
|
131
|
+
class="pa-3 mb-3 bordered-field"
|
|
132
|
+
>
|
|
133
|
+
<v-text-field
|
|
134
|
+
v-model="member.name"
|
|
135
|
+
label="Name"
|
|
136
|
+
density="comfortable"
|
|
137
|
+
variant="outlined"
|
|
138
|
+
hide-details
|
|
139
|
+
class="mb-2"
|
|
140
|
+
/>
|
|
141
|
+
<v-text-field
|
|
142
|
+
v-model="member.nric"
|
|
143
|
+
label="NRIC / Passport No. (Optional)"
|
|
144
|
+
density="comfortable"
|
|
145
|
+
variant="outlined"
|
|
146
|
+
hide-details
|
|
147
|
+
class="mb-2"
|
|
148
|
+
/>
|
|
149
|
+
<InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details />
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<v-btn variant="outlined" block class="mb-3" prepend-icon="mdi-plus" @click="addDraftMember">
|
|
153
|
+
Add
|
|
154
|
+
</v-btn>
|
|
155
|
+
<v-btn color="primary" variant="flat" block @click="saveMembers">Save</v-btn>
|
|
156
|
+
</v-card>
|
|
157
|
+
</v-dialog>
|
|
158
|
+
</v-form>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<script lang="ts" setup>
|
|
162
|
+
const prop = defineProps({
|
|
163
|
+
site: { type: String, required: true },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const emit = defineEmits(["done"]);
|
|
167
|
+
|
|
168
|
+
const { createVisitor } = usePublicVisitorOnboarding();
|
|
169
|
+
const { isSupported: isContactPickerSupported, pickContact } = useContactPicker();
|
|
170
|
+
|
|
171
|
+
const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
|
|
172
|
+
|
|
173
|
+
const contractorTypes = [
|
|
174
|
+
{ title: "Home Contractor", value: "home-contractor" },
|
|
175
|
+
{ title: "House Mover", value: "house-mover" },
|
|
176
|
+
{ title: "Estate Contractor", value: "estate-contractor" },
|
|
177
|
+
{ title: "Property Agent", value: "property-agent" },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const visitor = reactive({
|
|
181
|
+
expectedCheckIn: null as string | null,
|
|
182
|
+
contractorType: "",
|
|
183
|
+
name: "",
|
|
184
|
+
nric: "",
|
|
185
|
+
email: "",
|
|
186
|
+
contact: "",
|
|
187
|
+
block: "",
|
|
188
|
+
level: "",
|
|
189
|
+
unit: "",
|
|
190
|
+
company: "",
|
|
191
|
+
plateNumber: "",
|
|
192
|
+
remarks: "",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
type TDraftMember = { name: string; nric: string; contact: string };
|
|
196
|
+
const members = ref<TDraftMember[]>([]);
|
|
197
|
+
const draftMembers = ref<TDraftMember[]>([]);
|
|
198
|
+
const showMembersDialog = ref(false);
|
|
199
|
+
|
|
200
|
+
watch(showMembersDialog, (open) => {
|
|
201
|
+
if (open) {
|
|
202
|
+
draftMembers.value = members.value.length
|
|
203
|
+
? members.value.map((m) => ({ ...m }))
|
|
204
|
+
: [{ name: "", nric: "", contact: "" }];
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
function addDraftMember() {
|
|
209
|
+
draftMembers.value.push({ name: "", nric: "", contact: "" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function saveMembers() {
|
|
213
|
+
members.value = draftMembers.value.filter((m) => m.name && m.contact);
|
|
214
|
+
showMembersDialog.value = false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const formValid = ref(false);
|
|
218
|
+
const submitting = ref(false);
|
|
219
|
+
const submitError = ref("");
|
|
220
|
+
|
|
221
|
+
async function fillFromContactPicker() {
|
|
222
|
+
const contact = await pickContact();
|
|
223
|
+
if (!contact) return;
|
|
224
|
+
if (contact.name) visitor.name = contact.name;
|
|
225
|
+
if (contact.tel) visitor.contact = contact.tel;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function submit() {
|
|
229
|
+
submitError.value = "";
|
|
230
|
+
submitting.value = true;
|
|
231
|
+
try {
|
|
232
|
+
const payload: Record<string, any> = {
|
|
233
|
+
type: "contractor",
|
|
234
|
+
contractorType: visitor.contractorType || undefined,
|
|
235
|
+
name: visitor.name,
|
|
236
|
+
contact: visitor.contact,
|
|
237
|
+
nric: visitor.nric || undefined,
|
|
238
|
+
email: visitor.email || undefined,
|
|
239
|
+
block: visitor.block || undefined,
|
|
240
|
+
level: visitor.level || undefined,
|
|
241
|
+
unit: visitor.unit || undefined,
|
|
242
|
+
company: visitor.company,
|
|
243
|
+
plateNumber: visitor.plateNumber || undefined,
|
|
244
|
+
remarks: visitor.remarks || undefined,
|
|
245
|
+
members: members.value.length ? members.value : undefined,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const res: any = await createVisitor(prop.site, payload);
|
|
249
|
+
emit("done", res?.status || "pending");
|
|
250
|
+
} catch (error: any) {
|
|
251
|
+
submitError.value =
|
|
252
|
+
error?.data?.message || error?.message || "Unable to submit your registration. Please try again.";
|
|
253
|
+
} finally {
|
|
254
|
+
submitting.value = false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
</script>
|
|
258
|
+
|
|
259
|
+
<style scoped>
|
|
260
|
+
/* Matches the surrounding v-text-field/InputPhoneNumberV2 fields' own
|
|
261
|
+
border color (Vuetify's border color/opacity token) - the per-member row
|
|
262
|
+
in the Members dialog isn't a real Vuetify field, so without this it was
|
|
263
|
+
the one inconsistent border on the form. */
|
|
264
|
+
.bordered-field {
|
|
265
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
266
|
+
border-radius: 4px;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* InputDatePicker/InputPhoneNumberV2 hardcode `.filter-field` on their own
|
|
270
|
+
internal v-text-field/v-select, which sets border-radius: var(--r-inner)
|
|
271
|
+
globally (assets/css/screens.css) - overriding it just within this form
|
|
272
|
+
(including inside the teleported Members dialog; :deep()'s scoping
|
|
273
|
+
attribute stays on the element through teleport) rather than in the
|
|
274
|
+
shared components themselves, which are also used elsewhere. */
|
|
275
|
+
:deep(.filter-field .v-field) {
|
|
276
|
+
border-radius: 4px;
|
|
277
|
+
}
|
|
278
|
+
</style>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<p class="text-caption text-medium-emphasis mb-2">Location</p>
|
|
4
|
+
<v-row no-gutters class="flex-nowrap">
|
|
5
|
+
<v-col cols="4" class="pr-1">
|
|
6
|
+
<v-autocomplete
|
|
7
|
+
v-model="block"
|
|
8
|
+
:items="blocksArray"
|
|
9
|
+
item-title="title"
|
|
10
|
+
item-value="value"
|
|
11
|
+
label="Block"
|
|
12
|
+
:loading="blocksLoading"
|
|
13
|
+
:rules="[requiredRule]"
|
|
14
|
+
density="comfortable"
|
|
15
|
+
variant="outlined"
|
|
16
|
+
hide-details="auto"
|
|
17
|
+
@update:model-value="onBlockChange"
|
|
18
|
+
/>
|
|
19
|
+
</v-col>
|
|
20
|
+
<v-col cols="4" class="px-1">
|
|
21
|
+
<v-autocomplete
|
|
22
|
+
v-model="level"
|
|
23
|
+
:items="levelsArray"
|
|
24
|
+
item-title="title"
|
|
25
|
+
item-value="value"
|
|
26
|
+
label="Level"
|
|
27
|
+
:loading="levelsLoading"
|
|
28
|
+
:disabled="!block"
|
|
29
|
+
:rules="[requiredRule]"
|
|
30
|
+
density="comfortable"
|
|
31
|
+
variant="outlined"
|
|
32
|
+
hide-details="auto"
|
|
33
|
+
@update:model-value="onLevelChange"
|
|
34
|
+
/>
|
|
35
|
+
</v-col>
|
|
36
|
+
<v-col cols="4" class="pl-1">
|
|
37
|
+
<v-autocomplete
|
|
38
|
+
v-model="unit"
|
|
39
|
+
:items="unitsArray"
|
|
40
|
+
item-title="title"
|
|
41
|
+
item-value="value"
|
|
42
|
+
label="Unit"
|
|
43
|
+
:loading="unitsLoading"
|
|
44
|
+
:disabled="!level"
|
|
45
|
+
:rules="[requiredRule]"
|
|
46
|
+
density="comfortable"
|
|
47
|
+
variant="outlined"
|
|
48
|
+
hide-details="auto"
|
|
49
|
+
/>
|
|
50
|
+
</v-col>
|
|
51
|
+
</v-row>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<script lang="ts" setup>
|
|
56
|
+
const prop = defineProps({
|
|
57
|
+
site: { type: String, required: true },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const block = defineModel<string>("block", { default: "" });
|
|
61
|
+
const level = defineModel<string>("level", { default: "" });
|
|
62
|
+
const unit = defineModel<string>("unit", { default: "" });
|
|
63
|
+
|
|
64
|
+
const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "Required";
|
|
65
|
+
|
|
66
|
+
const { getBlocks, getLevels, getUnits } = usePublicVisitorOnboarding();
|
|
67
|
+
|
|
68
|
+
const blocksArray = ref<{ title: string; value: string }[]>([]);
|
|
69
|
+
const levelsArray = ref<{ title: string; value: string }[]>([]);
|
|
70
|
+
const unitsArray = ref<{ title: string; value: string }[]>([]);
|
|
71
|
+
const blocksLoading = ref(false);
|
|
72
|
+
const levelsLoading = ref(false);
|
|
73
|
+
const unitsLoading = ref(false);
|
|
74
|
+
|
|
75
|
+
function toArray(res: any) {
|
|
76
|
+
return Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function loadBlocks() {
|
|
80
|
+
blocksLoading.value = true;
|
|
81
|
+
try {
|
|
82
|
+
const res = await getBlocks(prop.site);
|
|
83
|
+
blocksArray.value = toArray(res).map((b: any) => ({ title: b.name, value: b._id }));
|
|
84
|
+
} finally {
|
|
85
|
+
blocksLoading.value = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Takes the new value as an argument rather than reading `block.value` -
|
|
91
|
+
* this handler and `v-model="block"` are both bound to the same
|
|
92
|
+
* `update:model-value` event on the same element, and listener order
|
|
93
|
+
* between them isn't guaranteed, so `block.value` can still be the OLD
|
|
94
|
+
* value at the moment this runs.
|
|
95
|
+
*/
|
|
96
|
+
async function onBlockChange(newBlock: string | null) {
|
|
97
|
+
level.value = "";
|
|
98
|
+
unit.value = "";
|
|
99
|
+
unitsArray.value = [];
|
|
100
|
+
levelsArray.value = [];
|
|
101
|
+
if (!newBlock) return;
|
|
102
|
+
levelsLoading.value = true;
|
|
103
|
+
try {
|
|
104
|
+
const res = await getLevels(prop.site, newBlock);
|
|
105
|
+
levelsArray.value = toArray(res).map((l: any) => ({ title: l.name, value: l._id }));
|
|
106
|
+
} finally {
|
|
107
|
+
levelsLoading.value = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function onLevelChange(newLevel: string | null) {
|
|
112
|
+
unit.value = "";
|
|
113
|
+
unitsArray.value = [];
|
|
114
|
+
if (!block.value || !newLevel) return;
|
|
115
|
+
unitsLoading.value = true;
|
|
116
|
+
try {
|
|
117
|
+
const res = await getUnits(prop.site, block.value, newLevel);
|
|
118
|
+
unitsArray.value = toArray(res).map((u: any) => ({ title: u.name, value: u._id }));
|
|
119
|
+
} finally {
|
|
120
|
+
unitsLoading.value = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onMounted(loadBlocks);
|
|
125
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card class="pa-0" elevation="2">
|
|
3
|
+
<v-card-title class="px-4 py-3">Self-service onboarding portal</v-card-title>
|
|
4
|
+
<v-divider />
|
|
5
|
+
<v-list class="pa-3">
|
|
6
|
+
<v-list-item
|
|
7
|
+
v-for="option in options"
|
|
8
|
+
:key="option.value"
|
|
9
|
+
class="mb-2 rounded-lg"
|
|
10
|
+
style="border: 1px solid rgba(128,128,128,0.3)"
|
|
11
|
+
@click="emit('select', option.value)"
|
|
12
|
+
>
|
|
13
|
+
<template #prepend>
|
|
14
|
+
<v-avatar color="grey-darken-3" class="mr-3">
|
|
15
|
+
<v-icon color="white">{{ option.icon }}</v-icon>
|
|
16
|
+
</v-avatar>
|
|
17
|
+
</template>
|
|
18
|
+
<v-list-item-title>{{ option.label }}</v-list-item-title>
|
|
19
|
+
<template #append>
|
|
20
|
+
<v-icon>mdi-chevron-right</v-icon>
|
|
21
|
+
</template>
|
|
22
|
+
</v-list-item>
|
|
23
|
+
</v-list>
|
|
24
|
+
</v-card>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script lang="ts" setup>
|
|
28
|
+
const emit = defineEmits(["select"]);
|
|
29
|
+
|
|
30
|
+
const options = [
|
|
31
|
+
{ label: "Visitor", value: "guest", icon: "mdi-walk" },
|
|
32
|
+
{ label: "Contractor", value: "contractor", icon: "mdi-account-hard-hat-outline" },
|
|
33
|
+
];
|
|
34
|
+
</script>
|