@7365admin1/layer-common 4.35.1-staging.385 → 4.37.0

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 (69) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/assets/css/primitives.css +0 -43
  3. package/components/AccessCardAddForm.vue +4 -5
  4. package/components/AccessCardAssignToUnitForm.vue +2 -3
  5. package/components/AccessCardPreviewDialog.vue +3 -4
  6. package/components/AppSelect.vue +3 -77
  7. package/components/CameraForm.vue +6 -20
  8. package/components/DashboardMain.vue +60 -66
  9. package/components/Dialog/UpdateMoreAction.vue +1 -11
  10. package/components/DocumentForm.vue +112 -407
  11. package/components/DocumentManagement.vue +12 -213
  12. package/components/EntryPass/QrTemplatePreview.vue +25 -21
  13. package/components/EntryPassInformation.vue +24 -119
  14. package/components/Facility/BookingSetup.vue +0 -63
  15. package/components/HidAccessLogDashboard.vue +17 -74
  16. package/components/HidIntercomManagement.vue +14 -180
  17. package/components/HidQrCodeConfiguration.vue +195 -17
  18. package/components/HidReaderManagement.vue +0 -398
  19. package/components/HidReaderUserRoster.vue +3 -44
  20. package/components/HidUserEnrollment.vue +155 -845
  21. package/components/InvitationClientForm.vue +1 -19
  22. package/components/Layout/NavigationDrawer.vue +1 -4
  23. package/components/NotificationBell.vue +7 -59
  24. package/components/RolePermissionFormCreate.vue +3 -4
  25. package/components/RolePermissionFormPreviewUpdate.vue +5 -12
  26. package/components/RolePermissionMain.vue +0 -1
  27. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  28. package/components/ServiceProviderMain.vue +30 -178
  29. package/components/VisitorForm.vue +0 -12
  30. package/components/VisitorManagement.vue +10 -285
  31. package/components/VisitorSocketPopUp.vue +1 -2
  32. package/composables/useANPRSettings.ts +15 -45
  33. package/composables/useAccessManagement.ts +0 -5
  34. package/composables/useCleaningPermission.ts +0 -2
  35. package/composables/useCommonPermission.ts +0 -32
  36. package/composables/useDocument.ts +0 -7
  37. package/composables/useFacility.ts +0 -11
  38. package/composables/useHidAmico.ts +0 -159
  39. package/composables/useHidNavigation.ts +1 -25
  40. package/composables/usePeople.ts +0 -19
  41. package/composables/usePropertyPermission.ts +0 -19
  42. package/composables/useSecurityPermission.ts +0 -19
  43. package/composables/useServiceProvider.ts +0 -3
  44. package/composables/useServiceProviderInvitation.ts +3 -101
  45. package/composables/useSiteEntryPassSettings.ts +0 -26
  46. package/composables/useWebUsb.ts +11 -14
  47. package/package.json +2 -2
  48. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  49. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  50. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  51. package/plugins/API.ts +0 -11
  52. package/types/document.d.ts +0 -14
  53. package/types/facility.d.ts +0 -4
  54. package/types/people.d.ts +1 -28
  55. package/utils/module-routes.ts +1 -8
  56. package/utils/nav-sections.ts +2 -8
  57. package/utils/notification-target.ts +8 -277
  58. package/utils/permission-labels.ts +2 -20
  59. package/components/ClientInvitations.vue +0 -291
  60. package/components/HidAccessPermissions.vue +0 -424
  61. package/components/HidProfileQrCode.vue +0 -332
  62. package/composables/useHidReaderSelection.ts +0 -50
  63. package/composables/useSiteCategory.ts +0 -45
  64. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  65. package/utils/dashboard-widget-prefs.ts +0 -54
  66. package/utils/hid-permission-assignments.ts +0 -190
  67. package/utils/hid-reader-selection.ts +0 -52
  68. package/utils/occupancy-role.ts +0 -109
  69. package/utils/sp-inviter-row.ts +0 -97
