@7365admin1/layer-common 3.2.1-staging.75 → 3.2.2-staging.100

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 (87) hide show
  1. package/.changeset/notification-preferences.md +17 -0
  2. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  3. package/CHANGELOG.md +6 -0
  4. package/assets/css/primitives.css +680 -0
  5. package/assets/css/screens.css +541 -0
  6. package/assets/css/shell.css +258 -0
  7. package/assets/css/tokens.css +220 -0
  8. package/components/AccessCardQrTagging.vue +1 -1
  9. package/components/AccessManagement.vue +53 -72
  10. package/components/AppButton.vue +52 -0
  11. package/components/AppCard.vue +20 -0
  12. package/components/AppDateField.vue +35 -0
  13. package/components/AppField.vue +46 -0
  14. package/components/AppKpiTile.vue +65 -0
  15. package/components/AppSegmented.vue +38 -0
  16. package/components/AppSelect.vue +203 -0
  17. package/components/AreaChecklistHistoryMain.vue +6 -2
  18. package/components/AreaMain.vue +9 -17
  19. package/components/AttendanceMain.vue +4 -3
  20. package/components/Avatar/Main.vue +54 -2
  21. package/components/BillingMain.vue +26 -30
  22. package/components/BuildingManagement/buildings.vue +27 -38
  23. package/components/BuildingManagement/units.vue +26 -42
  24. package/components/BulletinBoardManagement.vue +23 -10
  25. package/components/CardToolbar.vue +37 -0
  26. package/components/CleaningScheduleMain.vue +29 -46
  27. package/components/ClientMain.vue +90 -78
  28. package/components/DashboardEmptyState.vue +11 -1
  29. package/components/DashboardMain.vue +285 -207
  30. package/components/DocumentManagement.vue +129 -93
  31. package/components/EntryPassMain.vue +49 -27
  32. package/components/EquipmentItemMain.vue +4 -6
  33. package/components/EquipmentManagementMain.vue +9 -7
  34. package/components/FeedbackMain.vue +35 -49
  35. package/components/FormDialog.vue +60 -21
  36. package/components/HidIntercomManagement.vue +40 -24
  37. package/components/HidQrCodeConfiguration.vue +24 -2
  38. package/components/HidUserEnrollment.vue +68 -31
  39. package/components/InvitationMain.vue +46 -61
  40. package/components/Layout/Header.vue +289 -37
  41. package/components/Layout/NavigationDrawer.vue +299 -30
  42. package/components/ManageChecklistMain.vue +17 -17
  43. package/components/MemberMain.vue +79 -60
  44. package/components/MyAttendanceMain.vue +3 -5
  45. package/components/Nfc/NFCPatrolRouteMain.vue +64 -96
  46. package/components/NotificationSettings.vue +361 -0
  47. package/components/OnlineFormsConfiguration.vue +1 -1
  48. package/components/PageHeader.vue +43 -0
  49. package/components/PassInformation.vue +104 -45
  50. package/components/RolePermissionMain.vue +184 -91
  51. package/components/ScanVisitorQRCode.vue +35 -7
  52. package/components/ScheduleAreaMain.vue +15 -49
  53. package/components/ScheduleTaskMain.vue +23 -26
  54. package/components/ScheduleTastTicketMain.vue +18 -43
  55. package/components/ServiceProviderMain.vue +113 -168
  56. package/components/SiteSettings.vue +21 -8
  57. package/components/SpecificAttr.vue +10 -2
  58. package/components/StatusChip.vue +93 -0
  59. package/components/SwitchContext.vue +33 -1
  60. package/components/TableHygiene.vue +29 -46
  61. package/components/TableList.vue +1 -1
  62. package/components/TableListSecondary.vue +1 -1
  63. package/components/TableMain.vue +161 -77
  64. package/components/TableV3.vue +61 -66
  65. package/components/UnitMain.vue +9 -17
  66. package/components/VehicleManagement.vue +20 -9
  67. package/components/VisitorForm.vue +100 -7
  68. package/components/VisitorManagement.vue +219 -68
  69. package/components/WorkOrder/Main.vue +42 -51
  70. package/composables/useHidAmico.ts +26 -2
  71. package/composables/useNotificationPreference.ts +87 -0
  72. package/composables/useOrg.ts +1 -1
  73. package/composables/useRole.ts +22 -0
  74. package/composables/useSipWebPhone.ts +28 -3
  75. package/composables/useThemePreference.ts +63 -0
  76. package/composables/useVisitor.ts +6 -1
  77. package/nuxt.config.ts +31 -0
  78. package/package.json +3 -2
  79. package/pages/notification-settings.vue +19 -0
  80. package/plugins/vuetify.ts +32 -1
  81. package/types/org.d.ts +2 -0
  82. package/utils/nav-sections.test.ts +190 -0
  83. package/utils/nav-sections.ts +151 -0
  84. package/utils/status.test.ts +77 -0
  85. package/utils/status.ts +90 -0
  86. package/utils/theme.test.ts +325 -7
  87. package/utils/theme.ts +268 -137
