@7365admin1/layer-common 3.0.6 → 3.0.7

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.
@@ -14,6 +14,21 @@
14
14
  </v-row>
15
15
  </v-toolbar>
16
16
 
17
+ <!-- Submission metadata bar (readonly only) -->
18
+ <div
19
+ v-if="readonly && (submissionStatus)"
20
+ class="px-6 py-2 d-flex align-center ga-4"
21
+ style="background: #f5f5f5; border-bottom: 1px solid #e0e0e0; flex-wrap: wrap"
22
+ >
23
+ <v-chip
24
+ v-if="submissionStatus"
25
+ :color="submissionStatusColor"
26
+ variant="flat"
27
+ size="x-small"
28
+ class="text-capitalize"
29
+ >{{ submissionStatus }}</v-chip>
30
+ </div>
31
+
17
32
  <!-- Scrollable body -->
18
33
  <v-card-text
19
34
  style="max-height: 76vh; overflow-y: auto"
@@ -31,12 +46,12 @@
31
46
  <v-row no-gutters>
32
47
  <v-col cols="5">
33
48
  <span class="text-body-2">
34
- <strong>Date&nbsp;:&nbsp;</strong>{{ todayDate }}
49
+ <strong>Date&nbsp;:&nbsp;</strong>{{ (initialValues as any).formDate || submissionDate || todayDate }}
35
50
  </span>
36
51
  </v-col>
37
52
  <v-col cols="7">
38
53
  <span class="text-body-2">
39
- <strong>To&nbsp;&nbsp;&nbsp;:&nbsp;</strong>{{ resolveText(block.to) }}
54
+ <strong>To&nbsp;&nbsp;&nbsp;:&nbsp;</strong>{{ (initialValues as any).formTo || resolveText(block.to) }}
40
55
  </span>
41
56
  </v-col>
42
57
  </v-row>
@@ -220,12 +235,28 @@
220
235
  </div>
221
236
  <div
222
237
  v-else-if="block.type === 'signature-row' && readonly"
223
- class="mt-4 mb-2"
238
+ class="mt-3 mb-5"
224
239
  >
240
+ <span class="text-caption text-medium-emphasis d-block mb-1">{{ block.label }}</span>
241
+ <div
242
+ v-if="initialValues[block.key]"
243
+ style="
244
+ display: inline-block;
245
+ border-radius: 8px;
246
+ background: #111111;
247
+ padding: 8px;
248
+ max-width: 100%;
249
+ "
250
+ >
251
+ <img
252
+ :src="initialValues[block.key]"
253
+ style="display: block; max-width: 100%; max-height: 130px; object-fit: contain;"
254
+ />
255
+ </div>
225
256
  <div
257
+ v-else
226
258
  style="border-bottom: 1px dashed #bbb; height: 56px; background: #fafafa; border-radius: 4px"
227
259
  />
228
- <span class="text-caption text-medium-emphasis">{{ block.label }}</span>
229
260
  </div>
230
261
 
231
262
  <!-- ACKNOWLEDGEMENT -->
@@ -237,27 +268,38 @@
237
268
  <span class="text-body-2 font-weight-medium">{{ resolveText(block.text) }}</span>
238
269
  </div>
239
270
 
240
- <!-- MANAGEMENT USE (read-only, shown at bottom) -->
271
+ <!-- MANAGEMENT USE -->
241
272
  <div
242
273
  v-else-if="block.type === 'management-use'"
243
274
  class="pa-4 mt-2"
244
- style="background: #f0f0f0; border-radius: 6px; opacity: 0.75"
275
+ :style="approvalMode && selectedAction === 'approved'
276
+ ? 'background: #e8f5e9; border-radius: 6px; border: 1px solid #a5d6a7'
277
+ : 'background: #f0f0f0; border-radius: 6px; opacity: 0.75'"
245
278
  >
246
279
  <v-row no-gutters class="mb-2">
247
280
  <span class="text-caption font-weight-bold text-medium-emphasis">
248
281
  FOR MANAGEMENT USE ONLY
249
282
  </span>
250
283
  </v-row>