@@ -57,24 +57,7 @@
57
57
  </div>
58
58
  </v-col>
59
59
 
60
- <v-col cols="12">
61
- <v-tabs
62
- v-model="activeTab"
63
- class="doc-tabs"
64
- density="compact"
65
- :hide-slider="false"
66
- >
67
- <v-tab value="active" class="text-none">Documents</v-tab>
68
- <v-tab value="expired" class="text-none">Document (Expired)</v-tab>
69
- </v-tabs>
70
- </v-col>
71
-
72
- <!--
73
- Hidden on the Expired tab: that tab is a site-wide worklist rather than a
74
- place in the folder tree, so a breadcrumb there would name a folder whose
75
- contents are not what is on screen.
76
- -->
77
- <v-col cols="12" class="mb-2" v-if="isInsideFolder && !isExpiredTab">
60
+ <v-col cols="12" class="mb-2" v-if="isInsideFolder">
78
61
  <div class="folder-path-bar d-flex align-center ga-2 px-3 py-2">
79
62
  <v-btn
80
63
  icon="mdi-arrow-left"
@@ -166,79 +149,19 @@
166
149
  <span>{{ item.date_modified || "N/A" }}</span>
167
150
  </template>
168
151
 
169
- <template #item.expiry_date="{ item }">
170
- <span>{{ item.expiry_date || "N/A" }}</span>
171
- </template>
172
-
173
152
  <template #item.remarks="{ item }">
174
153
  <span>{{ item.remarks || "N/A" }}</span>
175
154
  </template>
176
155
 
177
156
  <template #item.action="{ item }">
178
- <!--
179
- A document carries its primary action and a kebab beside it. Which
180
- primary action depends on the tab: an expired document cannot be
181
- usefully downloaded, it needs replacing, so Download gives way to
182
- Update there.
183
- -->
184
- <template v-if="!isFolderItem(item._raw)">
185
- <v-btn
186
- v-if="isExpiredTab"
187
- variant="flat"
188
- class="table-download-btn text-none"
189
- :disabled="!canUpdateDocument"
190
- @click.stop="openUpdateDialog(item._raw)"
191
- >
192
- Update
193
- </v-btn>
194
-
195
- <v-btn
196
- v-else
197
- variant="flat"
198
- class="table-download-btn text-none"
199
- @click.stop="downloadDocument(item._raw)"
200
- >
201
- Download
202
- </v-btn>
203
-
204
- <v-menu
205
- v-if="canUpdateDocument || canDeleteDocument"
206
- v-model="documentActionMenu[item._raw._id]"
207
- location="bottom end"
208
- offset="6"
209
- >
210
- <template #activator="{ props }">
211
- <v-btn
212
- v-bind="props"
213
- icon="mdi-dots-vertical"
214
- variant="text"
215
- size="small"
216
- density="comfortable"
217
- class="folder-action-btn"
218
- @click.stop
219
- />
220
- </template>
221
-
222
- <v-list class="folder-action-list pa-0">
223
- <v-list-item
224
- v-if="canUpdateDocument"
225
- class="folder-action-item"
226
- @click.stop="openDocumentEdit(item._raw)"
227
- >
228
- <v-list-item-title class="text-body-2">Edit</v-list-item-title>
229
- </v-list-item>
230
- <v-list-item
231
- v-if="canDeleteDocument"
232
- class="folder-action-item"
233
- @click.stop="openDocumentDelete(item._raw)"
234
- >
235
- <v-list-item-title class="text-body-2">
236
- Delete
237
- </v-list-item-title>
238
- </v-list-item>
239
- </v-list>
240
- </v-menu>
241
- </template>
157
+ <v-btn
158
+ v-if="!isFolderItem(item._raw)"
159
+ variant="flat"
160
+ class="table-download-btn text-none"
161
+ @click.stop="downloadDocument(item._raw)"
162
+ >
163
+ Download
164
+ </v-btn>
242
165
 
243
166
  <v-menu
244
167
  v-else
@@ -277,11 +200,8 @@
277
200
  </TableV3>
