@7365admin1/layer-common 1.10.6 → 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 (35) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/components/AccessCardQrTagging.vue +314 -34
  3. package/components/AccessCardQrTaggingPrintQr.vue +75 -0
  4. package/components/AreaChecklistHistoryLogs.vue +9 -0
  5. package/components/BuildingForm.vue +36 -5
  6. package/components/BuildingManagement/buildings.vue +18 -9
  7. package/components/BuildingManagement/units.vue +12 -114
  8. package/components/BuildingUnitFormAdd.vue +39 -30
  9. package/components/BuildingUnitFormEdit.vue +265 -116
  10. package/components/CleaningScheduleMain.vue +60 -13
  11. package/components/Dialog/DeleteConfirmation.vue +2 -2
  12. package/components/Dialog/UpdateMoreAction.vue +2 -2
  13. package/components/EntryPassInformation.vue +215 -0
  14. package/components/Input/InputPhoneNumberV2.vue +8 -0
  15. package/components/ManageChecklistMain.vue +29 -3
  16. package/components/ScheduleAreaMain.vue +56 -0
  17. package/components/TableHygiene.vue +27 -0
  18. package/components/UnitPersonCard.vue +63 -0
  19. package/components/VehicleAddSelection.vue +2 -2
  20. package/components/VehicleForm.vue +78 -19
  21. package/components/VehicleManagement.vue +164 -40
  22. package/components/VisitorForm.vue +19 -0
  23. package/composables/useAccessManagement.ts +52 -0
  24. package/composables/usePeople.ts +4 -3
  25. package/composables/useVehicle.ts +15 -1
  26. package/package.json +3 -2
  27. package/types/cleaner-schedule.d.ts +1 -0
  28. package/types/html2pdf.d.ts +19 -0
  29. package/types/people.d.ts +2 -1
  30. package/types/site.d.ts +8 -0
  31. package/types/vehicle.d.ts +2 -3
  32. package/.playground/app.vue +0 -41
  33. package/.playground/eslint.config.mjs +0 -6
  34. package/.playground/nuxt.config.ts +0 -22
  35. package/.playground/pages/feedback.vue +0 -30
