@7365admin1/layer-common 3.0.3 → 3.0.6

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.
@@ -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&nbsp;:&nbsp;</strong>{{ todayDate }}
35
+ </span>
36
+ </v-col>
37
+ <v-col cols="7">
38
+ <span class="text-body-2">
39
+ <strong>To&nbsp;&nbsp;&nbsp;:&nbsp;</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>