278
201
  </v-col>
279
202
 
280
- <!--
281
- Wider than the other dialogs because Add Document holds a five-column
282
- table, one row per file, rather than a stacked pair of fields.
283
- -->
284
- <v-dialog v-model="createDialog" max-width="1100" persistent>
203
+ <!-- Create Dialog -->
204
+ <v-dialog v-model="createDialog" width="450" persistent>
285
205
  <DocumentForm
286
206
  v-if="createDialog"
287
207
  :parent-id="activeParentId"
@@ -290,17 +210,6 @@
290
210
  />
291
211
  </v-dialog>
292
212
 
293
- <!-- Update Dialog: renew an expired document by replacing its file. -->
294
- <v-dialog v-model="updateDialog" max-width="1100" persistent>
295
- <DocumentForm
296
- v-if="updateDialog"
297
- mode="update"
298
- :document="selectedDocument"
299
- @cancel="updateDialog = false"
300
- @success="successRenew()"
301
- />
302
- </v-dialog>
303
-
304
213
  <!-- Create Folder Dialog -->
305
214
  <v-dialog v-model="createFolderDialog" width="450" persistent>
306
215
  <v-card width="100%">
@@ -575,15 +484,6 @@
575
484
  </v-row>
576
485
  </v-col>
577
486
 
578
- <v-col cols="12" class="mb-2">
579
- <v-row no-gutters>
580
- <v-col cols="6" class="text-body-1">Expiry Date</v-col>
581
- <v-col cols="6" class="text-right text-body-1">{{
582
- previewDetails.expiryDate
583
- }}</v-col>
584
- </v-row>
585
- </v-col>
586
-
587
487
  <v-col cols="12">
588
488
  <v-row no-gutters>
589
489
  <v-col cols="6" class="text-body-1">Remarks</v-col>
@@ -734,10 +634,6 @@ const props = defineProps({
734
634
  title: "Date Modified",
735
635
  value: "date_modified",
736
636
  },
737
- {
738
- title: "Expiry Date",
739
- value: "expiry_date",
740
- },
741
637
  {
742
638
  title: "Remarks",
743
639
  value: "remarks",
@@ -837,13 +733,6 @@ const currentFolderPath = computed(() => {
837
733
  const fileTypes = ref(["all", "pdf", "csv", "doc"]);
838
734
  const selectedFileType = ref("all");
839
735
 
840
- /**
841
- * Which half of the list is on screen. The value is sent straight to the API as
842
- * its `expiry` filter, so the tab and the query cannot drift apart.
843
- */
844
- const activeTab = ref<"active" | "expired">("active");
845
- const isExpiredTab = computed(() => activeTab.value === "expired");
846
-
847
736
  const {
848
737
  data: getDocumentReq,
849
738
  refresh: getDocuments,
@@ -859,20 +748,12 @@ const {
859
748
  parentId: activeParentId.value,
860
749
  type:
861
750
  selectedFileType.value === "all" ? undefined : selectedFileType.value,
862
- expiry: activeTab.value,
863
751
  }),
864
752
  {
865
- watch: [page, headerSearch, activeParentId, selectedFileType, activeTab],
753
+ watch: [page, headerSearch, activeParentId, selectedFileType],
866
754
  }
867
755
  );
868
756
 
869
- // Page 3 of the Documents tab is rarely page 3 of a much shorter Expired list,
870
- // so a tab change that kept the page number would land on an empty table that
871
- // looks like "nothing has expired".
872
- watch(activeTab, () => {
873
- page.value = 1;
874
- });
875
-
876
757
  const loading = computed(() => getAllReqStatus.value === "pending");
877
758
 
878
759
  // The page totals are the server's count of the WHOLE list, read through the
@@ -907,28 +788,6 @@ function formatDate(value?: string | null) {
907
788
  });
908
789
  }
909
790
 
910
- /**
911
- * Expiry is a day, not a moment: the picker collects `YYYY-MM-DD` and the
912
- * design renders `13/12/2026`. Reusing `formatDate` would append a meaningless
913
- * 00:00 to every row.
914
- */
915
- function formatDateOnly(value?: string | null) {
916
- if (!value) {
917
- return "N/A";
918
- }
919
-
920
- const dateValue = new Date(value);
921
- if (Number.isNaN(dateValue.getTime())) {
922
- return "N/A";
923
- }
924
-
925
- return dateValue.toLocaleDateString("en-SG", {
926
- year: "numeric",
927
- month: "2-digit",
928
- day: "2-digit",
929
- });
930
- }
931
-
932
791
  function formatBytes(bytes = 0) {
933
792
  const b = Number(bytes) || 0;
934
793
  if (b === 0) return "0 B";
@@ -964,20 +823,13 @@ const tableItems = computed(() => {
964
823
  : formatBytes(Number(doc.file_size ?? doc.size ?? 0)),
965
824
  date_created: formatDate(doc.created_at || doc.createdAt),
966
825
  date_modified: formatDate(doc.updated_at || doc.updatedAt),
967
- // Folders never expire, so they read N/A rather than borrowing a date.
968
- expiry_date:
969
- doc.type === "folder"
970
- ? "N/A"
971
- : formatDateOnly(doc.expiry_date || doc.expiryDate),
972
826
  remarks: doc.remarks || "N/A",
973
827
  }));
974
828
  });