@@ -0,0 +1,215 @@
1
+ <template>
2
+ <div>
3
+ <v-skeleton-loader v-if="loading" type="list-item-two-line" width="100%" />
4
+ <template v-else>
5
+ <v-divider class="mb-4" />
6
+ <InputLabel title="Pass Type (Optional)" />
7
+ <v-row no-gutters class="ga-3 mt-1">
8
+ <v-col>
9
+ <v-card
10
+ :variant="passType === 'QR' ? 'tonal' : 'outlined'"
11
+ :color="passType === 'QR' ? 'primary' : undefined"
12
+ class="pa-3 d-flex flex-column align-center ga-2 cursor-pointer position-relative"
13
+ rounded="lg"
14
+ @click="passType = passType === 'QR' ? null : 'QR'"
15
+ >
16
+ <v-icon v-if="passType === 'QR'" size="18" color="primary" class="selected-check">mdi-check-circle</v-icon>
17
+ <v-icon size="32">mdi-qrcode</v-icon>
18
+ <span class="text-subtitle-2 font-weight-bold">QR Pass</span>
19
+ </v-card>
20
+ </v-col>
21
+ <v-col v-if="nfcEnabled">
22
+ <v-card
23
+ :variant="passType === 'NFC' ? 'tonal' : 'outlined'"
24
+ :color="passType === 'NFC' ? 'primary' : undefined"
25
+ class="pa-3 d-flex flex-column align-center ga-2 cursor-pointer position-relative"
26
+ rounded="lg"
27
+ @click="passType = passType === 'NFC' ? null : 'NFC'"
28
+ >
29
+ <v-icon v-if="passType === 'NFC'" size="18" color="primary" class="selected-check">mdi-check-circle</v-icon>
30
+ <v-icon size="32">mdi-nfc</v-icon>
31
+ <span class="text-subtitle-2 font-weight-bold">NFC</span>
32
+ </v-card>
33
+ </v-col>
34
+ </v-row>
35
+
36
+ <!-- QR Pass section -->
37
+ <template v-if="passType === 'QR'">
38
+ <v-alert
39
+ v-if="!hasPrinter"
40
+ type="warning"
41
+ variant="tonal"
42
+ density="compact"
43
+ class="mt-3"
44
+ icon="mdi-printer-alert"
45
+ >
46
+ No printer is configured in the settings. QR Pass may not print.
47
+ </v-alert>
48
+ <template v-else>
49
+ <InputLabel title="Quantity" class="mt-3" />
50
+ <v-text-field
51
+ v-model.number="quantity"
52
+ type="number"
53
+ density="comfortable"
54
+ :min="1"
55
+ hide-details
56
+ />
57
+ </template>
58
+ </template>
59
+
60
+ <!-- NFC Pass section -->
61
+ <template v-if="passType === 'NFC'">
62
+ <div class="mt-3">
63
+ <InputLabel title="Select Cards" />
64
+ <v-select
65
+ v-model="selectedCards"
66
+ :items="cards"
67
+ :loading="cardsLoading"
68
+ item-title="name"
69
+ item-value="_id"
70
+ density="comfortable"
71
+ multiple
72
+ chips
73
+ closable-chips
74
+ hide-details
75
+ placeholder="Select cards..."
76
+ >
77
+ <template #item="{ props: itemProps, item }">
78
+ <v-list-item v-bind="itemProps">
79
+ <template #subtitle>
80
+ <span class="text-caption text-grey">{{ item.raw.cardNumber }}</span>
81
+ </template>
82
+ </v-list-item>
83
+ </template>
84
+ </v-select>
85
+ </div>
86
+
87
+ <div class="mt-3">
88
+ <InputLabel title="Quantity" />
89
+ <v-text-field
90
+ :model-value="selectedCards.length"
91
+ type="number"
92
+ density="comfortable"
93
+ disabled
94
+ hide-details
95
+ />
96
+ </div>
97
+
98
+ <v-row no-gutters class="ga-3 mt-3">
99
+ <v-col>
100
+ <v-btn
101
+ variant="outlined"
102
+ color="primary"
103
+ block
104
+ prepend-icon="mdi-barcode-scan"
105
+ @click="emit('scan:barcode')"
106
+ >
107
+ Scan Via BarCode
108
+ </v-btn>
109
+ </v-col>
110
+ <v-col>
111
+ <v-btn
112
+ variant="outlined"
113
+ color="primary"
114
+ block
115
+ prepend-icon="mdi-camera"
116
+ @click="emit('scan:camera')"
117
+ >
118
+ Scan with Camera
119
+ </v-btn>
120
+ </v-col>
121
+ </v-row>
122
+ </template>
123
+ </template>
124
+ <v-divider class="mt-6" />
125
+ </div>
126
+ </template>
127
+
128
+ <script setup lang="ts">
129
+ const props = defineProps({
130
+ settings: {
131
+ type: Object as PropType<Record<string, any> | null>,
132
+ default: null,
133
+ },
134
+ loading: {
135
+ type: Boolean,
136
+ default: false,
137
+ },
138
+ modelValue: {
139
+ type: String as PropType<string | null>,
140
+ default: null,
141
+ },
142
+ quantity: {
143
+ type: Number as PropType<number | null>,
144
+ default: null,
145
+ },
146
+ cards: {
147
+ type: Array as PropType<string[]>,
148
+ default: () => [],
149
+ },
150
+ });
151
+
152
+ const emit = defineEmits(["update:modelValue", "update:quantity", "update:cards", "scan:barcode", "scan:camera"]);
153
+
154
+ const { getAll: getAllCards } = useCard();
155
+
156
+ const nfcEnabled = computed(
157
+ () => props.settings?.data?.settings?.nfcPass ?? false
158
+ );
159
+
160
+ const printer = computed(
161
+ () => props.settings?.data?.settings?.printer ?? { vendorId: null, productId: null }
162
+ );
163
+
164
+ const hasPrinter = computed(
165
+ () => !!printer.value.vendorId && !!printer.value.productId
166
+ );
167
+
168
+ const passType = computed({
169
+ get: () => props.modelValue,
170
+ set: (val) => emit("update:modelValue", val),
171
+ });
172
+
173
+ const quantity = computed({
174
+ get: () => props.quantity,
175
+ set: (val) => emit("update:quantity", val),
176
+ });
177
+
178
+ const selectedCards = computed({
179
+ get: () => props.cards,
180
+ set: (val) => {
181
+ emit("update:cards", val);
182
+ emit("update:quantity", val.length || null);
183
+ },
184
+ });
185
+
186
+ const cardItems = ref<TCard[]>([]);
187
+ const cardsLoading = ref(false);
188
+
189
+ const cards = computed(() => cardItems.value);
190
+
191
+ async function fetchCards() {
192
+ cardsLoading.value = true;
193
+ try {
194
+ const res = await getAllCards({ assignFilter: "available", limit: 100 });
195
+ cardItems.value = res?.data ?? [];
196
+ } finally {
197
+ cardsLoading.value = false;
198
+ }
199
+ }
200
+
201
+ watch(
202
+ () => passType.value,
203
+ (val) => {
204
+ if (val === "NFC") fetchCards();
205
+ }
206
+ );
207
+ </script>
208
+
209
+ <style scoped>
210
+ .selected-check {
211
+ position: absolute;
212
+ top: 6px;
213
+ right: 6px;
214
+ }
215
+ </style>
@@ -141,6 +141,14 @@ watch(input, (newInput) => {
141
141
  else phone.value = prefix + newInput
142
142
  })
