@7365admin1/layer-common 3.2.1 → 3.2.2-staging.76

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 (55) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/service-provider-invitation-actions.md +28 -0
  7. package/components/CameraWall.vue +1034 -0
  8. package/components/CameraWallTile.vue +818 -0
  9. package/components/DashboardMain.vue +84 -88
  10. package/components/DashboardPanel.vue +1 -1
  11. package/components/EntryPassInformation.vue +4 -4
  12. package/components/FeedbackMain.vue +7 -7
  13. package/components/HidAccessLogDashboard.vue +119 -44
  14. package/components/HidCameraCaptureDialog.vue +199 -0
  15. package/components/HidIntercomManagement.vue +808 -201
  16. package/components/HidQrCodeConfiguration.vue +907 -95
  17. package/components/HidServiceSettingsPanel.vue +20 -1
  18. package/components/HidUserEnrollment.vue +392 -81
  19. package/components/Layout/NavigationDrawer.vue +43 -2
  20. package/components/NavigationItem.vue +9 -0
  21. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  22. package/components/OnlineFormConfigurationForm.vue +620 -224
  23. package/components/OnlineFormFill.vue +36 -8
  24. package/components/OnlineFormsConfiguration.vue +6 -2
  25. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  26. package/components/QrTemplate/PrintDialog.vue +209 -196
  27. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  28. package/components/ServiceProviderMain.vue +258 -14
  29. package/components/SiteSettings.vue +4 -2
  30. package/components/VisitorForm.vue +201 -78
  31. package/components/VisitorManagement.vue +2 -3
  32. package/components/VisitorSocketPopUp.vue +56 -2
  33. package/composables/useComment.ts +3 -3
  34. package/composables/useHidAmico.ts +127 -0
  35. package/composables/useHidNavigation.ts +0 -7
  36. package/composables/useLocalAuth.ts +9 -36
  37. package/composables/useOnlineFormPages.ts +2 -0
  38. package/composables/useServiceProviderInvitation.ts +145 -0
  39. package/composables/useSipWebPhone.ts +311 -0
  40. package/composables/useSiteSettings.ts +50 -0
  41. package/composables/useVisitorSocket.ts +26 -0
  42. package/composables/useWebUsb.ts +127 -37
  43. package/package.json +3 -1
  44. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  45. package/plugins/secure-member.client.ts +21 -56
  46. package/plugins/vuetify.ts +37 -9
  47. package/test/visitor-socket.test.mjs +36 -0
  48. package/types/site.d.ts +71 -0
  49. package/utils/camera-wall.test.ts +571 -0
  50. package/utils/camera-wall.ts +926 -0
  51. package/utils/theme.test.ts +216 -0
  52. package/utils/theme.ts +163 -0
  53. package/components/HidIdentityMapping.vue +0 -975
  54. package/middleware/member.ts +0 -4
  55. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -1,181 +1,427 @@
1
1
  <template>
2
- <v-card width="100%">
2
+ <v-card width="100%" style="display: flex; flex-direction: column; max-height: 92vh">
3
3
  <v-toolbar>
4
4
  <v-row no-gutters class="fill-height px-6" align="center">
5
5
  <span class="font-weight-bold text-h5">
6
- {{ prop.mode === "edit" ? "Edit" : "Add" }} Online Form
6
+ {{ prop.mode === "edit" ? "Edit" : "New" }} Online Form
7
7
  </span>
8
8
  </v-row>
9
9
  </v-toolbar>
10
10
 
11
- <v-card-text style="max-height: 76vh; overflow-y: auto" class="pa-0">
12
- <v-form v-model="validForm" :disabled="disable">
11
+ <v-card-text style="flex: 1; overflow-y: auto; padding: 0">
12
+ <v-form v-model="validForm">
13
+ <div class="pa-4 pb-0">
13
14
 
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"
15
+ <!-- Form Name -->
16
+ <div class="mb-3">
17
+ <InputLabel title="Form Name" />
18
+ <v-text-field
19
+ v-if="prop.mode !== 'edit'"
20
+ v-model="formName"
21
+ density="compact"
22
+ variant="outlined"
23
+ placeholder="e.g. Renovation Permit Application"
24
+ :rules="[v => !!v || 'Form name is required']"
25
+ hide-details="auto"
41
26
  />