975
829
 
976
830
  const createDialog = ref(false);
977
831
  const editDialog = ref(false);
978
- const updateDialog = ref(false);
979
832
  const previewDialog = ref(false);
980
- const documentActionMenu = ref<Record<string, boolean>>({});
981
833
 
982
834
  // Create folder dialog state
983
835
  const createFolderDialog = ref(false);
@@ -1028,10 +880,6 @@ const previewDetails = computed(() => {
1028
880
  fileSize,
1029
881
  dateCreated: formatDate(document?.created_at || document?.createdAt),
1030
882
  dateModified: formatDate(document?.updated_at || document?.updatedAt),
1031
- expiryDate:
1032
- document?.type === "folder"
1033
- ? "N/A"
1034
- : formatDateOnly(document?.expiry_date || document?.expiryDate),
1035
883
  remarks: document?.remarks || "N/A",
1036
884
  };
1037
885
  });
@@ -1303,29 +1151,6 @@ function openDeleteDialog() {
1303
1151
  message.value = "";
1304
1152
  }
1305
1153
 
1306
- /*
1307
- * The three row-kebab entry points. Each names its document first: the dialogs
1308
- * below all read `selectedDocument`, which until now was only ever set by
1309
- * opening the detail modal, so firing one straight from a row without setting
1310
- * it would have acted on whichever document was inspected last.
1311
- */
1312
- function openDocumentEdit(document: TDocument) {
1313
- selectedDocument.value = document;
1314
- documentActionMenu.value = {};
1315
- editDialog.value = true;
1316
- }
1317
-
1318
- function openDocumentDelete(document: TDocument) {
1319
- selectedDocument.value = document;
1320
- documentActionMenu.value = {};
1321
- openDeleteDialog();
1322
- }
1323
-
1324
- function openUpdateDialog(document: TDocument) {
1325
- selectedDocument.value = document;
1326
- updateDialog.value = true;
1327
- }
1328
-
1329
1154
  function openEditDialog() {
1330
1155
  editDialog.value = true;
1331
1156
  }
@@ -1354,19 +1179,6 @@ function successUpdate() {
1354
1179
  showMessage("Document updated successfully!", "success");
1355
1180
  }
1356
1181
 