251
- <v-row no-gutters style="gap: 16px">
284
+ <v-row no-gutters style="gap: 16px; flex-wrap: wrap">
252
285
  <div
253
286
  v-for="(item, idx) in block.items"
254
287
  :key="idx"
255
- style="min-width: 160px"
288
+ style="min-width: 160px; flex: 1"
256
289
  >
257
290
  <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"
291
+ <v-text-field
292
+ v-if="approvalMode && selectedAction === 'approved'"
293
+ v-model="managementValues[item]"
294
+ density="compact"
295
+ variant="underlined"
296
+ :error="!(managementValues[item] ?? '').trim()"
297
+ :error-messages="!(managementValues[item] ?? '').trim() ? 'Required' : ''"
260
298
  />
299
+ <div
300
+ v-else
301
+ style="border-bottom: 1px solid #aaa; min-width: 120px; height: 22px; font-size: 13px; padding-top: 2px"
302
+ >{{ managementValues[item] || '' }}</div>
261
303
  </div>
262
304
  </v-row>
263
305
  </div>
@@ -271,53 +313,70 @@
271
313
  </v-form>
272
314
  </v-card-text>
273
315
 
274
- <!-- Action bar -->
275
- <v-toolbar density="compact">
316
+ <!-- Approval action panel -->
317
+ <div
318
+ v-if="readonly && approvalMode"
319
+ style="border-top: 1px solid #e0e0e0; padding: 12px 16px; background: #fafafa; display: flex; flex-direction: column; gap: 10px"
320
+ >
321
+ <v-text-field
322
+ v-model="adminRemarks"
323
+ label="Remarks"
324
+ density="compact"
325
+ variant="outlined"
326
+ :hide-details="adminRemarks.trim().length > 0"
327
+ :error="remarksRequired && adminRemarks.trim().length === 0"
328
+ :error-messages="remarksRequired && adminRemarks.trim().length === 0 ? 'Remarks are required' : ''"
329
+ />
330
+ <div class="d-flex align-center ga-2 justify-end">
331
+ <v-btn variant="text" class="text-none" size="small" @click="emit('cancel')">Close</v-btn>
332
+ <v-btn
333
+ :variant="selectedAction === 'resubmission' ? 'flat' : 'tonal'"
334
+ color="orange-darken-1"
335
+ class="text-none"
336
+ size="small"
337
+ @click="selectAction('resubmission')"
338
+ >Resubmit</v-btn>
339
+ <v-btn
340
+ :variant="selectedAction === 'rejected' ? 'flat' : 'tonal'"
341
+ color="red"
342
+ class="text-none"
343
+ size="small"
344
+ @click="selectAction('rejected')"
345
+ >Reject</v-btn>
346
+ <v-btn
347
+ :variant="selectedAction === 'approved' ? 'flat' : 'tonal'"
348
+ color="green"
349
+ class="text-none"
350
+ size="small"
351
+ @click="selectAction('approved')"
352
+ >Approve</v-btn>
353
+ <v-btn
354
+ v-if="selectedAction"
355
+ variant="flat"
356
+ color="grey-darken-3"
357
+ class="text-none"
358
+ size="small"
359
+ :disabled="!canConfirm"
360
+ :loading="updatingStatus"
361
+ @click="confirmStatusUpdate"
362
+ >Confirm</v-btn>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- Normal action bar -->
367
+ <v-toolbar v-else density="compact">
276
368
  <v-row no-gutters>
277
- <!-- Readonly: single Close button -->
278
369
  <template v-if="readonly">
279
370
  <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>
371
+ <v-btn tile block variant="text" class="text-none" size="48" @click="emit('cancel')">Close</v-btn>
290
372
  </v-col>
291
373
  </template>
292
- <!-- Interactive: Cancel + Submit -->
293
374
  <template v-else>
294
375
  <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>
376
+ <v-btn tile block variant="text" class="text-none" size="48" :disabled="submitting" @click="emit('cancel')">Cancel</v-btn>
306
377
  </v-col>
307
378
  <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>
379
+ <v-btn tile block variant="flat" color="black" class="text-none" size="48" :disabled="!validForm || submitting" :loading="submitting" @click="submit">Submit</v-btn>
321
380
  </v-col>
