@7365admin1/layer-common 3.0.26 → 3.0.27

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 3.0.27
4
+
5
+ ### Patch Changes
6
+
7
+ - c76ac10: Update Package Version
8
+ - 3d5034d: Update Version Package
9
+
3
10
  ## 3.0.26
4
11
 
5
12
  ### Patch Changes
@@ -30,7 +30,7 @@
30
30
  <div class="text-body-2 font-weight-medium">{{ details.type ?? "N/A" }}</div>
31
31
  </div>
32
32
 
33
- <div>
33
+ <div v-if="details.status">
34
34
  <div class="text-caption text-grey">Status</div>
35
35
  <v-chip
36
36
  :color="statusColor(details.status)"
@@ -47,7 +47,7 @@
47
47
  <div class="text-body-2 font-weight-medium">{{ details.isActivated ? "Yes" : "No" }}</div>
48
48
  </div>
49
49
 
50
- <div>
50
+ <div v-if="details.site?.name">
51
51
  <div class="text-caption text-grey">Site</div>
52
52
  <div class="text-body-2 font-weight-medium">{{ details.site?.name ?? "N/A" }}</div>
53
53
  </div>
@@ -101,6 +101,81 @@
101
101
  <div class="text-caption text-grey">Remarks</div>
102
102
  <div class="text-body-2 font-weight-medium">{{ details.remarks }}</div>
103
103
  </div>
104
+
105
+ <!-- History -->
106
+ <template v-if="details.history?.length > 0">
107
+ <v-divider />
108
+ <div
109
+ class="d-flex align-center justify-space-between cursor-pointer"
110
+ @click="historyOpen = !historyOpen"
111
+ >
112
+ <div class="text-caption font-weight-bold text-grey-darken-2 text-uppercase">
113
+ History ({{ details.history.length }})
114
+ </div>
115
+ <v-icon size="16" :icon="historyOpen ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
116
+ </div>
117
+
118
+ <template v-if="historyOpen">
119
+ <div
120
+ v-for="(entry, idx) in sortedHistory"
121
+ :key="entry._id ?? idx"
122
+ class="d-flex ga-2"
123
+ >
124
+ <!-- Timeline spine -->
125
+ <div class="d-flex flex-column align-center" style="width: 20px; flex-shrink: 0;">
126
+ <div
127
+ class="timeline-dot rounded-circle mt-1"
128
+ :class="entry.visitorId ? 'bg-blue' : 'bg-orange'"
129
+ />
130
+ <div
131
+ v-if="idx < sortedHistory.length - 1"
132
+ class="timeline-line bg-grey-lighten-2 mt-1"
133
+ />
134
+ </div>
135
+
136
+ <!-- Entry content -->
137
+ <div class="d-flex flex-column ga-1 pb-4 flex-grow-1">
138
+ <div class="d-flex align-center justify-space-between">
139
+ <v-chip
140
+ size="x-small"
141
+ variant="tonal"
142
+ :color="entry.visitorId ? 'blue' : 'orange'"
143
+ :prepend-icon="entry.visitorId ? 'mdi-account-arrow-right' : 'mdi-arrow-u-left-top'"
144
+ >
145
+ {{ entry.visitorId ? "Assigned" : "Returned" }}
146
+ </v-chip>
147
+ <span class="text-caption text-grey">{{ formatDate(entry.createdAt) }}</span>
148
+ </div>
149
+
150
+ <!-- Assignment entry -->
151
+ <template v-if="entry.visitorId">
152
+ <div class="text-body-2 font-weight-medium">{{ entry.name ?? "N/A" }}</div>
153
+ <div class="text-caption text-grey-darken-1">
154
+ <span v-if="entry.nric">NRIC: {{ entry.nric }}</span>
155
+ <span v-if="entry.contact" class="ml-2">· {{ entry.contact }}</span>
156
+ </div>
157
+ <div class="text-caption text-grey-darken-1">
158
+ <span v-if="entry.company">{{ entry.company }}</span>
159
+ <span v-if="entry.plateNumber" class="ml-2">· {{ entry.plateNumber }}</span>
160
+ </div>
161
+ </template>
162
+
163
+ <!-- Return/checkout entry -->
164
+ <template v-else>
165
+ <div class="d-flex align-center ga-2">
166
+ <v-chip size="x-small" variant="flat" :color="statusColor(entry.status?.toLowerCase())">
167
+ {{ entry.status ?? "N/A" }}
168
+ </v-chip>
169
+ <span v-if="entry.damage" class="text-caption text-error">Damaged</span>
170
+ </div>
171
+ <div v-if="entry.remarks" class="text-caption text-grey-darken-1">
172
+ Remarks: {{ entry.remarks }}
173
+ </div>
174
+ </template>
175
+ </div>
176
+ </div>
177
+ </template>
178
+ </template>
104
179
  </div>
