@7365admin1/layer-common 4.0.4-staging.241 → 4.0.4-staging.243
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/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/useServiceProviderPermission.ts +30 -4
- package/composables/useWebUsb.ts +64 -1
- package/constants/permissions.ts +9 -4
- package/package.json +1 -1
|
@@ -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>
|
|
@@ -0,0 +1,273 @@
|
|
|
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">Visitor Arrival</p>
|
|
6
|
+
<InputDatePicker v-model="visitor.expectedCheckIn" placeholder="DD/MM/YYYY" :rules="[requiredRule]" />
|
|
7
|
+
</v-col>
|
|
8
|
+
|
|
9
|
+
<v-col cols="6" class="mb-4 pr-1">
|
|
10
|
+
<v-text-field
|
|
11
|
+
v-model="visitor.arrivalTime"
|
|
12
|
+
type="time"
|
|
13
|
+
label="Time"
|
|
14
|
+
density="comfortable"
|
|
15
|
+
variant="outlined"
|
|
16
|
+
hide-details
|
|
17
|
+
/>
|
|
18
|
+
</v-col>
|
|
19
|
+
<v-col cols="6" class="mb-4 pl-1">
|
|
20
|
+
<v-text-field
|
|
21
|
+
v-model.number="visitor.duration"
|
|
22
|
+
type="number"
|
|
23
|
+
min="0"
|
|
24
|
+
label="Duration Hour(s)"
|
|
25
|
+
density="comfortable"
|
|
26
|
+
variant="outlined"
|
|
27
|
+
hide-details
|
|
28
|
+
/>
|
|
29
|
+
</v-col>
|
|
30
|
+
|
|
31
|
+
<v-col cols="12" class="mb-2">
|
|
32
|
+
<p class="text-caption text-medium-emphasis">Visitor Information</p>
|
|
33
|
+
</v-col>
|
|
34
|
+
|
|
35
|
+
<v-col cols="12" class="mb-4">
|
|
36
|
+
<v-text-field
|
|
37
|
+
v-model="visitor.name"
|
|
38
|
+
label="Name"
|
|
39
|
+
density="comfortable"
|
|
40
|
+
variant="outlined"
|
|
41
|
+
:rules="[requiredRule]"
|
|
42
|
+
/>
|
|
43
|
+
</v-col>
|
|
44
|
+
|
|
45
|
+
<v-col cols="12" class="mb-1">
|
|
46
|
+
<InputPhoneNumberV2 v-model="visitor.contact" density="comfortable" :rules="[requiredRule]" />
|
|
47
|
+
</v-col>
|
|
48
|
+
<v-col v-if="isContactPickerSupported" cols="12" class="mb-4 text-right">
|
|
49
|
+
<a href="#" class="text-caption" @click.prevent="fillFromContactPicker">
|
|
50
|
+
or select contact in your phone book
|
|
51
|
+
</a>
|
|
52
|
+
</v-col>
|
|
53
|
+
|
|
54
|
+
<v-col cols="12" class="mb-4">
|
|
55
|
+
<PublicOnboardingLocationFields
|
|
56
|
+
:site="site"
|
|
57
|
+
v-model:block="visitor.block"
|
|
58
|
+
v-model:level="visitor.level"
|
|
59
|
+
v-model:unit="visitor.unit"
|
|
60
|
+
/>
|
|
61
|
+
</v-col>
|
|
62
|
+
|
|
63
|
+
<v-col cols="12" class="mb-4">
|
|
64
|
+
<v-text-field
|
|
65
|
+
v-model="visitor.email"
|
|
66
|
+
type="email"
|
|
67
|
+
label="Email"
|
|
68
|
+
density="comfortable"
|
|
69
|
+
variant="outlined"
|
|
70
|
+
hide-details
|
|
71
|
+
/>
|
|
72
|
+
</v-col>
|
|
73
|
+
|
|
74
|
+
<v-col cols="12" class="mb-4">
|
|
75
|
+
<div class="d-flex align-center justify-space-between pa-3 bordered-field">
|
|
76
|
+
<span>Overnight Parking</span>
|
|
77
|
+
<v-switch
|
|
78
|
+
:model-value="visitor.isOvernightParking"
|
|
79
|
+
color="primary"
|
|
80
|
+
base-color="grey-lighten-1"
|
|
81
|
+
hide-details
|
|
82
|
+
@update:model-value="onToggleOvernightParking"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
</v-col>
|
|
86
|
+
|
|
87
|
+
<v-col cols="12" class="mb-4">
|
|
88
|
+
<div class="d-flex align-center justify-space-between pa-3 bordered-field">
|
|
89
|
+
<span>No. of passenger (Optional)</span>
|
|
90
|
+
<div class="d-flex align-center ga-3">
|
|
91
|
+
<v-btn icon size="small" variant="outlined" :disabled="!visitor.numberOfPassengers" @click="visitor.numberOfPassengers = Math.max(0, (visitor.numberOfPassengers || 0) - 1)">
|
|
92
|
+
<v-icon>mdi-minus</v-icon>
|
|
93
|
+
</v-btn>
|
|
94
|
+
<span>{{ visitor.numberOfPassengers || 0 }}</span>
|
|
95
|
+
<v-btn icon size="small" variant="outlined" @click="visitor.numberOfPassengers = (visitor.numberOfPassengers || 0) + 1">
|
|
96
|
+
<v-icon>mdi-plus</v-icon>
|
|
97
|
+
</v-btn>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</v-col>
|
|
101
|
+
|
|
102
|
+
<v-col cols="12" class="mb-4">
|
|
103
|
+
<v-textarea
|
|
104
|
+
v-model="visitor.purpose"
|
|
105
|
+
label="Enter purpose of visit"
|
|
106
|
+
density="comfortable"
|
|
107
|
+
variant="outlined"
|
|
108
|
+
rows="4"
|
|
109
|
+
auto-grow
|
|
110
|
+
/>
|
|
111
|
+
</v-col>
|
|
112
|
+
|
|
113
|
+
<v-col v-if="submitError" cols="12" class="mb-4">
|
|
114
|
+
<v-alert type="error" variant="tonal" density="compact">{{ submitError }}</v-alert>
|
|
115
|
+
</v-col>
|
|
116
|
+
|
|
117
|
+
<v-col cols="12">
|
|
118
|
+
<v-btn color="primary" variant="flat" block size="large" :loading="submitting" :disabled="!formValid" type="submit">
|
|
119
|
+
Submit
|
|
120
|
+
</v-btn>
|
|
121
|
+
</v-col>
|
|
122
|
+
</v-row>
|
|
123
|
+
|
|
124
|
+
<v-dialog v-model="showOvernightDialog" max-width="420" persistent>
|
|
125
|
+
<v-card class="pa-6">
|
|
126
|
+
<p class="text-body-1 text-center mb-4">
|
|
127
|
+
Overnight Parking requests outside our below business hours will not
|
|
128
|
+
be guaranteed a response
|
|
129
|
+
</p>
|
|
130
|
+
<div class="mb-4">
|
|
131
|
+
<div v-for="day in overnightScheduleDisplay" :key="day.label" class="d-flex justify-space-between text-body-2 py-1">
|
|
132
|
+
<span class="text-capitalize">{{ day.label }}</span>
|
|
133
|
+
<span>{{ day.text }}</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<v-row no-gutters class="ga-2">
|
|
137
|
+
<v-col>
|
|
138
|
+
<v-btn color="success" variant="tonal" block @click="confirmOvernightParking(true)">Yes</v-btn>
|
|
139
|
+
</v-col>
|
|
140
|
+
<v-col>
|
|
141
|
+
<v-btn color="error" variant="tonal" block @click="confirmOvernightParking(false)">No</v-btn>
|
|
142
|
+
</v-col>
|
|
143
|
+
</v-row>
|
|
144
|
+
</v-card>
|
|
145
|
+
</v-dialog>
|
|
146
|
+
</v-form>
|
|
147
|
+
</template>
|
|
148
|
+
|
|
149
|
+
<script lang="ts" setup>
|
|
150
|
+
const prop = defineProps({
|
|
151
|
+
site: { type: String, required: true },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const emit = defineEmits(["done"]);
|
|
155
|
+
|
|
156
|
+
const { createVisitor, getOvernightParkingHours } = usePublicVisitorOnboarding();
|
|
157
|
+
const { isSupported: isContactPickerSupported, pickContact } = useContactPicker();
|
|
158
|
+
|
|
159
|
+
const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
|
|
160
|
+
|
|
161
|
+
const visitor = reactive({
|
|
162
|
+
expectedCheckIn: null as string | null,
|
|
163
|
+
arrivalTime: "",
|
|
164
|
+
duration: null as number | null,
|
|
165
|
+
name: "",
|
|
166
|
+
contact: "",
|
|
167
|
+
block: "",
|
|
168
|
+
level: "",
|
|
169
|
+
unit: "",
|
|
170
|
+
email: "",
|
|
171
|
+
isOvernightParking: false,
|
|
172
|
+
numberOfPassengers: 0,
|
|
173
|
+
purpose: "",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const formValid = ref(false);
|
|
177
|
+
const submitting = ref(false);
|
|
178
|
+
const submitError = ref("");
|
|
179
|
+
|
|
180
|
+
async function fillFromContactPicker() {
|
|
181
|
+
const contact = await pickContact();
|
|
182
|
+
if (!contact) return;
|
|
183
|
+
if (contact.name) visitor.name = contact.name;
|
|
184
|
+
if (contact.tel) visitor.contact = contact.tel;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DAY_LABELS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
|
188
|
+
const overnightSchedule = ref<Record<string, any>>({});
|
|
189
|
+
const showOvernightDialog = ref(false);
|
|
190
|
+
|
|
191
|
+
const overnightScheduleDisplay = computed(() =>
|
|
192
|
+
DAY_LABELS.map((day) => {
|
|
193
|
+
const d = overnightSchedule.value[day];
|
|
194
|
+
return {
|
|
195
|
+
label: day,
|
|
196
|
+
text: d?.isEnabled && d?.startTime && d?.endTime ? `${d.startTime} - ${d.endTime}` : "Closed",
|
|
197
|
+
};
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
async function onToggleOvernightParking(value: boolean | null) {
|
|
202
|
+
if (!value) {
|
|
203
|
+
visitor.isOvernightParking = false;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const res: any = await getOvernightParkingHours(prop.site);
|
|
208
|
+
overnightSchedule.value = res?.data ?? res ?? {};
|
|
209
|
+
} catch {
|
|
210
|
+
overnightSchedule.value = {};
|
|
211
|
+
}
|
|
212
|
+
showOvernightDialog.value = true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function confirmOvernightParking(confirmed: boolean) {
|
|
216
|
+
visitor.isOvernightParking = confirmed;
|
|
217
|
+
showOvernightDialog.value = false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function submit() {
|
|
221
|
+
submitError.value = "";
|
|
222
|
+
submitting.value = true;
|
|
223
|
+
try {
|
|
224
|
+
const payload: Record<string, any> = {
|
|
225
|
+
type: "guest",
|
|
226
|
+
name: visitor.name,
|
|
227
|
+
contact: visitor.contact,
|
|
228
|
+
email: visitor.email || undefined,
|
|
229
|
+
block: visitor.block || undefined,
|
|
230
|
+
level: visitor.level || undefined,
|
|
231
|
+
unit: visitor.unit || undefined,
|
|
232
|
+
expectedCheckIn: visitor.expectedCheckIn || undefined,
|
|
233
|
+
arrivalTime: visitor.arrivalTime || undefined,
|
|
234
|
+
duration: visitor.duration ?? undefined,
|
|
235
|
+
isOvernightParking: visitor.isOvernightParking,
|
|
236
|
+
numberOfPassengers: visitor.numberOfPassengers || undefined,
|
|
237
|
+
purpose: visitor.purpose || undefined,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const res: any = await createVisitor(prop.site, payload);
|
|
241
|
+
emit("done", res?.status || "pending", res?._id);
|
|
242
|
+
} catch (error: any) {
|
|
243
|
+
submitError.value =
|
|
244
|
+
error?.data?.message || error?.message || "Unable to submit your registration. Please try again.";
|
|
245
|
+
} finally {
|
|
246
|
+
submitting.value = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
</script>
|
|
250
|
+
|
|
251
|
+
<style scoped>
|
|
252
|
+
/* Matches the surrounding v-text-field/v-select "outlined" variant's own
|
|
253
|
+
border exactly (Vuetify's field border color/opacity/radius tokens),
|
|
254
|
+
rather than an approximated color - these two rows aren't real Vuetify
|
|
255
|
+
fields, so without this they were the only inconsistent borders on the
|
|
256
|
+
form (one had a mismatched color/radius, the other had none at all). */
|
|
257
|
+
.bordered-field {
|
|
258
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
259
|
+
border-radius: 4px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* InputDatePicker/InputPhoneNumberV2 hardcode `.filter-field` on their own
|
|
263
|
+
internal v-text-field/v-select, independent of anything on this form's
|
|
264
|
+
own markup - that class sets border-radius: var(--r-inner) globally
|
|
265
|
+
(assets/css/screens.css), which is why they never matched the other
|
|
266
|
+
plain fields' 4px default no matter what class this component's own
|
|
267
|
+
elements had. Scoped :deep() here targets just their rendering within
|
|
268
|
+
this form (not the shared components' other usages elsewhere in the
|
|
269
|
+
app) with higher specificity than that global rule. */
|
|
270
|
+
:deep(.filter-field .v-field) {
|
|
271
|
+
border-radius: 4px;
|
|
272
|
+
}
|
|
273
|
+
</style>
|