@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.
- package/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/.changeset/camera-wall-plain-english.md +60 -0
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/.changeset/dark-primary-contrast.md +39 -0
- package/.changeset/dashboard-dark-theme-contrast.md +15 -0
- package/.changeset/service-provider-invitation-actions.md +28 -0
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/DashboardMain.vue +84 -88
- package/components/DashboardPanel.vue +1 -1
- package/components/EntryPassInformation.vue +4 -4
- package/components/FeedbackMain.vue +7 -7
- package/components/HidAccessLogDashboard.vue +119 -44
- package/components/HidCameraCaptureDialog.vue +199 -0
- package/components/HidIntercomManagement.vue +808 -201
- package/components/HidQrCodeConfiguration.vue +907 -95
- package/components/HidServiceSettingsPanel.vue +20 -1
- package/components/HidUserEnrollment.vue +392 -81
- package/components/Layout/NavigationDrawer.vue +43 -2
- package/components/NavigationItem.vue +9 -0
- package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
- package/components/OnlineFormConfigurationForm.vue +620 -224
- package/components/OnlineFormFill.vue +36 -8
- package/components/OnlineFormsConfiguration.vue +6 -2
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/ServiceProviderInvitationPrompt.vue +150 -0
- package/components/ServiceProviderMain.vue +258 -14
- package/components/SiteSettings.vue +4 -2
- package/components/VisitorForm.vue +201 -78
- package/components/VisitorManagement.vue +2 -3
- package/components/VisitorSocketPopUp.vue +56 -2
- package/composables/useComment.ts +3 -3
- package/composables/useHidAmico.ts +127 -0
- package/composables/useHidNavigation.ts +0 -7
- package/composables/useLocalAuth.ts +9 -36
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +311 -0
- package/composables/useSiteSettings.ts +50 -0
- package/composables/useVisitorSocket.ts +26 -0
- package/composables/useWebUsb.ts +127 -37
- package/package.json +3 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +37 -9
- package/test/visitor-socket.test.mjs +36 -0
- package/types/site.d.ts +71 -0
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/utils/theme.test.ts +216 -0
- package/utils/theme.ts +163 -0
- package/components/HidIdentityMapping.vue +0 -975
- package/middleware/member.ts +0 -4
- 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" : "
|
|
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="
|
|
12
|
-
<v-form v-model="validForm"
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
+
<v-chip v-else color="primary" variant="tonal" size="small">
|
|
28
|
+
{{ prop.onlineForm?.formType }}
|
|
29
|
+
</v-chip>
|
|
30
|
+
</div>
|
|
43
31
|
|
|
44
|
-
<!--
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
v-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
<InputLabel title="Attachment (Optional)" />
|
|
84
|
+
<v-divider class="mb-4" />
|
|
85
|
+
</div>
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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-
|
|
95
|
-
class="
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
159
|
-
|
|
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" : "
|
|
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
|
-
//
|
|
198
|
-
const
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.
|
|
211
|
-
|
|
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
|
-
//
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
232
|
-
const
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
if (def)
|
|
270
|
-
|
|
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:
|
|
281
|
-
fields: buildFields(
|
|
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
|
|
288
|
-
await _update(prop.onlineForm
|
|
708
|
+
const formType = prop.onlineForm?.formType as string;
|
|
709
|
+
await _update(prop.onlineForm?._id ?? "", {
|
|
289
710
|
formType,
|
|
290
|
-
fields: buildFields(
|
|
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
|
-
|
|
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>
|