42
- </v-list>
27
+ <v-chip v-else color="primary" variant="tonal" size="small">
28
+ {{ prop.onlineForm?.formType }}
29
+ </v-chip>
30
+ </div>
43
31
 
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>
32
+ <!-- Load template (add mode only) -->
33
+ <div v-if="prop.mode !== 'edit'" class="mb-4">
34
+ <v-btn
35
+ size="small"
36
+ variant="text"
37
+ color="primary"
38
+ class="text-none pa-0"
39
+ prepend-icon="mdi-file-outline"
40
+ @click="showTemplatePicker = true"
41
+ >
42
+ Start from existing template
43
+ </v-btn>
44
+ </div>
49
45
 
50
- <v-divider class="mt-4" />
46
+ <!-- Status (edit mode) -->
47
+ <div v-if="prop.mode === 'edit'" class="mb-3">
48
+ <InputLabel title="Status" />
49
+ <v-select
50
+ v-model="formStatus"
51
+ :items="['active', 'inactive']"
52
+ density="compact"
53
+ variant="outlined"
54
+ hide-details
55
+ />
56
+ </div>
51
57
 
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"
58
+ <!-- Payment -->
59
+ <v-divider class="mb-3" />
60
+ <div class="d-flex align-center ga-3 mb-1">
61
+ <span class="text-body-2 font-weight-medium">Requires Payment</span>
62
+ <v-switch
63
+ v-model="requiresPayment"
64
+ density="compact"
65
+ hide-details
66
+ color="primary"
67
+ inset
68
+ />
69
+ </div>
70
+ <v-text-field
71
+ v-if="requiresPayment"
72
+ v-model.number="paymentAmount"
73
+ density="compact"
74
+ variant="outlined"
75
+ type="number"
76
+ min="1"
77
+ label="Payment Amount (SGD)"
78
+ prefix="$"
79
+ class="mb-3"
80
+ hide-details="auto"
81
+ :rules="requiresPayment ? [v => (v > 0) || 'Amount must be greater than 0'] : []"
59
82
  />
60
- </v-col>
61
83
 
62
- <!-- Attachment -->
63
- <!-- <v-col cols="12" class="px-6 pt-3 pb-4">
64
- <InputLabel title="Attachment (Optional)" />
84
+ <v-divider class="mb-4" />
85
+ </div>
65
86
 
66
- <div v-if="attachmentId" class="d-flex align-center" style="gap: 8px">
67
- <v-card
68
- 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>
81
- <v-btn
82
- icon
83
- variant="text"
84
- size="small"
85
- density="comfortable"
86
- :disabled="disable"
87
- @click="clearAttachment"
88
- >
89
- <v-icon size="17">mdi-close</v-icon>
90
- </v-btn>
87
+ <!-- Block Builder -->
88
+ <div class="px-4 pb-4">
89
+ <div class="d-flex align-center justify-space-between mb-3">
90
+ <span class="text-subtitle-2 font-weight-bold">Form Blocks</span>
91
+ <v-menu :close-on-content-click="true">
92
+ <template #activator="{ props: menuProps }">
93
+ <v-btn
94
+ v-bind="menuProps"
95
+ size="small"
96
+ color="primary"
97
+ variant="tonal"
98
+ prepend-icon="mdi-plus"
99
+ class="text-none"
100
+ >
101
+ Add Block
102
+ </v-btn>
103
+ </template>
104
+ <v-list density="compact" min-width="220">
105
+ <v-list-subheader>Content</v-list-subheader>
106
+ <v-list-item title="Section Heading" prepend-icon="mdi-format-header-1" @click="addBlock('section-header')" />
107
+ <v-list-item title="Paragraph" prepend-icon="mdi-text" @click="addBlock('paragraph')" />
108
+ <v-list-item title="Numbered List" prepend-icon="mdi-format-list-numbered" @click="addBlock('ordered-list')" />
109
+ <v-list-item title="Bullet List" prepend-icon="mdi-format-list-bulleted" @click="addBlock('unordered-list')" />
110
+ <v-list-item title="Divider" prepend-icon="mdi-minus" @click="addBlock('divider')" />
111
+ <v-divider />
112
+ <v-list-subheader>Input Fields</v-list-subheader>
113
+ <v-list-item title="Text / Number / Date Field" prepend-icon="mdi-form-textbox" @click="addBlock('field')" />
114
+ <v-list-item title="Dropdown (Select)" prepend-icon="mdi-chevron-down-box-outline" @click="addBlock('field-select')" />
115
+ <v-list-item title="Checkbox Group" prepend-icon="mdi-checkbox-multiple-outline" @click="addBlock('checkbox-group')" />
116
+ <v-list-item title="Signature" prepend-icon="mdi-draw" @click="addBlock('signature-row')" />
117
+ </v-list>
118
+ </v-menu>
91
119
  </div>
