@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,79 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog
|
|
3
|
+
:model-value="modelValue"
|
|
4
|
+
transition="dialog-right-transition"
|
|
5
|
+
width="420"
|
|
6
|
+
persistent
|
|
7
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
8
|
+
>
|
|
9
|
+
<v-card class="screen-modal">
|
|
10
|
+
<div class="d-flex justify-end pa-2">
|
|
11
|
+
<v-btn
|
|
12
|
+
variant="text"
|
|
13
|
+
icon="mdi-close"
|
|
14
|
+
class="screen-modal__close"
|
|
15
|
+
aria-label="Close preview"
|
|
16
|
+
@click="emit('update:modelValue', false)"
|
|
17
|
+
></v-btn>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<v-card-text class="screen-modal__body pa-0 pb-4">
|
|
21
|
+
<!-- Matches the printed A5 layout: one QR, a "scan" subtext, and a
|
|
22
|
+
dark banner carrying the site name - not the multi-copy receipt
|
|
23
|
+
`EntryPassQrTemplatePreview` renders for physical passes. -->
|
|
24
|
+
<div class="onboarding-qr-preview mx-6">
|
|
25
|
+
<img v-if="qrImage" :src="qrImage" width="240" height="240" class="d-block mx-auto" />
|
|
26
|
+
<p class="onboarding-qr-preview__subtext text-center mt-4">
|
|
27
|
+
{{ (subText || "SCAN QR CODE").toUpperCase() }}
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="onboarding-qr-preview__banner text-center mt-4 py-4">
|
|
31
|
+
{{ (header || "").toUpperCase() }}
|
|
32
|
+
</div>
|
|
33
|
+
</v-card-text>
|
|
34
|
+
</v-card>
|
|
35
|
+
</v-dialog>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script lang="ts" setup>
|
|
39
|
+
import QRCode from "qrcode";
|
|
40
|
+
|
|
41
|
+
const props = defineProps({
|
|
42
|
+
modelValue: { type: Boolean, default: false },
|
|
43
|
+
header: { type: String, default: "" },
|
|
44
|
+
subText: { type: String, default: "" },
|
|
45
|
+
qrValue: { type: String, default: "" },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const emit = defineEmits(["update:modelValue"]);
|
|
49
|
+
|
|
50
|
+
const qrImage = ref<string>("");
|
|
51
|
+
|
|
52
|
+
async function renderQr() {
|
|
53
|
+
if (!props.qrValue) {
|
|
54
|
+
qrImage.value = "";
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
qrImage.value = await QRCode.toDataURL(props.qrValue, { width: 240, margin: 1 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
watch(() => props.qrValue, renderQr);
|
|
61
|
+
onMounted(renderQr);
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<style scoped>
|
|
65
|
+
.onboarding-qr-preview__subtext {
|
|
66
|
+
font-size: var(--fs-card-title);
|
|
67
|
+
font-weight: var(--fw-card-title);
|
|
68
|
+
color: var(--text);
|
|
69
|
+
letter-spacing: 0.5px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.onboarding-qr-preview__banner {
|
|
73
|
+
background: #000;
|
|
74
|
+
color: #fff;
|
|
75
|
+
font-size: 22px;
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
letter-spacing: 2px;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -391,6 +391,157 @@
|
|
|
391
391
|
|
|
392
392
|
</v-row>
|
|
393
393
|
</v-window-item>
|
|
394
|
+
|
|
395
|
+
<!-- ─── Tab 5: Self-Service ───────────────────────────── -->
|
|
396
|
+
<v-window-item value="onboarding">
|
|
397
|
+
<v-row no-gutters class="px-6 pt-4">
|
|
398
|
+
|
|
399
|
+
<!-- Self-Service Auto-Approvals -->
|
|
400
|
+
<v-col cols="12">
|
|
401
|
+
<div class="d-flex mt-3 pb-5 pr-5">
|
|
402
|
+
<p class="mr-6 font-weight-medium flex-grow-1">
|
|
403
|
+
Self-Service Auto-Approvals
|
|
404
|
+
<span class="d-block text-caption screen-hint mt-1">
|
|
405
|
+
Automatically issue active passes the moment a contractor or
|
|
406
|
+
visitor submits their registration form via the self-service
|
|
407
|
+
web portal.
|
|
408
|
+
</span>
|
|
409
|
+
</p>
|
|
410
|
+
<v-switch
|
|
411
|
+
color="success"
|
|
412
|
+
hide-details
|
|
413
|
+
v-model="currentEntryPassSettings.onboardingEnabled"
|
|
414
|
+
@update:modelValue="updateOnboardingEnabled()"
|
|
415
|
+
/>
|
|
416
|
+
</div>
|
|
417
|
+
</v-col>
|
|
418
|
+
|
|
419
|
+
<v-divider />
|
|
420
|
+
|
|
421
|
+
<!-- Printer Setup -->
|
|
422
|
+
<v-col cols="12">
|
|
423
|
+
<div class="mt-5 pb-5">
|
|
424
|
+
<p class="font-weight-medium mb-3">Printer Setup</p>
|
|
425
|
+
<v-row align="center">
|
|
426
|
+
<v-col cols="12" md="3">
|
|
427
|
+
<v-text-field
|
|
428
|
+
label="Vendor ID"
|
|
429
|
+
hide-details
|
|
430
|
+
clearable
|
|
431
|
+
v-model="currentEntryPassSettings.onboardingPrinterVendorId"
|
|
432
|
+
/>
|
|
433
|
+
</v-col>
|
|
434
|
+
<v-col cols="12" md="3">
|
|
435
|
+
<v-text-field
|
|
436
|
+
label="Product ID"
|
|
437
|
+
hide-details
|
|
438
|
+
clearable
|
|
439
|
+
v-model="currentEntryPassSettings.onboardingPrinterProductId"
|
|
440
|
+
/>
|
|
441
|
+
</v-col>
|
|
442
|
+
<v-col cols="12" md="3" class="d-flex align-end">
|
|
443
|
+
<v-btn
|
|
444
|
+
color="primary"
|
|
445
|
+
variant="outlined"
|
|
446
|
+
prepend-icon="mdi-magnify"
|
|
447
|
+
block
|
|
448
|
+
height="48"
|
|
449
|
+
:loading="scanningOnboardingPrinter"
|
|
450
|
+
@click="scanOnboardingPrinter()"
|
|
451
|
+
>
|
|
452
|
+
Scan Printer
|
|
453
|
+
</v-btn>
|
|
454
|
+
</v-col>
|
|
455
|
+
<v-col cols="12" md="3" class="d-flex align-end pr-0">
|
|
456
|
+
<v-btn
|
|
457
|
+
color="success"
|
|
458
|
+
variant="outlined"
|
|
459
|
+
prepend-icon="mdi-printer-check"
|
|
460
|
+
block
|
|
461
|
+
height="48"
|
|
462
|
+
:loading="testingOnboardingPrinter"
|
|
463
|
+
@click="testAndPrintOnboarding()"
|
|
464
|
+
>
|
|
465
|
+
Test & Print
|
|
466
|
+
</v-btn>
|
|
467
|
+
</v-col>
|
|
468
|
+
</v-row>
|
|
469
|
+
<v-row class="mt-4">
|
|
470
|
+
<v-col cols="12" md="3">
|
|
471
|
+
<v-btn
|
|
472
|
+
color="primary"
|
|
473
|
+
variant="flat"
|
|
474
|
+
prepend-icon="mdi-content-save"
|
|
475
|
+
block
|
|
476
|
+
@click="saveOnboardingSettings()"
|
|
477
|
+
>
|
|
478
|
+
Save Settings
|
|
479
|
+
</v-btn>
|
|
480
|
+
</v-col>
|
|
481
|
+
</v-row>
|
|
482
|
+
</div>
|
|
483
|
+
</v-col>
|
|
484
|
+
|
|
485
|
+
<v-divider />
|
|
486
|
+
|
|
487
|
+
<!-- Self-Service Pass QR Code -->
|
|
488
|
+
<v-col cols="12">
|
|
489
|
+
<div class="mt-5 pb-5">
|
|
490
|
+
<p class="font-weight-medium mb-3">Self-Service Pass QR Code</p>
|
|
491
|
+
<v-row no-gutters>
|
|
492
|
+
<v-col cols="12" md="6" class="pr-md-2">
|
|
493
|
+
<v-text-field
|
|
494
|
+
label="Header"
|
|
495
|
+
placeholder="Enter QR code header"
|
|
496
|
+
hide-details
|
|
497
|
+
density="comfortable"
|
|
498
|
+
clearable
|
|
499
|
+
v-model="currentEntryPassSettings.onboardingHeader"
|
|
500
|
+
/>
|
|
501
|
+
</v-col>
|
|
502
|
+
<v-col cols="12" md="6" class="pl-md-2 mt-3 mt-md-0">
|
|
503
|
+
<v-text-field
|
|
504
|
+
label="Subtext"
|
|
505
|
+
placeholder="Enter QR code subtext"
|
|
506
|
+
hide-details
|
|
507
|
+
density="comfortable"
|
|
508
|
+
clearable
|
|
509
|
+
v-model="currentEntryPassSettings.onboardingSubText"
|
|
510
|
+
/>
|
|
511
|
+
</v-col>
|
|
512
|
+
<v-col cols="12">
|
|
513
|
+
<v-row class="mt-3">
|
|
514
|
+
<v-col cols="12" md="4">
|
|
515
|
+
<v-btn
|
|
516
|
+
color="success"
|
|
517
|
+
variant="flat"
|
|
518
|
+
prepend-icon="mdi-eye"
|
|
519
|
+
block
|
|
520
|
+
@click="isShowOnboardingPreview = true"
|
|
521
|
+
>
|
|
522
|
+
Preview
|
|
523
|
+
</v-btn>
|
|
524
|
+
</v-col>
|
|
525
|
+
<v-col cols="12" md="4">
|
|
526
|
+
<v-btn
|
|
527
|
+
color="primary"
|
|
528
|
+
variant="flat"
|
|
529
|
+
prepend-icon="mdi-printer"
|
|
530
|
+
block
|
|
531
|
+
:loading="printingOnboardingQr"
|
|
532
|
+
@click="printOnboardingQrCode()"
|
|
533
|
+
>
|
|
534
|
+
Print QR Code
|
|
535
|
+
</v-btn>
|
|
536
|
+
</v-col>
|
|
537
|
+
</v-row>
|
|
538
|
+
</v-col>
|
|
539
|
+
</v-row>
|
|
540
|
+
</div>
|
|
541
|
+
</v-col>
|
|
542
|
+
|
|
543
|
+
</v-row>
|
|
544
|
+
</v-window-item>
|
|
394
545
|
</v-window>
|
|
395
546
|
</AppCard>
|
|
396
547
|
|
|
@@ -401,6 +552,13 @@
|
|
|
401
552
|
:header="currentEntryPassSettings.qrCodeTemplateHeader"
|
|
402
553
|
:sub-text="currentEntryPassSettings.qrCodeTemplateHeaderSubText"
|
|
403
554
|
/>
|
|
555
|
+
|
|
556
|
+
<EntryPassSelfServiceQrPreview
|
|
557
|
+
v-model="isShowOnboardingPreview"
|
|
558
|
+
:header="currentEntryPassSettings.onboardingHeader"
|
|
559
|
+
:sub-text="currentEntryPassSettings.onboardingSubText"
|
|
560
|
+
:qr-value="onboardingQrValue"
|
|
561
|
+
/>
|
|
404
562
|
</div>
|
|
405
563
|
</template>
|
|
406
564
|
|
|
@@ -420,6 +578,7 @@ const entryPassTabs = [
|
|
|
420
578
|
{ value: "qrcode", label: "QR Code", icon: "mdi-qrcode" },
|
|
421
579
|
{ value: "printer", label: "Printer", icon: "mdi-printer" },
|
|
422
580
|
{ value: "entrypass", label: "Entrypass", icon: "mdi-link-variant" },
|
|
581
|
+
{ value: "onboarding", label: "Self-Service", icon: "mdi-account-multiple-check-outline" },
|
|
423
582
|
];
|
|
424
583
|
|
|
425
584
|
const currentEntryPassSettings = ref<Record<string, any>>({
|
|
@@ -445,6 +604,11 @@ const currentEntryPassSettings = ref<Record<string, any>>({
|
|
|
445
604
|
disclaimerMessage: "",
|
|
446
605
|
url: "",
|
|
447
606
|
template: { id: "", name: "" },
|
|
607
|
+
onboardingEnabled: false,
|
|
608
|
+
onboardingHeader: "",
|
|
609
|
+
onboardingSubText: "",
|
|
610
|
+
onboardingPrinterVendorId: "",
|
|
611
|
+
onboardingPrinterProductId: "",
|
|
448
612
|
status: "",
|
|
449
613
|
createdBy: "",
|
|
450
614
|
updatedBy: "",
|
|
@@ -474,6 +638,18 @@ const isShowTemplatePreview = ref(false);
|
|
|
474
638
|
const uploadingTemplate = ref(false);
|
|
475
639
|
const templateFileInput = ref<HTMLInputElement | null>(null);
|
|
476
640
|
|
|
641
|
+
const scanningOnboardingPrinter = ref(false);
|
|
642
|
+
const testingOnboardingPrinter = ref(false);
|
|
643
|
+
const printingOnboardingQr = ref(false);
|
|
644
|
+
const isShowOnboardingPreview = ref(false);
|
|
645
|
+
|
|
646
|
+
/** The URL printed on the self-service QR code — the public, unauthenticated
|
|
647
|
+
page a walk-up visitor lands on after scanning it at the gate. */
|
|
648
|
+
const runtimeConfig = useRuntimeConfig().public as Record<string, any>;
|
|
649
|
+
const onboardingQrValue = computed(
|
|
650
|
+
() => `${runtimeConfig.APP_PROPERTY_MANAGEMENT}/public-view/visitor-onboarding/${siteId}`
|
|
651
|
+
);
|
|
652
|
+
|
|
477
653
|
function triggerTemplateUpload() {
|
|
478
654
|
templateFileInput.value?.click();
|
|
479
655
|
}
|
|
@@ -614,6 +790,15 @@ watchEffect(() => {
|
|
|
614
790
|
disclaimerMessage: data.settings?.disclaimerMessage ?? "",
|
|
615
791
|
url: data.settings?.url ?? "",
|
|
616
792
|
template: data.settings?.template ?? { id: "", name: "" },
|
|
793
|
+
onboardingEnabled: data.settings?.onboarding?.enabled ?? false,
|
|
794
|
+
onboardingHeader: data.settings?.onboarding?.header ?? "",
|
|
795
|
+
onboardingSubText: data.settings?.onboarding?.subText ?? "",
|
|
796
|
+
onboardingPrinterVendorId: data.settings?.onboarding?.printer?.vendorId
|
|
797
|
+
? String(data.settings.onboarding.printer.vendorId)
|
|
798
|
+
: "",
|
|
799
|
+
onboardingPrinterProductId: data.settings?.onboarding?.printer?.productId
|
|
800
|
+
? String(data.settings.onboarding.printer.productId)
|
|
801
|
+
: "",
|
|
617
802
|
createdAt: data.createdAt ?? "",
|
|
618
803
|
updatedAt: data.updatedAt ?? "",
|
|
619
804
|
};
|
|
@@ -651,6 +836,15 @@ function buildPayload() {
|
|
|
651
836
|
template: currentEntryPassSettings.value.template?.id
|
|
652
837
|
? currentEntryPassSettings.value.template
|
|
653
838
|
: null,
|
|
839
|
+
onboarding: {
|
|
840
|
+
enabled: currentEntryPassSettings.value.onboardingEnabled ?? false,
|
|
841
|
+
header: currentEntryPassSettings.value.onboardingHeader || "",
|
|
842
|
+
subText: currentEntryPassSettings.value.onboardingSubText || "",
|
|
843
|
+
printer: {
|
|
844
|
+
vendorId: Number(currentEntryPassSettings.value.onboardingPrinterVendorId) || null,
|
|
845
|
+
productId: Number(currentEntryPassSettings.value.onboardingPrinterProductId) || null,
|
|
846
|
+
},
|
|
847
|
+
},
|
|
654
848
|
},
|
|
655
849
|
};
|
|
656
850
|
}
|
|
@@ -693,6 +887,101 @@ async function saveEntryPassUrl() {
|
|
|
693
887
|
getSiteEntryPassSettings();
|
|
694
888
|
}
|
|
695
889
|
|
|
890
|
+
async function updateOnboardingEnabled() {
|
|
891
|
+
await _save(buildPayload());
|
|
892
|
+
showMessage("Self-service auto-approval updated!", "success");
|
|
893
|
+
getSiteEntryPassSettings();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function scanOnboardingPrinter() {
|
|
897
|
+
if (!isWebUsbSupported.value) {
|
|
898
|
+
showMessage("Web USB is not supported in this browser", "error");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
scanningOnboardingPrinter.value = true;
|
|
903
|
+
const device = await requestDevice();
|
|
904
|
+
currentEntryPassSettings.value.onboardingPrinterVendorId = String(device.vendorId);
|
|
905
|
+
currentEntryPassSettings.value.onboardingPrinterProductId = String(device.productId);
|
|
906
|
+
showMessage(
|
|
907
|
+
`Printer found: ${device.productName} (${device.vendorId}:${device.productId})`,
|
|
908
|
+
"success",
|
|
909
|
+
);
|
|
910
|
+
} catch (error: any) {
|
|
911
|
+
if (error.message !== "No device selected") {
|
|
912
|
+
showMessage(error.message || "Failed to scan printer", "error");
|
|
913
|
+
}
|
|
914
|
+
} finally {
|
|
915
|
+
scanningOnboardingPrinter.value = false;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function testAndPrintOnboarding() {
|
|
920
|
+
if (!isWebUsbSupported.value) {
|
|
921
|
+
showMessage("Web USB is not supported in this browser", "error");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const vendorId = Number(currentEntryPassSettings.value.onboardingPrinterVendorId);
|
|
925
|
+
const productId = Number(currentEntryPassSettings.value.onboardingPrinterProductId);
|
|
926
|
+
if (!vendorId || !productId) {
|
|
927
|
+
showMessage("Please set a valid Vendor ID and Product ID first", "warning");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
testingOnboardingPrinter.value = true;
|
|
932
|
+
const result = await testConnection(vendorId, productId);
|
|
933
|
+
showMessage(result.message, result.success ? "success" : "error");
|
|
934
|
+
} catch (error: any) {
|
|
935
|
+
showMessage(error.message || "Test print failed", "error");
|
|
936
|
+
} finally {
|
|
937
|
+
testingOnboardingPrinter.value = false;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function saveOnboardingSettings() {
|
|
942
|
+
await _save(buildPayload());
|
|
943
|
+
showMessage("Self-service settings saved successfully!", "success");
|
|
944
|
+
getSiteEntryPassSettings();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function printOnboardingQrCode() {
|
|
948
|
+
if (!isWebUsbSupported.value) {
|
|
949
|
+
showMessage("Web USB is not supported in this browser", "error");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const vendorId = Number(currentEntryPassSettings.value.onboardingPrinterVendorId);
|
|
953
|
+
const productId = Number(currentEntryPassSettings.value.onboardingPrinterProductId);
|
|
954
|
+
if (!vendorId || !productId) {
|
|
955
|
+
showMessage("Please set up and save a printer first", "warning");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
printingOnboardingQr.value = true;
|
|
960
|
+
// Header/subtext are printed onto the physical QR, so persist them
|
|
961
|
+
// before sending to the printer - otherwise a printed pass could show
|
|
962
|
+
// text the settings screen never actually saved.
|
|
963
|
+
await _save(buildPayload());
|
|
964
|
+
const result = await testConnection(
|
|
965
|
+
vendorId,
|
|
966
|
+
productId,
|
|
967
|
+
true,
|
|
968
|
+
onboardingQrValue.value,
|
|
969
|
+
null,
|
|
970
|
+
null,
|
|
971
|
+
undefined,
|
|
972
|
+
undefined,
|
|
973
|
+
currentEntryPassSettings.value.onboardingHeader,
|
|
974
|
+
currentEntryPassSettings.value.onboardingSubText,
|
|
975
|
+
"onboarding",
|
|
976
|
+
);
|
|
977
|
+
showMessage(result.message, result.success ? "success" : "error");
|
|
978
|
+
} catch (error: any) {
|
|
979
|
+
showMessage(error.message || "Failed to print QR code", "error");
|
|
980
|
+
} finally {
|
|
981
|
+
printingOnboardingQr.value = false;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
696
985
|
function showMessage(msg: string, color: string) {
|
|
697
986
|
message.value = msg;
|
|
698
987
|
messageColor.value = color;
|
|
@@ -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>
|