@7365admin1/layer-common 1.10.5 → 1.10.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/components/AccessCardDetailsDialog.vue +144 -0
  3. package/components/AccessCardPreviewDialog.vue +7 -2
  4. package/components/AccessCardQrTagging.vue +314 -34
  5. package/components/AccessCardQrTaggingPrintQr.vue +75 -0
  6. package/components/AccessManagement.vue +5 -1
  7. package/components/AreaChecklistHistoryLogs.vue +9 -0
  8. package/components/BuildingForm.vue +36 -5
  9. package/components/BuildingManagement/buildings.vue +18 -9
  10. package/components/BuildingManagement/units.vue +12 -114
  11. package/components/BuildingUnitFormAdd.vue +39 -30
  12. package/components/BuildingUnitFormEdit.vue +265 -116
  13. package/components/CleaningScheduleMain.vue +60 -13
  14. package/components/Dialog/DeleteConfirmation.vue +2 -2
  15. package/components/Dialog/UpdateMoreAction.vue +2 -2
  16. package/components/EntryPassInformation.vue +215 -0
  17. package/components/Input/InputPhoneNumberV2.vue +11 -0
  18. package/components/ManageChecklistMain.vue +29 -3
  19. package/components/PlateNumberDisplay.vue +9 -1
  20. package/components/ScheduleAreaMain.vue +56 -0
  21. package/components/TableHygiene.vue +265 -113
  22. package/components/UnitPersonCard.vue +63 -0
  23. package/components/VehicleAddSelection.vue +2 -2
  24. package/components/VehicleForm.vue +169 -47
  25. package/components/VehicleManagement.vue +169 -43
  26. package/components/VisitorForm.vue +19 -0
  27. package/components/VisitorManagement.vue +1 -1
  28. package/components/WorkOrder/Main.vue +2 -1
  29. package/composables/useAccessManagement.ts +63 -0
  30. package/composables/useFeedback.ts +2 -2
  31. package/composables/usePeople.ts +14 -3
  32. package/composables/useVehicle.ts +15 -1
  33. package/composables/useWorkOrder.ts +2 -2
  34. package/package.json +3 -2
  35. package/types/cleaner-schedule.d.ts +1 -0
  36. package/types/html2pdf.d.ts +19 -0
  37. package/types/people.d.ts +4 -0
  38. package/types/site.d.ts +8 -0
  39. package/types/vehicle.d.ts +3 -4
  40. package/.playground/app.vue +0 -41
  41. package/.playground/eslint.config.mjs +0 -6
  42. package/.playground/nuxt.config.ts +0 -22
  43. package/.playground/pages/feedback.vue +0 -30
  44. package/components/AccessCardHistoryDialog.vue +0 -133
@@ -1,6 +1,5 @@
1
1
  <template>
2
2
  <v-row no-gutters>
3
- <!-- Top Actions -->
4
3
  <v-col cols="12" class="mb-2" v-if="canCreate || $slots.actions">
5
4
  <v-row no-gutters>
6
5
  <slot name="actions">
@@ -18,7 +17,6 @@
18
17
  </v-row>
19
18
  </v-col>
20
19
 
21
- <!-- List Card -->
22
20
  <v-col cols="12">
23
21
  <v-card
24
22
  width="100%"
@@ -27,7 +25,6 @@
27
25
  rounded="lg"
28
26
  :loading="loading"
29
27
  >
30
- <!-- Toolbar -->
31
28
  <v-toolbar
32
29
  density="compact"
33
30
  color="grey-lighten-4"
@@ -40,7 +37,10 @@
40
37
  <slot name="prepend-additional" />
41
38
  </template>
42
39
 
43
- <v-toolbar-title v-if="title" class="text-subtitle-1 font-weight-medium">
40
+ <v-toolbar-title
41
+ v-if="title"
42
+ class="text-subtitle-1 font-weight-medium"
43
+ >
44
44
  {{ title }}
45
45
  </v-toolbar-title>
46
46
 
@@ -64,126 +64,236 @@
64
64
 
65
65
  <v-divider />
66
66
 