105
180
  </v-card-text>
106
181
  </v-card>
@@ -131,6 +206,13 @@ const { getCardDetails } = useAccessManagement();
131
206
 
132
207
  const details = ref<Record<string, any> | null>(null);
133
208
  const pending = ref(false);
209
+ const historyOpen = ref(false);
210
+
211
+ const sortedHistory = computed(() =>
212
+ [...(details.value?.history ?? [])].sort(
213
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
214
+ )
215
+ );
134
216
 
135
217
  watch(
136
218
  () => props.modelValue,
@@ -145,6 +227,7 @@ watch(
145
227
  }
146
228
  } else {
147
229
  details.value = null;
230
+ historyOpen.value = false;
148
231
  }
149
232
  }
150
233
  );
@@ -172,3 +255,16 @@ function formatDate(date: string) {
172
255
  }).format(new Date(date));
173
256
  }
174
257
  </script>
258
+
259
+ <style scoped>
260
+ .timeline-dot {
261
+ width: 10px;
262
+ height: 10px;
263
+ flex-shrink: 0;
264
+ }
265
+ .timeline-line {
266
+ width: 2px;
267
+ flex-grow: 1;
268
+ min-height: 16px;
269
+ }
270
+ </style>
@@ -116,9 +116,9 @@
116
116
  <!-- Available Physical -->
117
117
  <v-col cols="12" class="mt-3">
118
118
  <strong>Available Physical</strong>
119
- <div v-if="unit?.available?.physical?.length" class="mt-1">
119
+ <div v-if="availablePhysical.length" class="mt-1">
120
120
  <v-chip
121
- v-for="card in unit.available.physical"
121
+ v-for="card in availablePhysical"
122
122
  :key="card._id"
123
123
  size="small"
124
124
  class="mr-1 mb-1"
@@ -133,6 +133,26 @@
133
133
  <span v-else class="text-caption text-grey ml-1">None</span>
134
134
  </v-col>
135
135
 
136
+ <!-- Damage Physical -->
137
+ <v-col cols="12" class="mt-2">
138
+ <strong>Damage</strong>
139
+ <div v-if="damagedPhysical.length" class="mt-1">
140
+ <v-chip
141
+ v-for="card in damagedPhysical"
142
+ :key="card._id"
143
+ size="small"
144
+ class="mr-1 mb-1"
145
+ color="deep-orange"
146
+ :variant="selectedCardInUnit?._id === card._id ? 'flat' : 'tonal'"
147
+ style="cursor: pointer"
148
+ @click="toggleCard(card)"
149
+ >
150
+ {{ card.cardNo }}
151
+ </v-chip>
152
+ </div>
153
+ <span v-else class="text-caption text-grey ml-1">None</span>
154
+ </v-col>
155
+
136
156
  <!-- Available Non-Physical -->
137
157
  <v-col cols="12" class="mt-2">
138
158
  <strong>Available Non-Physical</strong>
@@ -424,11 +444,19 @@ function showToast(message: string, color: "success" | "error") {
424
444
  snackbar.value = true;
425
445
  }
426
446
 