92
120
 
121
+ <!-- Empty state -->
93
122
  <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()"
123
+ v-if="blocks.length === 0"
124
+ class="text-center pa-8 text-medium-emphasis text-caption"
125
+ style="border: 2px dashed #e0e0e0; border-radius: 8px"
101
126
  >
102
- <v-progress-circular
103
- v-if="isUploading"
104
- indeterminate
105
- size="24"
106
- width="2"
107
- color="primary"
108
- class="mb-1"
109
- />
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"
127
- />
127
+ No blocks yet. Click "Add Block" to start building your form.
128
128
  </div>
129
129
 
130
- <span v-if="attachmentError" class="text-caption text-error d-block mt-1">
131
- {{ attachmentError }}
132
- </span>
133
- </v-col> -->
130
+ <!-- Block list -->
131
+ <div
132
+ v-for="(block, idx) in blocks"
133
+ :key="block._uid"
134
+ class="mb-2"
135
+ >
136
+ <v-card variant="outlined" class="rounded-lg">
137
+ <!-- Header row -->
138
+ <div class="d-flex align-center px-3 py-2 ga-2">
139
+ <v-chip
140
+ size="x-small"
141
+ :color="blockTypeColor(block.type)"
142
+ variant="flat"
143
+ label
144
+ style="min-width: 110px; justify-content: center; flex-shrink: 0"
145
+ >
146
+ {{ blockTypeLabel(block.type) }}
147
+ </v-chip>
148
+ <span
149
+ class="text-caption text-medium-emphasis flex-grow-1"
150
+ style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
151
+ >
152
+ {{ blockSummary(block) }}
153
+ </span>
154
+ <div class="d-flex align-center" style="gap: 2px; flex-shrink: 0">
155
+ <v-btn icon size="x-small" variant="text" :disabled="idx === 0" @click="moveBlock(idx, -1)">
156
+ <v-icon size="15">mdi-arrow-up</v-icon>
157
+ </v-btn>
158
+ <v-btn icon size="x-small" variant="text" :disabled="idx === blocks.length - 1" @click="moveBlock(idx, 1)">
159
+ <v-icon size="15">mdi-arrow-down</v-icon>
160
+ </v-btn>
161
+ <v-btn icon size="x-small" variant="text" :color="editingIdx === idx ? 'primary' : undefined" @click="toggleEdit(idx)">
162
+ <v-icon size="15">mdi-pencil</v-icon>
163
+ </v-btn>
164
+ <v-btn icon size="x-small" variant="text" color="error" @click="deleteBlock(idx)">
165
+ <v-icon size="15">mdi-delete</v-icon>
166
+ </v-btn>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Inline editor -->
171
+ <div v-if="editingIdx === idx">
172
+ <v-divider />
173
+ <div class="pa-3">
134
174
 