67
- <!-- List Items -->
68
- <v-list
69
- :style="`max-height: calc(100vh - (${offset}px)); overflow-y: auto;`"
67
+ <v-sheet
68
+ :max-height="`calc(100vh - (${offset}px))`"
69
+ class="overflow-y-auto"
70
70
  >
71
- <v-list-item
71
+ <v-row
72
72
  v-if="groupedItems.length === 0"
73
- class="py-6 text-center text-medium-emphasis"
73
+ no-gutters
74
+ justify="center"
75
+ class="py-10"
74
76
  >
75
- {{ noDataText }}
76
- </v-list-item>
77
+ <v-col
78
+ cols="auto"
79
+ class="text-center text-medium-emphasis text-body-2"
80
+ >
81
+ {{ noDataText }}
82
+ </v-col>
83
+ </v-row>
77
84
 
78
85
  <template
79
86
  v-for="(group, groupIndex) in groupedItems"
80
87
  :key="`group-${groupIndex}`"
81
88
  >
82
- <div
83
- v-if="group.set !== undefined"
84
- class="d-flex align-center justify-space-between px-4 py-1 text-caption text-medium-emphasis"
85
- style="min-height: 32px"
86
- >
87
- <span>Set {{ group.set }}</span>
88
- <div class="d-flex align-center ga-2">
89
- <span v-if="group.completedByName">
90
- Completed by: {{ group.completedByName }}
91
- </span>
92
- <v-btn
93
- v-if="group.attachments && group.attachments.length > 0"
94
- size="x-small"
95
- variant="tonal"
96
- color="primary"
97
- class="text-none"
98
- prepend-icon="mdi-paperclip"
99
- @click.stop="
100
- openAttachmentDialog(group.set, group.attachments)
101
- "
102
- >
103
- View Attachment ({{ group.attachments.length }})
104
- </v-btn>
105
- </div>
106
- </div>
107
-
108
- <v-list-item
89
+ <v-sheet color="grey-lighten-4" border="b t">
90
+ <v-row no-gutters align="center" class="px-4 py-2">
91
+ <v-col cols="auto" class="d-flex align-center ga-2">
92
+ <span
93
+ class="text-caption font-weight-bold text-medium-emphasis text-uppercase"
94
+ >
95
+ Set {{ group.set }}
96
+ </span>
97
+ <v-chip
98
+ v-if="group.isScheduleTask"
99
+ size="x-small"
100
+ color="info"
101
+ variant="tonal"
102
+ prepend-icon="mdi-calendar-clock"
103
+ class="text-none"
104
+ >
105
+ Schedule Task
106
+ </v-chip>
107
+ <v-chip
108
+ v-if="group.completedByName && isGroupComplete(group)"
109
+ size="x-small"
110
+ color="success"
111
+ variant="tonal"
112
+ prepend-icon="mdi-check-circle-outline"
113
+ class="text-none"
114
+ >
115
+ Completed · {{ group.completedByName }}
116
+ </v-chip>
117
+ <v-chip
118
+ v-else-if="
119
+ group.completedByName && isGroupInProgress(group)
120
+ "
121
+ size="x-small"
122
+ color="warning"
123
+ variant="tonal"
124
+ prepend-icon="mdi-progress-clock"
125
+ class="text-none"
126
+ >
127
+ Ongoing · {{ group.completedByName }}
128
+ </v-chip>
129
+ </v-col>
130
+ <v-spacer />
131
+ <v-col cols="auto">
132
+ <v-btn
133
+ v-if="group.attachments && group.attachments.length > 0"
134
+ size="x-small"
135
+ variant="tonal"
136
+ color="primary"
137
+ class="text-none"
138
+ prepend-icon="mdi-paperclip"
139
+ @click.stop="
140
+ openAttachmentDialog(group.set, group.attachments)
141
+ "
142
+ >
143
+ {{ group.attachments.length }} attachment{{
144
+ group.attachments.length > 1 ? "s" : ""
145
+ }}
146
+ </v-btn>
147
+ </v-col>
148
+ </v-row>
149
+ </v-sheet>
150
+
151
+ <v-sheet
109
152
  v-for="item in group.items"