322
381
  </template>
323
382
  </v-row>
@@ -339,6 +398,12 @@ const props = defineProps({
339
398
  orgId: { type: String, required: true },
340
399
  siteName: { type: String, default: "" },
341
400
  readonly: { type: Boolean, default: false },
401
+ initialValues: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
402
+ submissionDate: { type: String, default: "" },
403
+ submissionStatus: { type: String, default: "" },
404
+ submissionUnit: { type: String, default: "" },
405
+ approvalMode: { type: Boolean, default: false },
406
+ initialManagementValues: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
342
407
  /** Optional custom submit handler. Receives the collected form values.
343
408
  * If omitted the component submits to /api/online-forms automatically. */
344
409
  submitFn: {
@@ -347,7 +412,7 @@ const props = defineProps({
347
412
  },
348
413
  });
349
414
 
350
- const emit = defineEmits(["cancel", "success"]);
415
+ const emit = defineEmits(["cancel", "success", "update-status"]);
351
416
 
352
417
  function resolveText(text: string): string {
353
418
  return text.replace(/\{siteName\}/g, props.siteName || "the Management");
@@ -361,8 +426,85 @@ const submitting = ref(false);
361
426
  const message = ref("");
362
427
  const values = ref<Record<string, any>>({});
363
428
 
429
+ const selectedAction = ref("");
430
+ const adminRemarks = ref("");
431
+ const updatingStatus = ref(false);
432
+ const managementValues = ref<Record<string, string>>({});
433
+
434
+ const managementUseItems = computed<string[]>(() => {
435
+ const result: string[] = [];
436
+ for (const block of props.definition.blocks) {
437
+ if (block.type === "management-use") result.push(...block.items);
438
+ }
439
+ return result;
440
+ });
441
+
442
+ // Load previously saved management values (saved keys have colons stripped; remap to match item labels)
443
+ watch(() => props.initialManagementValues, (saved) => {
444
+ if (!saved || !Object.keys(saved).length) return;
445
+ const loaded: Record<string, string> = {};
446
+ for (const item of managementUseItems.value) {
447
+ const stripped = item.replace(/:+\s*$/, "").trim();
448
+ const value = saved[stripped] ?? saved[item] ?? "";
449
+ if (value) loaded[item] = value;
450
+ }
451
+ managementValues.value = { ...loaded, ...managementValues.value };
452
+ }, { immediate: true });
453
+
454
+ const remarksRequired = computed(() => !!selectedAction.value);
455
+
456
+ const managementComplete = computed(() => {
457
+ if (selectedAction.value !== "approved") return true;
458
+ return managementUseItems.value.every(item => (managementValues.value[item] ?? "").trim().length > 0);
459
+ });
460
+
461
+ const canConfirm = computed(() =>
462
+ !!selectedAction.value &&
463
+ adminRemarks.value.trim().length > 0 &&
464
+ managementComplete.value
465
+ );
466
+
467
+ function prePopulateManagement() {
468
+ const today = new Date().toLocaleDateString("en-SG", { day: "2-digit", month: "short", year: "numeric" });
469
+ const userName = currentUser.value?.name ?? "";
470
+ for (const item of managementUseItems.value) {
471
+ if (managementValues.value[item]) continue;
472
+ const lower = item.toLowerCase();
473
+ if (lower.includes("by:") || lower.endsWith(" by")) {
474
+ managementValues.value[item] = userName;
475
+ } else if (lower.includes("date")) {
476
+ managementValues.value[item] = today;
477
+ }
478
+ }
479
+ }
480
+
481
+ function selectAction(action: string) {
482
+ selectedAction.value = selectedAction.value === action ? "" : action;
483
+ if (selectedAction.value === "approved") prePopulateManagement();
484
+ }
485
+
486
+ function confirmStatusUpdate() {
487
+ if (!canConfirm.value) return;
488
+ updatingStatus.value = true;
489
+ emit("update-status", {
490
+ status: selectedAction.value,
491
+ remarks: adminRemarks.value,
492
+ managementValues: managementValues.value,
493
+ });
494
+ }
495
+
364
496
  const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
365
497
 
498
+ const submissionStatusColor = computed(() => {
499
+ switch (props.submissionStatus?.toLowerCase()) {
500
+ case "approved": return "green";
501
+ case "pending": return "orange";
502
+ case "resubmission": return "orange";
503
+ case "rejected": return "red";
504
+ default: return "grey";
505
+ }
506
+ });
507
+
366
508
  // Today's date formatted DD MMM YYYY
367
509
  const todayDate = computed(() => {
368
510
  const d = new Date();
@@ -390,7 +532,7 @@ function initValues(def: FormDefinition) {
390
532
  return init;
391
533
  }
392
534
 
393
- values.value = initValues(props.definition);
535
+ values.value = { ...initValues(props.definition), ...props.initialValues };
394
536
 
395
537
  const NAME_LABELS = new Set([
396
538
  "name",
@@ -421,7 +563,7 @@ function prePopulate(u: any) {
421
563
  }
422
564
  }
423
565
 
424
- watch(currentUser, (u) => { if (u) prePopulate(u); }, { immediate: true });
566
+ watch(currentUser, (u) => { if (u && !props.readonly) prePopulate(u); }, { immediate: true });
425
567
 
426
568
  async function submit() {
427
569
  submitting.value = true;
@@ -18,7 +18,8 @@
18
18
  {{ value ? `Blk ${value}` : "" }}
19
19
  </template>
20
20
  <template #item.nric="{ value }">
21
- {{ maskNRIC(value) }}
21
+ <span v-if="value" v-tooltip:top="value">{{ maskNRIC(value) }}</span>
22
+ <span v-else>{{ maskNRIC(value) }}</span>
22
23
  </template>
23
24
 
24
25
  <template #item.plates="{ value, item }">
@@ -59,7 +60,10 @@
59
60
  <template v-for="(label, key) in formattedFields" :key="key">
60
61
 
61
62
  <v-col v-if="key === 'nric' && selectedVehicleObject['nric']" cols="12">
62
- <span class="d-flex ga-3 align-center"><strong>{{ label }}:</strong> {{ maskNRIC(selectedVehicleObject[key]) }}</span>
63
+ <span class="d-flex ga-3 align-center">
64
+ <strong>{{ label }}:</strong>
65
+ <span v-tooltip:top="selectedVehicleObject[key]">{{ maskNRIC(selectedVehicleObject[key]) }}</span>
66
+ </span>
63
67
  </v-col>
64
68
 
65
69
  <v-col v-else-if="key === 'plates'" class="d-flex flex-column ga-2">
@@ -343,7 +343,7 @@
343
343
  <span v-else-if="
344
344
  key === 'nric' && selectedVisitorObject['nric']" class="d-flex align-center">
345
345
  <strong>{{ label }}:</strong>
346
- <span class="ml-2">{{ maskNRIC(selectedVisitorObject[key]) }}</span>
346
+ <span v-tooltip:top="selectedVisitorObject[key]" class="ml-2">{{ maskNRIC(selectedVisitorObject[key]) }}</span>
347
347
  </span>
348
348
  <span v-else-if="key === 'attachments' && selectedVisitorObject?.[key]?.length > 0"
349
349
  class="d-flex flex-column">
@@ -17,12 +17,16 @@ export default function () {
17
17
 
18
18
  function getDashboardHygieneWeeklyActivity(
19
19
  site = "",
20
- serviceType: TServiceType = "cleaning_services"
20
+ serviceType: TServiceType = "cleaning_services",
21
+ period?: TDashboardValues
21
22
  ) {
22
23
  return useNuxtApp().$api<THygieneWeeklyActivityItem[]>(
23
24
  `/api/dashboard/serviceType/${serviceType}/site/${site}/weekly-activity`,
24
25
  {
25
26
  method: "GET",
27
+ query: {
28
+ period,
29
+ },
26
30
  }
27
31
  );
28
32
  }
@@ -80,11 +84,31 @@ export default function () {
80
84
  );
81
85
  }
82
86
 
87
+ function getDashboardPropertyManagement(
88
+ site = "",
89
+ period: TDashboardValues = "today",
90
+ type = "",
91
+ facility = ""
92
+ ) {
93
+ return useNuxtApp().$api<Record<string, any>>(
94
+ `/api/dashboard/serviceType/property_management_agency/site/${site}`,
95
+ {
96
+ method: "GET",
97
+ query: {
98
+ period,
99
+ ...(type ? { type } : {}),
100
+ ...(facility ? { facility } : {}),
101
+ },
102
+ }
103
+ );
104
+ }
105
+
83
106
  return {
84
107
  getDashboardHygiene,
85
108
  getDashboardHygieneWeeklyActivity,
86
109
  getDashboardHygieneTaskSchedule,
87
110
  getDashboardHygieneAttendance,
88
111
  getDashboardHygieneFeedbacks,
112
+ getDashboardPropertyManagement,
89
113
  };
90
114
  }
@@ -46,22 +46,32 @@ export default function useOnlineForm() {
46
46
  query: { id },
47
47
  });
48
48
  }
49
+ function getFormEntryById(id: string) {
50
+ return useNuxtApp().$api<Record<string, any>>(`/api/form-entries/id/${id}`, {
51
+ method: "GET",
52
+ });
53
+ }
54
+ function updateFormEntryById(id: string, value: any) {
55
+ return useNuxtApp().$api<Record<string, any>>(`/api/form-entries/id/${id}`, {
56
+ method: "PUT",
57
+ body: value,
58
+ });
59
+ }
49
60
  function getAllBySiteId(
50
61
  siteId: string,
51
- { page = 1, search = "", limit = 10, status = "active" }
62
+ { page = 1, search = "", limit = 10, status = "", org = "" } = {}
52
63
  ) {
53
- return useNuxtApp().$api<Record<string, any>>(
54
- `/api/online-forms/site/${siteId}`,
55
- {
56
- method: "GET",
57
- query: {
58
- page,
59
- search,
60
- limit,
61
- status,
62
- },
63
- }
64
- );
64
+ return useNuxtApp().$api<Record<string, any>>(`/api/form-entries`, {
65
+ method: "GET",
66
+ query: {
67
+ page,
68
+ search,
69
+ limit,
70
+ status,
71
+ org,
72
+ site: siteId,
73
+ },
74
+ });
65
75
  }
66
76
  return {
67
77
  getAll,
@@ -70,5 +80,7 @@ export default function useOnlineForm() {
70
80
  updateOnlineFormById,
71
81
  deleteOnlineFormById,
72
82
  getAllBySiteId,
83
+ getFormEntryById,
84
+ updateFormEntryById,
73
85
  };
74
86
  }
@@ -17,7 +17,7 @@ export type FormBlock =
17
17
  | { type: "address"; to: string }
18
18
  | { type: "row"; fields: FormFieldDef[] }
19
19
  | { type: "field"; field: FormFieldDef }
20
- | { type: "checkbox-group"; title?: string; items: CheckboxItemDef[] }
20
+ | { type: "checkbox-group"; title?: string; single?: boolean; items: CheckboxItemDef[] }
21
21
  | { type: "paragraph"; text: string }
22
22
  | { type: "section-header"; text: string }
23
23
  | { type: "divider" }
@@ -506,7 +506,7 @@ const formPages: Record<string, FormDefinition> = {
506
506
  },
507
507
 
508
508
  "Application for Booking of Rooftop Bar/Dining": {
509
- title: "APPLICATION FOR BOOKING OF ROOFTOP BAR/DINNING",
509
+ title: "APPLICATION FOR BOOKING OF ROOFTOP BAR/DINING",
510
510
  blocks: [
511
511
  { type: "address", to: "{siteName}" },
512
512
  { type: "row", fields: [
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.0.6",
5
+ "version": "3.0.7",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -30,6 +30,7 @@ declare type TDashboardMetric = {
30
30
  outOfStock?: number;
31
31
  completed?: number;
32
32
  total?: number;
33
+ items?: { name: string; qty: number }[];
33
34
  };
34
35
 
35
36
  declare type THygieneDashboardData = {
@@ -74,4 +75,3 @@ declare type THygienePaginatedResponse<T> = {
74
75
  pages: number;
75
76
  pageRange: string;
76
77
  };
77
-