135
- <v-col v-if="message" cols="12" class="px-6">
175
+ <!-- section-header -->
176
+ <template v-if="block.type === 'section-header'">
177
+ <v-text-field v-model="block.text" label="Heading Text" density="compact" variant="outlined" hide-details />
178
+ </template>
179
+
180
+ <!-- paragraph -->
181
+ <template v-else-if="block.type === 'paragraph'">
182
+ <v-textarea v-model="block.text" label="Paragraph Text" density="compact" variant="outlined" rows="3" auto-grow hide-details />
183
+ </template>
184
+
185
+ <!-- ordered-list / unordered-list -->
186
+ <template v-else-if="block.type === 'ordered-list' || block.type === 'unordered-list'">
187
+ <v-text-field v-model="block.title" label="Section Title (optional)" density="compact" variant="outlined" class="mb-3" hide-details />
188
+ <div class="text-caption font-weight-medium mb-2">Items:</div>
189
+ <div
190
+ v-for="(item, itemIdx) in block.items"
191
+ :key="itemIdx"
192
+ class="d-flex align-center ga-2 mb-2"
193
+ >
194
+ <span class="text-caption text-medium-emphasis" style="min-width: 20px; text-align: right">
195
+ {{ block.type === 'ordered-list' ? `${itemIdx + 1}.` : '•' }}
196
+ </span>
197
+ <v-text-field
198
+ :model-value="item"
199
+ @update:model-value="block.items[itemIdx] = $event"
200
+ density="compact"
201
+ variant="outlined"
202
+ hide-details
203
+ class="flex-grow-1"
204
+ />
205
+ <v-btn icon size="x-small" variant="text" color="error" @click="block.items.splice(itemIdx, 1)">
206
+ <v-icon size="13">mdi-close</v-icon>
207
+ </v-btn>
208
+ </div>
209
+ <v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="block.items.push('')">
210
+ Add item
211
+ </v-btn>
212
+ </template>
213
+
214
+ <!-- divider -->
215
+ <template v-else-if="block.type === 'divider'">
216
+ <span class="text-caption text-medium-emphasis">Horizontal divider — no settings needed.</span>
217
+ </template>
218
+
219
+ <!-- field -->
220
+ <template v-else-if="block.type === 'field'">
221
+ <v-row dense>
222
+ <v-col cols="12">
223
+ <v-text-field
224
+ v-model="block.field.label"
225
+ label="Field Label"
226
+ density="compact"
227
+ variant="outlined"
228
+ hide-details
229
+ @update:model-value="onLabelChange(block, $event)"
230
+ />
231
+ </v-col>
232
+ <v-col cols="7">
233
+ <v-text-field
234
+ v-model="block.field.key"
235
+ label="Field Key"
236
+ density="compact"
237
+ variant="outlined"
238
+ hint="Auto-generated · editable"
239
+ persistent-hint
240
+ />
241
+ </v-col>
242
+ <v-col cols="5">
243
+ <v-select
244
+ v-model="block.field.fieldType"
245
+ :items="fieldTypeOptions"
246
+ label="Type"
247
+ density="compact"
248
+ variant="outlined"
249
+ hide-details
250
+ @update:model-value="onFieldTypeChange(block, $event)"
251
+ />
252
+ </v-col>
253
+ <v-col cols="12">
254
+ <v-switch
255
+ v-model="block.field.required"
256
+ label="Required"
257
+ density="compact"
258
+ hide-details
259
+ color="primary"
260
+ inset
261
+ />
262
+ </v-col>
263
+ <!-- Options for select type -->
264
+ <v-col v-if="block.field.fieldType === 'select'" cols="12">
265
+ <div class="text-caption font-weight-medium mb-2">Dropdown Options:</div>
266
+ <div
267
+ v-for="(opt, optIdx) in (block.field.options ?? [])"
268
+ :key="optIdx"
269
+ class="d-flex align-center ga-2 mb-2"
270
+ >
271
+ <v-text-field
272
+ :model-value="opt"
273
+ @update:model-value="block.field.options[optIdx] = $event"
274
+ density="compact"
275
+ variant="outlined"
276
+ hide-details
277
+ class="flex-grow-1"
278
+ :label="`Option ${optIdx + 1}`"
279
+ />
280
+ <v-btn icon size="x-small" variant="text" color="error" @click="block.field.options.splice(optIdx, 1)">
281
+ <v-icon size="13">mdi-close</v-icon>
282
+ </v-btn>
283
+ </div>
284
+ <v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="(block.field.options ??= []).push('')">
285
+ Add option
286
+ </v-btn>
287
+ </v-col>
288
+ </v-row>
289
+ </template>
290
+
291
+ <!-- checkbox-group -->
292
+ <template v-else-if="block.type === 'checkbox-group'">
293
+ <v-text-field v-model="block.title" label="Section Title (optional)" density="compact" variant="outlined" class="mb-3" hide-details />
294
+ <v-switch v-model="block.single" label="Single selection (radio behavior)" density="compact" hide-details color="primary" inset class="mb-3" />
295
+ <div class="text-caption font-weight-medium mb-2">Checkboxes:</div>
296
+ <div
297
+ v-for="(item, itemIdx) in block.items"
298
+ :key="itemIdx"
299
+ class="d-flex align-center ga-2 mb-2"
300
+ >
301
+ <v-text-field
302
+ v-model="item.label"
303
+ density="compact"
304
+ variant="outlined"
305
+ hide-details
306
+ label="Label"
307
+ class="flex-grow-1"
308
+ @update:model-value="onCheckboxLabelChange(item, $event)"
309
+ />
310
+ <v-text-field
311
+ v-model="item.key"
312
+ density="compact"
313
+ variant="outlined"
314
+ hide-details
315
+ label="Key"
316
+ style="max-width: 130px"
317
+ />
318
+ <v-btn icon size="x-small" variant="text" color="error" @click="block.items.splice(itemIdx, 1)">
319
+ <v-icon size="13">mdi-close</v-icon>
320
+ </v-btn>
321
+ </div>
322
+ <v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="block.items.push({ key: '', label: '' })">
323
+ Add checkbox
324
+ </v-btn>
325
+ </template>
326
+
327
+ <!-- signature-row -->
328
+ <template v-else-if="block.type === 'signature-row'">
329
+ <v-row dense>
330
+ <v-col cols="12">
331
+ <v-text-field
332
+ v-model="block.label"
333
+ label="Signature Label"
334
+ density="compact"
335
+ variant="outlined"
336
+ hide-details
337
+ @update:model-value="block.key = labelToKey($event)"
338
+ />
339
+ </v-col>
340
+ <v-col cols="12">
341
+ <v-text-field
342
+ v-model="block.key"
343
+ label="Key"
344
+ density="compact"
345
+ variant="outlined"
346
+ hint="Unique identifier"
347
+ persistent-hint
348
+ />
349
+ </v-col>
350
+ </v-row>
351
+ </template>
352
+
353
+ </div>
354
+ </div>
355
+ </v-card>
356
+ </div>
357
+ </div>
358
+
359
+ <!-- Error -->
360
+ <div v-if="message" class="px-4 pb-3">
136
361
  <span class="text-caption text-error">{{ message }}</span>
