@7365admin1/layer-common 3.0.3 → 3.0.5
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/CHANGELOG.md +14 -0
- package/components/InvitationForm.vue +6 -2
- package/components/OnlineFormConfigurationForm.vue +141 -239
- package/components/OnlineFormFill.vue +456 -0
- package/components/OnlineFormsConfiguration.vue +81 -126
- package/components/RolePermissionFormCreate.vue +5 -1
- package/components/SignaturePad.vue +132 -61
- package/composables/useMember.ts +13 -1
- package/composables/useOnlineFormPages.ts +642 -0
- package/package.json +1 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card width="100%">
|
|
3
|
+
<!-- Title bar -->
|
|
4
|
+
<v-toolbar color="grey-darken-3" style="min-height: 56px">
|
|
5
|
+
<v-row no-gutters class="fill-height px-6" align="center">
|
|
6
|
+
<span
|
|
7
|
+
class="font-weight-bold text-subtitle-1 text-white text-uppercase"
|
|
8
|
+
style="letter-spacing: 0.5px; line-height: 1.3"
|
|
9
|
+
>
|
|
10
|
+
{{ definition.title }}
|
|
11
|
+
</span>
|
|
12
|
+
<v-spacer v-if="$slots['title-actions']" />
|
|
13
|
+
<slot name="title-actions" />
|
|
14
|
+
</v-row>
|
|
15
|
+
</v-toolbar>
|
|
16
|
+
|
|
17
|
+
<!-- Scrollable body -->
|
|
18
|
+
<v-card-text
|
|
19
|
+
style="max-height: 76vh; overflow-y: auto"
|
|
20
|
+
class="pa-6"
|
|
21
|
+
>
|
|
22
|
+
<v-form v-model="validForm">
|
|
23
|
+
<template v-for="(block, i) in definition.blocks" :key="i">
|
|
24
|
+
|
|
25
|
+
<!-- ADDRESS BLOCK -->
|
|
26
|
+
<div
|
|
27
|
+
v-if="block.type === 'address'"
|
|
28
|
+
class="mb-4 pa-3"
|
|
29
|
+
style="background: #f5f5f5; border-radius: 6px"
|
|
30
|
+
>
|
|
31
|
+
<v-row no-gutters>
|
|
32
|
+
<v-col cols="5">
|
|
33
|
+
<span class="text-body-2">
|
|
34
|
+
<strong>Date : </strong>{{ todayDate }}
|
|
35
|
+
</span>
|
|
36
|
+
</v-col>
|
|
37
|
+
<v-col cols="7">
|
|
38
|
+
<span class="text-body-2">
|
|
39
|
+
<strong>To : </strong>{{ resolveText(block.to) }}
|
|
40
|
+
</span>
|
|
41
|
+
</v-col>
|
|
42
|
+
</v-row>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<!-- FIELD ROW (2–3 fields side by side) -->
|
|
46
|
+
<v-row v-else-if="block.type === 'row'" class="mb-0" dense>
|
|
47
|
+
<v-col
|
|
48
|
+
v-for="field in block.fields"
|
|
49
|
+
:key="field.key"
|
|
50
|
+
:cols="field.cols || 6"
|
|
51
|
+
>
|
|
52
|
+
<InputLabel :title="field.label" />
|
|
53
|
+
<v-text-field
|
|
54
|
+
v-if="field.fieldType === 'text'"
|
|
55
|
+
v-model="values[field.key]"
|
|
56
|
+
density="comfortable"
|
|
57
|
+
:disabled="readonly"
|
|
58
|
+
:rules="!readonly && field.required ? [requiredRule] : []"
|
|
59
|
+
/>
|
|
60
|
+
<v-text-field
|
|
61
|
+
v-else-if="field.fieldType === 'number'"
|
|
62
|
+
v-model.number="values[field.key]"
|
|
63
|
+
type="number"
|
|
64
|
+
density="comfortable"
|
|
65
|
+
:disabled="readonly"
|
|
66
|
+
:rules="!readonly && field.required ? [requiredRule] : []"
|
|
67
|
+
/>
|
|
68
|
+
<v-text-field
|
|
69
|
+
v-else-if="field.fieldType === 'date'"
|
|
70
|
+
v-model="values[field.key]"
|
|
71
|
+
type="date"
|
|
72
|
+
density="comfortable"
|
|
73
|
+
:disabled="readonly"
|
|
74
|
+
:rules="!readonly && field.required ? [requiredRule] : []"
|
|
75
|
+
/>
|
|
76
|
+
<v-select
|
|
77
|
+
v-else-if="field.fieldType === 'select'"
|
|
78
|
+
v-model="values[field.key]"
|
|
79
|
+
:items="field.options || []"
|
|
80
|
+
density="comfortable"
|
|
81
|
+
:disabled="readonly"
|
|
82
|
+
:rules="!readonly && field.required ? [requiredRule] : []"
|
|
83
|
+
/>
|
|
84
|
+
<v-textarea
|
|
85
|
+
v-else-if="field.fieldType === 'textarea'"
|
|
86
|
+
v-model="values[field.key]"
|
|
87
|
+
density="comfortable"
|
|
88
|
+
rows="3"
|
|
89
|
+
auto-grow
|
|
90
|
+
:disabled="readonly"
|
|
91
|
+
:rules="!readonly && field.required ? [requiredRule] : []"
|
|
92
|
+
/>
|
|
93
|
+
</v-col>
|
|
94
|
+
</v-row>
|
|
95
|
+
|
|
96
|
+
<!-- SINGLE FULL-WIDTH FIELD -->
|
|
97
|
+
<div v-else-if="block.type === 'field'" class="mb-0">
|
|
98
|
+
<InputLabel :title="block.field.label" />
|
|
99
|
+
<v-text-field
|
|
100
|
+
v-if="block.field.fieldType === 'text'"
|
|
101
|
+
v-model="values[block.field.key]"
|
|
102
|
+
density="comfortable"
|
|
103
|
+
:disabled="readonly"
|
|
104
|
+
:rules="!readonly && block.field.required ? [requiredRule] : []"
|
|
105
|
+
/>
|
|
106
|
+
<v-text-field
|
|
107
|
+
v-else-if="block.field.fieldType === 'number'"
|
|
108
|
+
v-model.number="values[block.field.key]"
|
|
109
|
+
type="number"
|
|
110
|
+
density="comfortable"
|
|
111
|
+
:disabled="readonly"
|
|
112
|
+
:rules="!readonly && block.field.required ? [requiredRule] : []"
|
|
113
|
+
/>
|
|
114
|
+
<v-text-field
|
|
115
|
+
v-else-if="block.field.fieldType === 'date'"
|
|
116
|
+
v-model="values[block.field.key]"
|
|
117
|
+
type="date"
|
|
118
|
+
density="comfortable"
|
|
119
|
+
:disabled="readonly"
|
|
120
|
+
:rules="!readonly && block.field.required ? [requiredRule] : []"
|
|
121
|
+
/>
|
|
122
|
+
<v-select
|
|
123
|
+
v-else-if="block.field.fieldType === 'select'"
|
|
124
|
+
v-model="values[block.field.key]"
|
|
125
|
+
:items="block.field.options || []"
|
|
126
|
+
density="comfortable"
|
|
127
|
+
:disabled="readonly"
|
|
128
|
+
:rules="!readonly && block.field.required ? [requiredRule] : []"
|
|
129
|
+
/>
|
|
130
|
+
<v-textarea
|
|
131
|
+
v-else-if="block.field.fieldType === 'textarea'"
|
|
132
|
+
v-model="values[block.field.key]"
|
|
133
|
+
density="comfortable"
|
|
134
|
+
rows="3"
|
|
135
|
+
auto-grow
|
|
136
|
+
:disabled="readonly"
|
|
137
|
+
:rules="!readonly && block.field.required ? [requiredRule] : []"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- SECTION HEADER -->
|
|
142
|
+
<div
|
|
143
|
+
v-else-if="block.type === 'section-header'"
|
|
144
|
+
class="mt-4 mb-2"
|
|
145
|
+
>
|
|
146
|
+
<span
|
|
147
|
+
class="text-body-2 font-weight-bold"
|
|
148
|
+
style="text-decoration: underline; text-underline-offset: 3px"
|
|
149
|
+
>
|
|
150
|
+
{{ resolveText(block.text) }}
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- CHECKBOX GROUP -->
|
|
155
|
+
<div
|
|
156
|
+
v-else-if="block.type === 'checkbox-group'"
|
|
157
|
+
class="mb-2"
|
|
158
|
+
>
|
|
159
|
+
<div
|
|
160
|
+
v-if="block.title"
|
|
161
|
+
class="text-body-2 font-weight-bold mb-1"
|
|
162
|
+
style="text-decoration: underline; text-underline-offset: 3px"
|
|
163
|
+
>
|
|
164
|
+
{{ block.title }}
|
|
165
|
+
</div>
|
|
166
|
+
<div
|
|
167
|
+
v-for="item in block.items"
|
|
168
|
+
:key="item.key"
|
|
169
|
+
class="d-flex align-center"
|
|
170
|
+
style="gap: 4px; min-height: 32px"
|
|
171
|
+
>
|
|
172
|
+
<v-checkbox
|
|
173
|
+
v-model="values[item.key]"
|
|
174
|
+
density="compact"
|
|
175
|
+
hide-details
|
|
176
|
+
class="mt-0 pt-0 flex-shrink-0"
|
|
177
|
+
:disabled="readonly"
|
|
178
|
+
/>
|
|
179
|
+
<div class="flex-grow-1">
|
|
180
|
+
<span class="text-body-2" style="line-height: 1.4">{{ item.label }}</span>
|
|
181
|
+
<v-text-field
|
|
182
|
+
v-if="item.elaborateKey && values[item.key]"
|
|
183
|
+
v-model="values[item.elaborateKey]"
|
|
184
|
+
density="compact"
|
|
185
|
+
placeholder="Please elaborate..."
|
|
186
|
+
class="mt-1"
|
|
187
|
+
style="max-width: 360px"
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<!-- PARAGRAPH (terms / info text) -->
|
|
194
|
+
<p
|
|
195
|
+
v-else-if="block.type === 'paragraph'"
|
|
196
|
+
class="text-body-2 mb-3"
|
|
197
|
+
style="
|
|
198
|
+
line-height: 1.65;
|
|
199
|
+
color: #333;
|
|
200
|
+
text-align: justify;
|
|
201
|
+
border-left: 3px solid #ddd;
|
|
202
|
+
padding-left: 10px;
|
|
203
|
+
"
|
|
204
|
+
>
|
|
205
|
+
{{ resolveText(block.text) }}
|
|
206
|
+
</p>
|
|
207
|
+
|
|
208
|
+
<!-- DIVIDER -->
|
|
209
|
+
<v-divider v-else-if="block.type === 'divider'" class="my-4" />
|
|
210
|
+
|
|
211
|
+
<!-- SIGNATURE ROW -->
|
|
212
|
+
<div
|
|
213
|
+
v-else-if="block.type === 'signature-row' && !readonly"
|
|
214
|
+
class="mt-4 mb-4"
|
|
215
|
+
>
|
|
216
|
+
<SignaturePad
|
|
217
|
+
v-model="values[block.key]"
|
|
218
|
+
:label="block.label"
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
<div
|
|
222
|
+
v-else-if="block.type === 'signature-row' && readonly"
|
|
223
|
+
class="mt-4 mb-2"
|
|
224
|
+
>
|
|
225
|
+
<div
|
|
226
|
+
style="border-bottom: 1px dashed #bbb; height: 56px; background: #fafafa; border-radius: 4px"
|
|
227
|
+
/>
|
|
228
|
+
<span class="text-caption text-medium-emphasis">{{ block.label }}</span>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<!-- ACKNOWLEDGEMENT -->
|
|
232
|
+
<div
|
|
233
|
+
v-else-if="block.type === 'acknowledgement'"
|
|
234
|
+
class="mb-4 pa-3"
|
|
235
|
+
style="background: #f9f9f9; border-radius: 6px; border: 1px solid #eee"
|
|
236
|
+
>
|
|
237
|
+
<span class="text-body-2 font-weight-medium">{{ resolveText(block.text) }}</span>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- MANAGEMENT USE (read-only, shown at bottom) -->
|
|
241
|
+
<div
|
|
242
|
+
v-else-if="block.type === 'management-use'"
|
|
243
|
+
class="pa-4 mt-2"
|
|
244
|
+
style="background: #f0f0f0; border-radius: 6px; opacity: 0.75"
|
|
245
|
+
>
|
|
246
|
+
<v-row no-gutters class="mb-2">
|
|
247
|
+
<span class="text-caption font-weight-bold text-medium-emphasis">
|
|
248
|
+
FOR MANAGEMENT USE ONLY
|
|
249
|
+
</span>
|
|
250
|
+
</v-row>
|
|
251
|
+
<v-row no-gutters style="gap: 16px">
|
|
252
|
+
<div
|
|
253
|
+
v-for="(item, idx) in block.items"
|
|
254
|
+
:key="idx"
|
|
255
|
+
style="min-width: 160px"
|
|
256
|
+
>
|
|
257
|
+
<div class="text-caption text-medium-emphasis mb-1">{{ item }}</div>
|
|
258
|
+
<div
|
|
259
|
+
style="border-bottom: 1px solid #aaa; min-width: 120px; height: 22px"
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
</v-row>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
</template>
|
|
266
|
+
|
|
267
|
+
<!-- Error message -->
|
|
268
|
+
<v-row v-if="message" no-gutters justify="center" class="mt-3">
|
|
269
|
+
<span class="text-caption text-error">{{ message }}</span>
|
|
270
|
+
</v-row>
|
|
271
|
+
</v-form>
|
|
272
|
+
</v-card-text>
|
|
273
|
+
|
|
274
|
+
<!-- Action bar -->
|
|
275
|
+
<v-toolbar density="compact">
|
|
276
|
+
<v-row no-gutters>
|
|
277
|
+
<!-- Readonly: single Close button -->
|
|
278
|
+
<template v-if="readonly">
|
|
279
|
+
<v-col cols="12">
|
|
280
|
+
<v-btn
|
|
281
|
+
tile
|
|
282
|
+
block
|
|
283
|
+
variant="text"
|
|
284
|
+
class="text-none"
|
|
285
|
+
size="48"
|
|
286
|
+
@click="emit('cancel')"
|
|
287
|
+
>
|
|
288
|
+
Close
|
|
289
|
+
</v-btn>
|
|
290
|
+
</v-col>
|
|
291
|
+
</template>
|
|
292
|
+
<!-- Interactive: Cancel + Submit -->
|
|
293
|
+
<template v-else>
|
|
294
|
+
<v-col cols="6">
|
|
295
|
+
<v-btn
|
|
296
|
+
tile
|
|
297
|
+
block
|
|
298
|
+
variant="text"
|
|
299
|
+
class="text-none"
|
|
300
|
+
size="48"
|
|
301
|
+
:disabled="submitting"
|
|
302
|
+
@click="emit('cancel')"
|
|
303
|
+
>
|
|
304
|
+
Cancel
|
|
305
|
+
</v-btn>
|
|
306
|
+
</v-col>
|
|
307
|
+
<v-col cols="6">
|
|
308
|
+
<v-btn
|
|
309
|
+
tile
|
|
310
|
+
block
|
|
311
|
+
variant="flat"
|
|
312
|
+
color="black"
|
|
313
|
+
class="text-none"
|
|
314
|
+
size="48"
|
|
315
|
+
:disabled="!validForm || submitting"
|
|
316
|
+
:loading="submitting"
|
|
317
|
+
@click="submit"
|
|
318
|
+
>
|
|
319
|
+
Submit
|
|
320
|
+
</v-btn>
|
|
321
|
+
</v-col>
|
|
322
|
+
</template>
|
|
323
|
+
</v-row>
|
|
324
|
+
</v-toolbar>
|
|
325
|
+
</v-card>
|
|
326
|
+
</template>
|
|
327
|
+
<script setup lang="ts">
|
|
328
|
+
import SignaturePad from "./SignaturePad.vue";
|
|
329
|
+
import useOnlineForm from "../composables/useOnlineForm";
|
|
330
|
+
import useLocalAuth from "../composables/useLocalAuth";
|
|
331
|
+
import type { FormDefinition, FormFieldDef } from "../composables/useOnlineFormPages";
|
|
332
|
+
|
|
333
|
+
const props = defineProps({
|
|
334
|
+
definition: {
|
|
335
|
+
type: Object as PropType<FormDefinition>,
|
|
336
|
+
required: true,
|
|
337
|
+
},
|
|
338
|
+
siteId: { type: String, required: true },
|
|
339
|
+
orgId: { type: String, required: true },
|
|
340
|
+
siteName: { type: String, default: "" },
|
|
341
|
+
readonly: { type: Boolean, default: false },
|
|
342
|
+
/** Optional custom submit handler. Receives the collected form values.
|
|
343
|
+
* If omitted the component submits to /api/online-forms automatically. */
|
|
344
|
+
submitFn: {
|
|
345
|
+
type: Function as PropType<(values: Record<string, any>) => Promise<void>>,
|
|
346
|
+
default: null,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const emit = defineEmits(["cancel", "success"]);
|
|
351
|
+
|
|
352
|
+
function resolveText(text: string): string {
|
|
353
|
+
return text.replace(/\{siteName\}/g, props.siteName || "the Management");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const { add } = useOnlineForm();
|
|
357
|
+
const { currentUser } = useLocalAuth();
|
|
358
|
+
|
|
359
|
+
const validForm = ref(false);
|
|
360
|
+
const submitting = ref(false);
|
|
361
|
+
const message = ref("");
|
|
362
|
+
const values = ref<Record<string, any>>({});
|
|
363
|
+
|
|
364
|
+
const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
|
|
365
|
+
|
|
366
|
+
// Today's date formatted DD MMM YYYY
|
|
367
|
+
const todayDate = computed(() => {
|
|
368
|
+
const d = new Date();
|
|
369
|
+
return d.toLocaleDateString("en-SG", { day: "2-digit", month: "short", year: "numeric" });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Collect all field keys from all blocks and initialise values
|
|
373
|
+
function initValues(def: FormDefinition) {
|
|
374
|
+
const init: Record<string, any> = {};
|
|
375
|
+
for (const block of def.blocks) {
|
|
376
|
+
if (block.type === "row") {
|
|
377
|
+
for (const f of block.fields) init[f.key] = f.fieldType === "number" ? null : "";
|
|
378
|
+
} else if (block.type === "field") {
|
|
379
|
+
const f = block.field;
|
|
380
|
+
init[f.key] = f.fieldType === "number" ? null : "";
|
|
381
|
+
} else if (block.type === "checkbox-group") {
|
|
382
|
+
for (const item of block.items) {
|
|
383
|
+
init[item.key] = false;
|
|
384
|
+
if (item.elaborateKey) init[item.elaborateKey] = "";
|
|
385
|
+
}
|
|
386
|
+
} else if (block.type === "signature-row") {
|
|
387
|
+
init[block.key] = ""; // stores base64 PNG string
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return init;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
values.value = initValues(props.definition);
|
|
394
|
+
|
|
395
|
+
const NAME_LABELS = new Set([
|
|
396
|
+
"name",
|
|
397
|
+
"owner name",
|
|
398
|
+
"unit owner name",
|
|
399
|
+
"unit owner / tenant name",
|
|
400
|
+
"owner / tenant name",
|
|
401
|
+
"resident name",
|
|
402
|
+
"marketing agent name",
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
function prePopulate(u: any) {
|
|
406
|
+
if (!u) return;
|
|
407
|
+
const allFields: FormFieldDef[] = [];
|
|
408
|
+
for (const block of props.definition.blocks) {
|
|
409
|
+
if (block.type === "row") allFields.push(...block.fields);
|
|
410
|
+
else if (block.type === "field") allFields.push(block.field);
|
|
411
|
+
}
|
|
412
|
+
for (const f of allFields) {
|
|
413
|
+
const label = f.label.toLowerCase();
|
|
414
|
+
if (NAME_LABELS.has(label)) {
|
|
415
|
+
values.value[f.key] = u.name || "";
|
|
416
|
+
} else if (!values.value[f.key] && (label.includes("contact") || label === "hp" || label === "mobile")) {
|
|
417
|
+
values.value[f.key] = u.contact || u.primaryPhone || u.mobilePhone || "";
|
|
418
|
+
} else if (label === "email" || label === "email address") {
|
|
419
|
+
values.value[f.key] = u.email || "";
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
watch(currentUser, (u) => { if (u) prePopulate(u); }, { immediate: true });
|
|
425
|
+
|
|
426
|
+
async function submit() {
|
|
427
|
+
submitting.value = true;
|
|
428
|
+
message.value = "";
|
|
429
|
+
try {
|
|
430
|
+
const payload = { ...values.value };
|
|
431
|
+
|
|
432
|
+
if (props.submitFn) {
|
|
433
|
+
await props.submitFn(payload);
|
|
434
|
+
} else {
|
|
435
|
+
const unitEntry = Object.entries(payload).find(
|
|
436
|
+
([k]) => k.toLowerCase().includes("unitno")
|
|
437
|
+
);
|
|
438
|
+
const unitNumber = unitEntry ? String(unitEntry[1] || "") : "";
|
|
439
|
+
await add({
|
|
440
|
+
typeOfForm: props.definition.title,
|
|
441
|
+
unitNumber,
|
|
442
|
+
fields: payload,
|
|
443
|
+
status: "pending",
|
|
444
|
+
site: props.siteId,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
emit("success");
|
|
449
|
+
} catch (error: any) {
|
|
450
|
+
message.value =
|
|
451
|
+
error?.response?._data?.message || "Failed to submit form. Please try again.";
|
|
452
|
+
} finally {
|
|
453
|
+
submitting.value = false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
</script>
|