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

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 (126) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/notification-preferences.md +17 -0
  7. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  8. package/.changeset/service-provider-invitation-actions.md +28 -0
  9. package/assets/css/primitives.css +680 -0
  10. package/assets/css/screens.css +541 -0
  11. package/assets/css/shell.css +258 -0
  12. package/assets/css/tokens.css +220 -0
  13. package/components/AccessCardQrTagging.vue +1 -1
  14. package/components/AccessManagement.vue +53 -72
  15. package/components/AppButton.vue +52 -0
  16. package/components/AppCard.vue +20 -0
  17. package/components/AppDateField.vue +35 -0
  18. package/components/AppField.vue +51 -0
  19. package/components/AppKpiTile.vue +65 -0
  20. package/components/AppSegmented.vue +38 -0
  21. package/components/AppSelect.vue +203 -0
  22. package/components/AreaChecklistHistoryLogs.vue +51 -44
  23. package/components/AreaChecklistHistoryMain.vue +6 -2
  24. package/components/AreaMain.vue +9 -17
  25. package/components/AttendanceMain.vue +4 -3
  26. package/components/Avatar/Main.vue +54 -2
  27. package/components/BillingMain.vue +26 -30
  28. package/components/BuildingManagement/buildings.vue +27 -38
  29. package/components/BuildingManagement/units.vue +26 -42
  30. package/components/BulletinBoardManagement.vue +23 -10
  31. package/components/CameraWall.vue +1034 -0
  32. package/components/CameraWallTile.vue +818 -0
  33. package/components/CardToolbar.vue +37 -0
  34. package/components/CleaningScheduleMain.vue +29 -46
  35. package/components/ClientMain.vue +90 -78
  36. package/components/DashboardEmptyState.vue +11 -1
  37. package/components/DashboardMain.vue +349 -275
  38. package/components/DashboardPanel.vue +1 -1
  39. package/components/DocumentManagement.vue +129 -93
  40. package/components/EntryPassInformation.vue +4 -4
  41. package/components/EntryPassMain.vue +49 -27
  42. package/components/EquipmentItemMain.vue +4 -6
  43. package/components/EquipmentManagementMain.vue +9 -7
  44. package/components/FeedbackMain.vue +42 -56
  45. package/components/FormDialog.vue +60 -21
  46. package/components/HidAccessLogDashboard.vue +119 -44
  47. package/components/HidCameraCaptureDialog.vue +199 -0
  48. package/components/HidIntercomManagement.vue +825 -202
  49. package/components/HidQrCodeConfiguration.vue +927 -93
  50. package/components/HidServiceSettingsPanel.vue +20 -1
  51. package/components/HidUserEnrollment.vue +454 -106
  52. package/components/InvitationMain.vue +46 -61
  53. package/components/Layout/Header.vue +289 -37
  54. package/components/Layout/NavigationDrawer.vue +340 -30
  55. package/components/ManageChecklistMain.vue +17 -17
  56. package/components/MemberMain.vue +79 -60
  57. package/components/MyAttendanceMain.vue +3 -5
  58. package/components/NavigationItem.vue +9 -0
  59. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  60. package/components/NotificationSettings.vue +361 -0
  61. package/components/OnlineFormConfigurationForm.vue +620 -224
  62. package/components/OnlineFormFill.vue +36 -8
  63. package/components/OnlineFormsConfiguration.vue +7 -3
  64. package/components/PageHeader.vue +43 -0
  65. package/components/PassInformation.vue +104 -45
  66. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  67. package/components/QrTemplate/PrintDialog.vue +209 -196
  68. package/components/RolePermissionMain.vue +184 -91
  69. package/components/ScanVisitorQRCode.vue +35 -7
  70. package/components/ScheduleAreaMain.vue +15 -49
  71. package/components/ScheduleTaskMain.vue +23 -26
  72. package/components/ScheduleTastTicketMain.vue +18 -43
  73. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  74. package/components/ServiceProviderMain.vue +346 -157
  75. package/components/SiteSettings.vue +25 -10
  76. package/components/SiteSettingsWorkOrder.vue +61 -30
  77. package/components/SpecificAttr.vue +10 -2
  78. package/components/StatusChip.vue +93 -0
  79. package/components/StockCard.vue +17 -12
  80. package/components/SwitchContext.vue +33 -1
  81. package/components/TableHygiene.vue +29 -46
  82. package/components/TableList.vue +1 -1
  83. package/components/TableListSecondary.vue +1 -1
  84. package/components/TableMain.vue +161 -77
  85. package/components/TableV3.vue +61 -66
  86. package/components/UnitMain.vue +9 -17
  87. package/components/VehicleManagement.vue +20 -9
  88. package/components/VisitorForm.vue +294 -78
  89. package/components/VisitorManagement.vue +221 -71
  90. package/components/VisitorSocketPopUp.vue +56 -2
  91. package/components/WorkOrder/Main.vue +42 -51
  92. package/composables/useComment.ts +3 -3
  93. package/composables/useHidAmico.ts +151 -0
  94. package/composables/useHidNavigation.ts +0 -7
  95. package/composables/useLocalAuth.ts +9 -36
  96. package/composables/useNotificationPreference.ts +87 -0
  97. package/composables/useOnlineFormPages.ts +2 -0
  98. package/composables/useOrg.ts +1 -1
  99. package/composables/useRole.ts +22 -0
  100. package/composables/useServiceProviderInvitation.ts +145 -0
  101. package/composables/useSipWebPhone.ts +336 -0
  102. package/composables/useSiteSettings.ts +50 -0
  103. package/composables/useThemePreference.ts +63 -0
  104. package/composables/useVisitor.ts +6 -1
  105. package/composables/useVisitorSocket.ts +26 -0
  106. package/composables/useWebUsb.ts +127 -37
  107. package/nuxt.config.ts +31 -0
  108. package/package.json +4 -1
  109. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  110. package/pages/notification-settings.vue +19 -0
  111. package/plugins/secure-member.client.ts +21 -56
  112. package/plugins/vuetify.ts +68 -9
  113. package/test/visitor-socket.test.mjs +36 -0
  114. package/types/org.d.ts +2 -0
  115. package/types/site.d.ts +71 -0
  116. package/utils/camera-wall.test.ts +571 -0
  117. package/utils/camera-wall.ts +926 -0
  118. package/utils/nav-sections.test.ts +190 -0
  119. package/utils/nav-sections.ts +151 -0
  120. package/utils/status.test.ts +77 -0
  121. package/utils/status.ts +90 -0
  122. package/utils/theme.test.ts +534 -0
  123. package/utils/theme.ts +294 -0
  124. package/components/HidIdentityMapping.vue +0 -975
  125. package/middleware/member.ts +0 -4
  126. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -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
