@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.
Files changed (283) hide show
  1. package/assets/css/primitives.css +1113 -0
  2. package/assets/css/screens.css +1097 -0
  3. package/assets/css/shell.css +258 -0
  4. package/assets/css/tokens.css +236 -0
  5. package/components/AcceptDialog.vue +17 -18
  6. package/components/AccessCard/AvailableStats.vue +24 -16
  7. package/components/AccessCardAddForm.vue +42 -42
  8. package/components/AccessCardAssignToUnitForm.vue +13 -21
  9. package/components/AccessCardDeleteDialog.vue +41 -39
  10. package/components/AccessCardDetailsDialog.vue +125 -79
  11. package/components/AccessCardPreviewDialog.vue +58 -84
  12. package/components/AccessCardQrTagging.vue +60 -63
  13. package/components/AccessCardReplaceForm.vue +27 -23
  14. package/components/AccessManagement.vue +86 -85
  15. package/components/AddEqupmentForm.vue +18 -22
  16. package/components/AddPassKeyToVisitor.vue +95 -73
  17. package/components/AppButton.vue +62 -0
  18. package/components/AppCard.vue +20 -0
  19. package/components/AppDateField.vue +49 -0
  20. package/components/AppField.vue +76 -0
  21. package/components/AppKpiTile.vue +65 -0
  22. package/components/AppSegmented.vue +38 -0
  23. package/components/AppSelect.vue +247 -0
  24. package/components/AreaChecklistHistoryLogs.vue +51 -44
  25. package/components/AreaChecklistHistoryMain.vue +14 -7
  26. package/components/AreaFormDialog.vue +71 -59
  27. package/components/AreaMain.vue +73 -107
  28. package/components/AttachmentsViewer.vue +84 -22
  29. package/components/AttendanceCheckInOutDialog.vue +85 -88
  30. package/components/AttendanceDetailsDialog.vue +57 -63
  31. package/components/AttendanceMain.vue +4 -3
  32. package/components/AttendanceMapSearchDialog.vue +42 -44
  33. package/components/AttendanceSettingsDialog.vue +79 -63
  34. package/components/Avatar/Main.vue +54 -2
  35. package/components/BillingMain.vue +26 -30
  36. package/components/BtnUploadFile.vue +53 -11
  37. package/components/BuildingForm.vue +34 -27
  38. package/components/BuildingManagement/buildings.vue +39 -39
  39. package/components/BuildingManagement/units.vue +26 -42
  40. package/components/BuildingUnitFormAdd.vue +20 -23
  41. package/components/BuildingUnitFormEdit.vue +8 -4
  42. package/components/BulletinBoardForm.vue +19 -24
  43. package/components/BulletinBoardManagement.vue +49 -57
  44. package/components/BulletinBoardView.vue +9 -6
  45. package/components/BulletinExpirationChip.vue +13 -4
  46. package/components/Button/Close.vue +2 -2
  47. package/components/CameraForm.vue +21 -22
  48. package/components/CameraMain.vue +50 -49
  49. package/components/Card/DeleteConfirmation.vue +24 -22
  50. package/components/Card/MemberInfoSummary.vue +28 -3
  51. package/components/Card/Toggle.vue +15 -3
  52. package/components/CardToolbar.vue +37 -0
  53. package/components/Carousel.vue +56 -36
  54. package/components/Chat/Bubbles.vue +43 -14
  55. package/components/Chat/Information.vue +49 -32
  56. package/components/Chat/ListCard.vue +38 -9
  57. package/components/Chat/Message.vue +41 -15
  58. package/components/Chat/Navigation.vue +27 -15
  59. package/components/Chat/SkeletonLoader.vue +9 -2
  60. package/components/ChecklistItemRow.vue +25 -1
  61. package/components/CleaningScheduleMain.vue +44 -49
  62. package/components/ClientDetailForm.vue +81 -56
  63. package/components/ClientMain.vue +92 -82
  64. package/components/ConfirmDialog.vue +14 -8
  65. package/components/Container/Standard.vue +9 -7
  66. package/components/CreateSubsciptionPlan.vue +148 -89
  67. package/components/DashboardEmptyState.vue +48 -3
  68. package/components/DashboardMain.vue +924 -676
  69. package/components/DashboardPanel.vue +84 -21
  70. package/components/DeliveryCompany.vue +34 -20
  71. package/components/Dialog/DeleteConfirmation.vue +20 -21
  72. package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
  73. package/components/Dialog/ReusablePrompt.vue +30 -29
  74. package/components/Dialog/UpdateMoreAction.vue +62 -59
  75. package/components/DocumentForm.vue +14 -21
  76. package/components/DocumentManagement.vue +162 -108
  77. package/components/DocumentViewer.vue +29 -12
  78. package/components/DrawImage.vue +29 -21
  79. package/components/EntryPass/QrTemplatePreview.vue +27 -16
  80. package/components/EntryPassInformation.vue +40 -10
  81. package/components/EntryPassMain.vue +49 -27
  82. package/components/EquipmentItemMain.vue +84 -97
  83. package/components/EquipmentManagementMain.vue +81 -103
  84. package/components/Facility/BookingSetup.vue +43 -52
  85. package/components/Feedback/Form.vue +26 -12
  86. package/components/FeedbackMain.vue +35 -49
  87. package/components/FileInput.vue +75 -58
  88. package/components/FileInputWithList.vue +59 -12
  89. package/components/FormDialog.vue +60 -21
  90. package/components/HidAccessLogDashboard.vue +512 -490
  91. package/components/HidCameraCaptureDialog.vue +216 -0
  92. package/components/HidEnabledGate.vue +67 -9
  93. package/components/HidIntercomManagement.vue +1322 -653
  94. package/components/HidQrCodeConfiguration.vue +1030 -258
  95. package/components/HidReaderForm.vue +142 -59
  96. package/components/HidReaderManagement.vue +211 -323
  97. package/components/HidServiceSettingsPanel.vue +44 -13
  98. package/components/HidUserEnrollment.vue +1185 -412
  99. package/components/HygieneUpdateMoreAction.vue +44 -44
  100. package/components/IncidentReport/Authorities.vue +30 -18
  101. package/components/IncidentReport/IncidentInformation.vue +10 -10
  102. package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
  103. package/components/IncidentReport/affectedEntities.vue +41 -20
  104. package/components/Input/Date.vue +3 -0
  105. package/components/Input/DatePicker.vue +1 -2
  106. package/components/Input/DateTimePicker.vue +1 -1
  107. package/components/Input/File.vue +55 -10
  108. package/components/Input/FileV2.vue +39 -6
  109. package/components/Input/InputPhoneNumberV2.vue +2 -1
  110. package/components/Input/ListGroupSelection.vue +56 -8
  111. package/components/Input/NRICNumber.vue +1 -0
  112. package/components/Input/NewDate.vue +1 -0
  113. package/components/Input/Number.vue +1 -0
  114. package/components/Input/Password.vue +1 -0
  115. package/components/Input/PhoneNumber.vue +21 -1
  116. package/components/Input/VehicleNumber.vue +1 -0
  117. package/components/InputLabel.vue +2 -2
  118. package/components/InvitationClientForm.vue +21 -19
  119. package/components/InvitationForm.vue +15 -21
  120. package/components/InvitationMain.vue +46 -61
  121. package/components/Layout/Header.vue +263 -36
  122. package/components/Layout/NavigationDrawer.vue +300 -30
  123. package/components/ListItem.vue +33 -3
  124. package/components/LocalPagination.vue +27 -3
  125. package/components/ManageChecklistMain.vue +136 -86
  126. package/components/MemberInformation.vue +32 -16
  127. package/components/MemberMain.vue +113 -93
  128. package/components/MyAttendanceMain.vue +3 -5
  129. package/components/NavigationItem.vue +83 -3
  130. package/components/Nfc/NFCPatrolReportMain.vue +47 -33
  131. package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
  132. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  133. package/components/Nfc/NFCPatrolSettings.vue +14 -4
  134. package/components/Nfc/NFCTagForm.vue +22 -8
  135. package/components/Nfc/NFCTagMain.vue +41 -43
  136. package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
  137. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
  138. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
  139. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
  140. package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
  141. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
  142. package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
  143. package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
  144. package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
  145. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
  146. package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
  147. package/components/NotificationSettings.vue +373 -0
  148. package/components/NumberSettingField.vue +9 -2
  149. package/components/OnlineFormConfigurationForm.vue +725 -237
  150. package/components/OnlineFormFill.vue +213 -106
  151. package/components/OnlineFormRenderPreview.vue +25 -30
  152. package/components/OnlineFormsConfiguration.vue +113 -109
  153. package/components/OrgViewDialog.vue +34 -16
  154. package/components/OvernightParkingAvailability.vue +49 -26
  155. package/components/OvernightParkingManagement.vue +78 -18
  156. package/components/PageHeader.vue +43 -0
  157. package/components/PassInformation.vue +109 -53
  158. package/components/PasswordConfirmation.vue +44 -26
  159. package/components/PeopleForm.vue +41 -20
  160. package/components/PhotoUpload.vue +110 -48
  161. package/components/PlaceholderComponent.vue +35 -19
  162. package/components/PlanActionsMenu.vue +12 -2
  163. package/components/PlanStatusBadge.vue +7 -8
  164. package/components/PlateNumberDisplay.vue +9 -6
  165. package/components/QrTemplate/PrintDialog.vue +66 -43
  166. package/components/RolePermissionFormCreate.vue +104 -79
  167. package/components/RolePermissionFormPreviewUpdate.vue +111 -109
  168. package/components/RolePermissionMain.vue +183 -81
  169. package/components/ScanVisitorQRCode.vue +99 -35
  170. package/components/ScheduleAreaMain.vue +34 -59
  171. package/components/ScheduleTaskForm.vue +61 -42
  172. package/components/ScheduleTaskMain.vue +56 -69
  173. package/components/SearchVehicleNumberUser.vue +39 -37
  174. package/components/ServiceProviderFormCreate.vue +29 -20
  175. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  176. package/components/ServiceProviderMain.vue +601 -432
  177. package/components/Signature.vue +40 -34
  178. package/components/SignaturePad.vue +5 -29
  179. package/components/SiteSettings.vue +41 -11
  180. package/components/SiteSettingsWorkOrder.vue +61 -30
  181. package/components/SlideCardGroup.vue +35 -11
  182. package/components/Snackbar.vue +36 -3
  183. package/components/SpecificAttr.vue +18 -8
  184. package/components/StatusChip.vue +156 -0
  185. package/components/StockCard.vue +17 -12
  186. package/components/SubscriptionPlanTable.vue +207 -140
  187. package/components/SwitchContext.vue +201 -9
  188. package/components/TableHygiene.vue +36 -46
  189. package/components/TableList.vue +10 -10
  190. package/components/TableListSecondary.vue +105 -54
  191. package/components/TableMain.vue +251 -78
  192. package/components/TableV3.vue +61 -70
  193. package/components/TiptapEditor.vue +33 -40
  194. package/components/TiptapEditorQr.vue +5 -2
  195. package/components/Tooltip/Info.vue +69 -24
  196. package/components/UnitMain.vue +40 -60
  197. package/components/UnitPersonCard.vue +42 -13
  198. package/components/VehicleAddSelection.vue +48 -32
  199. package/components/VehicleForm.vue +112 -129
  200. package/components/VehicleManagement.vue +26 -12
  201. package/components/VehicleUpdateMoreAction.vue +59 -46
  202. package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
  203. package/components/VisitorForm.vue +774 -210
  204. package/components/VisitorFormSelection.vue +40 -37
  205. package/components/VisitorManagement.vue +239 -82
  206. package/components/VisitorPassKeyQRScanner.vue +65 -18
  207. package/components/VisitorSearchResultSummary.vue +60 -16
  208. package/components/VisitorSocketPopUp.vue +22 -14
  209. package/components/VisitorsReportPreview.vue +44 -29
  210. package/components/WorkOrder/Create.vue +26 -17
  211. package/components/WorkOrder/ListView.vue +38 -7
  212. package/components/WorkOrder/Main.vue +62 -59
  213. package/composables/useFeedback.ts +3 -3
  214. package/composables/useHidAmico.ts +269 -0
  215. package/composables/useHidNavigation.ts +2 -9
  216. package/composables/useMember.ts +3 -3
  217. package/composables/useNFCPatrolDailyReport.ts +13 -0
  218. package/composables/useNFCPatrolMonthlyReport.ts +22 -56
  219. package/composables/useNFCPatrolReport.ts +13 -0
  220. package/composables/useNotificationPreference.ts +87 -0
  221. package/composables/useOnlineFormPages.ts +2 -0
  222. package/composables/useRole.ts +22 -0
  223. package/composables/useServiceProvider.ts +3 -3
  224. package/composables/useServiceProviderInvitation.ts +145 -0
  225. package/composables/useSipWebPhone.ts +336 -0
  226. package/composables/useSubscriptionPlan.ts +62 -0
  227. package/composables/useThemePreference.ts +63 -0
  228. package/composables/useVisitor.ts +6 -1
  229. package/composables/useWebUsb.ts +127 -37
  230. package/composables/useWorkOrder.ts +17 -3
  231. package/error.vue +30 -6
  232. package/nuxt.config.ts +36 -0
  233. package/package.json +25 -4
  234. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
  235. package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
  236. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  237. package/pages/notification-settings.vue +19 -0
  238. package/pages/payment-method-linked.vue +34 -10
  239. package/pages/require-customer.vue +48 -10
  240. package/pages/require-organization-membership.vue +32 -8
  241. package/pages/unauthorized.vue +29 -4
  242. package/plugins/secure-member.client.ts +21 -56
  243. package/plugins/vuetify.ts +69 -2
  244. package/types/nfc-patrol-report.ts +3 -2
  245. package/types/site.d.ts +71 -0
  246. package/types/subscription.ts +18 -11
  247. package/utils/breadcrumb.test.ts +108 -0
  248. package/utils/breadcrumb.ts +82 -0
  249. package/utils/dashboard.test.ts +118 -0
  250. package/utils/dashboard.ts +53 -0
  251. package/utils/data.test.ts +95 -0
  252. package/utils/data.ts +40 -2
  253. package/utils/hid-discover.test.ts +102 -0
  254. package/utils/list-page.test.ts +60 -0
  255. package/utils/list-page.ts +38 -0
  256. package/utils/materialSymbols.ts +363 -0
  257. package/utils/nav-sections.test.ts +190 -0
  258. package/utils/nav-sections.ts +151 -0
  259. package/utils/nfc-patrol-report.test.ts +130 -0
  260. package/utils/nfc-patrol-report.ts +101 -0
  261. package/utils/role.test.ts +69 -0
  262. package/utils/role.ts +10 -0
  263. package/utils/route-name.test.ts +85 -0
  264. package/utils/route-name.ts +24 -0
  265. package/utils/status.test.ts +110 -0
  266. package/utils/status.ts +135 -0
  267. package/utils/table-card.test.ts +84 -0
  268. package/utils/theme-aa-ledger.ts +148 -0
  269. package/utils/theme.test.ts +485 -7
  270. package/utils/theme.ts +295 -110
  271. package/.changeset/README.md +0 -8
  272. package/.changeset/config.json +0 -11
  273. package/.editorconfig +0 -12
  274. package/.github/workflows/main.yml +0 -17
  275. package/.github/workflows/publish-staging.yml +0 -47
  276. package/.github/workflows/publish.yml +0 -39
  277. package/PUBLISHING.md +0 -269
  278. package/components/HidIdentityMapping.vue +0 -975
  279. package/components/ScheduleTaskAreaFormDialog.vue +0 -144
  280. package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
  281. package/components/ScheduleTastTicketMain.vue +0 -182
  282. package/components/TableWithButton.vue +0 -94
  283. package/test/visitor-socket.test.mjs +0 -36