143
143
 
144
+ watch(
145
+ () => props.modelValue,
146
+ (val) => {
147
+ phone.value = val || ''
148
+ },
149
+ { immediate: true }
150
+ )
151
+
144
152
 
145
153
  watch(phone, (newVal) => {
146
154
  const prefix = phonePrefix.value
@@ -8,6 +8,13 @@
8
8
  Back
9
9
  </v-btn>
10
10
  </v-col>
11
+ <v-spacer />
12
+ <v-col v-if="isScheduleClosed" cols="auto" class="d-flex align-center">
13
+ <v-chip color="error" variant="tonal" class="text-capitalize">
14
+ <v-icon start>mdi-lock</v-icon>
15
+ Closed – View Only
16
+ </v-chip>
17
+ </v-col>
11
18
  </v-row>
12
19
  <TableHygiene
13
20
  ref="tableHygieneRef"
@@ -22,8 +29,8 @@
22
29
  :loading="loading"
23
30
  :no-data-text="`No checklist found`"
24
31
  :show-header="true"
25
- :can-manage-schedule-tasks="canManageScheduleTasks"
26
- :can-add-remarks="canAddRemarks"
32
+ :can-manage-schedule-tasks="!isScheduleClosed && canManageScheduleTasks"
33
+ :can-add-remarks="!isScheduleClosed && canAddRemarks"
27
34
  @refresh="getUnitCleanerChecklistRefresh"
28
35
  @update:selected="selectedItems = $event"
29
36
  @action-click="handleActionClick"
@@ -113,6 +120,9 @@ const { back } = useUtils();
113
120
  const { canAddRemarks, canManageScheduleTasks } =
114
121
  useCleaningSchedulePermission();
115
122
 
123
+ const selectedScheduleStatus = useState<string>('selectedScheduleStatus', () => '')
124
+ const isScheduleClosed = computed(() => selectedScheduleStatus.value.toLowerCase() === 'closed')
125
+
116
126
  const page = ref<number>(1);
117
127
  const pages = ref<number>(0);
118
128
  const pageRange = ref<string>("-- - -- of --");
@@ -241,6 +251,11 @@ function openCompletionDialog({
241
251
  approvedItems: Array<{ key: string; item: any; action: "approve" }>;
242
252
  lastApprovedKey: string | null;
243
253
  }): void {
254
+ if (isScheduleClosed.value) {
255
+ showMessage("This schedule is closed. No actions can be performed.", "error");
256
+ return;
257
+ }
258
+
244
259
  // If user does not need to add remarks, perform approvals immediately
245
260
  if (!canAddRemarks.value) {
246
261
  // call update directly without opening modal
@@ -283,6 +298,11 @@ async function handleActionClick(data: {
283
298
  item: TFlattenedUnitItem;
284
299
  action: "approve" | "reject";
285
300
  }): Promise<void> {
301
+ if (isScheduleClosed.value) {
302
+ showMessage("This schedule is closed. No actions can be performed.", "error");
303
+ return;
304
+ }
305
+
286
306
  const { item, action } = data;
287
307
 
288
308
  if (!item?.unit || item?.set === undefined) {
@@ -322,9 +342,15 @@ async function handleActionClick(data: {
322
342
  await getUnitCleanerChecklistRefresh();
323
343
  } catch (error: any) {
324
344
  console.error("Error updating unit checklist:", error);
325
- showMessage(error?.message || "Failed to update checklist", "error");
345
+ showMessage(
346
+ error?.data?.message || error?.message || "Failed to update checklist",
347
+ "error"
348
+ );
326
349
  } finally {
327
350
  submitting.value = false;
351
+ if (action === "approve") {
352
+ tableHygieneRef.value?.stopLoadingAction(`${item.unit}_${item.set}`);
353
+ }
328
354
  }
329
355
  }
330
356
 
@@ -68,6 +68,15 @@
68
68
  <v-spacer />
69
69
 
70
70
  <v-col cols="auto">
71
+ <v-chip
72
+ v-if="isScheduleClosed"
73
+ color="error"
74
+ variant="tonal"
75
+ class="text-capitalize mr-2"
76
+ >
77
+ <v-icon start>mdi-lock</v-icon>
78
+ Closed – View Only
79
+ </v-chip>
71
80
  <v-btn
72
81
  v-if="canViewHistory"
73
82
  class="text-none mr-2"
@@ -86,6 +95,7 @@
86
95
  aria-label="Generate"
87
96
  @click="_generateChecklist"
88
97
  :loading="submitting"
98
+ :disabled="isScheduleClosed"
89
99
  >
90
100
  Generate List
91
101
  </v-btn>
@@ -103,6 +113,15 @@
103
113
  {{ item.acceptedByName || item.acceptedBy || "Accepted" }}
104
114
  </span>
105
115
  </div>
116
+ <div v-else-if="isScheduleClosed">
117
+ <v-chip
118
+ size="small"
119
+ color="error"
120
+ variant="tonal"
121
+ class="text-capitalize"
122
+ >Closed</v-chip
123
+ >
124
+ </div>
106
125
  <div v-else>
107
126
  <v-btn
108
127
  v-if="canManageScheduleTasks"
@@ -176,6 +195,14 @@ const { formatDate, back } = useUtils();
176
195
  const { canGenerateChecklist, canViewHistory, canManageScheduleTasks } =
177
196
  useCleaningSchedulePermission();
178
197
 
198
+ const selectedScheduleStatus = useState<string>(
199
+ "selectedScheduleStatus",
200
+ () => ""
201
+ );
202
+ const isScheduleClosed = computed(
203
+ () => selectedScheduleStatus.value.toLowerCase() === "closed"
204
+ );
205
+
179
206
  const items = ref<Array<Record<string, any>>>([]);
180
207
 
181
208
  const statusFilter = ref<TScheduleAreaStatus>("All");
@@ -313,4 +340,33 @@ async function _generateChecklist() {
313
340
  submitting.value = false;
314
341
  }
315
342
  }
343
+
344
+ onMounted(() => {
345
+ try {
346
+ const { $socket } = useNuxtApp() as any;
347
+ if ($socket) {
348
+ $socket.emit("join:cleaning-area", props.scheduleAreaId);
349
+
350
+ $socket.on(
351
+ "area-checklist:generated",
352
+ (payload: { scheduleId: string; siteId: string }) => {
353
+ if (payload?.scheduleId !== props.scheduleAreaId) return;
354
+ if (typeof getScheduleAreasRefresh === "function") {
355
+ getScheduleAreasRefresh();
356
+ }
357
+ }
358
+ );
359
+ }
360
+ } catch (_) {}
361
+ });
362
+
363
+ onUnmounted(() => {
364
+ try {
365
+ const { $socket } = useNuxtApp() as any;
366
+ if ($socket) {
367
+ $socket.emit("leave:cleaning-area", props.scheduleAreaId);
368
+ $socket.off("area-checklist:generated");
369
+ }
370
+ } catch (_) {}
371
+ });
316
372
  </script>
@@ -94,6 +94,16 @@
94
94
  >
95
95
  Set {{ group.set }}
96
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>
97
107
  <v-chip
98
108
  v-if="group.completedByName && isGroupComplete(group)"
99
109
  size="x-small"
@@ -264,6 +274,8 @@
264
274
  : 'text'
265
275
  "
266
276
  color="success"
277
+ :loading="!!loadingActions[getKey(item, group.set)]"
278
+ :disabled="!!loadingActions[getKey(item, group.set)]"
267
279
  @click.stop="
268
280
  handleActionClick(item, group.set, 'approve')
269
281
  "
@@ -448,12 +460,14 @@ const emits = defineEmits([
448
460
 
449
461
  defineExpose({
450
462
  revertSetApprovals,
463
+ stopLoadingAction,
451
464
  });
452
465
 
453
466
  const internalPage = ref(props.page);
454
467
  const selected = shallowRef<any[]>(props.selected);
455
468
  const activeActions = reactive<Record<string, "approve" | "reject">>({});
456
469
  const persistedActions = reactive<Record<string, "approve" | "reject">>({});
470
+ const loadingActions = reactive<Record<string, boolean>>({});
457
471
  const completedSets = ref<Set<number>>(new Set());
458
472
  const itemOrderMap = new Map<string, number>();
459
473
 
@@ -497,6 +511,7 @@ const groupedItems = computed(() => {
497
511
 
498
512
  return {
499
513
  set: item.set,
514
+ isScheduleTask: item.isScheduleTask ?? false,
500
515
  completedByName: item.completedByName ?? null,
501
516
  attachments:
502
517
  (item.attachment as string[] | undefined) ??
@@ -709,6 +724,13 @@ function handleActionClick(
709
724
  return;
710
725
  }
711
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
+
712
734
  console.debug("TableHygiene: emitting action-click (approve immediate)", {
713
735
  item: { ...item, set },
714
736
  action,
@@ -717,6 +739,10 @@ function handleActionClick(
717
739
  }
718
740
  }
719
741
 
742
+ function stopLoadingAction(key: string): void {
743
+ delete loadingActions[key];
744
+ }
745
+
720
746
  function revertSetApprovals(setNumber: number): void {
721
747
  const group = groupedItems.value.find((g) => g.set === setNumber);
722
748
  if (group) {
@@ -736,6 +762,7 @@ watch(
736
762
  () => props.items,
737
763
  () => {
738
764
  completedSets.value.clear();
765
+ Object.keys(loadingActions).forEach((k) => delete loadingActions[k]);
739
766
  },
740
767
  { deep: true }
741
768
  );
@@ -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>