@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,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>
|
|
@@ -457,6 +457,15 @@
|
|
|
457
457
|
>
|
|
458
458
|
{{ item?.overnightParking?.status }}
|
|
459
459
|
</v-card>
|
|
460
|
+
<v-card
|
|
461
|
+
v-else-if="activeTab === 'guests' && item?.status"
|
|
462
|
+
size="small"
|
|
463
|
+
class="rounded-xl text-capitalize text-center elevation-0 py-1 px-2"
|
|
464
|
+
:color="getVisitorStatusColor(item.status)"
|
|
465
|
+
style="max-width: 150px; margin: auto"
|
|
466
|
+
>
|
|
467
|
+
{{ getVisitorStatusLabel(item.status) }}
|
|
468
|
+
</v-card>
|
|
460
469
|
<span v-else> N/A </span>
|
|
461
470
|
</template>
|
|
462
471
|
|
|
@@ -536,6 +545,27 @@
|
|
|
536
545
|
<v-divider />
|
|
537
546
|
</v-card>
|
|
538
547
|
</v-menu>
|
|
548
|
+
<v-menu v-else-if="activeTab === 'guests' && item?.status === 'pending' && canUpdateVisitor">
|
|
549
|
+
<template #activator="{ props }">
|
|
550
|
+
<v-avatar v-bind="props" class="rounded-xl border-md">
|
|
551
|
+
<v-icon icon="mdi-dots-vertical" />
|
|
552
|
+
</v-avatar>
|
|
553
|
+
</template>
|
|
554
|
+
|
|
555
|
+
<v-card>
|
|
556
|
+
<v-list-item @click.stop="openApproveRejectVisitorDialog(item, 'approved')">
|
|
557
|
+
<template #title>
|
|
558
|
+
<span class="text-caption">Approve</span>
|
|
559
|
+
</template>
|
|
560
|
+
</v-list-item>
|
|
561
|
+
<v-divider />
|
|
562
|
+
<v-list-item @click.stop="openApproveRejectVisitorDialog(item, 'rejected')">
|
|
563
|
+
<template #title>
|
|
564
|
+
<span class="text-caption">Reject</span>
|
|
565
|
+
</template>
|
|
566
|
+
</v-list-item>
|
|
567
|
+
</v-card>
|
|
568
|
+
</v-menu>
|
|
539
569
|
</template>
|
|
540
570
|
|
|
541
571
|
<template v-slot:item.checkInRemarks="{ item }">
|
|
@@ -1179,6 +1209,78 @@
|
|
|
1179
1209
|
</v-card>
|
|
1180
1210
|
</v-dialog>
|
|
1181
1211
|
|
|
1212
|
+
<v-dialog
|
|
1213
|
+
v-model="dialog.approveRejectVisitorDialog"
|
|
1214
|
+
transition="dialog-bottom-transition"
|
|
1215
|
+
persistent
|
|
1216
|
+
width="60vh"
|
|
1217
|
+
>
|
|
1218
|
+
<v-card>
|
|
1219
|
+
<v-toolbar density="compact">
|
|
1220
|
+
<v-row
|
|
1221
|
+
no-gutters
|
|
1222
|
+
class="d-flex fill-height justify-space-between align-center px-4"
|
|
1223
|
+
>
|
|
1224
|
+
<span class="font-weight-bold">{{ approveRejectVisitorDialogTitle }}</span>
|
|
1225
|
+
<v-btn
|
|
1226
|
+
icon="mdi-close"
|
|
1227
|
+
variant="text"
|
|
1228
|
+
@click="closeApproveRejectVisitorDialog"
|
|
1229
|
+
/>
|
|
1230
|
+
</v-row>
|
|
1231
|
+
</v-toolbar>
|
|
1232
|
+
<v-card-text class="px-4 pb-6">
|
|
1233
|
+
<v-row no-gutters justify="center" align-content="center">
|
|
1234
|
+
<v-col cols="12" class="text-h6 text-center">
|
|
1235
|
+
{{
|
|
1236
|
+
`Are you sure you want to ${approveRejectVisitorDialog.text} the ${approveRejectVisitorDialogTitle}?`
|
|
1237
|
+
}}
|
|
1238
|
+
</v-col>
|
|
1239
|
+
|
|
1240
|
+
<v-col cols="12" md="8" class="mt-4">
|
|
1241
|
+
<v-textarea
|
|
1242
|
+
v-model="visitorApproveRejectRemarks"
|
|
1243
|
+
label="Remarks"
|
|
1244
|
+
placeholder="Enter remarks"
|
|
1245
|
+
outlined
|
|
1246
|
+
rows="3"
|
|
1247
|
+
clearable
|
|
1248
|
+
/>
|
|
1249
|
+
</v-col>
|
|
1250
|
+
</v-row>
|
|
1251
|
+
</v-card-text>
|
|
1252
|
+
<v-toolbar class="pa-0" density="compact">
|
|
1253
|
+
<v-row no-gutters>
|
|
1254
|
+
<v-col cols="6">
|
|
1255
|
+
<v-btn
|
|
1256
|
+
text="No"
|
|
1257
|
+
color="grey-lighten-3"
|
|
1258
|
+
variant="flat"
|
|
1259
|
+
height="48"
|
|
1260
|
+
tile
|
|
1261
|
+
block
|
|
1262
|
+
:disabled="isApprovingRejectingVisitor"
|
|
1263
|
+
@click="closeApproveRejectVisitorDialog"
|
|
1264
|
+
/>
|
|
1265
|
+
</v-col>
|
|
1266
|
+
<v-col cols="6">
|
|
1267
|
+
<v-btn
|
|
1268
|
+
text="Yes"
|
|
1269
|
+
color="success"
|
|
1270
|
+
variant="flat"
|
|
1271
|
+
height="48"
|
|
1272
|
+
tile
|
|
1273
|
+
block
|
|
1274
|
+
:disabled="!visitorApproveRejectRemarks || isApprovingRejectingVisitor"
|
|
1275
|
+
@click="updateVisitorApprovalStatus"
|
|
1276
|
+
:loading="isApprovingRejectingVisitor"
|
|
1277
|
+
/>
|
|
1278
|
+
</v-col>
|
|
1279
|
+
</v-row>
|
|
1280
|
+
</v-toolbar>
|
|
1281
|
+
</v-card>
|
|
1282
|
+
</v-dialog>
|
|
1283
|
+
|
|
1182
1284
|
<v-dialog v-model="hidQrDialog" max-width="440" persistent>
|
|
1183
1285
|
<v-card>
|
|
1184
1286
|
<v-toolbar color="transparent" density="comfortable" class="px-2">
|
|
@@ -1436,6 +1538,7 @@ const dialog = reactive({
|
|
|
1436
1538
|
showVisitorDataFromScannedQRCode: false,
|
|
1437
1539
|
approveRejectOvernightParkingRequestDialog: false,
|
|
1438
1540
|
returnNfcCard: false,
|
|
1541
|
+
approveRejectVisitorDialog: false,
|
|
1439
1542
|
});
|
|
1440
1543
|
|
|
1441
1544
|
const snapshotImageUrl = ref("");
|
|
@@ -1488,6 +1591,18 @@ const headers = computed(() => {
|
|
|
1488
1591
|
list.push({ title: "Action", value: "action" });
|
|
1489
1592
|
}
|
|
1490
1593
|
|
|
1594
|
+
// Guests is the only non-overnight-parking tab whose rows carry a
|
|
1595
|
+
// top-level status (approved/pending/rejected) and an Approve/Reject
|
|
1596
|
+
// action - without these two, the item.status/item.action template slots
|
|
1597
|
+
// have no header to render against and v-data-table never shows them,
|
|
1598
|
+
// no matter what their own v-if says.
|
|
1599
|
+
if (tab === "guests") {
|
|
1600
|
+
list.push(
|
|
1601
|
+
{ title: "Status", value: "status" },
|
|
1602
|
+
{ title: "Action", value: "action" },
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1491
1606
|
return list;
|
|
1492
1607
|
});
|
|
1493
1608
|
|
|
@@ -1590,6 +1705,77 @@ function closeApproveRejectOvernightParkingDialog() {
|
|
|
1590
1705
|
overNightParkingRequestApproveRejectRemarks.value = "";
|
|
1591
1706
|
}
|
|
1592
1707
|
|
|
1708
|
+
/** Self-service submissions arrive with `status: "pending"` when the site's
|
|
1709
|
+
Self-Service Auto-Approvals toggle is off - Approve/Reject here is what
|
|
1710
|
+
actually decides the visitor's outcome, mirroring the Overnight Parking
|
|
1711
|
+
approve/reject pattern above but against the top-level visitor status
|
|
1712
|
+
(via the existing staff update endpoint) rather than a nested field. */
|
|
1713
|
+
function getVisitorStatusColor(status: string) {
|
|
1714
|
+
switch (status) {
|
|
1715
|
+
case "approved":
|
|
1716
|
+
return "success";
|
|
1717
|
+
case "rejected":
|
|
1718
|
+
return "error";
|
|
1719
|
+
case "pending":
|
|
1720
|
+
return "warning";
|
|
1721
|
+
default:
|
|
1722
|
+
return "grey";
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function getVisitorStatusLabel(status: string) {
|
|
1727
|
+
return status === "pending" ? "Pending Approval" : status;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const selectedVisitorForApproval = ref<Record<string, any>>({});
|
|
1731
|
+
const visitorApproveRejectRemarks = ref("");
|
|
1732
|
+
const approveRejectVisitorDialog = ref({ text: "", status: "" });
|
|
1733
|
+
const isApprovingRejectingVisitor = ref(false);
|
|
1734
|
+
|
|
1735
|
+
const approveRejectVisitorDialogTitle = computed(() => {
|
|
1736
|
+
const type = selectedVisitorForApproval.value?.type as string | undefined;
|
|
1737
|
+
const label = type ? type.replace("-", " ") : "Visitor";
|
|
1738
|
+
return `${label.charAt(0).toUpperCase()}${label.slice(1)} Registration Request`;
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
function openApproveRejectVisitorDialog(visitor: Record<string, any>, status: "approved" | "rejected") {
|
|
1742
|
+
selectedVisitorForApproval.value = visitor;
|
|
1743
|
+
approveRejectVisitorDialog.value = {
|
|
1744
|
+
text: status === "approved" ? "Approve" : "Reject",
|
|
1745
|
+
status,
|
|
1746
|
+
};
|
|
1747
|
+
visitorApproveRejectRemarks.value = "";
|
|
1748
|
+
dialog.approveRejectVisitorDialog = true;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function closeApproveRejectVisitorDialog() {
|
|
1752
|
+
dialog.approveRejectVisitorDialog = false;
|
|
1753
|
+
selectedVisitorForApproval.value = {};
|
|
1754
|
+
approveRejectVisitorDialog.value = { text: "", status: "" };
|
|
1755
|
+
visitorApproveRejectRemarks.value = "";
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function updateVisitorApprovalStatus() {
|
|
1759
|
+
isApprovingRejectingVisitor.value = true;
|
|
1760
|
+
try {
|
|
1761
|
+
await updateVisitor(selectedVisitorForApproval.value._id, {
|
|
1762
|
+
status: approveRejectVisitorDialog.value.status,
|
|
1763
|
+
remarks: visitorApproveRejectRemarks.value,
|
|
1764
|
+
updatedBy: currentUser.value?._id,
|
|
1765
|
+
} as any);
|
|
1766
|
+
showMessage(
|
|
1767
|
+
`Visitor registration successfully ${approveRejectVisitorDialog.value.status}`,
|
|
1768
|
+
"success"
|
|
1769
|
+
);
|
|
1770
|
+
await getVisitorRefresh();
|
|
1771
|
+
closeApproveRejectVisitorDialog();
|
|
1772
|
+
} catch (error) {
|
|
1773
|
+
showMessage(errorConverter(error), "error");
|
|
1774
|
+
} finally {
|
|
1775
|
+
isApprovingRejectingVisitor.value = false;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1593
1779
|
function mappedAttachments(attachments: string[]) {
|
|
1594
1780
|
return attachments.map((x, index) => {
|
|
1595
1781
|
return {
|
|
@@ -1665,17 +1851,12 @@ function toRoute(tab: any) {
|
|
|
1665
1851
|
|
|
1666
1852
|
const obj = tabOptions.value.find((x) => x.value === tab);
|
|
1667
1853
|
if (!obj) return;
|
|
1854
|
+
// Don't call getVisitorRefresh() here - activeTab is already in the
|
|
1855
|
+
// useLazyAsyncData `watch` array below, so changing it (which already
|
|
1856
|
+
// happened via v-model before this handler runs) already triggers a
|
|
1857
|
+
// refetch on its own. Calling refresh() again here fired the same
|
|
1858
|
+
// request a second time on every tab click.
|
|
1668
1859
|
page.value = 1;
|
|
1669
|
-
getVisitorRefresh();
|
|
1670
|
-
// navigateTo({
|
|
1671
|
-
// name: routeName,
|
|
1672
|
-
// params: {
|
|
1673
|
-
// org: orgId,
|
|
1674
|
-
// },
|
|
1675
|
-
// // query: {
|
|
1676
|
-
// // tab
|
|
1677
|
-
// // },
|
|
1678
|
-
// });
|
|
1679
1860
|
}
|
|
1680
1861
|
const {
|
|
1681
1862
|
data: getVisitorReq,
|
|
@@ -1702,8 +1883,15 @@ const {
|
|
|
1702
1883
|
};
|
|
1703
1884
|
|
|
1704
1885
|
if (activeTab.value === "guests") {
|
|
1705
|
-
|
|
1706
|
-
|
|
1886
|
+
// Self-service Contractor submissions land here too, not just Visitor
|
|
1887
|
+
// (type: "guest") - the "Registered" tab hard-filters status="registered",
|
|
1888
|
+
// which a pending/approved self-service contractor never has, so without
|
|
1889
|
+
// this it had no tab that would ever show it.
|
|
1890
|
+
params.type = "guest,contractor";
|
|
1891
|
+
// A self-service submission awaiting Approve/Reject (Self-Service
|
|
1892
|
+
// Auto-Approvals off) lands here as "pending" alongside already
|
|
1893
|
+
// "approved" guests, rather than being hidden until someone acts on it.
|
|
1894
|
+
params.status = "approved,pending";
|
|
1707
1895
|
} else if (activeTab.value === "resident-transactions") {
|
|
1708
1896
|
params.status = "registered";
|
|
1709
1897
|
params.type = "resident,tenant";
|
|
@@ -2237,8 +2425,11 @@ function buildReportParams(): any {
|
|
|
2237
2425
|
};
|
|
2238
2426
|
|
|
2239
2427
|
if (activeTab.value === "guests") {
|
|
2240
|
-
|
|
2241
|
-
|
|
2428
|
+
// Kept in sync with the Guests tab's own list query above - otherwise a
|
|
2429
|
+
// report generated from this tab would silently omit contractors and
|
|
2430
|
+
// pending rows the screen is actually showing.
|
|
2431
|
+
params.type = "guest,contractor";
|
|
2432
|
+
params.status = "approved,pending";
|
|
2242
2433
|
} else if (activeTab.value === "resident-transactions") {
|
|
2243
2434
|
params.status = "registered";
|
|
2244
2435
|
params.type = "resident,tenant";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps the browser's Contact Picker API ("or select contact in your phone
|
|
3
|
+
* book" on the self-service onboarding form). Support is narrow - Android
|
|
4
|
+
* Chrome/Edge over HTTPS only, not iOS Safari or desktop - so `isSupported`
|
|
5
|
+
* must gate whether the UI even offers this, rather than showing a button
|
|
6
|
+
* that silently does nothing everywhere else.
|
|
7
|
+
*/
|
|
8
|
+
export default function useContactPicker() {
|
|
9
|
+
const isSupported = computed(() => {
|
|
10
|
+
return (
|
|
11
|
+
typeof navigator !== "undefined" &&
|
|
12
|
+
"contacts" in navigator &&
|
|
13
|
+
"ContactsManager" in window
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function pickContact(): Promise<{ name?: string; tel?: string } | null> {
|
|
18
|
+
if (!isSupported.value) return null;
|
|
19
|
+
try {
|
|
20
|
+
const props = ["name", "tel"];
|
|
21
|
+
const contacts = await (navigator as any).contacts.select(props, { multiple: false });
|
|
22
|
+
const contact = contacts?.[0];
|
|
23
|
+
if (!contact) return null;
|
|
24
|
+
return {
|
|
25
|
+
name: contact.name?.[0],
|
|
26
|
+
tel: contact.tel?.[0],
|
|
27
|
+
};
|
|
28
|
+
} catch {
|
|
29
|
+
// User cancelled the picker, or the browser refused - either way this
|
|
30
|
+
// is not an error worth surfacing, the visitor can just type instead.
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { isSupported, pickContact };
|
|
36
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public, unauthenticated calls for the self-service visitor onboarding page
|
|
3
|
+
* (reached by scanning the gate QR code printed from Site Settings > Entry
|
|
4
|
+
* Pass > Self-Service). Every endpoint here is deliberately a separate,
|
|
5
|
+
* narrower public route from its staff-facing equivalent in `useBuilding`/
|
|
6
|
+
* `useVisitor`/`useSiteEntryPassSettings` - see the server-side route
|
|
7
|
+
* comments for why each one is safe to leave unauthenticated.
|
|
8
|
+
*/
|
|
9
|
+
export default function usePublicVisitorOnboarding() {
|
|
10
|
+
function getOnboardingSettings(siteId: string) {
|
|
11
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
12
|
+
`/api/access-management/settings/${siteId}/onboarding`,
|
|
13
|
+
{ method: "GET" },
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getBlocks(siteId: string) {
|
|
18
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
19
|
+
`/api/buildings/list/site/resident/${siteId}`,
|
|
20
|
+
{ method: "GET" },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLevels(siteId: string, blockId: string) {
|
|
25
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
26
|
+
`/api/building-levels/list/site/resident/${siteId}`,
|
|
27
|
+
{ method: "GET", query: { block: blockId } },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getUnits(siteId: string, block: string, level: string) {
|
|
32
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
33
|
+
`/api/building-units/resident/site/${siteId}/block/${block}/level/${level}`,
|
|
34
|
+
{ method: "GET" },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createVisitor(siteId: string, payload: Record<string, any>) {
|
|
39
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
40
|
+
`/api/visitor-transactions/self-service/${siteId}`,
|
|
41
|
+
{ method: "POST", body: payload },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The "2" collection is the one actually populated/used by
|
|
46
|
+
// OvernightParkingAvailability.vue - the un-suffixed v1 collection this
|
|
47
|
+
// used to read from is a separate, unpopulated legacy store.
|
|
48
|
+
function getOvernightParkingHours(siteId: string) {
|
|
49
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
50
|
+
`/api/overnight-parking-approval-settings2/site/${siteId}/public`,
|
|
51
|
+
{ method: "GET" },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Backs the visitor's own preview/QR page, linked from their registration
|
|
56
|
+
// email so it still works after they've closed the browser.
|
|
57
|
+
function getSelfServicePreview(id: string) {
|
|
58
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
59
|
+
`/api/visitor-transactions/self-service/preview/${id}`,
|
|
60
|
+
{ method: "GET" },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
getOnboardingSettings,
|
|
66
|
+
getBlocks,
|
|
67
|
+
getLevels,
|
|
68
|
+
getUnits,
|
|
69
|
+
createVisitor,
|
|
70
|
+
getOvernightParkingHours,
|
|
71
|
+
getSelfServicePreview,
|
|
72
|
+
};
|
|
73
|
+
}
|