@@ -515,13 +515,49 @@
515
515
  </div>
516
516
 
517
517
  <div class="hid-qr-image-shell my-5">
518
- <v-img :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
518
+ <v-img v-if="hidQrVisible" :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
519
+ <button v-else type="button" class="hid-qr-reveal" aria-label="Show QR code" @click="hidQrVisible = true">
520
+ <v-icon icon="mdi-eye-outline" size="42" />
521
+ <span>Click to show QR code</span>
522
+ </button>
519
523
  </div>
520
524
 
525
+ <v-btn
526
+ variant="text"
527
+ density="comfortable"
528
+ class="text-none"
529
+ :prepend-icon="hidQrVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
530
+ @click="hidQrVisible = !hidQrVisible"
531
+ >
532
+ {{ hidQrVisible ? "Hide QR code" : "Show QR code" }}
533
+ </v-btn>
534
+
521
535
  <div class="text-h6 font-weight-bold">{{ visitor.name }}</div>
522
536
  <div v-if="hidQrPreview.expiresAt" class="text-caption text-medium-emphasis mt-1">
523
537
  Valid until {{ formatHidQrExpiry(hidQrPreview.expiresAt) }}
524
538
  </div>
539
+ <v-chip
540
+ v-if="hidQrPreview.expiresAt"
541
+ class="mt-2"
542
+ size="small"
543
+ :color="hidQrExpired ? 'error' : 'success'"
544
+ variant="tonal"
545
+ >
546
+ {{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
547
+ </v-chip>
548
+
549
+ <v-btn
550
+ v-if="hidQrExpired"
551
+ block
552
+ class="mt-4 text-none"
553
+ color="primary-button"
554
+ variant="flat"
555
+ prepend-icon="mdi-refresh"
556
+ :loading="hidQrRegenerating"
557
+ @click="regenerateHidQrCode"
558
+ >
559
+ Generate QR again
560
+ </v-btn>
525
561
 
526
562
  <v-alert v-if="hidQrPreviewError" type="error" variant="tonal" density="compact" class="mt-4 text-left">
527
563
  {{ hidQrPreviewError }}
@@ -684,6 +720,11 @@ const isNricFieldFocused = ref(false);
684
720
  const hidQrPrinting = ref(false);
685
721
  const hidQrPreviewDialog = ref(false);
686
722
  const hidQrPreviewError = ref("");
723
+ const hidQrVisible = ref(false);
724
+ const hidQrRegenerating = ref(false);
725
+ const hidQrVisitorId = ref("");
726
+ const hidQrNow = ref(Date.now());
727
+ let hidQrTimer: ReturnType<typeof setInterval> | null = null;
687
728
  const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
688
729
  const hidQrPreview = reactive({
689
730
  imageUrl: "",
@@ -691,6 +732,24 @@ const hidQrPreview = reactive({
691
732
  expiresAt: "",
692
733
  });
693
734
 
735
+ const hidQrRemainingSeconds = computed(() => {
736
+ const expiry = new Date(hidQrPreview.expiresAt).getTime();
737
+ return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
738
+ });
739
+ const hidQrExpired = computed(() => Boolean(hidQrPreview.expiresAt) && hidQrRemainingSeconds.value === 0);
740
+
741
+ watch(hidQrPreviewDialog, (open) => {
742
+ if (hidQrTimer) clearInterval(hidQrTimer);
743
+ hidQrTimer = null;
744
+ if (!open) return;
745
+ hidQrNow.value = Date.now();
746
+ hidQrTimer = setInterval(() => { hidQrNow.value = Date.now(); }, 1000);
747
+ });
748
+
749
+ onBeforeUnmount(() => {
750
+ if (hidQrTimer) clearInterval(hidQrTimer);
751
+ });
752
+
694
753
  const registeredUnitCompanyName = ref("N/A");
695
754
 
696
755
  const dialog = reactive({
@@ -818,10 +877,6 @@ const hidQrValidityMinutes = computed(() => {
818
877
  });
819
878
 
820
879
  const hidQrReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
821
- const hidQrFormat = computed<"0" | "1" | "2">(() => {
822
- const value = String(hidQrCodePassConfig.value?.qrFormat || "0");
823
- return value === "1" || value === "2" ? value : "0";
824
- });
825
880
 
826
881
  const hidQrCodePassAllowed = computed(() => {
827
882
  return (
@@ -2088,10 +2143,10 @@ async function prepareHidQrCodePreview(visitorId: string) {
2088
2143
  }
2089
2144
 
2090
2145
  try {
2146
+ hidQrVisitorId.value = visitorId;
2091
2147
  const response = await issueVisitorQr(hidQrReaderId.value, {
2092
2148
  visitorId,
2093
2149
  validityMinutes: hidQrValidityMinutes.value,
2094
- qrFormat: hidQrFormat.value,
2095
2150
  });
2096
2151
  const credential = "data" in response ? response.data : response;
2097
2152
  hidQrPrintPayload.value = credential.qrValue;
@@ -2107,14 +2162,37 @@ async function prepareHidQrCodePreview(visitorId: string) {
2107
2162
  });
2108
2163
  hidQrPreview.issuedAt = credential.issuedAt || "";
2109
2164
  hidQrPreview.expiresAt = credential.expiresAt || "";
2165
+ hidQrVisible.value = false;
2166
+ hidQrNow.value = Date.now();
2110
2167
  hidQrPreviewDialog.value = true;
2111
2168
  return true;
2112
2169
  } catch (error: unknown) {
2113
- errorMessage.value = errorConverter(error) || "Unable to generate HID QR Code.";
2170
+ const message = errorConverter(error) || "Unable to generate HID QR Code.";
2171
+ if (hidQrPreviewDialog.value) hidQrPreviewError.value = message;
2172
+ else errorMessage.value = message;
2114
2173
  return false;
2115
2174
  }
2116
2175
  }
2117
2176
 
2177
+ function formatHidQrCountdown(totalSeconds: number) {
2178
+ const safe = Math.max(0, totalSeconds);
2179
+ const hours = Math.floor(safe / 3600);
2180
+ const minutes = Math.floor((safe % 3600) / 60);
2181
+ const seconds = safe % 60;
2182
+ return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
2183
+ }
2184
+
2185
+ async function regenerateHidQrCode() {
2186
+ if (!hidQrVisitorId.value) return;
2187
+ hidQrRegenerating.value = true;
2188
+ hidQrPreviewError.value = "";
2189
+ try {
2190
+ await prepareHidQrCodePreview(hidQrVisitorId.value);
2191
+ } finally {
2192
+ hidQrRegenerating.value = false;
2193
+ }
2194
+ }
2195
+
2118
2196
  async function printHidQrCodePass() {
2119
2197
  if (hidQrPrinted.value) return true;
2120
2198
 
@@ -2335,4 +2413,19 @@ onMounted(() => {
2335
2413
  border: 1px solid #dce3e8;
2336
2414
  background: #ffffff;
2337
2415
  }
2416
+
2417
+ .hid-qr-reveal {
2418
+ display: flex;
2419
+ width: 280px;
2420
+ height: 280px;
2421
+ align-items: center;
2422
+ justify-content: center;
2423
+ border: 0;
2424
+ background: #f5f7f9;
2425
+ color: #344054;
2426
+ cursor: pointer;
2427
+ flex-direction: column;
2428
+ gap: 10px;
2429
+ font: inherit;
2430
+ }
2338
2431
  </style>
@@ -1,50 +1,28 @@
1
1
  <template>
2
2
  <v-row no-gutters>
3
- <TableMain
4
- :headers="headers"
5
- :items="items"
6
- :loading="getVisitorPending"
7
- :page="page"
8
- :pages="pages"
9
- :extension-height="110"
10
- :offset="300"
11
- :pageRange="pageRange"
12
- @refresh="getVisitorRefresh"
13
- @update:page="handleUpdatePage"
14
- show-header
15
- @row-click="handleRowClick"
16
- >
17
- <template #actions>
18
- <v-row no-gutters>
19
- <v-btn
20
- v-if="canAddVisitor"
21
- class="text-none"
22
- rounded="pill"
23
- variant="tonal"
24
- size="large"
25
- @click="handleAddNew"
26
- text="Add Visitor"
27
- />
28
- <v-btn
3
+ <!--
4
+ THE DESIGN'S PAGE TITLE ROW (Phase 9).
5
+
6
+ Drawn here rather than through `TableMain`'s `title` prop because the
7
+ design puts the screen's actions on the title line, and `TableMain`
8
+ renders its `actions` slot as a separate row under the heading. Same
9
+ three buttons, same handlers, same permission gates - moved, not changed.
10
+ -->
11
+ <v-col cols="12">
12
+ <PageHeader title="Visitor Mgmt">
13
+ <template #actions>
14
+ <AppButton
29
15
  v-if="canScanVisitorQRCode"
30
- text="Scan QR Code"
31
- class="text-none ml-2"
32
- rounded="pill"
33
- variant="tonal"
34
- size="large"
16
+ variant="ghost"
35
17
  @click="dialog.scanVisitorQRCode = true"
36
- />
18
+ >
19
+ Scan QR Code
20
+ </AppButton>
37
21
  <v-menu>
38
22
  <template #activator="{ props }">
39
- <v-btn
40
- v-bind="props"
41
- text="Download Report"
42
- class="text-none ml-2"
43
- prepend-icon="mdi-download"
44
- rounded="pill"
45
- variant="tonal"
46
- size="large"
47
- />
23
+ <AppButton v-bind="props" variant="ghost" icon="mdi-download">
24
+ Download Report
25
+ </AppButton>
48
26
  </template>
49
27
  <v-list>
50
28
  <v-list-item
@@ -59,8 +37,27 @@
59
37
  />
60
38
  </v-list>
61
39
  </v-menu>
62
- </v-row>
63
- </template>
40
+ <AppButton v-if="canAddVisitor" icon="mdi-plus" @click="handleAddNew">
41
+ Add Visitor
42
+ </AppButton>
43
+ </template>
44
+ </PageHeader>
45
+ </v-col>
46
+
47
+ <TableMain
48
+ :headers="headers"
49
+ :items="items"
50
+ :loading="getVisitorPending"
51
+ :page="page"
52
+ :pages="pages"
53
+ :extension-height="110"
54
+ :offset="300"
55
+ :pageRange="pageRange"
56
+ @refresh="getVisitorRefresh"
57
+ @update:page="handleUpdatePage"
58
+ show-header
59
+ @row-click="handleRowClick"
60
+ >
64
61
  <template #extension>
65
62
  <v-row no-gutters class="w-100 d-flex flex-column">
66
63
  <v-tabs
@@ -109,32 +106,18 @@
109
106
  hide-details
110
107
  />
111
108
  <InputDatePicker v-model="dateTo" density="compact" hide-details />
112
- <v-select
109
+ <AppSelect
113
110
  v-if="activeTab == 'registered'"
114
111
  v-model="filterTypes"
115
112
  label="Filter by types"
116
113
  item-title="label"
117
114
  item-value="value"
118
115
  :items="visitorSelection"
119
- density="compact"
116
+ class="visitor-type-filter"
117
+ compact
120
118
  clearable
121
119
  multiple
122
- max-width="200"
123
- hide-details
124
- >
125
- <template v-slot:selection="{ item, index }">
126
- <div class="d-flex align-center text-caption text-nowrap">
127
- <v-chip
128
- v-if="index === 0"
129
- color="error"
130
- :text="filterTypeSelectionLabel()"
131
- size="x-small"
132
- variant="tonal"
133
- class="ml-2"
134
- />
135
- </div>
136
- </template>
137
- </v-select>
120
+ />
138
121
  </v-card>
139
122
  </v-row>
140
123
  </template>
@@ -303,6 +286,18 @@
303
286
  >
304
287
  </div>
305
288
 
289
+ <v-btn
290
+ v-if="hidQrEnabled"
291
+ icon="mdi-eye-outline"
292
+ size="x-small"
293
+ variant="text"
294
+ color="primary"
295
+ title="View HID QR code"
296
+ aria-label="View HID QR code"
297
+ class="align-self-start mt-1"
298
+ @click.stop="openVisitorHidQr(item)"
299
+ />
300
+
306
301
  <v-menu :close-on-content-click="true">
307
302
  <template v-slot:activator="{ props: menuProps }">
308
303
  <div
@@ -1177,12 +1172,52 @@
1177
1172
  </v-card>
1178
1173
  </v-dialog>
1179
1174
 
1175
+ <v-dialog v-model="hidQrDialog" max-width="440" persistent>
1176
+ <v-card>
1177
+ <v-toolbar color="transparent" density="comfortable" class="px-2">
1178
+ <v-toolbar-title class="text-subtitle-1 font-weight-bold">Visitor QR Code</v-toolbar-title>
1179
+ <v-btn icon="mdi-close" variant="text" title="Close" @click="hidQrDialog = false" />
1180
+ </v-toolbar>
1181
+ <v-card-text class="px-8 pb-6 text-center">
1182
+ <v-progress-circular v-if="hidQrLoading" indeterminate color="primary" class="my-10" />
1183
+ <template v-else-if="hidQrImageUrl">
1184
+ <div class="text-h6 font-weight-bold">{{ hidQrVisitor?.name || "Visitor Access Pass" }}</div>
1185
+ <div class="visitor-hid-qr my-5">
1186
+ <v-img :src="hidQrImageUrl" width="280" height="280" alt="Visitor HID QR code" />
1187
+ </div>
1188
+ <div class="text-caption text-medium-emphasis">
1189
+ Valid until {{ formatHidQrExpiry(hidQrExpiresAt) }}
1190
+ </div>
1191
+ <v-chip class="mt-2" size="small" :color="hidQrExpired ? 'error' : 'success'" variant="tonal">
1192
+ {{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
1193
+ </v-chip>
1194
+ </template>
1195
+ <v-alert v-if="hidQrError" type="warning" variant="tonal" density="compact" class="mt-4 text-left">
1196
+ {{ hidQrError }}
1197
+ </v-alert>
1198
+ <v-btn
1199
+ v-if="!hidQrLoading && (!hidQrImageUrl || hidQrExpired)"
1200
+ block
1201
+ color="primary-button"
1202
+ variant="flat"
1203
+ class="mt-4 text-none"
1204
+ prepend-icon="mdi-refresh"
1205
+ :loading="hidQrGenerating"
1206
+ @click="generateVisitorHidQr"
1207
+ >
1208
+ {{ hidQrExpired ? "Generate QR again" : "Generate HID QR code" }}
1209
+ </v-btn>
1210
+ </v-card-text>
1211
+ </v-card>
1212
+ </v-dialog>
1213
+
1180
1214
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
1181
1215
  </v-row>
1182
1216
  </template>
1183
1217
 
1184
1218
  <script setup lang="ts">
1185
1219
  import moment from "moment-timezone";
1220
+ import QRCode from "qrcode";
1186
1221
 
1187
1222
  definePageMeta({
1188
1223
  middleware: ["01-auth", "02-org"],
@@ -1244,6 +1279,8 @@ const {
1244
1279
  } = useUtils();
1245
1280
  const { formatLocation } = useSecurityUtils();
1246
1281
  const { getFileUrlAnpr } = useFile();
1282
+ const { getSiteById } = useSite();
1283
+ const { getIdentities, issueVisitorQr } = useHidAmico();
1247
1284
  // const { status: visitorStatus, search } = useRoute().query as { status: string, search: string};
1248
1285
 
1249
1286
  const routeName = route.name;
@@ -1255,6 +1292,106 @@ const pageRange = ref("-- - -- of --");
1255
1292
  const activeTab = ref("registered");
1256
1293
  const activeVisitorFormType = ref<TVisitorType | null>(null);
1257
1294
  const selectedVisitorId = ref<string | null>(""); // selected visitor for viewing/actions
1295
+ const hidQrDialog = ref(false);
1296
+ const hidQrLoading = ref(false);
1297
+ const hidQrGenerating = ref(false);
1298
+ const hidQrError = ref("");
1299
+ const hidQrImageUrl = ref("");
1300
+ const hidQrExpiresAt = ref("");
1301
+ const hidQrNow = ref(Date.now());
1302
+ const hidQrVisitor = ref<Record<string, any> | null>(null);
1303
+ const hidQrConfig = ref({ enabled: false, readerId: "", validityMinutes: 60 });
1304
+ let hidQrTimer: ReturnType<typeof setInterval> | null = null;
1305
+ const hidQrEnabled = computed(() => hidQrConfig.value.enabled && Boolean(hidQrConfig.value.readerId));
1306
+ const hidQrRemainingSeconds = computed(() => {
1307
+ const expiry = new Date(hidQrExpiresAt.value).getTime();
1308
+ return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
1309
+ });
1310
+ const hidQrExpired = computed(() => Boolean(hidQrExpiresAt.value) && hidQrRemainingSeconds.value === 0);
1311
+
1312
+ async function loadHidQrConfig() {
1313
+ try {
1314
+ const response: any = await getSiteById(siteId);
1315
+ const site = response?.data ?? response;
1316
+ const config = site?.metadata?.hidQrCodePass ?? {};
1317
+ hidQrConfig.value = {
1318
+ enabled: Boolean(config.enabled && config.onlineMode !== false),
1319
+ readerId: String(config.readerId || ""),
1320
+ validityMinutes: Number(config.validityMinutes) > 0 ? Number(config.validityMinutes) : 60,
1321
+ };
1322
+ } catch {
1323
+ hidQrConfig.value = { enabled: false, readerId: "", validityMinutes: 60 };
1324
+ }
1325
+ }
1326
+
1327
+ function extractIdentityItems(response: any) {
1328
+ return response?.items ?? response?.data?.items ?? [];
1329
+ }
1330
+
1331
+ async function openVisitorHidQr(visitor: Record<string, any>) {
1332
+ hidQrVisitor.value = visitor;
1333
+ hidQrDialog.value = true;
1334
+ hidQrLoading.value = true;
1335
+ hidQrError.value = "";
1336
+ hidQrImageUrl.value = "";
1337
+ hidQrExpiresAt.value = "";
1338
+ try {
1339
+ const response = await getIdentities(hidQrConfig.value.readerId, { type: "visitor", limit: 100 });
1340
+ const identity = extractIdentityItems(response).find(
1341
+ (item: any) => String(item.visitor || item.metadata?.visitor || "") === String(visitor._id),
1342
+ );
1343
+ const qrValue = String(identity?.metadata?.qrValue || identity?.cardNo || "");
1344
+ hidQrExpiresAt.value = String(identity?.metadata?.expiresAt || "");
1345
+ if (!qrValue || !hidQrExpiresAt.value) {
1346
+ hidQrError.value = "This visitor does not have an HID QR code yet.";
1347
+ return;
1348
+ }
1349
+ hidQrImageUrl.value = await QRCode.toDataURL(qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
1350
+ hidQrNow.value = Date.now();
1351
+ } catch (error: unknown) {
1352
+ hidQrError.value = errorConverter(error) || "Unable to load the visitor HID QR code.";
1353
+ } finally {
1354
+ hidQrLoading.value = false;
1355
+ }
1356
+ }
1357
+
1358
+ async function generateVisitorHidQr() {
1359
+ if (!hidQrVisitor.value?._id || !hidQrConfig.value.readerId) return;
1360
+ hidQrGenerating.value = true;
1361
+ hidQrError.value = "";
1362
+ try {
1363
+ const response = await issueVisitorQr(hidQrConfig.value.readerId, {
1364
+ visitorId: String(hidQrVisitor.value._id),
1365
+ validityMinutes: hidQrConfig.value.validityMinutes,
1366
+ });
1367
+ const credential = "data" in response ? response.data : response;
1368
+ hidQrImageUrl.value = await QRCode.toDataURL(credential.qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
1369
+ hidQrExpiresAt.value = credential.expiresAt;
1370
+ hidQrNow.value = Date.now();
1371
+ } catch (error: unknown) {
1372
+ hidQrError.value = errorConverter(error) || "Unable to generate the visitor HID QR code.";
1373
+ } finally {
1374
+ hidQrGenerating.value = false;
1375
+ }
1376
+ }
1377
+
1378
+ function formatHidQrExpiry(value: string) {
1379
+ const date = new Date(value);
1380
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
1381
+ }
1382
+
1383
+ function formatHidQrCountdown(totalSeconds: number) {
1384
+ const safe = Math.max(0, totalSeconds);
1385
+ const hours = Math.floor(safe / 3600);
1386
+ const minutes = Math.floor((safe % 3600) / 60);
1387
+ const seconds = safe % 60;
1388
+ return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
1389
+ }
1390
+
1391
+ watch(hidQrDialog, (open) => {
1392
+ if (hidQrTimer) clearInterval(hidQrTimer);
1393
+ hidQrTimer = open ? setInterval(() => { hidQrNow.value = Date.now(); }, 1000) : null;
1394
+ });
1258
1395
 
1259
1396
  //filter states
1260
1397
  const searchInput = ref("");
@@ -1505,11 +1642,6 @@ const formattedFields = {
1505
1642
  attachments: "Attachments",
1506
1643
  } as const;
1507
1644
 
1508
- function filterTypeSelectionLabel() {
1509
- const length = filterTypes.value.length;
1510
- return `${length} selected ${length === 1 ? "type" : "types"}` as string;
1511
- }
1512
-
1513
1645
  const passStatusOptions = [
1514
1646
  { label: "Returned", value: "Returned" },
1515
1647
  { label: "Not Returned", value: "In Use" },
@@ -2346,7 +2478,14 @@ const syncVisitorFiltersFromRoute = () => {
2346
2478
  }
2347
2479
  };
2348
2480
 
2349
- onMounted(syncVisitorFiltersFromRoute);
2481
+ onMounted(() => {
2482
+ syncVisitorFiltersFromRoute();
2483
+ void loadHidQrConfig();
2484
+ });
2485
+
2486
+ onBeforeUnmount(() => {
2487
+ if (hidQrTimer) clearInterval(hidQrTimer);
2488
+ });
2350
2489
 
2351
2490
  watch(() => route.query, syncVisitorFiltersFromRoute, { deep: true });
2352
2491
 
@@ -2397,4 +2536,16 @@ watchEffect(async () => {
2397
2536
  });
2398
2537
  </script>
2399
2538
 
2400
- <style scoped></style>
2539
+ <style scoped>
2540
+ /* The width the type filter carried as `max-width="200"` on the v-select. */
2541
+ .visitor-type-filter {
2542
+ max-width: 200px;
2543
+ }
2544
+
2545
+ .visitor-hid-qr {
2546
+ display: inline-flex;
2547
+ padding: 12px;
2548
+ border: 1px solid #dce3e8;
2549
+ background: #fff;
2550
+ }
2551
+ </style>
@@ -1,58 +1,47 @@
1
1
  <template>
2
- <v-row no-gutters>
3
- <v-col cols="12" class="mb-2">
4
- <v-row no-gutters align="center" justify="space-between">
5
- <v-btn
6
- class="text-none text-capitalize"
7
- rounded="pill"
8
- variant="tonal"
9
- size="large"
10
- @click="(showCreateDialog = true), (isEditMode = false)"
11
- v-if="canCreateWorkOrder"
12
- >
2
+ <div>
3
+ <!--
4
+ The design's page scaffold. The screen had no heading, its Create button
5
+ sat top-left and the search floated top-right outside the card, with
6
+ refresh and the pager stacked on their own bar above the table. Same
7
+ button, same dialog, same permission, same debounced search, same pager.
8
+ -->
9
+ <PageHeader title="Work Orders">
10
+ <template v-if="canCreateWorkOrder" #actions>
11
+ <AppButton @click="(showCreateDialog = true), (isEditMode = false)">
13
12
  Create Work Order
14
- </v-btn>
15
-
16
- <v-text-field
17
- v-model="searchText"
18
- placeholder="Search work order..."
19
- variant="outlined"
20
- density="comfortable"
21
- clearable
22
- hide-details
23
- class="ml-2"
24
- style="max-width: 250px"
25
- />
26
- </v-row>
27
- </v-col>
13
+ </AppButton>
14
+ </template>
15
+ </PageHeader>
16
+
17
+ <v-row no-gutters>
28
18
  <v-col cols="12">
29
- <v-card
30
- width="100%"
31
- variant="outlined"
32
- border="thin"
33
- rounded="lg"
34
- :loading="loading"
35
- >
36
- <v-toolbar density="compact" color="grey-lighten-4">
37
- <template #prepend>
38
- <v-btn fab icon density="comfortable" @click="updatePage(1)">
39
- <v-icon>mdi-refresh</v-icon>
40
- </v-btn>
19
+ <AppCard class="table-card">
20
+ <CardToolbar :count="pageRange">
21
+ <template #left>
22
+ <AppButton
23
+ variant="icon"
24
+ icon="mdi-refresh"
25
+ aria-label="Refresh"
26
+ @click="updatePage(1)"
27
+ />
41
28
  </template>
42
29
 
43
- <template #append>
44
- <v-row no-gutters justify="end" align="center">
45
- <span class="mr-2 text-caption text-fontgray">
46
- {{ pageRange }}
47
- </span>
48
- <local-pagination
49
- v-model="page"
50
- :length="pages"
51
- @update:value="updatePage"
52
- />
53
- </v-row>
30
+ <template #right>
31
+ <AppField
32
+ v-model="searchText"
33
+ search
34
+ compact
35
+ placeholder="Search work order..."
36
+ klass="app-toolbar__search"
37
+ />
38
+ <local-pagination
39
+ v-model="page"
40
+ :length="pages"
41
+ @update:value="updatePage"
42
+ />
54
43
  </template>
55
- </v-toolbar>
44
+ </CardToolbar>
56
45
 
57
46
  <v-data-table
58
47
  :headers="headers"
@@ -62,6 +51,7 @@
62
51
  fixed-header
63
52
  hide-default-footer
64
53
  :hide-default-header="false"
54
+ :loading="loading"
65
55
  @click:row="tableRowClickHandler"
66
56
  style="max-height: calc(100vh - (200px))"
67
57
  >
@@ -69,9 +59,10 @@
69
59
  {{ formatDateDDMMYYYYLocal(item.createdAt) }}
70
60
  </template>
71
61
  </v-data-table>
72
- </v-card>
62
+ </AppCard>
73
63
  </v-col>
74
- </v-row>
64
+ </v-row>
65
+ </div>
75
66
 
76
67
  <WorkOrderCreate
77
68
  v-model="showCreateDialog"