@7365admin1/layer-common 3.2.7 → 3.2.8-staging.201
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/assets/css/primitives.css +1113 -0
- package/assets/css/screens.css +1097 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +236 -0
- package/components/AcceptDialog.vue +17 -18
- package/components/AccessCard/AvailableStats.vue +24 -16
- package/components/AccessCardAddForm.vue +42 -42
- package/components/AccessCardAssignToUnitForm.vue +13 -21
- package/components/AccessCardDeleteDialog.vue +41 -39
- package/components/AccessCardDetailsDialog.vue +125 -79
- package/components/AccessCardPreviewDialog.vue +58 -84
- package/components/AccessCardQrTagging.vue +60 -63
- package/components/AccessCardReplaceForm.vue +27 -23
- package/components/AccessManagement.vue +86 -85
- package/components/AddEqupmentForm.vue +18 -22
- package/components/AddPassKeyToVisitor.vue +95 -73
- package/components/AppButton.vue +62 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +49 -0
- package/components/AppField.vue +76 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +247 -0
- package/components/AreaChecklistHistoryLogs.vue +51 -44
- package/components/AreaChecklistHistoryMain.vue +14 -7
- package/components/AreaFormDialog.vue +71 -59
- package/components/AreaMain.vue +73 -107
- package/components/AttachmentsViewer.vue +84 -22
- package/components/AttendanceCheckInOutDialog.vue +85 -88
- package/components/AttendanceDetailsDialog.vue +57 -63
- package/components/AttendanceMain.vue +4 -3
- package/components/AttendanceMapSearchDialog.vue +42 -44
- package/components/AttendanceSettingsDialog.vue +79 -63
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BtnUploadFile.vue +53 -11
- package/components/BuildingForm.vue +34 -27
- package/components/BuildingManagement/buildings.vue +39 -39
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BuildingUnitFormAdd.vue +20 -23
- package/components/BuildingUnitFormEdit.vue +8 -4
- package/components/BulletinBoardForm.vue +19 -24
- package/components/BulletinBoardManagement.vue +49 -57
- package/components/BulletinBoardView.vue +9 -6
- package/components/BulletinExpirationChip.vue +13 -4
- package/components/Button/Close.vue +2 -2
- package/components/CameraForm.vue +21 -22
- package/components/CameraMain.vue +50 -49
- package/components/Card/DeleteConfirmation.vue +24 -22
- package/components/Card/MemberInfoSummary.vue +28 -3
- package/components/Card/Toggle.vue +15 -3
- package/components/CardToolbar.vue +37 -0
- package/components/Carousel.vue +56 -36
- package/components/Chat/Bubbles.vue +43 -14
- package/components/Chat/Information.vue +49 -32
- package/components/Chat/ListCard.vue +38 -9
- package/components/Chat/Message.vue +41 -15
- package/components/Chat/Navigation.vue +27 -15
- package/components/Chat/SkeletonLoader.vue +9 -2
- package/components/ChecklistItemRow.vue +25 -1
- package/components/CleaningScheduleMain.vue +44 -49
- package/components/ClientDetailForm.vue +81 -56
- package/components/ClientMain.vue +92 -82
- package/components/ConfirmDialog.vue +14 -8
- package/components/Container/Standard.vue +9 -7
- package/components/CreateSubsciptionPlan.vue +148 -89
- package/components/DashboardEmptyState.vue +48 -3
- package/components/DashboardMain.vue +924 -676
- package/components/DashboardPanel.vue +84 -21
- package/components/DeliveryCompany.vue +34 -20
- package/components/Dialog/DeleteConfirmation.vue +20 -21
- package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
- package/components/Dialog/ReusablePrompt.vue +30 -29
- package/components/Dialog/UpdateMoreAction.vue +62 -59
- package/components/DocumentForm.vue +14 -21
- package/components/DocumentManagement.vue +162 -108
- package/components/DocumentViewer.vue +29 -12
- package/components/DrawImage.vue +29 -21
- package/components/EntryPass/QrTemplatePreview.vue +27 -16
- package/components/EntryPassInformation.vue +40 -10
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +84 -97
- package/components/EquipmentManagementMain.vue +81 -103
- package/components/Facility/BookingSetup.vue +43 -52
- package/components/Feedback/Form.vue +26 -12
- package/components/FeedbackMain.vue +35 -49
- package/components/FileInput.vue +75 -58
- package/components/FileInputWithList.vue +59 -12
- package/components/FormDialog.vue +60 -21
- package/components/HidAccessLogDashboard.vue +512 -490
- package/components/HidCameraCaptureDialog.vue +216 -0
- package/components/HidEnabledGate.vue +67 -9
- package/components/HidIntercomManagement.vue +1322 -653
- package/components/HidQrCodeConfiguration.vue +1030 -258
- package/components/HidReaderForm.vue +142 -59
- package/components/HidReaderManagement.vue +211 -323
- package/components/HidServiceSettingsPanel.vue +44 -13
- package/components/HidUserEnrollment.vue +1185 -412
- package/components/HygieneUpdateMoreAction.vue +44 -44
- package/components/IncidentReport/Authorities.vue +30 -18
- package/components/IncidentReport/IncidentInformation.vue +10 -10
- package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
- package/components/IncidentReport/affectedEntities.vue +41 -20
- package/components/Input/Date.vue +3 -0
- package/components/Input/DatePicker.vue +1 -2
- package/components/Input/DateTimePicker.vue +1 -1
- package/components/Input/File.vue +55 -10
- package/components/Input/FileV2.vue +39 -6
- package/components/Input/InputPhoneNumberV2.vue +2 -1
- package/components/Input/ListGroupSelection.vue +56 -8
- package/components/Input/NRICNumber.vue +1 -0
- package/components/Input/NewDate.vue +1 -0
- package/components/Input/Number.vue +1 -0
- package/components/Input/Password.vue +1 -0
- package/components/Input/PhoneNumber.vue +21 -1
- package/components/Input/VehicleNumber.vue +1 -0
- package/components/InputLabel.vue +2 -2
- package/components/InvitationClientForm.vue +21 -19
- package/components/InvitationForm.vue +15 -21
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +263 -36
- package/components/Layout/NavigationDrawer.vue +300 -30
- package/components/ListItem.vue +33 -3
- package/components/LocalPagination.vue +27 -3
- package/components/ManageChecklistMain.vue +136 -86
- package/components/MemberInformation.vue +32 -16
- package/components/MemberMain.vue +113 -93
- package/components/MyAttendanceMain.vue +3 -5
- package/components/NavigationItem.vue +83 -3
- package/components/Nfc/NFCPatrolReportMain.vue +47 -33
- package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
- package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
- package/components/Nfc/NFCPatrolSettings.vue +14 -4
- package/components/Nfc/NFCTagForm.vue +22 -8
- package/components/Nfc/NFCTagMain.vue +41 -43
- package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
- package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
- package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
- package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
- package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
- package/components/NotificationSettings.vue +373 -0
- package/components/NumberSettingField.vue +9 -2
- package/components/OnlineFormConfigurationForm.vue +725 -237
- package/components/OnlineFormFill.vue +213 -106
- package/components/OnlineFormRenderPreview.vue +25 -30
- package/components/OnlineFormsConfiguration.vue +113 -109
- package/components/OrgViewDialog.vue +34 -16
- package/components/OvernightParkingAvailability.vue +49 -26
- package/components/OvernightParkingManagement.vue +78 -18
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +109 -53
- package/components/PasswordConfirmation.vue +44 -26
- package/components/PeopleForm.vue +41 -20
- package/components/PhotoUpload.vue +110 -48
- package/components/PlaceholderComponent.vue +35 -19
- package/components/PlanActionsMenu.vue +12 -2
- package/components/PlanStatusBadge.vue +7 -8
- package/components/PlateNumberDisplay.vue +9 -6
- package/components/QrTemplate/PrintDialog.vue +66 -43
- package/components/RolePermissionFormCreate.vue +104 -79
- package/components/RolePermissionFormPreviewUpdate.vue +111 -109
- package/components/RolePermissionMain.vue +183 -81
- package/components/ScanVisitorQRCode.vue +99 -35
- package/components/ScheduleAreaMain.vue +34 -59
- package/components/ScheduleTaskForm.vue +61 -42
- package/components/ScheduleTaskMain.vue +56 -69
- package/components/SearchVehicleNumberUser.vue +39 -37
- package/components/ServiceProviderFormCreate.vue +29 -20
- package/components/ServiceProviderInvitationPrompt.vue +155 -0
- package/components/ServiceProviderMain.vue +601 -432
- package/components/Signature.vue +40 -34
- package/components/SignaturePad.vue +5 -29
- package/components/SiteSettings.vue +41 -11
- package/components/SiteSettingsWorkOrder.vue +61 -30
- package/components/SlideCardGroup.vue +35 -11
- package/components/Snackbar.vue +36 -3
- package/components/SpecificAttr.vue +18 -8
- package/components/StatusChip.vue +156 -0
- package/components/StockCard.vue +17 -12
- package/components/SubscriptionPlanTable.vue +207 -140
- package/components/SwitchContext.vue +201 -9
- package/components/TableHygiene.vue +36 -46
- package/components/TableList.vue +10 -10
- package/components/TableListSecondary.vue +105 -54
- package/components/TableMain.vue +251 -78
- package/components/TableV3.vue +61 -70
- package/components/TiptapEditor.vue +33 -40
- package/components/TiptapEditorQr.vue +5 -2
- package/components/Tooltip/Info.vue +69 -24
- package/components/UnitMain.vue +40 -60
- package/components/UnitPersonCard.vue +42 -13
- package/components/VehicleAddSelection.vue +48 -32
- package/components/VehicleForm.vue +112 -129
- package/components/VehicleManagement.vue +26 -12
- package/components/VehicleUpdateMoreAction.vue +59 -46
- package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
- package/components/VisitorForm.vue +774 -210
- package/components/VisitorFormSelection.vue +40 -37
- package/components/VisitorManagement.vue +239 -82
- package/components/VisitorPassKeyQRScanner.vue +65 -18
- package/components/VisitorSearchResultSummary.vue +60 -16
- package/components/VisitorSocketPopUp.vue +22 -14
- package/components/VisitorsReportPreview.vue +44 -29
- package/components/WorkOrder/Create.vue +26 -17
- package/components/WorkOrder/ListView.vue +38 -7
- package/components/WorkOrder/Main.vue +62 -59
- package/composables/useFeedback.ts +3 -3
- package/composables/useHidAmico.ts +269 -0
- package/composables/useHidNavigation.ts +2 -9
- package/composables/useMember.ts +3 -3
- package/composables/useNFCPatrolDailyReport.ts +13 -0
- package/composables/useNFCPatrolMonthlyReport.ts +22 -56
- package/composables/useNFCPatrolReport.ts +13 -0
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useRole.ts +22 -0
- package/composables/useServiceProvider.ts +3 -3
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +336 -0
- package/composables/useSubscriptionPlan.ts +62 -0
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/composables/useWebUsb.ts +127 -37
- package/composables/useWorkOrder.ts +17 -3
- package/error.vue +30 -6
- package/nuxt.config.ts +36 -0
- package/package.json +25 -4
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/pages/notification-settings.vue +19 -0
- package/pages/payment-method-linked.vue +34 -10
- package/pages/require-customer.vue +48 -10
- package/pages/require-organization-membership.vue +32 -8
- package/pages/unauthorized.vue +29 -4
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +69 -2
- package/types/nfc-patrol-report.ts +3 -2
- package/types/site.d.ts +71 -0
- package/types/subscription.ts +18 -11
- package/utils/breadcrumb.test.ts +108 -0
- package/utils/breadcrumb.ts +82 -0
- package/utils/dashboard.test.ts +118 -0
- package/utils/dashboard.ts +53 -0
- package/utils/data.test.ts +95 -0
- package/utils/data.ts +40 -2
- package/utils/hid-discover.test.ts +102 -0
- package/utils/list-page.test.ts +60 -0
- package/utils/list-page.ts +38 -0
- package/utils/materialSymbols.ts +363 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/nfc-patrol-report.test.ts +130 -0
- package/utils/nfc-patrol-report.ts +101 -0
- package/utils/role.test.ts +69 -0
- package/utils/role.ts +10 -0
- package/utils/route-name.test.ts +85 -0
- package/utils/route-name.ts +24 -0
- package/utils/status.test.ts +110 -0
- package/utils/status.ts +135 -0
- package/utils/table-card.test.ts +84 -0
- package/utils/theme-aa-ledger.ts +148 -0
- package/utils/theme.test.ts +485 -7
- package/utils/theme.ts +295 -110
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.editorconfig +0 -12
- package/.github/workflows/main.yml +0 -17
- package/.github/workflows/publish-staging.yml +0 -47
- package/.github/workflows/publish.yml +0 -39
- package/PUBLISHING.md +0 -269
- package/components/HidIdentityMapping.vue +0 -975
- package/components/ScheduleTaskAreaFormDialog.vue +0 -144
- package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
- package/components/ScheduleTastTicketMain.vue +0 -182
- package/components/TableWithButton.vue +0 -94
- package/test/visitor-socket.test.mjs +0 -36
|
@@ -1,181 +1,450 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<v-card-text style="max-height: 76vh; overflow-y: auto" class="pa-0">
|
|
12
|
-
<v-form v-model="validForm" :disabled="disable">
|
|
13
|
-
|
|
14
|
-
<!-- Form Type -->
|
|
15
|
-
<v-col cols="12" class="px-6 pt-4 pb-0">
|
|
16
|
-
<InputLabel title="Form Type" />
|
|
17
|
-
|
|
18
|
-
<!-- Add mode: selectable list -->
|
|
19
|
-
<v-list
|
|
20
|
-
v-if="prop.mode !== 'edit'"
|
|
21
|
-
density="compact"
|
|
22
|
-
lines="one"
|
|
23
|
-
max-height="220"
|
|
24
|
-
style="overflow-y: auto; border: 1px solid rgba(0,0,0,0.18); border-radius: 4px;"
|
|
25
|
-
class="pa-0"
|
|
26
|
-
>
|
|
27
|
-
<v-list-item
|
|
28
|
-
v-if="availableFormTypes.length === 0"
|
|
29
|
-
disabled
|
|
30
|
-
title="All forms have already been added for this site"
|
|
31
|
-
class="text-caption text-medium-emphasis"
|
|
32
|
-
/>
|
|
33
|
-
<v-list-item
|
|
34
|
-
v-for="name in availableFormTypes"
|
|
35
|
-
:key="name"
|
|
36
|
-
:title="name"
|
|
37
|
-
:active="selectedFormType === name"
|
|
38
|
-
active-color="primary"
|
|
39
|
-
class="text-caption"
|
|
40
|
-
@click="selectedFormType = name"
|
|
41
|
-
/>
|
|
42
|
-
</v-list>
|
|
43
|
-
|
|
44
|
-
<!-- Edit mode: read-only label -->
|
|
45
|
-
<v-chip v-else color="primary" variant="tonal" size="small">
|
|
46
|
-
{{ prop.onlineForm.formType }}
|
|
47
|
-
</v-chip>
|
|
48
|
-
</v-col>
|
|
49
|
-
|
|
50
|
-
<v-divider class="mt-4" />
|
|
51
|
-
|
|
52
|
-
<!-- Status (edit mode only) -->
|
|
53
|
-
<v-col v-if="prop.mode === 'edit'" cols="12" class="px-6 pt-3 pb-0">
|
|
54
|
-
<InputLabel title="Status" />
|
|
55
|
-
<v-select
|
|
56
|
-
v-model="formStatus"
|
|
57
|
-
:items="['active', 'inactive']"
|
|
58
|
-
density="comfortable"
|
|
59
|
-
/>
|
|
60
|
-
</v-col>
|
|
2
|
+
<!-- The card wears `.screen-modal` for the 16px corner, the `--card` surface
|
|
3
|
+
and the modal shadow. Its title row and its Cancel/Create row were both
|
|
4
|
+
`v-toolbar`s - Vuetify `surface` slabs that stay a visible grey bar on
|
|
5
|
+
the dark card - and are now a `v-card-title` and the shared
|
|
6
|
+
`.screen-modal__footer` on the card's own surface. -->
|
|
7
|
+
<v-card class="screen-modal d-flex flex-column" width="100%">
|
|
8
|
+
<v-card-title class="flex-grow-0">
|
|
9
|
+
{{ prop.mode === "edit" ? "Edit" : "New" }} Online Form
|
|
10
|
+
</v-card-title>
|
|
61
11
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
12
|
+
<v-card-text class="screen-modal__body flex-grow-1 pa-0">
|
|
13
|
+
<!-- `.filter-field` is a DESCENDANT selector (`.filter-field .v-field`),
|
|
14
|
+
so one class on the form gives every input below it the design's
|
|
15
|
+
40px/10px/13.5px geometry. Not one `AppField` anywhere in here: the
|
|
16
|
+
Create gate runs off these `:rules`, and AppField carries none. -->
|
|
17
|
+
<v-form v-model="validForm" class="filter-field">
|
|
18
|
+
<div class="pa-4 pb-0">
|
|
65
19
|
|
|
66
|
-
|
|
67
|
-
|
|
20
|
+
<!-- Form Name -->
|
|
21
|
+
<div class="mb-3">
|
|
22
|
+
<!-- Create is gated on this being filled, so the label says so. -->
|
|
23
|
+
<InputLabel title="Form Name" :required="prop.mode !== 'edit'" />
|
|
24
|
+
<v-text-field
|
|
25
|
+
v-if="prop.mode !== 'edit'"
|
|
26
|
+
v-model="formName"
|
|
27
|
+
density="compact"
|
|
68
28
|
variant="outlined"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
</v-card>
|
|
29
|
+
placeholder="e.g. Renovation Permit Application"
|
|
30
|
+
:rules="[v => !!v || 'Form name is required']"
|
|
31
|
+
hide-details="auto"
|
|
32
|
+
/>
|
|
33
|
+
<v-chip v-else color="primary" variant="tonal" size="small">
|
|
34
|
+
{{ prop.onlineForm?.formType }}
|
|
35
|
+
</v-chip>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<!-- Load template (add mode only) -->
|
|
39
|
+
<div v-if="prop.mode !== 'edit'" class="mb-4">
|
|
81
40
|
<v-btn
|
|
82
|
-
icon
|
|
83
|
-
variant="text"
|
|
84
41
|
size="small"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
42
|
+
variant="text"
|
|
43
|
+
color="primary"
|
|
44
|
+
class="text-none pa-0"
|
|
45
|
+
prepend-icon="mdi-file-outline"
|
|
46
|
+
@click="showTemplatePicker = true"
|
|
88
47
|
>
|
|
89
|
-
|
|
48
|
+
Start from existing template
|
|
90
49
|
</v-btn>
|
|
91
50
|
</div>
|
|
92
51
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<v-progress-circular
|
|
103
|
-
v-if="isUploading"
|
|
104
|
-
indeterminate
|
|
105
|
-
size="24"
|
|
106
|
-
width="2"
|
|
107
|
-
color="primary"
|
|
108
|
-
class="mb-1"
|
|
52
|
+
<!-- Status (edit mode) -->
|
|
53
|
+
<div v-if="prop.mode === 'edit'" class="mb-3">
|
|
54
|
+
<InputLabel title="Status" />
|
|
55
|
+
<v-select
|
|
56
|
+
v-model="formStatus"
|
|
57
|
+
:items="['active', 'inactive']"
|
|
58
|
+
density="compact"
|
|
59
|
+
variant="outlined"
|
|
60
|
+
hide-details
|
|
109
61
|
/>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
ref="fileInputRef"
|
|
123
|
-
type="file"
|
|
124
|
-
accept=".pdf,application/pdf,image/*"
|
|
125
|
-
style="display: none"
|
|
126
|
-
@change="handleFileInputChange"
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Payment -->
|
|
65
|
+
<v-divider class="mb-3" />
|
|
66
|
+
<div class="d-flex align-center ga-3 mb-1">
|
|
67
|
+
<span class="text-body-2 font-weight-medium">Requires Payment</span>
|
|
68
|
+
<v-switch
|
|
69
|
+
v-model="requiresPayment"
|
|
70
|
+
density="compact"
|
|
71
|
+
hide-details
|
|
72
|
+
color="primary"
|
|
73
|
+
inset
|
|
127
74
|
/>
|
|
128
75
|
</div>
|
|
76
|
+
<v-text-field
|
|
77
|
+
v-if="requiresPayment"
|
|
78
|
+
v-model.number="paymentAmount"
|
|
79
|
+
density="compact"
|
|
80
|
+
variant="outlined"
|
|
81
|
+
type="number"
|
|
82
|
+
min="1"
|
|
83
|
+
label="Payment Amount (SGD)"
|
|
84
|
+
prefix="$"
|
|
85
|
+
class="mb-3"
|
|
86
|
+
hide-details="auto"
|
|
87
|
+
:rules="requiresPayment ? [v => (v > 0) || 'Amount must be greater than 0'] : []"
|
|
88
|
+
/>
|
|
129
89
|
|
|
130
|
-
<
|
|
131
|
-
|
|
90
|
+
<v-divider class="mb-4" />
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Block Builder -->
|
|
94
|
+
<div class="px-4 pb-4">
|
|
95
|
+
<div class="d-flex align-center justify-space-between mb-3">
|
|
96
|
+
<span class="text-subtitle-2 font-weight-bold">Form Blocks</span>
|
|
97
|
+
<v-menu :close-on-content-click="true">
|
|
98
|
+
<template #activator="{ props: menuProps }">
|
|
99
|
+
<!-- The design's primary button. `tonal` painted Vuetify's own
|
|
100
|
+
wash over the accent; `.screen-btn-primary` is the 40px
|
|
101
|
+
accent fill the rest of the layer uses. -->
|
|
102
|
+
<v-btn
|
|
103
|
+
v-bind="menuProps"
|
|
104
|
+
variant="flat"
|
|
105
|
+
class="screen-btn-primary"
|
|
106
|
+
prepend-icon="mdi-plus"
|
|
107
|
+
>
|
|
108
|
+
Add Block
|
|
109
|
+
</v-btn>
|
|
110
|
+
</template>
|
|
111
|
+
<v-list density="compact" min-width="220">
|
|
112
|
+
<v-list-subheader>Content</v-list-subheader>
|
|
113
|
+
<v-list-item title="Section Heading" prepend-icon="mdi-format-header-1" @click="addBlock('section-header')" />
|
|
114
|
+
<v-list-item title="Paragraph" prepend-icon="mdi-text" @click="addBlock('paragraph')" />
|
|
115
|
+
<v-list-item title="Numbered List" prepend-icon="mdi-format-list-numbered" @click="addBlock('ordered-list')" />
|
|
116
|
+
<v-list-item title="Bullet List" prepend-icon="mdi-format-list-bulleted" @click="addBlock('unordered-list')" />
|
|
117
|
+
<v-list-item title="Divider" prepend-icon="mdi-minus" @click="addBlock('divider')" />
|
|
118
|
+
<v-divider />
|
|
119
|
+
<v-list-subheader>Input Fields</v-list-subheader>
|
|
120
|
+
<v-list-item title="Text / Number / Date Field" prepend-icon="mdi-form-textbox" @click="addBlock('field')" />
|
|
121
|
+
<v-list-item title="Dropdown (Select)" prepend-icon="mdi-chevron-down-box-outline" @click="addBlock('field-select')" />
|
|
122
|
+
<v-list-item title="Checkbox Group" prepend-icon="mdi-checkbox-multiple-outline" @click="addBlock('checkbox-group')" />
|
|
123
|
+
<v-list-item title="Signature" prepend-icon="mdi-draw" @click="addBlock('signature-row')" />
|
|
124
|
+
</v-list>
|
|
125
|
+
</v-menu>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<!-- Empty state. The dashed edge was a hardcoded `#e0e0e0` - the same
|
|
129
|
+
near-white line on the dark card, where it read as a bright box
|
|
130
|
+
around nothing. `--border` is the design's own and follows the
|
|
131
|
+
theme; the dashed treatment stays, because this is a drop target
|
|
132
|
+
and not a card. -->
|
|
133
|
+
<div
|
|
134
|
+
v-if="blocks.length === 0"
|
|
135
|
+
class="online-form-config__empty"
|
|
136
|
+
>
|
|
137
|
+
No blocks yet. Click "Add Block" to start building your form.
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- Block list -->
|
|
141
|
+
<div
|
|
142
|
+
v-for="(block, idx) in blocks"
|
|
143
|
+
:key="block._uid"
|
|
144
|
+
class="mb-2"
|
|
145
|
+
>
|
|
146
|
+
<v-card class="screen-card" flat>
|
|
147
|
+
<!-- Header row -->
|
|
148
|
+
<div class="d-flex align-center px-3 py-2 ga-2">
|
|
149
|
+
<!-- MEASURED, and the colour coding is deliberately GONE.
|
|
150
|
+
`blockTypeColor` painted eight fixed Vuetify palette fills
|
|
151
|
+
- teal, purple, orange, indigo - the SAME on both themes,
|
|
152
|
+
with white 10px text on them: "Heading" read 4.37:1 and
|
|
153
|
+
"Field" 3.67:1, identically light and dark, which is the
|
|
154
|
+
tell that nothing about them followed the theme. The
|
|
155
|
+
design has one tag, and it is neutral; the tag already
|
|
156
|
+
PRINTS the block type, so the eight colours told the user
|
|
157
|
+
nothing the word did not. Deleting the map is the fix, not
|
|
158
|
+
re-tinting it. -->
|
|
159
|
+
<StatusChip
|
|
160
|
+
:label="blockTypeLabel(block.type)"
|
|
161
|
+
tone="neutral"
|
|
162
|
+
:dot="false"
|
|
163
|
+
class="online-form-config__tag"
|
|
164
|
+
/>
|
|
165
|
+
<span
|
|
166
|
+
class="text-caption text-medium-emphasis flex-grow-1"
|
|
167
|
+
style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
|
|
168
|
+
>
|
|
169
|
+
{{ blockSummary(block) }}
|
|
170
|
+
</span>
|
|
171
|
+
<div class="d-flex align-center" style="gap: 2px; flex-shrink: 0">
|
|
172
|
+
<v-btn icon size="x-small" variant="text" :disabled="idx === 0" @click="moveBlock(idx, -1)">
|
|
173
|
+
<v-icon size="15">mdi-arrow-up</v-icon>
|
|
174
|
+
</v-btn>
|
|
175
|
+
<v-btn icon size="x-small" variant="text" :disabled="idx === blocks.length - 1" @click="moveBlock(idx, 1)">
|
|
176
|
+
<v-icon size="15">mdi-arrow-down</v-icon>
|
|
177
|
+
</v-btn>
|
|
178
|
+
<v-btn icon size="x-small" variant="text" :color="editingIdx === idx ? 'primary' : undefined" @click="toggleEdit(idx)">
|
|
179
|
+
<v-icon size="15">mdi-pencil</v-icon>
|
|
180
|
+
</v-btn>
|
|
181
|
+
<v-btn icon size="x-small" variant="text" color="error" @click="deleteBlock(idx)">
|
|
182
|
+
<v-icon size="15">mdi-delete</v-icon>
|
|
183
|
+
</v-btn>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Inline editor -->
|
|
188
|
+
<div v-if="editingIdx === idx">
|
|
189
|
+
<v-divider />
|
|
190
|
+
<div class="pa-3">
|
|
191
|
+
|
|
192
|
+
<!-- section-header -->
|
|
193
|
+
<template v-if="block.type === 'section-header'">
|
|
194
|
+
<v-text-field v-model="block.text" label="Heading Text" density="compact" variant="outlined" hide-details />
|
|
195
|
+
</template>
|
|
196
|
+
|
|
197
|
+
<!-- paragraph -->
|
|
198
|
+
<template v-else-if="block.type === 'paragraph'">
|
|
199
|
+
<v-textarea v-model="block.text" label="Paragraph Text" density="compact" variant="outlined" rows="3" auto-grow hide-details />
|
|
200
|
+
</template>
|
|
201
|
+
|
|
202
|
+
<!-- ordered-list / unordered-list -->
|
|
203
|
+
<template v-else-if="block.type === 'ordered-list' || block.type === 'unordered-list'">
|
|
204
|
+
<v-text-field v-model="block.title" label="Section Title (optional)" density="compact" variant="outlined" class="mb-3" hide-details />
|
|
205
|
+
<div class="text-caption font-weight-medium mb-2">Items:</div>
|
|
206
|
+
<div
|
|
207
|
+
v-for="(item, itemIdx) in block.items"
|
|
208
|
+
:key="itemIdx"
|
|
209
|
+
class="d-flex align-center ga-2 mb-2"
|
|
210
|
+
>
|
|
211
|
+
<span class="text-caption text-medium-emphasis" style="min-width: 20px; text-align: right">
|
|
212
|
+
{{ block.type === 'ordered-list' ? `${itemIdx + 1}.` : '•' }}
|
|
213
|
+
</span>
|
|
214
|
+
<v-text-field
|
|
215
|
+
:model-value="item"
|
|
216
|
+
@update:model-value="block.items[itemIdx] = $event"
|
|
217
|
+
density="compact"
|
|
218
|
+
variant="outlined"
|
|
219
|
+
hide-details
|
|
220
|
+
class="flex-grow-1"
|
|
221
|
+
/>
|
|
222
|
+
<v-btn icon size="x-small" variant="text" color="error" @click="block.items.splice(itemIdx, 1)">
|
|
223
|
+
<v-icon size="13">mdi-close</v-icon>
|
|
224
|
+
</v-btn>
|
|
225
|
+
</div>
|
|
226
|
+
<v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="block.items.push('')">
|
|
227
|
+
Add item
|
|
228
|
+
</v-btn>
|
|
229
|
+
</template>
|
|
230
|
+
|
|
231
|
+
<!-- divider -->
|
|
232
|
+
<template v-else-if="block.type === 'divider'">
|
|
233
|
+
<span class="text-caption text-medium-emphasis">Horizontal divider — no settings needed.</span>
|
|
234
|
+
</template>
|
|
235
|
+
|
|
236
|
+
<!-- field -->
|
|
237
|
+
<template v-else-if="block.type === 'field'">
|
|
238
|
+
<v-row dense>
|
|
239
|
+
<v-col cols="12">
|
|
240
|
+
<v-text-field
|
|
241
|
+
v-model="block.field.label"
|
|
242
|
+
label="Field Label"
|
|
243
|
+
density="compact"
|
|
244
|
+
variant="outlined"
|
|
245
|
+
hide-details
|
|
246
|
+
@update:model-value="onLabelChange(block, $event)"
|
|
247
|
+
/>
|
|
248
|
+
</v-col>
|
|
249
|
+
<v-col cols="7">
|
|
250
|
+
<v-text-field
|
|
251
|
+
v-model="block.field.key"
|
|
252
|
+
label="Field Key"
|
|
253
|
+
density="compact"
|
|
254
|
+
variant="outlined"
|
|
255
|
+
hint="Auto-generated · editable"
|
|
256
|
+
persistent-hint
|
|
257
|
+
/>
|
|
258
|
+
</v-col>
|
|
259
|
+
<v-col cols="5">
|
|
260
|
+
<v-select
|
|
261
|
+
v-model="block.field.fieldType"
|
|
262
|
+
:items="fieldTypeOptions"
|
|
263
|
+
label="Type"
|
|
264
|
+
density="compact"
|
|
265
|
+
variant="outlined"
|
|
266
|
+
hide-details
|
|
267
|
+
@update:model-value="onFieldTypeChange(block, $event)"
|
|
268
|
+
/>
|
|
269
|
+
</v-col>
|
|
270
|
+
<v-col cols="12">
|
|
271
|
+
<v-switch
|
|
272
|
+
v-model="block.field.required"
|
|
273
|
+
label="Required"
|
|
274
|
+
density="compact"
|
|
275
|
+
hide-details
|
|
276
|
+
color="primary"
|
|
277
|
+
inset
|
|
278
|
+
/>
|
|
279
|
+
</v-col>
|
|
280
|
+
<!-- Options for select type -->
|
|
281
|
+
<v-col v-if="block.field.fieldType === 'select'" cols="12">
|
|
282
|
+
<div class="text-caption font-weight-medium mb-2">Dropdown Options:</div>
|
|
283
|
+
<div
|
|
284
|
+
v-for="(opt, optIdx) in (block.field.options ?? [])"
|
|
285
|
+
:key="optIdx"
|
|
286
|
+
class="d-flex align-center ga-2 mb-2"
|
|
287
|
+
>
|
|
288
|
+
<v-text-field
|
|
289
|
+
:model-value="opt"
|
|
290
|
+
@update:model-value="block.field.options[optIdx] = $event"
|
|
291
|
+
density="compact"
|
|
292
|
+
variant="outlined"
|
|
293
|
+
hide-details
|
|
294
|
+
class="flex-grow-1"
|
|
295
|
+
:label="`Option ${optIdx + 1}`"
|
|
296
|
+
/>
|
|
297
|
+
<v-btn icon size="x-small" variant="text" color="error" @click="block.field.options.splice(optIdx, 1)">
|
|
298
|
+
<v-icon size="13">mdi-close</v-icon>
|
|
299
|
+
</v-btn>
|
|
300
|
+
</div>
|
|
301
|
+
<v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="(block.field.options ??= []).push('')">
|
|
302
|
+
Add option
|
|
303
|
+
</v-btn>
|
|
304
|
+
</v-col>
|
|
305
|
+
</v-row>
|
|
306
|
+
</template>
|
|
307
|
+
|
|
308
|
+
<!-- checkbox-group -->
|
|
309
|
+
<template v-else-if="block.type === 'checkbox-group'">
|
|
310
|
+
<v-text-field v-model="block.title" label="Section Title (optional)" density="compact" variant="outlined" class="mb-3" hide-details />
|
|
311
|
+
<v-switch v-model="block.single" label="Single selection (radio behavior)" density="compact" hide-details color="primary" inset class="mb-3" />
|
|
312
|
+
<div class="text-caption font-weight-medium mb-2">Checkboxes:</div>
|
|
313
|
+
<div
|
|
314
|
+
v-for="(item, itemIdx) in block.items"
|
|
315
|
+
:key="itemIdx"
|
|
316
|
+
class="d-flex align-center ga-2 mb-2"
|
|
317
|
+
>
|
|
318
|
+
<v-text-field
|
|
319
|
+
v-model="item.label"
|
|
320
|
+
density="compact"
|
|
321
|
+
variant="outlined"
|
|
322
|
+
hide-details
|
|
323
|
+
label="Label"
|
|
324
|
+
class="flex-grow-1"
|
|
325
|
+
@update:model-value="onCheckboxLabelChange(item, $event)"
|
|
326
|
+
/>
|
|
327
|
+
<v-text-field
|
|
328
|
+
v-model="item.key"
|
|
329
|
+
density="compact"
|
|
330
|
+
variant="outlined"
|
|
331
|
+
hide-details
|
|
332
|
+
label="Key"
|
|
333
|
+
style="max-width: 130px"
|
|
334
|
+
/>
|
|
335
|
+
<v-btn icon size="x-small" variant="text" color="error" @click="block.items.splice(itemIdx, 1)">
|
|
336
|
+
<v-icon size="13">mdi-close</v-icon>
|
|
337
|
+
</v-btn>
|
|
338
|
+
</div>
|
|
339
|
+
<v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="addCheckboxItem(block)">
|
|
340
|
+
Add checkbox
|
|
341
|
+
</v-btn>
|
|
342
|
+
</template>
|
|
343
|
+
|
|
344
|
+
<!-- signature-row -->
|
|
345
|
+
<template v-else-if="block.type === 'signature-row'">
|
|
346
|
+
<v-row dense>
|
|
347
|
+
<v-col cols="12">
|
|
348
|
+
<v-text-field
|
|
349
|
+
v-model="block.label"
|
|
350
|
+
label="Signature Label"
|
|
351
|
+
density="compact"
|
|
352
|
+
variant="outlined"
|
|
353
|
+
hide-details
|
|
354
|
+
@update:model-value="onSignatureLabelChange(block, $event)"
|
|
355
|
+
/>
|
|
356
|
+
</v-col>
|
|
357
|
+
<v-col cols="12">
|
|
358
|
+
<v-text-field
|
|
359
|
+
v-model="block.key"
|
|
360
|
+
label="Key"
|
|
361
|
+
density="compact"
|
|
362
|
+
variant="outlined"
|
|
363
|
+
hint="Unique identifier"
|
|
364
|
+
persistent-hint
|
|
365
|
+
/>
|
|
366
|
+
</v-col>
|
|
367
|
+
</v-row>
|
|
368
|
+
</template>
|
|
369
|
+
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</v-card>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<!-- At least one fillable block is required — a form with only
|
|
377
|
+
headings/paragraphs/dividers has nothing for a resident to
|
|
378
|
+
actually submit. -->
|
|
379
|
+
<div v-if="blocks.length > 0 && !hasFillableBlock" class="px-4 pb-3">
|
|
380
|
+
<span class="text-caption text-error">
|
|
381
|
+
Add at least one Field, Checkbox Group, or Signature block before saving.
|
|
132
382
|
</span>
|
|
133
|
-
</
|
|
383
|
+
</div>
|
|
134
384
|
|
|
135
|
-
|
|
385
|
+
<!-- Error -->
|
|
386
|
+
<div v-if="message" class="px-4 pb-3">
|
|
136
387
|
<span class="text-caption text-error">{{ message }}</span>
|
|
137
|
-
</
|
|
388
|
+
</div>
|
|
138
389
|
</v-form>
|
|
139
390
|
</v-card-text>
|
|
140
391
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
392
|
+
<!-- The design's footer: two equal-width buttons, ghost then primary. The
|
|
393
|
+
confirming button was a hardcoded `color="black"` fill, the same black
|
|
394
|
+
on both themes. `loading` keeps it a `v-btn` - AppButton has no
|
|
395
|
+
in-flight state. `:disabled` is unchanged, so the submit gate is
|
|
396
|
+
exactly the gate it was. -->
|
|
397
|
+
<div class="screen-modal__footer flex-grow-0">
|
|
398
|
+
<v-btn variant="outlined" class="screen-btn-ghost" @click="emit('cancel')">Cancel</v-btn>
|
|
399
|
+
<v-btn
|
|
400
|
+
variant="flat"
|
|
401
|
+
class="screen-btn-primary"
|
|
402
|
+
:disabled="!canSubmit || loading"
|
|
403
|
+
:loading="loading"
|
|
404
|
+
@click="submit"
|
|
405
|
+
>
|
|
406
|
+
{{ prop.mode === "edit" ? "Update" : "Create Form" }}
|
|
407
|
+
</v-btn>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<!-- Template picker dialog -->
|
|
411
|
+
<v-dialog v-model="showTemplatePicker" width="520" scrollable>
|
|
412
|
+
<v-card class="screen-modal">
|
|
413
|
+
<v-card-title>Load Template</v-card-title>
|
|
414
|
+
<div class="screen-hint px-6 pb-2" style="white-space: normal">
|
|
415
|
+
Select a template to pre-populate form blocks. You can customise them after loading.
|
|
416
|
+
</div>
|
|
417
|
+
<v-card-text class="screen-modal__body pa-0" style="max-height: 55vh">
|
|
418
|
+
<!-- `bg-color="transparent"` so the list sits on the card's own
|
|
419
|
+
surface rather than Vuetify's `surface` slab. -->
|
|
420
|
+
<v-list density="compact" bg-color="transparent">
|
|
421
|
+
<v-list-item
|
|
422
|
+
v-if="sortedTemplateNames.length === 0"
|
|
423
|
+
disabled
|
|
424
|
+
title="All templates have already been added for this site"
|
|
425
|
+
/>
|
|
426
|
+
<v-list-item
|
|
427
|
+
v-for="name in sortedTemplateNames"
|
|
428
|
+
:key="name"
|
|
429
|
+
:title="name"
|
|
430
|
+
@click="loadTemplate(name)"
|
|
431
|
+
/>
|
|
432
|
+
</v-list>
|
|
433
|
+
</v-card-text>
|
|
434
|
+
<div class="screen-modal__footer">
|
|
435
|
+
<v-btn variant="outlined" class="screen-btn-ghost" @click="showTemplatePicker = false">Close</v-btn>
|
|
436
|
+
</div>
|
|
437
|
+
</v-card>
|
|
438
|
+
</v-dialog>
|
|
172
439
|
</v-card>
|
|
173
440
|
</template>
|
|
441
|
+
|
|
174
442
|
<script setup lang="ts">
|
|
175
|
-
import useFile from "../composables/useFile";
|
|
176
443
|
import useOnlineFormConfiguration from "../composables/useOnlineFormConfiguration";
|
|
177
444
|
import useOnlineFormPages from "../composables/useOnlineFormPages";
|
|
178
445
|
|
|
446
|
+
type AnyBlock = Record<string, any>;
|
|
447
|
+
|
|
179
448
|
const prop = defineProps({
|
|
180
449
|
mode: { type: String, default: "add" },
|
|
181
450
|
onlineForm: {
|
|
@@ -188,139 +457,358 @@ const emit = defineEmits(["cancel", "success"]);
|
|
|
188
457
|
|
|
189
458
|
const { formPages } = useOnlineFormPages();
|
|
190
459
|
const { create: _add, updateById: _update, getAll: _getAll } = useOnlineFormConfiguration();
|
|
191
|
-
const { addFile, getFileUrl } = useFile();
|
|
192
460
|
|
|
193
461
|
const route = useRoute();
|
|
194
462
|
const siteId = route.params.site as string;
|
|
195
463
|
const orgId = route.params.org as string;
|
|
196
464
|
|
|
197
|
-
//
|
|
198
|
-
const
|
|
465
|
+
// ── Form-level settings ──────────────────────────────────────────────────────
|
|
466
|
+
const formName = ref("");
|
|
467
|
+
const formStatus = ref<string>(prop.onlineForm?.status || "active");
|
|
468
|
+
const requiresPayment = ref(false);
|
|
469
|
+
const paymentAmount = ref<number>(0);
|
|
470
|
+
const validForm = ref(false);
|
|
471
|
+
const loading = ref(false);
|
|
472
|
+
const message = ref("");
|
|
473
|
+
|
|
474
|
+
// ── Blocks ───────────────────────────────────────────────────────────────────
|
|
475
|
+
let _uid = 0;
|
|
476
|
+
function nextUid() { return `b${++_uid}`; }
|
|
477
|
+
|
|
478
|
+
const blocks = ref<AnyBlock[]>([]);
|
|
479
|
+
const editingIdx = ref<number | null>(null);
|
|
199
480
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
481
|
+
// ── Template picker ───────────────────────────────────────────────────────────
|
|
482
|
+
const showTemplatePicker = ref(false);
|
|
483
|
+
const existingFormTypes = ref<string[]>([]);
|
|
484
|
+
const sortedTemplateNames = computed(() =>
|
|
485
|
+
Object.keys(formPages)
|
|
486
|
+
.filter((name) => !existingFormTypes.value.includes(name))
|
|
487
|
+
.sort((a, b) => a.localeCompare(b))
|
|
203
488
|
);
|
|
204
489
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.
|
|
211
|
-
|
|
490
|
+
async function loadExistingFormTypes() {
|
|
491
|
+
try {
|
|
492
|
+
const res = await _getAll({ status: "active", org: orgId, site: siteId, limit: 100 });
|
|
493
|
+
existingFormTypes.value = (res?.items ?? []).map((c: any) => c.formType);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.error("Failed to load existing form types for template filtering", error);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
500
|
+
onMounted(() => {
|
|
501
|
+
if (prop.mode === "edit") {
|
|
502
|
+
if (prop.onlineForm?.fields?.blocks) {
|
|
503
|
+
try {
|
|
504
|
+
const parsed = JSON.parse(prop.onlineForm.fields.blocks);
|
|
505
|
+
blocks.value = (parsed as any[]).map((b) => ({ ...b, _uid: nextUid() }));
|
|
506
|
+
} catch {}
|
|
507
|
+
}
|
|
508
|
+
const pa = Number(prop.onlineForm?.fields?.paymentAmount ?? 0);
|
|
509
|
+
if (pa > 0) { requiresPayment.value = true; paymentAmount.value = pa; }
|
|
510
|
+
} else {
|
|
511
|
+
loadExistingFormTypes();
|
|
512
|
+
}
|
|
212
513
|
});
|
|
213
514
|
|
|
214
|
-
//
|
|
215
|
-
|
|
515
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
516
|
+
function labelToKey(label: string): string {
|
|
517
|
+
const words = label
|
|
518
|
+
.replace(/[^a-zA-Z0-9 ]/g, "")
|
|
519
|
+
.trim()
|
|
520
|
+
.split(/\s+/)
|
|
521
|
+
.filter(Boolean);
|
|
522
|
+
if (!words.length) return "";
|
|
523
|
+
return (
|
|
524
|
+
words[0].toLowerCase() +
|
|
525
|
+
words
|
|
526
|
+
.slice(1)
|
|
527
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
528
|
+
.join("")
|
|
529
|
+
);
|
|
530
|
+
}
|
|
216
531
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
532
|
+
/**
|
|
533
|
+
* Every field key currently in use across the form, so a newly-generated key
|
|
534
|
+
* never collides with a sibling's — otherwise two fields left blank (or given
|
|
535
|
+
* the same label) end up bound to the same value in the resident app, and
|
|
536
|
+
* filling one silently fills the other.
|
|
537
|
+
*/
|
|
538
|
+
function collectUsedKeys(excludeRef?: any): Set<string> {
|
|
539
|
+
const keys = new Set<string>();
|
|
540
|
+
for (const b of blocks.value) {
|
|
541
|
+
if (b.type === "field" && b.field && b.field !== excludeRef && b.field.key) {
|
|
542
|
+
keys.add(b.field.key);
|
|
543
|
+
}
|
|
544
|
+
if (b.type === "checkbox-group" && Array.isArray(b.items)) {
|
|
545
|
+
for (const item of b.items) {
|
|
546
|
+
if (item !== excludeRef && item?.key) keys.add(item.key);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (b.type === "signature-row" && b !== excludeRef && b.key) {
|
|
550
|
+
keys.add(b.key);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return keys;
|
|
229
554
|
}
|
|
230
555
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
556
|
+
/** `base` (or "field" when blank) made unique against `used` via a numeric suffix. */
|
|
557
|
+
function uniqueKey(base: string, used: Set<string>): string {
|
|
558
|
+
const root = base || "field";
|
|
559
|
+
if (!used.has(root)) return root;
|
|
560
|
+
let i = 2;
|
|
561
|
+
while (used.has(`${root}${i}`)) i++;
|
|
562
|
+
return `${root}${i}`;
|
|
234
563
|
}
|
|
235
564
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
565
|
+
const fieldTypeOptions = [
|
|
566
|
+
{ title: "Text", value: "text" },
|
|
567
|
+
{ title: "Textarea (multi-line)", value: "textarea" },
|
|
568
|
+
{ title: "Number", value: "number" },
|
|
569
|
+
{ title: "Date", value: "date" },
|
|
570
|
+
{ title: "Dropdown (Select)", value: "select" },
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
function blockTypeLabel(type: string): string {
|
|
574
|
+
const map: Record<string, string> = {
|
|
575
|
+
"section-header": "Heading",
|
|
576
|
+
paragraph: "Paragraph",
|
|
577
|
+
"ordered-list": "Numbered List",
|
|
578
|
+
"unordered-list": "Bullet List",
|
|
579
|
+
divider: "Divider",
|
|
580
|
+
field: "Field",
|
|
581
|
+
"checkbox-group": "Checkboxes",
|
|
582
|
+
"signature-row": "Signature",
|
|
583
|
+
};
|
|
584
|
+
return map[type] ?? type;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function blockSummary(block: AnyBlock): string {
|
|
588
|
+
switch (block.type) {
|
|
589
|
+
case "section-header":
|
|
590
|
+
case "paragraph":
|
|
591
|
+
return (block.text ?? "").slice(0, 70) || "(empty)";
|
|
592
|
+
case "ordered-list":
|
|
593
|
+
case "unordered-list":
|
|
594
|
+
return block.title || `${block.items?.length ?? 0} item${block.items?.length === 1 ? "" : "s"}`;
|
|
595
|
+
case "divider":
|
|
596
|
+
return "──────────────";
|
|
597
|
+
case "field":
|
|
598
|
+
return block.field?.label
|
|
599
|
+
? `${block.field.label} · ${block.field.fieldType}`
|
|
600
|
+
: "(no label)";
|
|
601
|
+
case "checkbox-group":
|
|
602
|
+
return block.title || `${block.items?.length ?? 0} checkbox${block.items?.length === 1 ? "" : "es"}`;
|
|
603
|
+
case "signature-row":
|
|
604
|
+
return block.label || "(no label)";
|
|
605
|
+
default:
|
|
606
|
+
return "";
|
|
247
607
|
}
|
|
248
608
|
}
|
|
249
609
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
attachmentError.value = "";
|
|
254
|
-
if (fileInputRef.value) fileInputRef.value.value = "";
|
|
610
|
+
// ── Block operations ──────────────────────────────────────────────────────────
|
|
611
|
+
function toggleEdit(idx: number) {
|
|
612
|
+
editingIdx.value = editingIdx.value === idx ? null : idx;
|
|
255
613
|
}
|
|
256
614
|
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const
|
|
615
|
+
function moveBlock(idx: number, dir: -1 | 1) {
|
|
616
|
+
const target = idx + dir;
|
|
617
|
+
if (target < 0 || target >= blocks.value.length) return;
|
|
618
|
+
const copy = [...blocks.value];
|
|
619
|
+
[copy[idx], copy[target]] = [copy[target], copy[idx]];
|
|
620
|
+
blocks.value = copy;
|
|
621
|
+
editingIdx.value = null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function deleteBlock(idx: number) {
|
|
625
|
+
blocks.value.splice(idx, 1);
|
|
626
|
+
if (editingIdx.value === idx) editingIdx.value = null;
|
|
627
|
+
else if (editingIdx.value !== null && editingIdx.value > idx) editingIdx.value--;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function addBlock(type: string) {
|
|
631
|
+
let b: AnyBlock;
|
|
632
|
+
switch (type) {
|
|
633
|
+
case "section-header":
|
|
634
|
+
b = { _uid: nextUid(), type: "section-header", text: "" };
|
|
635
|
+
break;
|
|
636
|
+
case "paragraph":
|
|
637
|
+
b = { _uid: nextUid(), type: "paragraph", text: "" };
|
|
638
|
+
break;
|
|
639
|
+
case "ordered-list":
|
|
640
|
+
b = { _uid: nextUid(), type: "ordered-list", title: "", items: [""] };
|
|
641
|
+
break;
|
|
642
|
+
case "unordered-list":
|
|
643
|
+
b = { _uid: nextUid(), type: "unordered-list", title: "", items: [""] };
|
|
644
|
+
break;
|
|
645
|
+
case "divider":
|
|
646
|
+
b = { _uid: nextUid(), type: "divider" };
|
|
647
|
+
break;
|
|
648
|
+
case "field": {
|
|
649
|
+
const uid = nextUid();
|
|
650
|
+
b = { _uid: uid, type: "field", field: { key: uniqueKey(`field${uid}`, collectUsedKeys()), label: "", fieldType: "text", required: false } };
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case "field-select": {
|
|
654
|
+
const uid = nextUid();
|
|
655
|
+
b = { _uid: uid, type: "field", field: { key: uniqueKey(`field${uid}`, collectUsedKeys()), label: "", fieldType: "select", required: false, options: [] } };
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
case "checkbox-group": {
|
|
659
|
+
const uid = nextUid();
|
|
660
|
+
b = { _uid: uid, type: "checkbox-group", title: "", single: false, items: [{ key: uniqueKey(`item${uid}`, collectUsedKeys()), label: "" }] };
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
case "signature-row": {
|
|
664
|
+
const uid = nextUid();
|
|
665
|
+
b = { _uid: uid, type: "signature-row", label: "", key: uniqueKey(`signature${uid}`, collectUsedKeys()) };
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
default:
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
blocks.value.push(b);
|
|
672
|
+
editingIdx.value = blocks.value.length - 1;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Auto-generate key from label (field blocks)
|
|
676
|
+
function onLabelChange(block: AnyBlock, newLabel: string) {
|
|
677
|
+
if (!block._keyEdited) {
|
|
678
|
+
block.field.key = uniqueKey(labelToKey(newLabel), collectUsedKeys(block.field));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function onFieldTypeChange(block: AnyBlock, newType: string) {
|
|
683
|
+
if (newType === "select" && !block.field.options) block.field.options = [];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Auto-generate key from label (checkbox items)
|
|
687
|
+
function onCheckboxLabelChange(item: AnyBlock, newLabel: string) {
|
|
688
|
+
if (!item._keyEdited) item.key = uniqueKey(labelToKey(newLabel), collectUsedKeys(item));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function addCheckboxItem(block: AnyBlock) {
|
|
692
|
+
block.items.push({ key: uniqueKey(`item${nextUid()}`, collectUsedKeys()), label: "" });
|
|
693
|
+
}
|
|
261
694
|
|
|
262
|
-
|
|
263
|
-
|
|
695
|
+
// Auto-generate key from label (signature-row)
|
|
696
|
+
function onSignatureLabelChange(block: AnyBlock, newLabel: string) {
|
|
697
|
+
block.key = uniqueKey(labelToKey(newLabel), collectUsedKeys(block));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Template loading ──────────────────────────────────────────────────────────
|
|
701
|
+
function loadTemplate(name: string) {
|
|
702
|
+
const def = formPages[name];
|
|
703
|
+
if (!def) return;
|
|
704
|
+
formName.value = name;
|
|
705
|
+
blocks.value = def.blocks.map((b) => ({
|
|
706
|
+
...JSON.parse(JSON.stringify(b)),
|
|
707
|
+
_uid: nextUid(),
|
|
708
|
+
}));
|
|
709
|
+
showTemplatePicker.value = false;
|
|
710
|
+
editingIdx.value = null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Computed ──────────────────────────────────────────────────────────────────
|
|
714
|
+
// Only these block types actually collect something from the resident — a
|
|
715
|
+
// form built entirely out of headings/paragraphs/dividers has nothing to
|
|
716
|
+
// submit, so it isn't a usable form yet.
|
|
717
|
+
const FILLABLE_BLOCK_TYPES = ["field", "checkbox-group", "signature-row"];
|
|
718
|
+
const hasFillableBlock = computed(() =>
|
|
719
|
+
blocks.value.some((b) => FILLABLE_BLOCK_TYPES.includes(b.type)),
|
|
264
720
|
);
|
|
265
721
|
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
722
|
+
const canSubmit = computed(() => {
|
|
723
|
+
const nameOk = prop.mode === "edit" || !!formName.value.trim();
|
|
724
|
+
const payOk = !requiresPayment.value || paymentAmount.value > 0;
|
|
725
|
+
return nameOk && payOk && hasFillableBlock.value && !loading.value;
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// ── Payload builder ───────────────────────────────────────────────────────────
|
|
729
|
+
function buildCleanBlocks(): Record<string, any>[] {
|
|
730
|
+
return blocks.value.map((b) => {
|
|
731
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
732
|
+
const { _uid, _keyEdited, ...rest } = b;
|
|
733
|
+
if (rest.field) {
|
|
734
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
735
|
+
const { _keyEdited: _fke, ...fieldRest } = rest.field;
|
|
736
|
+
rest.field = fieldRest;
|
|
737
|
+
}
|
|
738
|
+
if (Array.isArray(rest.items)) {
|
|
739
|
+
rest.items = rest.items.map((item: any) => {
|
|
740
|
+
if (item && typeof item === "object") {
|
|
741
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
742
|
+
const { _keyEdited: _ike, ...itemRest } = item;
|
|
743
|
+
return itemRest;
|
|
744
|
+
}
|
|
745
|
+
return item;
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
return rest;
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function buildFields(): Record<string, string> {
|
|
753
|
+
const fields: Record<string, string> = {
|
|
754
|
+
blocks: JSON.stringify(buildCleanBlocks()),
|
|
755
|
+
};
|
|
756
|
+
if (requiresPayment.value && paymentAmount.value > 0) {
|
|
757
|
+
fields.paymentAmount = String(paymentAmount.value);
|
|
758
|
+
}
|
|
271
759
|
return fields;
|
|
272
760
|
}
|
|
273
761
|
|
|
762
|
+
// ── Submit ────────────────────────────────────────────────────────────────────
|
|
274
763
|
async function submit() {
|
|
275
|
-
disable.value = true;
|
|
276
764
|
message.value = "";
|
|
765
|
+
loading.value = true;
|
|
277
766
|
try {
|
|
278
767
|
if (prop.mode === "add") {
|
|
768
|
+
const name = formName.value.trim();
|
|
279
769
|
await _add({
|
|
280
|
-
formType:
|
|
281
|
-
fields: buildFields(
|
|
770
|
+
formType: name,
|
|
771
|
+
fields: buildFields(),
|
|
282
772
|
status: "active",
|
|
283
773
|
org: orgId,
|
|
284
774
|
site: siteId,
|
|
285
775
|
});
|
|
286
776
|
} else {
|
|
287
|
-
const formType = prop.onlineForm
|
|
288
|
-
await _update(prop.onlineForm
|
|
777
|
+
const formType = prop.onlineForm?.formType as string;
|
|
778
|
+
await _update(prop.onlineForm?._id ?? "", {
|
|
289
779
|
formType,
|
|
290
|
-
fields: buildFields(
|
|
780
|
+
fields: buildFields(),
|
|
291
781
|
status: formStatus.value,
|
|
292
782
|
});
|
|
293
783
|
}
|
|
294
784
|
emit("success");
|
|
295
785
|
} catch (error: any) {
|
|
296
|
-
message.value = error?.response?._data?.message || "Failed to save form";
|
|
786
|
+
message.value = error?.response?._data?.message || "Failed to save form. Please try again.";
|
|
297
787
|
} finally {
|
|
298
|
-
|
|
788
|
+
loading.value = false;
|
|
299
789
|
}
|
|
300
790
|
}
|
|
301
791
|
</script>
|
|
302
792
|
|
|
303
793
|
<style scoped>
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
padding:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
transition: border-color 0.2s, background-color 0.2s;
|
|
315
|
-
user-select: none;
|
|
794
|
+
/* The block builder's drop target. Same copy, same dashed treatment, on the
|
|
795
|
+
design's own border colour and secondary ink so it follows the theme. */
|
|
796
|
+
.online-form-config__empty {
|
|
797
|
+
padding: 32px 16px;
|
|
798
|
+
text-align: center;
|
|
799
|
+
font-family: var(--font-sans);
|
|
800
|
+
font-size: var(--fs-cell);
|
|
801
|
+
color: var(--muted);
|
|
802
|
+
border: 2px dashed var(--border);
|
|
803
|
+
border-radius: var(--r-inner);
|
|
316
804
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
805
|
+
|
|
806
|
+
/* The tags sit in a column, so they keep the fixed width the `v-chip` had -
|
|
807
|
+
otherwise "Divider" and "Numbered List" start their summaries at different
|
|
808
|
+
x positions down the list. */
|
|
809
|
+
.online-form-config__tag {
|
|
810
|
+
min-width: 110px;
|
|
811
|
+
justify-content: center;
|
|
812
|
+
flex-shrink: 0;
|
|
325
813
|
}
|
|
326
814
|
</style>
|