137
- </v-col>
362
+ </div>
138
363
  </v-form>
139
364
  </v-card-text>
140
365
 
366
+ <!-- Action bar -->
141
367
  <v-toolbar density="compact">
142
368
  <v-row no-gutters>
143
369
  <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>
370
+ <v-btn tile block variant="text" class="text-none" size="48" @click="emit('cancel')">Cancel</v-btn>
154
371
  </v-col>
155
372
  <v-col cols="6">
156
373
  <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"
374
+ tile block variant="flat" color="black" class="text-none" size="48"
375
+ :disabled="!canSubmit || loading"
376
+ :loading="loading"
165
377
  @click="submit"
166
378
  >
167
- {{ prop.mode === "edit" ? "Update" : "Submit" }}
379
+ {{ prop.mode === "edit" ? "Update" : "Create Form" }}
168
380
  </v-btn>
169
381
  </v-col>
170
382
  </v-row>
171
383
  </v-toolbar>
384
+
385
+ <!-- Template picker dialog -->
386
+ <v-dialog v-model="showTemplatePicker" width="520" scrollable>
387
+ <v-card>
388
+ <v-toolbar density="compact">
389
+ <span class="pl-4 font-weight-medium text-subtitle-1">Load Template</span>
390
+ </v-toolbar>
391
+ <div class="pt-3 px-4 pb-2 text-caption text-medium-emphasis" style="white-space: normal">
392
+ Select a template to pre-populate form blocks. You can customise them after loading.
393
+ </div>
394
+ <v-card-text style="max-height: 55vh; overflow-y: auto; padding: 0">
395
+ <v-list density="compact">
396
+ <v-list-item
397
+ v-if="sortedTemplateNames.length === 0"
398
+ disabled
399
+ title="All templates have already been added for this site"
400
+ class="text-caption text-medium-emphasis"
401
+ />
402
+ <v-list-item
403
+ v-for="name in sortedTemplateNames"
404
+ :key="name"
405
+ :title="name"
406
+ class="text-caption"
407
+ @click="loadTemplate(name)"
408
+ />
409
+ </v-list>
410
+ </v-card-text>
411
+ <v-toolbar density="compact">
412
+ <v-btn tile block variant="text" class="text-none" size="48" @click="showTemplatePicker = false">Close</v-btn>
413
+ </v-toolbar>
414
+ </v-card>
415
+ </v-dialog>
172
416
  </v-card>
173
417
  </template>
418
+
174
419
  <script setup lang="ts">
175
- import useFile from "../composables/useFile";
176
420
  import useOnlineFormConfiguration from "../composables/useOnlineFormConfiguration";
177
421
  import useOnlineFormPages from "../composables/useOnlineFormPages";
178
422
 