110
153
  :key="item[itemValue]"
111
- class="py-3"
112
- :class="
113
- isItemSelected(item, group.set)
114
- ? ['bg-grey-lighten-4', 'rounded']
115
- : []
154
+ :color="
155
+ isItemSelected(item, group.set) ? 'grey-lighten-4' : 'white'
116
156
  "
157
+ border="b"
117
158
  >
118
- <slot name="list-item" :item="item">
119
- <v-list-item-title v-if="headers[0]">
120
- {{ getItemValue(item, headers[0].value) }}
121
- </v-list-item-title>
122
-
123
- <v-list-item-subtitle
124
- v-if="headers[1]"
125
- class="mb-1 text-high-emphasis opacity-100"
126
- >
127
- {{ getItemValue(item, headers[1].value) || "N/A" }}
128
- </v-list-item-subtitle>
129
-
130
- <v-list-item-subtitle
131
- v-if="headers[2]"
132
- class="text-high-emphasis"
133
- >
134
- {{ getItemValue(item, headers[2].value) || "N/A" }}
135
- </v-list-item-subtitle>
136
- </slot>
137
-
138
- <template v-slot:append>
139
- <slot
140
- name="list-item-append"
141
- :item="item"
142
- :isSelected="isItemSelected(item, group.set)"
143
- >
144
- <v-list-item-action class="d-flex flex-row ga-2">
145
- <template v-if="canManageScheduleTasks">
146
- <v-btn
147
- icon="mdi-close"
148
- size="small"
149
- :variant="
150
- activeActions[getKey(item, group.set)] === 'reject'
151
- ? 'flat'
152
- : 'text'
153
- "
154
- color="error"
155
- @click.stop="
156
- handleActionClick(item, group.set, 'reject')
157
- "
158
- />
159
-
160
- <v-btn
161
- icon="mdi-check"
162
- size="small"
163
- :variant="
164
- activeActions[getKey(item, group.set)] === 'approve'
165
- ? 'flat'
166
- : 'text'
167
- "
168
- color="success"
169
- @click.stop="
170
- handleActionClick(item, group.set, 'approve')
159
+ <v-row no-gutters align="center" class="px-4 py-2">
160
+ <v-col cols="auto" class="mr-3">
161
+ <v-icon
162
+ size="20"
163
+ :color="
164
+ activeActions[getKey(item, group.set)] === 'approve'
165
+ ? 'success'
166
+ : activeActions[getKey(item, group.set)] === 'reject'
167
+ ? 'error'
168
+ : 'grey-lighten-2'
169
+ "
170
+ >
171
+ {{
172
+ activeActions[getKey(item, group.set)] === "approve"
173
+ ? "mdi-check-circle"
174
+ : activeActions[getKey(item, group.set)] === "reject"
175
+ ? "mdi-close-circle"
176
+ : "mdi-circle-outline"
177
+ }}
178
+ </v-icon>
179
+ </v-col>
180
+ <v-col>
181
+ <slot name="list-item" :item="item">
182
+ <v-row no-gutters align-center>
183
+ <v-col cols="12">
184
+ <span
185
+ class="text-body-2 font-weight-medium"
186
+ :class="
187
+ activeActions[getKey(item, group.set)] === 'approve'
188
+ ? 'text-decoration-line-through text-medium-emphasis'
189
+ : ''
190
+ "
191
+ >
192
+ {{ getItemValue(item, headers[0].value) }}
193
+ </span>
194
+ </v-col>
195
+ <v-col
196
+ v-if="
197
+ item.timestamp ||
198
+ (headers[1] &&
199
+ getItemValue(item, headers[1].value)) ||
200
+ (headers[2] && getItemValue(item, headers[2].value))
171
201
  "