@@ -1,181 +1,450 @@
1
1
  <template>
2
- <v-card width="100%">
3
- <v-toolbar>
4
- <v-row no-gutters class="fill-height px-6" align="center">
5
- <span class="font-weight-bold text-h5">
6
- {{ prop.mode === "edit" ? "Edit" : "Add" }} Online Form
7
- </span>
8
- </v-row>
9
- </v-toolbar>
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
- <!-- Attachment -->
63
- <!-- <v-col cols="12" class="px-6 pt-3 pb-4">
64
- <InputLabel title="Attachment (Optional)" />
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
- <div v-if="attachmentId" class="d-flex align-center" style="gap: 8px">
67
- <v-card
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
- border="thin"
70
- class="px-3 flex-grow-1 text-caption"
71
- height="40"
72
- :href="getFileUrl(attachmentId)"
73
- target="_blank"
74
- >
75
- <v-row no-gutters class="fill-height" align="center" style="gap: 6px">
76
- <v-icon size="15">mdi-file-document-outline</v-icon>
77
- {{ attachmentFileName || "View attachment" }}
78
- <v-icon size="13" class="ml-auto">mdi-open-in-new</v-icon>
79
- </v-row>
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
- density="comfortable"
86
- :disabled="disable"
87
- @click="clearAttachment"
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
- <v-icon size="17">mdi-close</v-icon>
48
+ Start from existing template
90
49
  </v-btn>
