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

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 (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/components/EntryPassInformation.vue +4 -4
  3. package/components/FeedbackMain.vue +7 -7
  4. package/components/HidAccessLogDashboard.vue +44 -119
  5. package/components/HidIdentityMapping.vue +975 -0
  6. package/components/HidIntercomManagement.vue +201 -808
  7. package/components/HidQrCodeConfiguration.vue +95 -907
  8. package/components/HidServiceSettingsPanel.vue +1 -20
  9. package/components/HidUserEnrollment.vue +81 -392
  10. package/components/Layout/NavigationDrawer.vue +2 -43
  11. package/components/NavigationItem.vue +0 -9
  12. package/components/Nfc/NFCPatrolRouteMain.vue +7 -52
  13. package/components/OnlineFormConfigurationForm.vue +224 -620
  14. package/components/OnlineFormFill.vue +8 -36
  15. package/components/OnlineFormsConfiguration.vue +2 -6
  16. package/components/QrTemplate/CreditCardLandscape.vue +12 -19
  17. package/components/QrTemplate/PrintDialog.vue +196 -209
  18. package/components/SiteSettings.vue +2 -4
  19. package/components/VisitorForm.vue +78 -201
  20. package/components/VisitorManagement.vue +2 -1
  21. package/composables/useComment.ts +3 -3
  22. package/composables/useHidAmico.ts +0 -127
  23. package/composables/useHidNavigation.ts +7 -0
  24. package/composables/useOnlineFormPages.ts +0 -2
  25. package/composables/useSiteSettings.ts +0 -50
  26. package/composables/useWebUsb.ts +37 -127
  27. package/middleware/member.ts +4 -0
  28. package/package.json +1 -2
  29. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +23 -0
  30. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  31. package/plugins/secure-member.client.ts +56 -21
  32. package/plugins/vuetify.ts +9 -37
  33. package/types/site.d.ts +0 -71
  34. package/.changeset/camera-wall-gated-endpoint.md +0 -47
  35. package/.changeset/camera-wall-plain-english.md +0 -60
  36. package/.changeset/camera-wall-selection-and-guidance.md +0 -65
  37. package/.changeset/dark-primary-contrast.md +0 -39
  38. package/components/CameraWall.vue +0 -1034
  39. package/components/CameraWallTile.vue +0 -818
  40. package/components/HidCameraCaptureDialog.vue +0 -199
  41. package/composables/useSipWebPhone.ts +0 -311
  42. package/utils/camera-wall.test.ts +0 -571
  43. package/utils/camera-wall.ts +0 -926
  44. package/utils/theme.test.ts +0 -201
  45. package/utils/theme.ts +0 -136
@@ -1,427 +1,181 @@
1
1
  <template>
2
- <v-card width="100%" style="display: flex; flex-direction: column; max-height: 92vh">
2
+ <v-card width="100%">
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" : "New" }} Online Form
6
+ {{ prop.mode === "edit" ? "Edit" : "Add" }} Online Form
7
7
  </span>
8
8
  </v-row>
9
9
  </v-toolbar>
10
10
 
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">
11
+ <v-card-text style="max-height: 76vh; overflow-y: auto" class="pa-0">
12
+ <v-form v-model="validForm" :disabled="disable">
14
13
 
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"
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"
26
32
  />
27
- <v-chip v-else color="primary" variant="tonal" size="small">
28
- {{ prop.onlineForm?.formType }}
29
- </v-chip>
30
- </div>
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>
31
43
 
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>
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>
45
49
 
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>
50
+ <v-divider class="mt-4" />
57
51
 
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'] : []"
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"
82
59
  />
60
+ </v-col>
83
61
 
84
- <v-divider class="mb-4" />
85
- </div>
62
+ <!-- Attachment -->
63
+ <!-- <v-col cols="12" class="px-6 pt-3 pb-4">
64
+ <InputLabel title="Attachment (Optional)" />
86
65
 
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>
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>
119
91
  </div>
120
92
 
121
- <!-- Empty state -->
122
93
  <div
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"
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()"
126
101
  >
127
- No blocks yet. Click "Add Block" to start building your form.
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
+ />
128
128
  </div>
129
129
 
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">
130
+ <span v-if="attachmentError" class="text-caption text-error d-block mt-1">
131
+ {{ attachmentError }}
132
+ </span>
133
+ </v-col> -->
174
134
 
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">
135
+ <v-col v-if="message" cols="12" class="px-6">
361
136
  <span class="text-caption text-error">{{ message }}</span>
362
- </div>
137
+ </v-col>
363
138
  </v-form>
364
139
  </v-card-text>
365
140
 
366
- <!-- Action bar -->
367
141
  <v-toolbar density="compact">
368
142
  <v-row no-gutters>
369
143
  <v-col cols="6">
370
- <v-btn tile block variant="text" class="text-none" size="48" @click="emit('cancel')">Cancel</v-btn>
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>
371
154
  </v-col>
372
155
  <v-col cols="6">
373
156
  <v-btn
374
- tile block variant="flat" color="black" class="text-none" size="48"
375
- :disabled="!canSubmit || loading"
376
- :loading="loading"
157
+ tile
158
+ block
159
+ variant="flat"
160
+ color="black"
161
+ class="text-none"
162
+ size="48"
163
+ :disabled="!canSubmit || disable"
164
+ :loading="disable"
377
165
  @click="submit"
378
166
  >
379
- {{ prop.mode === "edit" ? "Update" : "Create Form" }}
167
+ {{ prop.mode === "edit" ? "Update" : "Submit" }}
380
168
  </v-btn>
381
169
  </v-col>
382
170
  </v-row>
383
171
  </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>
416
172
  </v-card>
417
173
  </template>
418
-
419
174
  <script setup lang="ts">
175
+ import useFile from "../composables/useFile";
420
176
  import useOnlineFormConfiguration from "../composables/useOnlineFormConfiguration";
421
177
  import useOnlineFormPages from "../composables/useOnlineFormPages";
422
178
 
423
- type AnyBlock = Record<string, any>;
424
-
425
179
  const prop = defineProps({
426
180
  mode: { type: String, default: "add" },
427
181
  onlineForm: {
@@ -434,289 +188,139 @@ const emit = defineEmits(["cancel", "success"]);
434
188
 
435
189
  const { formPages } = useOnlineFormPages();
436
190
  const { create: _add, updateById: _update, getAll: _getAll } = useOnlineFormConfiguration();
191
+ const { addFile, getFileUrl } = useFile();
437
192
 
438
193
  const route = useRoute();
439
194
  const siteId = route.params.site as string;
440
195
  const orgId = route.params.org as string;
441
196
 
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}`; }
197
+ // --- form type selection (add mode) ---
198
+ const selectedFormType = ref("");
454
199
 
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))
200
+ const { data: existingFormsData } = useLazyAsyncData(
201
+ `online-form-config-existing-${siteId}`,
202
+ () => _getAll({ site: siteId, org: orgId, status: "active", limit: 100 })
465
203
  );
466
204
 
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
- }
490
- });
491
-
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("")
205
+ const availableFormTypes = computed(() => {
206
+ const existing = new Set(
207
+ existingFormsData.value?.items?.map((f: any) => f.formType) ?? []
506
208
  );
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
- ];
516
-
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;
529
- }
530
-
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";
543
- }
544
-
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 "";
565
- }
566
- }
209
+ return Object.keys(formPages)
210
+ .filter((name) => !existing.has(name))
211
+ .sort((a, b) => a.localeCompare(b));
212
+ });
567
213
 
568
- // ── Block operations ──────────────────────────────────────────────────────────
569
- function toggleEdit(idx: number) {
570
- editingIdx.value = editingIdx.value === idx ? null : idx;
571
- }
214
+ // --- status (edit mode) ---
215
+ const formStatus = ref<string>(prop.onlineForm?.status || "active");
572
216
 
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;
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);
580
229
  }
581
230
 
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--;
231
+ function handleFileInputChange(e: Event) {
232
+ const file = (e.target as HTMLInputElement).files?.[0];
233
+ if (file) uploadAttachment(file);
586
234
  }
587
235
 
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;
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;
620
247
  }
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
248
  }
629
249
 
630
- function onFieldTypeChange(block: AnyBlock, newType: string) {
631
- if (newType === "select" && !block.field.options) block.field.options = [];
250
+ function clearAttachment() {
251
+ attachmentId.value = "";
252
+ attachmentFileName.value = "";
253
+ attachmentError.value = "";
254
+ if (fileInputRef.value) fileInputRef.value.value = "";
632
255
  }
633
256
 
634
- // Auto-generate key from label (checkbox items)
635
- function onCheckboxLabelChange(item: AnyBlock, newLabel: string) {
636
- if (!item._keyEdited) item.key = labelToKey(newLabel);
637
- }
638
-
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
- });
257
+ // --- submit ---
258
+ const validForm = ref(false);
259
+ const disable = ref(false);
260
+ const message = ref("");
658
261
 
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
- }
262
+ const canSubmit = computed(() =>
263
+ prop.mode === "edit" ? true : selectedFormType.value !== ""
264
+ );
682
265
 
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
- }
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;
690
271
  return fields;
691
272
  }
692
273
 
693
- // ── Submit ────────────────────────────────────────────────────────────────────
694
274
  async function submit() {
275
+ disable.value = true;
695
276
  message.value = "";
696
- loading.value = true;
697
277
  try {
698
278
  if (prop.mode === "add") {
699
- const name = formName.value.trim();
700
279
  await _add({
701
- formType: name,
702
- fields: buildFields(),
280
+ formType: selectedFormType.value,
281
+ fields: buildFields(selectedFormType.value),
703
282
  status: "active",
704
283
  org: orgId,
705
284
  site: siteId,
706
285
  });
707
286
  } else {
708
- const formType = prop.onlineForm?.formType as string;
709
- await _update(prop.onlineForm?._id ?? "", {
287
+ const formType = prop.onlineForm.formType as string;
288
+ await _update(prop.onlineForm._id ?? "", {
710
289
  formType,
711
- fields: buildFields(),
290
+ fields: buildFields(formType),
712
291
  status: formStatus.value,
713
292
  });
714
293
  }
715
294
  emit("success");
716
295
  } catch (error: any) {
717
- message.value = error?.response?._data?.message || "Failed to save form. Please try again.";
296
+ message.value = error?.response?._data?.message || "Failed to save form";
718
297
  } finally {
719
- loading.value = false;
298
+ disable.value = false;
720
299
  }
721
300
  }
722
301
  </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>