172
- />
173
- </template>
174
- </v-list-item-action>
175
- </slot>
176
- </template>
177
- </v-list-item>
202
+ cols="12"
203
+ >
204
+ <v-row
205
+ no-gutters
206
+ align="center"
207
+ class="ga-3 flex-wrap mt-1"
208
+ >
209
+ <v-col
210
+ v-if="item.timestamp"
211
+ cols="auto"
212
+ class="d-flex align-center ga-1 text-caption text-medium-emphasis pa-0"
213
+ >
214
+ <v-icon size="11">mdi-clock-outline</v-icon>
215
+ {{ formatTimestamp(item.timestamp) }}
216
+ </v-col>
217
+ <v-col
218
+ v-if="
219
+ headers[1] && getItemValue(item, headers[1].value)
220
+ "
221
+ cols="auto"
222
+ class="text-caption text-medium-emphasis pa-0"
223
+ >
224
+ {{ getItemValue(item, headers[1].value) }}
225
+ </v-col>
226
+ <v-col
227
+ v-if="
228
+ headers[2] && getItemValue(item, headers[2].value)
229
+ "
230
+ cols="auto"
231
+ class="text-caption text-medium-emphasis pa-0"
232
+ >
233
+ {{ getItemValue(item, headers[2].value) }}
234
+ </v-col>
235
+ </v-row>
236
+ </v-col>
237
+ </v-row>
238
+ </slot>
239
+ </v-col>
240
+
241
+ <v-col cols="auto">
242
+ <slot
243
+ name="list-item-append"
244
+ :item="item"
245
+ :isSelected="isItemSelected(item, group.set)"
246
+ >
247
+ <v-row
248
+ v-if="canManageScheduleTasks"
249
+ no-gutters
250
+ align="center"
251
+ >
252
+ <v-col cols="auto">
253
+ <v-btn
254
+ icon="mdi-close"
255
+ size="small"
256
+ :variant="
257
+ activeActions[getKey(item, group.set)] === 'reject'
258
+ ? 'flat'
259
+ : 'text'
260
+ "
261
+ color="error"
262
+ @click.stop="
263
+ handleActionClick(item, group.set, 'reject')
264
+ "
265
+ />
266
+ </v-col>
267
+ <v-col cols="auto">
268
+ <v-btn
269
+ icon="mdi-check"
270
+ size="small"
271
+ :variant="
272
+ activeActions[getKey(item, group.set)] === 'approve'
273
+ ? 'flat'
274
+ : 'text'
275
+ "
276
+ color="success"
277
+ :loading="!!loadingActions[getKey(item, group.set)]"
278
+ :disabled="!!loadingActions[getKey(item, group.set)]"
279
+ @click.stop="
280
+ handleActionClick(item, group.set, 'approve')
281
+ "
282
+ />
283
+ </v-col>
284
+ </v-row>
285
+ </slot>
286
+ </v-col>
287
+ </v-row>
288
+ </v-sheet>
178
289
  </template>
179
- </v-list>
290
+ </v-sheet>
180
291
 
181
292
  <slot name="footer" />
182
293
  </v-card>
183
294
  </v-col>
184
295
  </v-row>
185
296
 
186
- <!-- Attachment Preview Dialog -->
187
297
  <v-dialog v-model="showAttachmentDialog" max-width="700" scrollable>
188
298
  <v-card>
189
299
  <v-card-title class="d-flex align-center pa-4">
@@ -248,7 +358,6 @@
248
358
  </v-card>
249
359
  </v-dialog>
250
360
 
251
- <!-- Full Image Lightbox -->
252
361
  <v-dialog v-model="showLightbox" max-width="900">
253
362
  <v-card>
254
363
  <v-card-actions class="pa-2 justify-end">