1357
- /**
1358
- * A renewal usually moves the document off the tab it was renewed from, so the
1359
- * refresh is what makes it disappear from the Expired list. A renewal that did
1360
- * not move the expiry date forward stays put, which is the intended behaviour
1361
- * rather than a failed save - hence the neutral wording.
1362
- */
1363
- function successRenew() {
1364
- updateDialog.value = false;
1365
- previewDialog.value = false;
1366
- getDocuments();
1367
- showMessage("Document updated successfully!", "success");
1368
- }
1369
-
1370
1182
  function selectFileType(type: string) {
1371
1183
  selectedFileType.value = type;
1372
1184
  filterMenu.value = false;
@@ -1391,19 +1203,6 @@ function selectFileType(type: string) {
1391
1203
  color: var(--text);
1392
1204
  }
1393
1205
 
1394
- /* The tab strip sits on the table card, so it takes the card's rule rather
1395
- than Vuetify's default underline, and the cell type rather than the button
1396
- scale - the same treatment the toolbar above it already gets. */
1397
- .doc-tabs {
1398
- border-bottom: 1px solid var(--border);
1399
- }
1400
-
1401
- .doc-tabs :deep(.v-tab) {
1402
- font-size: var(--fs-cell);
1403
- letter-spacing: normal;
1404
- min-width: 0;
1405
- }
1406
-
1407
1206
  .table-download-btn {
1408
1207
  min-width: 106px;
1409
1208
  height: 30px;
@@ -32,18 +32,28 @@
32
32
  </v-col>
33
33
 
34
34
  <v-col cols="12" class="mt-5">
35
- <!-- The pass used to print five QRs - four 80px copies at the
36
- corners around a 130px centre one - so a strip could be cut
37
- apart. It now carries a single large QR, matching what
38
- `createReceiptLayout` sends to the thermal printer. -->
39
- <img
40
- v-if="qrLarge"
41
- :src="qrLarge"
42
- width="260"
43
- height="260"
44
- class="qr-preview__code my-2"
45
- alt="Entry pass QR code"
46
- />
35
+ <!-- UPPER QR -->
36
+ <v-row no-gutters>
37
+ <v-col cols="6">
38
+ <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
39
+ </v-col>
40
+ <v-col cols="6">
41
+ <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
42
+ </v-col>
43
+ </v-row>
44
+
45
+ <!-- CENTER QR -->
46
+ <img v-if="qrLarge" :src="qrLarge" width="130" height="130" class="my-2" />
47
+
48
+ <!-- LOWER QR -->
49
+ <v-row no-gutters>
50
+ <v-col cols="6">
51
+ <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
52
+ </v-col>
53
+ <v-col cols="6">
54
+ <img v-if="qrSmall" :src="qrSmall" width="80" height="80" />
55
+ </v-col>
56
+ </v-row>
47
57
  </v-col>
48
58
 
49
59
  <v-col cols="12" class="mt-5">
@@ -80,11 +90,12 @@ defineProps({
80
90
 
81
91
  const emit = defineEmits(["update:modelValue"]);
82
92
 
93
+ const qrSmall = ref<string>("");
83
94
  const qrLarge = ref<string>("");
84
95
 
85
96
  onMounted(async () => {
86
- // Rendered at the size it is displayed at, so the modules stay crisp.
87
- qrLarge.value = await QRCode.toDataURL("123123", { width: 260, margin: 1 });
97
+ qrSmall.value = await QRCode.toDataURL("123123", { width: 80, margin: 1 });
98
+ qrLarge.value = await QRCode.toDataURL("123123", { width: 130, margin: 1 });
88
99
  });
89
100
 
90
101
  const now = new Date();
@@ -101,11 +112,4 @@ const timeStr = now.toLocaleTimeString();
101
112
  font-weight: var(--fw-card-title);
102
113
  color: var(--text);
103
114
  }
104
-
105
- /* The dialog is 550px wide but narrows on small screens, so the QR is capped
106
- at its own width rather than a fixed 260px that would overflow the card. */
107
- .qr-preview__code {
108
- max-width: 100%;
109
- height: auto;
110
- }
111
115
  </style>
@@ -99,18 +99,10 @@
99
99
  <template v-if="passType === 'NFC'">
100
100
  <div class="mt-3">
101
101
  <InputLabel title="Select Cards" />
102
- <!-- Was a `v-select` over the first 100 cards, which only lets the
103
- operator scroll. Typing now goes to the endpoint as `search`
104
- (debounced, page 1), and reaching the end of the list fetches
105
- the next page. Vuetify's own client-side match on `cardNo` is
106
- left on as well: API-core does not read `search` yet, so until
107
- it does that is what makes typing narrow the list. -->
108
- <v-autocomplete
102
+ <v-select
109
103
  v-model="selectedCards"
110
- v-model:search="cardSearch"
111
104
  :items="filteredCardItems"
112
105
  :loading="cardsLoading"
113
- :hide-no-data="false"
114
106
  item-title="cardNo"
115
107
  item-value="_id"
116
108
  density="comfortable"
@@ -118,30 +110,10 @@
118
110
  chips
119
111
  closable-chips
120
112
  hide-details
121
- placeholder="Search or select cards..."
113
+ placeholder="Select cards..."
122
114
  return-object
123
- @update:model-value="cardSearch = ''"
124
115
  >
125
- <template #append-item>
126
- <!-- Sentinel for infinite scroll. Keyed on the page so it
127
- re-mounts after each append: an IntersectionObserver only
128
- reports changes, so a sentinel that stays in view after a
129
- short page would otherwise never fire again. -->
130
- <div
131
- v-if="cardHasMore"
132
- :key="cardPage"
133
- v-intersect="onCardListEndReached"
134
- class="d-flex justify-center py-2"
135
- >
136
- <v-progress-circular
137
- v-if="cardsLoadingMore"
138
- indeterminate
139
- size="20"
140
- width="2"
141
- />
142
- </div>
143
- </template>
144
- </v-autocomplete>
116
+ </v-select>
145
117
  </div>
146
118
 
147
119
  <div class="mt-3">
@@ -350,7 +322,6 @@ const props = defineProps({
350
322
  const emit = defineEmits(["update:modelValue", "update:quantity", "update:cards", "update:availableQrCount"]);
351
323
 
352
324
  const { getAvailableContractorCards, generateQrVms } = useAccessManagement();
353
- const { debounce } = useUtils();
354
325
 
355
326
  const nfcEnabled = computed(() => props.settings?.data?.settings?.nfcPass ?? false);
356
327
  const hidQrCodeEnabled = computed(() => {
@@ -396,53 +367,21 @@ const filteredCardItems = computed(() =>
396
367
  : cardItems.value
397
368
  );
398
369
  const cardsLoading = ref(false);
399
- const cardsLoadingMore = ref(false);
400
- // Typed text in the card picker. Reset after each pick so the next search
401
- // starts from the full list instead of staying narrowed to the last term.
402
- const cardSearch = ref("");
403
- const CARD_PAGE_SIZE = 100;
404
- const cardPage = ref(1);
405
- const cardHasMore = ref(false);
406
- // Bumped on every request. A response whose number is no longer current is
407
- // dropped, so a slow page-1 search cannot overwrite a newer one and a
408
- // load-more cannot append onto a list that has since been replaced.
409
- let cardRequestSeq = 0;
410
370
  const generating = ref(false);
411
371
  const availableCount = ref<number | null>(null);
412
372
 
413
- // The picker is NFC-only (see the template's `v-if`), so only NFC fetches
414
- // carry the search term and only NFC reacts to typing. The QR path keeps
415
- // its page-1, no-search fetch and its top-up generation untouched.
416
- const cardFetchType = computed<"NFC" | "QRCODE" | null>(() =>
417
- passType.value === "NFC" ? "NFC" : passType.value === "QR" ? "QRCODE" : null,
418
- );
419
-
420
- async function fetchCards(
421
- type: "NFC" | "QRCODE" = "NFC",
422
- opts: { page?: number; append?: boolean } = {},
423
- ) {
373
+ async function fetchCards(type: "NFC" | "QRCODE" = "NFC") {
424
374
  if (!props.siteId || !props.unitId) return;
425
- const page = opts.page ?? 1;
426
- const append = opts.append === true;
427
- const seq = ++cardRequestSeq;
428
- const query = {
429
- type,
430
- siteId: props.siteId,
431
- unitId: props.unitId,
432
- page,
433
- limit: CARD_PAGE_SIZE,
434
- // Vuetify writes null into the search model when it clears the field.
435
- search: type === "NFC" ? cardSearch.value ?? "" : undefined,
436
- };
437
-
438
- if (append) cardsLoadingMore.value = true;
439
- else cardsLoading.value = true;
375
+ cardsLoading.value = true;
440
376
  if (type === "QRCODE") emit("update:availableQrCount", null);
441
377
  try {
442
- const res = await getAvailableContractorCards(query);
443
- if (seq !== cardRequestSeq) return;
444
- let items: any[] = res?.data?.[0]?.items ?? [];
445
- availableCount.value = (res?.data?.[2] as any)?.count ?? items.length;
378
+ const res = await getAvailableContractorCards({
379
+ type,
380
+ siteId: props.siteId,
381
+ unitId: props.unitId,
382
+ });
383
+ cardItems.value = res?.data?.[0]?.items ?? [];
384
+ availableCount.value = (res?.data?.[2] as any)?.count ?? cardItems.value.length;
446
385
 
447
386
  if (type === "QRCODE" && props.settings?.data?._id && availableCount.value !== null && availableCount.value > 0 && availableCount.value < 50) {
448
387
  cardsLoading.value = false;
@@ -453,64 +392,30 @@ async function fetchCards(
453
392
  quantity: 50,
454
393
  });
455
394
  // Re-fetch to get the updated count after generation
456
- const refreshed = await getAvailableContractorCards(query);
457
- if (seq !== cardRequestSeq) return;
458
- items = refreshed?.data?.[0]?.items ?? [];
459
- availableCount.value = (refreshed?.data?.[2] as any)?.count ?? items.length;
395
+ const refreshed = await getAvailableContractorCards({
396
+ type,
397
+ siteId: props.siteId,
398
+ unitId: props.unitId,
399
+ });
400
+ cardItems.value = refreshed?.data?.[0]?.items ?? [];
401
+ availableCount.value = (refreshed?.data?.[2] as any)?.count ?? cardItems.value.length;
460
402
  generating.value = false;
461
403
  }
462
404
 
463
- cardItems.value = append ? [...cardItems.value, ...items] : items;
464
- cardPage.value = page;
465
- // A short page is the last one. An exact multiple of the page size costs
466
- // one extra, empty fetch, which then turns this off.
467
- cardHasMore.value = items.length === CARD_PAGE_SIZE;
468
-
469
405
  if (type === "QRCODE") emit("update:availableQrCount", availableCount.value);
470
406
  } catch (err: any) {
471
- if (seq !== cardRequestSeq) return;
472
407
  const msg = err?.data?.message ?? err?.message ?? String(err);
473
408
  showSnackbar(`Entry pass service error: ${msg}`);
409
+ availableCount.value = null;
410
+ cardItems.value = [];
474
411
  generating.value = false;
475
- // A failed load-more keeps what is already listed; a failed first page
476
- // clears the list as before.
477
- if (!append) {
478
- availableCount.value = null;
479
- cardItems.value = [];
480
- cardHasMore.value = false;
481
- if (type === "QRCODE") emit("update:availableQrCount", null);
482
- }
412
+ if (type === "QRCODE") emit("update:availableQrCount", null);
483
413
  } finally {
484
- // Only the request that is still current may clear the flags; a stale
485
- // one finishing late must not switch off a newer request's spinner.
486
- if (seq === cardRequestSeq) {
487
- cardsLoading.value = false;
488
- cardsLoadingMore.value = false;
489
- generating.value = false;
490
- }
414
+ cardsLoading.value = false;
415
+ generating.value = false;
491
416
  }
492
417
  }
493
418
 
494
- function onCardListEndReached(isIntersecting: boolean) {
495
- if (!isIntersecting || !cardHasMore.value) return;
496
- if (cardsLoading.value || cardsLoadingMore.value) return;
497
- const type = cardFetchType.value;
498
- if (!type) return;
499
- fetchCards(type, { page: cardPage.value + 1, append: true });
500
- }
501
-
502
- const searchCards = debounce(() => {
503
- if (cardFetchType.value !== "NFC") return;
504
- fetchCards("NFC");
505
- }, 500);
506
-
507
- // Vuetify hands back null when it clears the field, and re-sets "" on blur;
508
- // only a real change of the trimmed term should cost a request.
509
- watch(cardSearch, (val, old) => {
510
- if ((val ?? "").trim() === (old ?? "").trim()) return;
511
- searchCards();
512
- });
513
-
514
419
  function addCard(card: any) {
515
420
  const already = selectedCards.value.some((c) => c._id === card._id);
516
421
  if (!already) selectedCards.value = [...selectedCards.value, card];
@@ -482,55 +482,6 @@
482
482
  </draggable>
483
483
  </section>
484
484
 
485
- <!-- RESIDENT CHECKLISTS -->
486
- <v-col cols="12" class="mt-4 border-t">
487
- <v-row align="center" no-gutters class="pt-5">
488
- <v-col cols="9" class="d-flex align-center text-body-2">
489
- <v-row no-gutters>
490
- <v-col cols="12" class="header-style">
491
- Resident Checklists
492
- </v-col>
493
- <v-col cols="12" class="text-body-2 text-medium-emphasis">
494
- Allow residents to view and complete assigned checklists. Each
495
- checklist requires a photo upload before submission.
496
- </v-col>
497
- </v-row>
498
- </v-col>
499
- <v-col cols="3" class="d-flex align-center justify-end">
500
- <v-switch
501
- v-model="facility.isResidentChecklistEnabled"
502
- class="d-flex align-center"
503
- color="accent"
504
- hide-details
505
- @update:model-value="onToggleResidentChecklist"
506
- />
507
- </v-col>
508
- </v-row>
509
-
510
- <!-- SECURITY CHECKLISTS -->
511
- <v-row align="center" no-gutters class="border-t pt-5 mt-5">
512
- <v-col cols="9" class="d-flex align-center text-body-2">
513
- <v-row no-gutters>
514
- <v-col cols="12" class="header-style">
515
- Security Checklists
516
- </v-col>
517
- <v-col cols="12" class="text-body-2 text-medium-emphasis">
518
- Allow Security to view and complete assigned checklists
519
- </v-col>
520
- </v-row>
521
- </v-col>
522
- <v-col cols="3" class="d-flex align-center justify-end">
523
- <v-switch
524
- v-model="facility.isSecurityChecklistEnabled"
525
- class="d-flex align-center"
526
- color="accent"
527
- hide-details
528
- @update:model-value="onToggleSecurityChecklist"
529
- />
530
- </v-col>
531
- </v-row>
532
- </v-col>
533
-
534
485
  <v-col cols="12" class="mt-10">
535
486
  <p class="header-style font-weight-bold mb-5 mt-md-0 ml-md-4">
536
487
  Other Settings
@@ -1069,20 +1020,6 @@ watchEffect(() => {
1069
1020
  }
1070
1021
  });
1071
1022
 
1072
- /**
1073
- * One checklist, one owner, and always one. A facility is completed either by
1074
- * its residents or by Security, so the two switches are each other's inverse:
1075
- * turning either off turns the other on. There is no state where a facility
1076
- * has no checklist owner.
1077
- */
1078
- function onToggleResidentChecklist(enabled: boolean | null) {
1079
- facility.value.isSecurityChecklistEnabled = !enabled;
1080
- }
1081
-
1082
- function onToggleSecurityChecklist(enabled: boolean | null) {
1083
- facility.value.isResidentChecklistEnabled = !enabled;
1084
- }
1085
-
1086
1023
  function onToggleBookingUnitLimit(val: any) {
1087
1024
  if (val) {
1088
1025
  isBookingUnitLimitEnabled.value = true;