423
+ type AnyBlock = Record<string, any>;
424
+
179
425
  const prop = defineProps({
180
426
  mode: { type: String, default: "add" },
181
427
  onlineForm: {
@@ -188,139 +434,289 @@ const emit = defineEmits(["cancel", "success"]);
188
434
 
189
435
  const { formPages } = useOnlineFormPages();
190
436
  const { create: _add, updateById: _update, getAll: _getAll } = useOnlineFormConfiguration();
191
- const { addFile, getFileUrl } = useFile();
192
437
 
193
438
  const route = useRoute();
194
439
  const siteId = route.params.site as string;
195
440
  const orgId = route.params.org as string;
196
441
 
197
- // --- form type selection (add mode) ---
198
- const selectedFormType = ref("");
442
+ // ── Form-level settings ──────────────────────────────────────────────────────
443
+ const formName = ref("");
444
+ const formStatus = ref<string>(prop.onlineForm?.status || "active");
445
+ const requiresPayment = ref(false);
446
+ const paymentAmount = ref<number>(0);
447
+ const validForm = ref(false);
448
+ const loading = ref(false);
449
+ const message = ref("");
450
+
451
+ // ── Blocks ───────────────────────────────────────────────────────────────────
452
+ let _uid = 0;
453
+ function nextUid() { return `b${++_uid}`; }
199
454
 
200
- const { data: existingFormsData } = useLazyAsyncData(
201
- `online-form-config-existing-${siteId}`,
202
- () => _getAll({ site: siteId, org: orgId, status: "active", limit: 100 })
455
+ const blocks = ref<AnyBlock[]>([]);
456
+ const editingIdx = ref<number | null>(null);
457
+
458
+ // ── Template picker ───────────────────────────────────────────────────────────
459
+ const showTemplatePicker = ref(false);
460
+ const existingFormTypes = ref<string[]>([]);
461
+ const sortedTemplateNames = computed(() =>
462
+ Object.keys(formPages)
463
+ .filter((name) => !existingFormTypes.value.includes(name))
464
+ .sort((a, b) => a.localeCompare(b))
203
465
  );
204
466
 
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));
467
+ async function loadExistingFormTypes() {
468
+ try {
469
+ const res = await _getAll({ status: "active", org: orgId, site: siteId, limit: 100 });
470
+ existingFormTypes.value = (res?.items ?? []).map((c: any) => c.formType);
471
+ } catch (error) {
472
+ console.error("Failed to load existing form types for template filtering", error);
473
+ }
474
+ }
475
+
476
+ // ── Init ──────────────────────────────────────────────────────────────────────
477
+ onMounted(() => {
478
+ if (prop.mode === "edit") {
479
+ if (prop.onlineForm?.fields?.blocks) {
480
+ try {
481
+ const parsed = JSON.parse(prop.onlineForm.fields.blocks);
482
+ blocks.value = (parsed as any[]).map((b) => ({ ...b, _uid: nextUid() }));
483
+ } catch {}
484
+ }
485
+ const pa = Number(prop.onlineForm?.fields?.paymentAmount ?? 0);
486
+ if (pa > 0) { requiresPayment.value = true; paymentAmount.value = pa; }
487
+ } else {
488
+ loadExistingFormTypes();
489
+ }
212
490
  });
213
491
 
214
- // --- status (edit mode) ---
215
- const formStatus = ref<string>(prop.onlineForm?.status || "active");
492
+ // ── Helpers ───────────────────────────────────────────────────────────────────
493
+ function labelToKey(label: string): string {
494
+ const words = label
495
+ .replace(/[^a-zA-Z0-9 ]/g, "")
496
+ .trim()
497
+ .split(/\s+/)
498
+ .filter(Boolean);
499
+ if (!words.length) return "";
500
+ return (
501
+ words[0].toLowerCase() +
502
+ words
503
+ .slice(1)
504
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
505
+ .join("")
506
+ );
507
+ }
508
+
509
+ const fieldTypeOptions = [
510
+ { title: "Text", value: "text" },
511
+ { title: "Textarea (multi-line)", value: "textarea" },
512
+ { title: "Number", value: "number" },
513
+ { title: "Date", value: "date" },
514
+ { title: "Dropdown (Select)", value: "select" },
515
+ ];
216
516
 
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);
517
+ function blockTypeLabel(type: string): string {
518
+ const map: Record<string, string> = {
519
+ "section-header": "Heading",
520
+ paragraph: "Paragraph",
521
+ "ordered-list": "Numbered List",
522
+ "unordered-list": "Bullet List",
523
+ divider: "Divider",
524
+ field: "Field",
525
+ "checkbox-group": "Checkboxes",
526
+ "signature-row": "Signature",
527
+ };
528
+ return map[type] ?? type;
229
529
  }
230
530
 
231
- function handleFileInputChange(e: Event) {
232
- const file = (e.target as HTMLInputElement).files?.[0];
233
- if (file) uploadAttachment(file);
531
+ function blockTypeColor(type: string): string {
532
+ const map: Record<string, string> = {
533
+ "section-header": "blue-grey",
534
+ paragraph: "grey",
535
+ "ordered-list": "indigo",
536
+ "unordered-list": "indigo",
537
+ divider: "grey",
538
+ field: "teal",
539
+ "checkbox-group": "purple",
540
+ "signature-row": "orange",
541
+ };
542
+ return map[type] ?? "grey";
234
543
  }
235
544
 
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;
545
+ function blockSummary(block: AnyBlock): string {
546
+ switch (block.type) {
547
+ case "section-header":
548
+ case "paragraph":
549
+ return (block.text ?? "").slice(0, 70) || "(empty)";
550
+ case "ordered-list":
551
+ case "unordered-list":
552
+ return block.title || `${block.items?.length ?? 0} item${block.items?.length === 1 ? "" : "s"}`;
553
+ case "divider":
554
+ return "──────────────";
555
+ case "field":
556
+ return block.field?.label
557
+ ? `${block.field.label} · ${block.field.fieldType}`
558
+ : "(no label)";
559
+ case "checkbox-group":
560
+ return block.title || `${block.items?.length ?? 0} checkbox${block.items?.length === 1 ? "" : "es"}`;
561
+ case "signature-row":
562
+ return block.label || "(no label)";
563
+ default:
564
+ return "";
247
565
  }
248
566
  }
249
567
 
250
- function clearAttachment() {
251
- attachmentId.value = "";
252
- attachmentFileName.value = "";
253
- attachmentError.value = "";
254
- if (fileInputRef.value) fileInputRef.value.value = "";
568
+ // ── Block operations ──────────────────────────────────────────────────────────
569
+ function toggleEdit(idx: number) {
570
+ editingIdx.value = editingIdx.value === idx ? null : idx;
255
571
  }
256
572
 
257
- // --- submit ---
258
- const validForm = ref(false);
259
- const disable = ref(false);
260
- const message = ref("");
573
+ function moveBlock(idx: number, dir: -1 | 1) {
574
+ const target = idx + dir;
575
+ if (target < 0 || target >= blocks.value.length) return;
576
+ const copy = [...blocks.value];
577
+ [copy[idx], copy[target]] = [copy[target], copy[idx]];
578
+ blocks.value = copy;
579
+ editingIdx.value = null;
580
+ }
261
581
 
262
- const canSubmit = computed(() =>
263
- prop.mode === "edit" ? true : selectedFormType.value !== ""
264
- );
582
+ function deleteBlock(idx: number) {
583
+ blocks.value.splice(idx, 1);
584
+ if (editingIdx.value === idx) editingIdx.value = null;
585
+ else if (editingIdx.value !== null && editingIdx.value > idx) editingIdx.value--;
586
+ }
587
+
588
+ function addBlock(type: string) {
589
+ let b: AnyBlock;
590
+ switch (type) {
591
+ case "section-header":
592
+ b = { _uid: nextUid(), type: "section-header", text: "" };
593
+ break;
594
+ case "paragraph":
595
+ b = { _uid: nextUid(), type: "paragraph", text: "" };
596
+ break;
597
+ case "ordered-list":
598
+ b = { _uid: nextUid(), type: "ordered-list", title: "", items: [""] };
599
+ break;
600
+ case "unordered-list":
601
+ b = { _uid: nextUid(), type: "unordered-list", title: "", items: [""] };
602
+ break;
603
+ case "divider":
604
+ b = { _uid: nextUid(), type: "divider" };
605
+ break;
606
+ case "field":
607
+ b = { _uid: nextUid(), type: "field", field: { key: "", label: "", fieldType: "text", required: false } };
608
+ break;
609
+ case "field-select":
610
+ b = { _uid: nextUid(), type: "field", field: { key: "", label: "", fieldType: "select", required: false, options: [] } };
611
+ break;
612
+ case "checkbox-group":
613
+ b = { _uid: nextUid(), type: "checkbox-group", title: "", single: false, items: [{ key: "", label: "" }] };
614
+ break;
615
+ case "signature-row":
616
+ b = { _uid: nextUid(), type: "signature-row", label: "", key: "" };
617
+ break;
618
+ default:
619
+ return;
620
+ }
621
+ blocks.value.push(b);
622
+ editingIdx.value = blocks.value.length - 1;
623
+ }
624
+
625
+ // Auto-generate key from label (field blocks)
626
+ function onLabelChange(block: AnyBlock, newLabel: string) {
627
+ if (!block._keyEdited) block.field.key = labelToKey(newLabel);
628
+ }
629
+
630
+ function onFieldTypeChange(block: AnyBlock, newType: string) {
631
+ if (newType === "select" && !block.field.options) block.field.options = [];
632
+ }
633
+
634
+ // Auto-generate key from label (checkbox items)
635
+ function onCheckboxLabelChange(item: AnyBlock, newLabel: string) {
636
+ if (!item._keyEdited) item.key = labelToKey(newLabel);
637
+ }
265
638
 
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;
639
+ // ── Template loading ──────────────────────────────────────────────────────────
640
+ function loadTemplate(name: string) {
641
+ const def = formPages[name];
642
+ if (!def) return;
643
+ formName.value = name;
644
+ blocks.value = def.blocks.map((b) => ({
645
+ ...JSON.parse(JSON.stringify(b)),
646
+ _uid: nextUid(),
647
+ }));
648
+ showTemplatePicker.value = false;
649
+ editingIdx.value = null;
650
+ }
651
+
652
+ // ── Computed ──────────────────────────────────────────────────────────────────
653
+ const canSubmit = computed(() => {
654
+ const nameOk = prop.mode === "edit" || !!formName.value.trim();
655
+ const payOk = !requiresPayment.value || paymentAmount.value > 0;
656
+ return nameOk && payOk && !loading.value;
657
+ });
658
+
659
+ // ── Payload builder ───────────────────────────────────────────────────────────
660
+ function buildCleanBlocks(): Record<string, any>[] {
661
+ return blocks.value.map((b) => {
662
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
663
+ const { _uid, _keyEdited, ...rest } = b;
664
+ if (rest.field) {
665
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
666
+ const { _keyEdited: _fke, ...fieldRest } = rest.field;
667
+ rest.field = fieldRest;
668
+ }
669
+ if (Array.isArray(rest.items)) {
670
+ rest.items = rest.items.map((item: any) => {
671
+ if (item && typeof item === "object") {
672
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
673
+ const { _keyEdited: _ike, ...itemRest } = item;
674
+ return itemRest;
675
+ }
676
+ return item;
677
+ });
678
+ }
679
+ return rest;
680
+ });
681
+ }
682
+
683
+ function buildFields(): Record<string, string> {
684
+ const fields: Record<string, string> = {
685
+ blocks: JSON.stringify(buildCleanBlocks()),
686
+ };
687
+ if (requiresPayment.value && paymentAmount.value > 0) {
688
+ fields.paymentAmount = String(paymentAmount.value);
689
+ }
271
690
  return fields;
272
691
  }
273
692
 
693
+ // ── Submit ────────────────────────────────────────────────────────────────────
274
694
  async function submit() {
275
- disable.value = true;
276
695
  message.value = "";
696
+ loading.value = true;
277
697
  try {
278
698
  if (prop.mode === "add") {
699
+ const name = formName.value.trim();
279
700
  await _add({
280
- formType: selectedFormType.value,
281
- fields: buildFields(selectedFormType.value),
701
+ formType: name,
702
+ fields: buildFields(),
282
703
  status: "active",
283
704
  org: orgId,
284
705
  site: siteId,
285
706
  });
286
707
  } else {
287
- const formType = prop.onlineForm.formType as string;
288
- await _update(prop.onlineForm._id ?? "", {
708
+ const formType = prop.onlineForm?.formType as string;
709
+ await _update(prop.onlineForm?._id ?? "", {
289
710
  formType,
290
- fields: buildFields(formType),
711
+ fields: buildFields(),
291
712
  status: formStatus.value,
292
713
  });
293
714
  }
294
715
  emit("success");
295
716
  } catch (error: any) {
296
- message.value = error?.response?._data?.message || "Failed to save form";
717
+ message.value = error?.response?._data?.message || "Failed to save form. Please try again.";
297
718
  } finally {
298
- disable.value = false;
719
+ loading.value = false;
299
720
  }
300
721
  }
301
722
  </script>
302
-
303
- <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;
316
- }
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;
325
- }
326
- </style>