@@ -351,12 +460,14 @@ const emits = defineEmits([
351
460
 
352
461
  defineExpose({
353
462
  revertSetApprovals,
463
+ stopLoadingAction,
354
464
  });
355
465
 
356
466
  const internalPage = ref(props.page);
357
467
  const selected = shallowRef<any[]>(props.selected);
358
468
  const activeActions = reactive<Record<string, "approve" | "reject">>({});
359
469
  const persistedActions = reactive<Record<string, "approve" | "reject">>({});
470
+ const loadingActions = reactive<Record<string, boolean>>({});
360
471
  const completedSets = ref<Set<number>>(new Set());
361
472
  const itemOrderMap = new Map<string, number>();
362
473
 
@@ -400,6 +511,7 @@ const groupedItems = computed(() => {
400
511
 
401
512
  return {
402
513
  set: item.set,
514
+ isScheduleTask: item.isScheduleTask ?? false,
403
515
  completedByName: item.completedByName ?? null,
404
516
  attachments:
405
517
  (item.attachment as string[] | undefined) ??
@@ -431,6 +543,34 @@ const allItemsApproved = computed(() => {
431
543
  );
432
544
  });
433
545
 
546
+ function formatTimestamp(ts: string): string {
547
+ if (!ts) return "";
548
+ const date = new Date(ts);
549
+ return date.toLocaleString("en-SG", {
550
+ day: "2-digit",
551
+ month: "short",
552
+ year: "numeric",
553
+ hour: "2-digit",
554
+ minute: "2-digit",
555
+ hour12: true,
556
+ });
557
+ }
558
+
559
+ function isGroupComplete(group: { items: any[] }): boolean {
560
+ return (
561
+ group.items.length > 0 &&
562
+ group.items.every((item: any) => item.approve === true)
563
+ );
564
+ }
565
+
566
+ function isGroupInProgress(group: { items: any[] }): boolean {
567
+ return (
568
+ group.items.some(
569
+ (item: any) => item.approve === true || item.reject === true
570
+ ) && !isGroupComplete(group)
571
+ );
572
+ }
573
+
434
574
  function isSetFullyApproved(setNumber: number): boolean {
435
575
  const group = groupedItems.value.find((g) => g.set === setNumber);
436
576
  if (!group) return false;
@@ -442,7 +582,7 @@ function isSetFullyApproved(setNumber: number): boolean {
442
582
  }
443
583
 
444
584
  function getNewApprovedItemsForSet(
445
- setNumber: number,
585
+ setNumber: number
446
586
  ): Array<{ key: string; item: any; action: "approve" }> {
447
587
  const group = groupedItems.value.find((g) => g.set === setNumber);
448
588
  if (!group) return [];
@@ -472,14 +612,14 @@ watch(
472
612
  internalPage.value = val;
473
613
 
474
614
  itemOrderMap.clear();
475
- },
615
+ }
476
616
  );
477
617
 
478
618
  watch(
479
619
  () => props.selected,
480
620
  (val) => {
481
621
  selected.value = val;
482
- },
622
+ }
483
623
  );
484
624
 
485
625
  watch(selected, (val) => {
@@ -492,7 +632,7 @@ watch(
492
632
  if (!items || !Array.isArray(items)) return;
493
633
 
494
634
  Object.keys(persistedActions).forEach(
495
- (key) => delete persistedActions[key],
635
+ (key) => delete persistedActions[key]
496
636
  );
497
637
 
498
638
  items.forEach((group: any) => {
@@ -511,7 +651,7 @@ watch(
511
651
  });
512
652
  });
513
653
  },
514
- { immediate: true },
654
+ { immediate: true }
515
655
  );
516
656
 
517
657
  function getKey(item: any, set?: number): string {
@@ -530,7 +670,7 @@ function isItemSelected(item: any, set?: number): boolean {
530
670
 
531
671
  if (typeof selected.value[0] === "object" && "unit" in selected.value[0]) {
532
672
  return selected.value.some(
533
- (s: any) => s.unit === item[props.itemValue] && s.set === set,
673
+ (s: any) => s.unit === item[props.itemValue] && s.set === set
534
674
  );
535
675
  }
536
676
 
@@ -540,7 +680,7 @@ function isItemSelected(item: any, set?: number): boolean {
540
680
  function handleActionClick(
541
681
  item: any,
542
682
  set: number | undefined,
543
- action: "approve" | "reject",
683
+ action: "approve" | "reject"
544
684
  ): void {
545
685
  const key = getKey(item, set);
546
686
 
@@ -584,6 +724,13 @@ function handleActionClick(
584
724
  return;
585
725
  }
586
726
  }
727
+
728
+ // Non-completing approve: undo optimistic check and show loading instead
729
+ if (!isPersisted) {
730
+ delete activeActions[key];
731
+ loadingActions[key] = true;
732
+ }
733
+
587
734
  console.debug("TableHygiene: emitting action-click (approve immediate)", {
588
735
  item: { ...item, set },
589
736
  action,
@@ -592,6 +739,10 @@ function handleActionClick(
592
739
  }
593
740
  }
594
741
 
742
+ function stopLoadingAction(key: string): void {
743
+ delete loadingActions[key];
744
+ }
745
+
595
746
  function revertSetApprovals(setNumber: number): void {
596
747
  const group = groupedItems.value.find((g) => g.set === setNumber);
597
748
  if (group) {
@@ -611,7 +762,8 @@ watch(
611
762
  () => props.items,
612
763
  () => {
613
764
  completedSets.value.clear();
765
+ Object.keys(loadingActions).forEach((k) => delete loadingActions[k]);
614
766
  },
615
- { deep: true },
767
+ { deep: true }
616
768
  );
617
769
  </script>
@@ -0,0 +1,63 @@
1
+ <template>
2
+ <v-card-text>
3
+ <v-row>
4
+ <v-col cols="12" md="4" class="mt-2">
5
+ <InputLabel class="font-weight-bold" title="Name" />
6
+ <v-text-field :model-value="person.name" density="compact" :readonly="readOnly" :class="{'no-pointer': readOnly}"/>
7
+ </v-col>
8
+
9
+ <v-col cols="12" md="4" class="mt-2">
10
+ <InputLabel class="font-weight-bold" title="Email Address" />
11
+ <v-text-field :model-value="person.email" density="compact" :readonly="readOnly" :class="{'no-pointer': readOnly}"/>
12
+ </v-col>
13
+
14
+ <v-col cols="12" md="4" class="mt-2">
15
+ <InputLabel class="font-weight-bold" title="NRIC" />
16
+ <v-text-field :model-value="person.nric" density="compact" :readonly="readOnly" :class="{'no-pointer': readOnly}"/>
17
+ </v-col>
18
+
19
+ <v-col cols="12" md="4" class="mt-2">
20
+ <InputLabel class="font-weight-bold" title="Mobile Number" />
21
+ <v-text-field :model-value="person.contact" density="compact" :readonly="readOnly" :class="{'no-pointer': readOnly}"/>
22
+ </v-col>
23
+ </v-row>
24
+
25
+ <v-row>
26
+ <v-col cols="12" md="4" class="mt-2">
27
+ <InputLabel class="font-weight-bold" title="Other Documents and Files" :viewMode="readOnly" />
28
+
29
+ <p
30
+ v-if="!person.files?.length"
31
+ class="text-blue-darken-2 font-weight-thin text-caption ml-2 mt-5"
32
+ >
33
+ **No documents to display**
34
+ </p>
35
+
36
+ <InputFileV2
37
+ v-if="person.files?.length"
38
+ :files="person.files"
39
+ :viewMode="true"
40
+ />
41
+ </v-col>
42
+ </v-row>
43
+ </v-card-text>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ defineProps({
48
+ person: {
49
+ type: Object as PropType<TPeople>,
50
+ required: true,
51
+ },
52
+ readOnly: {
53
+ type: Boolean,
54
+ default: false,
55
+ },
56
+ })
57
+ </script>
58
+
59
+ <style lang="scss" scoped>
60
+ .no-pointer {
61
+ pointer-events: none;
62
+ }
63
+ </style>
@@ -40,7 +40,7 @@ const prop = defineProps({
40
40
 
41
41
  const emit = defineEmits(['cancel', 'select']);
42
42
 
43
- const selection = computed<{label: string, value: TVehicleType}[]>(() => {
43
+ const selection = computed<{label: string, value: TVehicleType | 'seasonpass'}[]>(() => {
44
44
  return [
45
45
  { label: "Whitelist", value: "whitelist" },
46
46
  { label: "Season Pass", value: "seasonpass" },
@@ -52,7 +52,7 @@ function cancel() {
52
52
  emit("cancel");
53
53
  }
54
54
 
55
- function select(value: string) {
55
+ function select(value: TVehicleType | 'seasonpass') {
56
56
  emit("select", value);
57
57
  }
58
58
  </script>