447
+ const availablePhysical = computed(() =>
448
+ (props.unit?.available?.physical ?? []).filter((c: any) => !c.damage)
449
+ );
450
+
451
+ const damagedPhysical = computed(() =>
452
+ (props.unit?.available?.physical ?? []).filter((c: any) => c.damage === true)
453
+ );
454
+
427
455
  const isSelectedCardAvailable = computed(() => {
428
456
  if (!props.selectedCardInUnit?._id) return false;
429
457
  const id = props.selectedCardInUnit._id;
430
458
  return [
431
- ...(props.unit?.available?.physical ?? []),
459
+ ...availablePhysical.value,
432
460
  ...(props.unit?.available?.non_physical ?? []),
433
461
  ].some((c) => c._id === id);
434
462
  });
@@ -301,7 +301,16 @@ const loading = computed(() => getAllReqStatus.value === "pending");
301
301
 
302
302
  watchEffect(() => {
303
303
  if (getCardReq.value?.data) {
304
- items.value = getCardReq.value.data.items;
304
+ items.value = getCardReq.value.data.items.map((item: any) => ({
305
+ ...item,
306
+ cardCounts: {
307
+ ...item.cardCounts,
308
+ available: {
309
+ ...item.cardCounts?.available,
310
+ physical: (item.available?.physical ?? []).filter((c: any) => !c.damage).length,
311
+ },
312
+ },
313
+ }));
305
314
  pages.value = getCardReq.value.data.pages;
306
315
  pageRange.value = getCardReq.value.data.pageRange;
307
316
  }
@@ -59,21 +59,39 @@
59
59
  No printer is configured in the settings. QR Pass may not print.
60
60
  </v-alert>
61
61
  <template v-else>
62
- <div class="d-flex align-center justify-space-between mt-3">
63
- <InputLabel title="Quantity" />
64
- <span v-if="availableCount !== null" class="text-caption text-grey">
65
- {{ availableCount }} available
66
- </span>
67
- <v-progress-circular v-else-if="cardsLoading" size="14" width="2" indeterminate color="grey" />
62
+ <!-- Generating / loading state -->
63
+ <div v-if="generating || cardsLoading" class="d-flex align-center ga-2 mt-3 text-caption text-grey-darken-1">
64
+ <v-progress-circular size="14" width="2" indeterminate color="grey" />
65
+ <span>{{ generating ? 'Generating QR passes...' : 'Checking availability...' }}</span>
68
66
  </div>
69
- <v-text-field
70
- v-model.number="quantity"
71
- type="number"
72
- density="comfortable"
73
- :min="1"
74
- :max="availableCount ?? undefined"
75
- hide-details
76
- />
67
+ <!-- 0 available after all loading -->
68
+ <v-alert
69
+ v-else-if="availableCount === 0"
70
+ type="warning"
71
+ variant="tonal"
72
+ density="compact"
73
+ class="mt-3"
74
+ icon="mdi-information-outline"
75
+ >
76
+ No QR passes available. Add at least 1 non-physical card to this unit in Access Management to enable auto-generation.
77
+ </v-alert>
78
+ <!-- Normal: quantity input -->
79
+ <template v-else>
80
+ <div class="d-flex align-center justify-space-between mt-3">
81
+ <InputLabel title="Quantity" />
82
+ <span v-if="availableCount !== null" class="text-caption text-grey">
83
+ {{ availableCount }} available
84
+ </span>
85
+ </div>
86
+ <v-text-field
87
+ v-model.number="quantity"
88
+ type="number"
89
+ density="comfortable"
90
+ :min="1"
91
+ :max="availableCount ?? undefined"
92
+ hide-details
93
+ />
94
+ </template>
77
95
  </template>
78
96
  </template>
79
97
 
@@ -83,7 +101,7 @@
83
101
  <InputLabel title="Select Cards" />
84
102
  <v-select
85
103
  v-model="selectedCards"
86
- :items="cardItems"
104
+ :items="filteredCardItems"
87
105
  :loading="cardsLoading"
88
106
  item-title="cardNo"
89
107
  item-value="_id"
@@ -265,9 +283,13 @@ const props = defineProps({
265
283
  type: Boolean,
266
284
  default: false,
267
285
  },
286
+ excludedCardIds: {
287
+ type: Array as PropType<string[]>,
288
+ default: () => [],
289
+ },
268
290
  });
269
291
 
270
- const emit = defineEmits(["update:modelValue", "update:quantity", "update:cards"]);
292
+ const emit = defineEmits(["update:modelValue", "update:quantity", "update:cards", "update:availableQrCount"]);
271
293
 
272
294
  const { getAvailableContractorCards, generateQrVms } = useAccessManagement();
273
295
 
@@ -309,12 +331,19 @@ function showSnackbar(text: string, color = "error") {
309
331
 
310
332
  // ─── Card fetching ────────────────────────────────────────────────
311
333
  const cardItems = ref<any[]>([]);
334
+ const filteredCardItems = computed(() =>
335
+ props.excludedCardIds.length
336
+ ? cardItems.value.filter((c) => !props.excludedCardIds.includes(c._id))
337
+ : cardItems.value
338
+ );
312
339
  const cardsLoading = ref(false);
340
+ const generating = ref(false);
313
341
  const availableCount = ref<number | null>(null);
314
342
 
315
343
  async function fetchCards(type: "NFC" | "QRCODE" = "NFC") {
316
344
  if (!props.siteId || !props.unitId) return;
317
345
  cardsLoading.value = true;
346
+ if (type === "QRCODE") emit("update:availableQrCount", null);
318
347
  try {
319
348
  const res = await getAvailableContractorCards({
320
349
  type,
@@ -324,7 +353,9 @@ async function fetchCards(type: "NFC" | "QRCODE" = "NFC") {
324
353
  cardItems.value = res?.data?.[0]?.items ?? [];
325
354
  availableCount.value = (res?.data?.[2] as any)?.count ?? cardItems.value.length;
326
355
 
327
- if (type === "QRCODE" && props.settings?.data?._id && availableCount.value !== null && availableCount.value < 50) {
356
+ if (type === "QRCODE" && props.settings?.data?._id && availableCount.value !== null && availableCount.value > 0 && availableCount.value < 50) {
357
+ cardsLoading.value = false;
358
+ generating.value = true;
328
359
  await generateQrVms({
329
360
  site: props.settings.data.site,
330
361
  unitId: props.unitId,
@@ -338,14 +369,20 @@ async function fetchCards(type: "NFC" | "QRCODE" = "NFC") {
338
369
  });
339
370
  cardItems.value = refreshed?.data?.[0]?.items ?? [];
340
371
  availableCount.value = (refreshed?.data?.[2] as any)?.count ?? cardItems.value.length;
372
+ generating.value = false;
341
373
  }
374
+
375
+ if (type === "QRCODE") emit("update:availableQrCount", availableCount.value);
342
376
  } catch (err: any) {
343
377
  const msg = err?.data?.message ?? err?.message ?? String(err);
344
378
  showSnackbar(`Entry pass service error: ${msg}`);
345
379
  availableCount.value = null;
346
380
  cardItems.value = [];
381
+ generating.value = false;
382
+ if (type === "QRCODE") emit("update:availableQrCount", null);
347
383
  } finally {
348
384
  cardsLoading.value = false;
385
+ generating.value = false;
349
386
  }
350
387
  }
351
388
 
@@ -500,6 +537,7 @@ watch(
500
537
  fetchCards("QRCODE");
501
538
  } else {
502
539
  availableCount.value = null;
540
+ emit("update:availableQrCount", null);
503
541
  isBarcodeScanningActive.value = false;
504
542
  barcodeBuffer.value = "";
505
543
  if (barcodeTimeout.value) clearTimeout(barcodeTimeout.value);
@@ -17,18 +17,16 @@
17
17
  class="mb-3"
18
18
  />
19
19
 
20
- <template v-if="mode === 'add'">
21
- <div class="field-label">Base URL / IP address <span>*</span></div>
22
- <v-text-field
23
- v-model="draft.baseUrl"
24
- aria-label="Base URL / IP address"
25
- placeholder="http://192.168.x.x."
26
- variant="outlined"
27
- density="compact"
28
- hide-details="auto"
29
- class="mb-3"
30
- />
31
- </template>
20
+ <div class="field-label">Base URL / IP address <span>*</span></div>
21
+ <v-text-field
22
+ v-model="draft.baseUrl"
23
+ aria-label="Base URL / IP address"
24
+ placeholder="https://your domain URL"
25
+ variant="outlined"
26
+ density="compact"
27
+ hide-details="auto"
28
+ class="mb-3"
29
+ />
32
30
 
33
31
  <div class="field-label">Device ID <span>*</span></div>
34
32
  <v-text-field
@@ -175,10 +173,6 @@ function submit() {
175
173
  status: monitorEnabled.value ? "active" : "inactive",
176
174
  };
177
175
 
178
- if (props.mode === "edit") {
179
- delete payload.baseUrl;
180
- }
181
-
182
176
  emit("submit", payload);
183
177
  }
184
178
  </script>
@@ -77,6 +77,22 @@
77
77
  </v-autocomplete>
78
78
  </v-col>
79
79
 
80
+ <v-col v-if="props.unitId" cols="12">
81
+ <EntryPassInformation
82
+ v-model="memberPassType"
83
+ v-model:quantity="memberPassQuantity"
84
+ v-model:cards="memberPassCards"
85
+ :settings="props.settings"
86
+ :loading="props.settingsLoading"
87
+ :site-id="props.site"
88
+ :unit-id="props.unitId"
89
+ :visitor-type="props.type"
90
+ :hid-qr-code-enabled="props.hidQrCodeEnabled"
91
+ :hid-qr-printer-configured="props.hidQrPrinterConfigured"
92
+ :excluded-card-ids="props.selectedNfcCards.map((c) => c._id)"
93
+ />
94
+ </v-col>
95
+
80
96
  <v-col cols="12">
81
97
  <InputLabel
82
98
  class="text-capitalize"
@@ -164,6 +180,30 @@ const props = defineProps({
164
180
  type: Boolean,
165
181
  default: false,
166
182
  },
183
+ settings: {
184
+ type: Object as PropType<Record<string, any> | null>,
185
+ default: null,
186
+ },
187
+ settingsLoading: {
188
+ type: Boolean,
189
+ default: false,
190
+ },
191
+ unitId: {
192
+ type: String as PropType<string | null>,
193
+ default: null,
194
+ },
195
+ hidQrCodeEnabled: {
196
+ type: Boolean,
197
+ default: false,
198
+ },
199
+ hidQrPrinterConfigured: {
200
+ type: Boolean,
201
+ default: false,
202
+ },
203
+ selectedNfcCards: {
204
+ type: Array as PropType<{ _id: string; cardNo: string }[]>,
205
+ default: () => [],
206
+ },
167
207
  });
168
208
 
169
209
  const { requiredRule } = useUtils();
@@ -201,6 +241,9 @@ const dialog = reactive({
201
241
  });
202
242
 
203
243
  const selectedPass = ref<string>("");
244
+ const memberPassType = ref<string | null>(null);
245
+ const memberPassQuantity = ref<number | null>(1);
246
+ const memberPassCards = ref<{ _id: string; cardNo: string }[]>([]);
204
247
 
205
248
  const members = defineModel<TMemberInfo[]>({ required: true, default: [] });
206
249
  const committedMembers = ref<TMemberInfo[]>([]);
@@ -303,12 +346,27 @@ watch(scannedPassValue, (newVal) => {
303
346
  showMessage("Scanned pass not available", "error");
304
347
  });
305
348
 
349
+ function resetAccessCard() {
350
+ memberPassType.value = null;
351
+ memberPassQuantity.value = 1;
352
+ memberPassCards.value = [];
353
+ }
354
+
306
355
  function handleClearForm() {
307
356
  formRef.value?.reset();
357
+ resetAccessCard();
308
358
  }
309
359
 
310
360
  async function handleAddMember() {
311
- committedMembers.value.push({ ...memberForm });
361
+ committedMembers.value.push({
362
+ ...memberForm,
363
+ ...(memberPassType.value ? {
364
+ passType: memberPassType.value,
365
+ passQuantity: memberPassQuantity.value,
366
+ passCards: memberPassCards.value,
367
+ } : {}),
368
+ } as any);
369
+ resetAccessCard();
312
370
  handleClearForm();
313
371
  }
314
372
 
@@ -204,12 +204,17 @@
204
204
  v-model:cards="passCards" :settings="entryPassSettings"
205
205
  :loading="entryPassSettingsPending || siteDataPending" :site-id="prop.site"
206
206
  :unit-id="visitor.unit || null" :visitor-type="prop.type" :hid-qr-code-enabled="hidQrCodePassAllowed"
207
- :hid-qr-printer-configured="hasHidQrPrinterConfig" />
207
+ :hid-qr-printer-configured="hasHidQrPrinterConfig"
208
+ @update:available-qr-count="(val) => availableQrCount = val" />
208
209
  </v-col>
209
210
 
210
211
  <v-col v-show="withStep3 && contractorStep === 3" cols="12">
211
212
  <MemberInformation v-model="visitor.members" :type="prop.type" :contractor-type="visitor.contractorType"
212
- :site="prop.site" :selected-visitor-pass="visitor.visitorPass" :clearable="true" />
213
+ :site="prop.site" :selected-visitor-pass="visitor.visitorPass" :clearable="true"
214
+ :settings="entryPassSettings" :settings-loading="entryPassSettingsPending || siteDataPending"
215
+ :unit-id="visitor.unit || null"
216
+ :hid-qr-code-enabled="hidQrCodePassAllowed" :hid-qr-printer-configured="hasHidQrPrinterConfig"
217
+ :selected-nfc-cards="passType === 'NFC' ? passCards : []" />
213
218
  </v-col>
214
219
 
215
220
  <v-col v-if="prop.mode === 'add'" cols="12" class="mt-2">
@@ -394,6 +399,7 @@ const visitor = reactive<Partial<TVisitorPayload>>({
394
399
  const passType = ref<string | null>(null);
395
400
  const passQuantity = ref<number | null>(1);
396
401
  const passCards = ref<{ _id: string; cardNo: string }[]>([]);
402
+ const availableQrCount = ref<number | null>(null);
397
403
  const hidQrPrintPayload = ref<string | null>(null);
398
404
  const hidQrPrinted = ref(false);
399
405
 
@@ -628,10 +634,10 @@ function selectNricSearchResult(item: Partial<TPeople>) {
628
634
  if (!visitor.company) {
629
635
  visitor.company = companyNames.value?.[0] || visitor.company;
630
636
  }
631
- visitor.block = (item.block as any) ?? (visitor.block as any) ?? "";
632
- visitor.level = (item.level as any) ?? (visitor.level as any) ?? "";
633
- visitor.unit = item.unit ?? visitor.unit ?? "";
634
- visitor.unitName = item.unitName ?? visitor.unitName;
637
+ // visitor.block = (item.block as any) ?? (visitor.block as any) ?? "";
638
+ // visitor.level = (item.level as any) ?? (visitor.level as any) ?? "";
639
+ // visitor.unit = item.unit ?? visitor.unit ?? "";
640
+ // visitor.unitName = item.unitName ?? visitor.unitName;
635
641
 
636
642
  setTimeout(() => {
637
643
  currentAutofillSource.value = null;
@@ -990,6 +996,16 @@ async function handleNextPage() {
990
996
  return;
991
997
  }
992
998
  const step = contractorStep.value;
999
+ if (step === 2 && passType.value === "QR") {
1000
+ if (availableQrCount.value === null) {
1001
+ errorMessage.value = "Please wait while QR passes are being generated...";
1002
+ return;
1003
+ }
1004
+ if (availableQrCount.value === 0) {
1005
+ errorMessage.value = "No QR passes available. Add a non-physical card to this unit in Access Management first.";
1006
+ return;
1007
+ }
1008
+ }
993
1009
  if (step === 2 && passType.value === "NFC" && passCards.value.length === 0) {
994
1010
  errorMessage.value = "Please select at least one NFC card before proceeding.";
995
1011
  return;