91
50
  </div>
92
51
 
93
- <div
94
- v-else
95
- class="upload-drop-zone"
96
- :class="{ 'drag-over': isDragOver, 'is-uploading': isUploading }"
97
- @dragover.prevent="isDragOver = true"
98
- @dragleave.prevent="isDragOver = false"
99
- @drop.prevent="handleDrop"
100
- @click="fileInputRef?.click()"
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
- <v-icon
111
- v-else
112
- size="26"
113
- :color="isDragOver ? 'primary' : undefined"
114
- class="mb-1 text-medium-emphasis"
115
- >
116
- mdi-cloud-upload-outline
117
- </v-icon>
118
- <span class="text-caption text-medium-emphasis">
119
- {{ isUploading ? "Uploading…" : "Drop file here or click to browse" }}
120
- </span>
121
- <input
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
- <span v-if="attachmentError" class="text-caption text-error d-block mt-1">
131
- {{ attachmentError }}
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
- </v-col> -->
383
+ </div>
134
384
 
135
- <v-col v-if="message" cols="12" class="px-6">
385
+ <!-- Error -->
386
+ <div v-if="message" class="px-4 pb-3">
136
387
  <span class="text-caption text-error">{{ message }}</span>
137
- </v-col>
388
+ </div>
138
389
  </v-form>