@@ -588,7 +583,7 @@
588
583
  :org="orgId"
589
584
  :site="siteId"
590
585
  :visitor-data="selectedVisitorDataObject"
591
- :current-user="currentUser._id"
586
+ :current-user="currentUser?._id"
592
587
  :type="activeVisitorFormType"
593
588
  :enable-unregistered="enableUnregistered"
594
589
  @back="handleClickBack"
@@ -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" },
@@ -1601,7 +1733,6 @@ const {
1601
1733
  watch(getVisitorReq, (newData: any) => {
1602
1734
  if (newData) {
1603
1735
  items.value = newData.items ?? [];
1604
- // items.value = [{ _id: "testid", name: "John Doe", type: "Contractor", company: "ABC Corp", location: "Block A, Level 1, Unit 101", contact: "12345678", plateNumber: "SGX1234A", checkIn: new Date().toISOString(), checkOut: null, status: "registered", visitorPass: [{ _id: "1", keyId: "pass1", prefixAndName: "VIP Pass" }, { _id: "2", keyId: "pass2", prefixAndName: "VIP Pass 2" }], passKeys: [{ _id: "1", keyId: "key2", prefixAndName: "Master Key" }] }];
1605
1736
  pages.value = newData.pages ?? 0;
1606
1737
  pageRange.value = newData?.pageRange ?? "-- - -- of --";
1607
1738
  }
@@ -1708,7 +1839,7 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
1708
1839
  site: visitorData.site ?? siteId,
1709
1840
  },
1710
1841
  update: {
1711
- updatedBy: currentUser.value._id,
1842
+ updatedBy: currentUser.value?._id,
1712
1843
  visitorPass: visitorPass.find(
1713
1844
  (i: Record<string, any>) => i.keyId === null
1714
1845
  )
@@ -2347,7 +2478,14 @@ const syncVisitorFiltersFromRoute = () => {
2347
2478
  }
2348
2479
  };
2349
2480
 
2350
- onMounted(syncVisitorFiltersFromRoute);
2481
+ onMounted(() => {
2482
+ syncVisitorFiltersFromRoute();
2483
+ void loadHidQrConfig();
2484
+ });
2485
+
2486
+ onBeforeUnmount(() => {
2487
+ if (hidQrTimer) clearInterval(hidQrTimer);
2488
+ });
2351
2489
 
2352
2490
  watch(() => route.query, syncVisitorFiltersFromRoute, { deep: true });
2353
2491
 
@@ -2398,4 +2536,16 @@ watchEffect(async () => {
2398
2536
  });
2399
2537
  </script>
2400
2538
 
2401
- <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>
@@ -66,6 +66,29 @@
66
66
  </template>
67
67
  </v-snackbar>
68
68
 
69
+ <!--
70
+ Camera health is its own alert, deliberately separate from the plate
71
+ snackbar above: it is not a visitor event, it must not be silenced by the
72
+ unregistered-visitor switch, and it must not be hidden on the unregistered
73
+ tab. Several failing cameras collapse into one line rather than one
74
+ snackbar each.
75
+ -->
76
+ <v-snackbar
77
+ v-if="canViewVisitor"
78
+ v-model="cameraAlert.modal"
79
+ :color="cameraFaults.size ? 'warning' : 'success'"
80
+ :timeout="10000"
81
+ location="top right"
82
+ class="rounded-xl"
83
+ multi-line
84
+ :z-index="100000000"
85
+ >
86
+ <span class="text-1-4rem">{{ cameraAlertText }}</span>
87
+ <template #actions>
88
+ <v-btn icon="mdi-close" variant="text" @click="cameraAlert.modal = false" />
89
+ </template>
90
+ </v-snackbar>
91
+
69
92
  <v-dialog v-model="permanentMessageDialog.modal" max-width="480" persistent>
70
93
  <v-card>
71
94
  <v-card-title class="text-h6">Notice</v-card-title>
@@ -132,6 +155,36 @@ const permanentMessageDialog = reactive({
132
155
  text: "",
133
156
  });
134
157
 
158
+ // Outstanding camera faults for this site, keyed by camera id so a repeat for
159
+ // the same camera replaces its line instead of adding another.
160
+ const cameraFaults = reactive(new Map<string, string>());
161
+ const cameraRecovery = ref("");
162
+ const cameraAlert = reactive({ modal: false });
163
+
164
+ const cameraAlertText = computed(
165
+ () => cameraAlertMessage([...cameraFaults.values()]) || cameraRecovery.value
166
+ );
167
+
168
+ const handleCameraHealth = (data: TVisitorSocketData) => {
169
+ // Older API-core builds send the fault text with no `event`, so a bare
170
+ // message is a fault.
171
+ if (data?.event === "camera-recovered") {
172
+ // Without a camera id there is no way to tell which fault cleared.
173
+ if (data?.camera) cameraFaults.delete(data.camera);
174
+ else cameraFaults.clear();
175
+ // Alerts are now infrequent, so the operator is told it came back —
176
+ // otherwise the only signal a camera recovered is silence.
177
+ cameraRecovery.value = cameraFaults.size ? "" : data?.message || "";
178
+ cameraAlert.modal = Boolean(cameraAlertText.value);
179
+ return;
180
+ }
181
+
182
+ if (!data?.message) return;
183
+ cameraRecovery.value = "";
184
+ cameraFaults.set(data?.camera || data.message, data.message);
185
+ cameraAlert.modal = true;
186
+ };
187
+
135
188
  watch(
136
189
  () => props.siteId,
137
190
  async (siteId) => {
@@ -233,8 +286,9 @@ const connectSocket = () => {
233
286
  }
234
287
 
235
288
 
236
- if (data?.message) {
237
- showTransientMessage(data.message, "error");
289
+ // Camera health, not a visitor event: its own alert, its own gate.
290
+ if (data?.message || data?.event) {
291
+ handleCameraHealth(data);
238
292
  }
239
293
 
240
294
  if (data?.messagePermanent) {
@@ -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"
@@ -89,13 +89,13 @@ export default function useComment() {
89
89
 
90
90
  comments.value = _comments.items
91
91
  .map((comment: any) => {
92
- if (comment.createdBy === currentUser.value._id) {
92
+ if (comment.createdBy === currentUser.value?._id) {
93
93
  comment.justify = "end";
94
94
  } else {
95
95
  comment.justify = "start";
96
96
  if (
97
97
  ((Array.isArray(comment.seenBy) &&
98
- !comment.seenBy.includes(currentUser.value._id)) ||
98
+ !comment.seenBy.includes(currentUser.value?._id)) ||
99
99
  !comment?.seenBy) &&
100
100
  comment?._id
101
101
  ) {
@@ -111,7 +111,7 @@ export default function useComment() {
111
111
  );
112
112
 
113
113
  if (Array.isArray(updateSeenIds) && updateSeenIds.length > 0) {
114
- const seenBy = await updateSeenBy(updateSeenIds, currentUser.value._id);
114
+ const seenBy = await updateSeenBy(updateSeenIds, currentUser.value?._id);
115
115
  }
116
116
  } catch (error) {
117
117
  console.log("error :", error);