139
390
  </v-card-text>
140
391
 
141
- <v-toolbar density="compact">
142
- <v-row no-gutters>
143
- <v-col cols="6">
144
- <v-btn
145
- tile
146
- block
147
- variant="text"
148
- class="text-none"
149
- size="48"
150
- @click="emit('cancel')"
151
- >
152
- Cancel
153
- </v-btn>
154
- </v-col>
155
- <v-col cols="6">
156
- <v-btn
157
- tile
158
- block
159
- variant="flat"
160
- color="black"
161
- class="text-none"
162
- size="48"
163
- :disabled="!canSubmit || disable"
164
- :loading="disable"
165
- @click="submit"
166
- >
167
- {{ prop.mode === "edit" ? "Update" : "Submit" }}
168
- </v-btn>
169
- </v-col>
170
- </v-row>
171
- </v-toolbar>
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
- // --- form type selection (add mode) ---
198
- const selectedFormType = ref("");
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
- const { data: existingFormsData } = useLazyAsyncData(
201
- `online-form-config-existing-${siteId}`,
202
- () => _getAll({ site: siteId, org: orgId, status: "active", limit: 100 })
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
- const availableFormTypes = computed(() => {
206
- const existing = new Set(
207
- existingFormsData.value?.items?.map((f: any) => f.formType) ?? []
208
- );
209
- return Object.keys(formPages)
210
- .filter((name) => !existing.has(name))
211
- .sort((a, b) => a.localeCompare(b));
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
- // --- status (edit mode) ---
215
- const formStatus = ref<string>(prop.onlineForm?.status || "active");
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
- // --- attachment ---
218
- const attachmentId = ref<string>(prop.onlineForm?.attachment || prop.onlineForm?.fields?.attachment || "");
219
- const attachmentFileName = ref("");
220
- const attachmentError = ref("");
221
- const isDragOver = ref(false);
222
- const isUploading = ref(false);
223
- const fileInputRef = ref<HTMLInputElement | null>(null);
224
-
225
- function handleDrop(e: DragEvent) {
226
- isDragOver.value = false;
227
- const file = e.dataTransfer?.files[0];
228
- if (file) uploadAttachment(file);
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
- function handleFileInputChange(e: Event) {
232
- const file = (e.target as HTMLInputElement).files?.[0];
233
- if (file) uploadAttachment(file);
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
- async function uploadAttachment(file: File) {
237
- isUploading.value = true;
238
- attachmentError.value = "";
239
- try {
240
- const res = await addFile(file);
241
- attachmentId.value = res.id;
242
- attachmentFileName.value = file.name;
243
- } catch (e: any) {
244
- attachmentError.value = e?.message || "Upload failed. Please try again.";
245
- } finally {
246
- isUploading.value = false;
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
- function clearAttachment() {
251
- attachmentId.value = "";
252
- attachmentFileName.value = "";
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
- // --- submit ---
258
- const validForm = ref(false);
259
- const disable = ref(false);
260
- const message = ref("");
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
- const canSubmit = computed(() =>
263
- prop.mode === "edit" ? true : selectedFormType.value !== ""
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
- function buildFields(formType: string): Record<string, string> {
267
- const def = formPages[formType];
268
- const fields: Record<string, string> = {};
269
- if (def) fields.blocks = JSON.stringify(def.blocks);
270
- if (attachmentId.value) fields.attachment = attachmentId.value;
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: selectedFormType.value,
281
- fields: buildFields(selectedFormType.value),
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.formType as string;
288
- await _update(prop.onlineForm._id ?? "", {
777
+ const formType = prop.onlineForm?.formType as string;
778
+ await _update(prop.onlineForm?._id ?? "", {
289
779
  formType,
290
- fields: buildFields(formType),
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
- disable.value = false;
788
+ loading.value = false;
299
789
  }
300
790
  }
301
791
  </script>
302
792
 
303
793
  <style scoped>
304
- .upload-drop-zone {
305
- border: 2px dashed rgba(0, 0, 0, 0.2);
306
- border-radius: 8px;
307
- padding: 18px 12px;
308
- display: flex;
309
- flex-direction: column;
310
- align-items: center;
311
- justify-content: center;
312
- cursor: pointer;
313
- min-height: 80px;
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
- .upload-drop-zone:hover,
318
- .upload-drop-zone.drag-over {
319
- border-color: rgb(var(--v-theme-primary));
320
- background-color: rgba(var(--v-theme-primary), 0.04);
321
- }
322
- .upload-drop-zone.is-uploading {
323
- pointer-events: none;
324
- opacity: 0.65;
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>