@7365admin1/layer-common 4.97.0 → 4.97.1-staging.500

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 (62) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +59 -6
  5. package/components/AccessCardPreviewDialog.vue +371 -53
  6. package/components/AppSelect.vue +143 -4
  7. package/components/BuildingUnitFormEdit.vue +6 -0
  8. package/components/BuildingUnitManagement.vue +298 -0
  9. package/components/Dialog/UpdateMoreAction.vue +11 -1
  10. package/components/DocumentForm.vue +407 -112
  11. package/components/DocumentManagement.vue +213 -12
  12. package/components/Facility/BookingSetup.vue +63 -0
  13. package/components/HidAccessLogDashboard.vue +74 -17
  14. package/components/HidAccessPermissions.vue +424 -0
  15. package/components/HidIntercomManagement.vue +183 -14
  16. package/components/HidProfileQrCode.vue +332 -0
  17. package/components/HidQrCodeConfiguration.vue +17 -195
  18. package/components/HidReaderManagement.vue +398 -0
  19. package/components/HidReaderUserRoster.vue +44 -3
  20. package/components/HidUserEnrollment.vue +2239 -276
  21. package/components/InvitationClientForm.vue +19 -1
  22. package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
  23. package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
  24. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
  25. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
  26. package/components/ServiceProviderMain.vue +48 -0
  27. package/components/SiteProviderChange.vue +371 -0
  28. package/components/TableMain.vue +22 -7
  29. package/components/VisitorForm.vue +12 -0
  30. package/components/VisitorManagement.vue +267 -5
  31. package/composables/useAccessManagement.ts +17 -1
  32. package/composables/useCustomerSite.ts +29 -0
  33. package/composables/useDocument.ts +7 -0
  34. package/composables/useFacility.ts +11 -0
  35. package/composables/useHidAmico.ts +165 -0
  36. package/composables/useHidNavigation.ts +25 -1
  37. package/composables/useHidReaderSelection.ts +50 -0
  38. package/composables/useMember.ts +12 -0
  39. package/composables/useNFCPatrolReport.ts +36 -32
  40. package/composables/useNFCPatrolReportFilters.ts +21 -3
  41. package/composables/useNFCPatrolRoute.ts +10 -3
  42. package/composables/usePeople.ts +19 -0
  43. package/composables/useServiceProvider.ts +45 -2
  44. package/composables/useSiteCategory.ts +45 -0
  45. package/package.json +1 -1
  46. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  47. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  49. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  50. package/types/document.d.ts +14 -0
  51. package/types/facility.d.ts +4 -0
  52. package/types/member.d.ts +4 -0
  53. package/types/nfc-patrol-report.ts +0 -2
  54. package/types/people.d.ts +28 -1
  55. package/types/service-provider.d.ts +5 -0
  56. package/types/site.d.ts +7 -1
  57. package/utils/hid-enrolment-subject.ts +344 -0
  58. package/utils/hid-permission-assignments.ts +198 -0
  59. package/utils/hid-reader-selection.ts +52 -0
  60. package/utils/nfc-patrol-time.ts +99 -0
  61. package/utils/occupancy-role.ts +109 -0
  62. package/utils/service-type.ts +30 -0
@@ -57,7 +57,24 @@
57
57
  </div>
58
58
  </v-col>
59
59
 
60
- <v-col cols="12" class="mb-2" v-if="isInsideFolder">
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">
61
78
  <div class="folder-path-bar d-flex align-center ga-2 px-3 py-2">
62
79
  <v-btn
63
80
  icon="mdi-arrow-left"
@@ -149,19 +166,79 @@
149
166
  <span>{{ item.date_modified || "N/A" }}</span>
150
167
  </template>
151
168
 
169
+ <template #item.expiry_date="{ item }">
170
+ <span>{{ item.expiry_date || "N/A" }}</span>
171
+ </template>
172
+
152
173
  <template #item.remarks="{ item }">
153
174
  <span>{{ item.remarks || "N/A" }}</span>
154
175
  </template>
155
176
 
156
177
  <template #item.action="{ item }">
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>
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>
165
242
 
166
243
  <v-menu
167
244
  v-else
@@ -200,8 +277,11 @@
200
277
  </TableV3>
201
278
  </v-col>
202
279
 
203
- <!-- Create Dialog -->
204
- <v-dialog v-model="createDialog" width="450" persistent>
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>
205
285
  <DocumentForm
206
286
  v-if="createDialog"
207
287
  :parent-id="activeParentId"
@@ -210,6 +290,17 @@
210
290
  />
211
291
  </v-dialog>
212
292
 
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
+
213
304
  <!-- Create Folder Dialog -->
214
305
  <v-dialog v-model="createFolderDialog" width="450" persistent>
215
306
  <v-card width="100%">
@@ -484,6 +575,15 @@
484
575
  </v-row>
485
576
  </v-col>
486
577
 
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
+
487
587
  <v-col cols="12">
488
588
  <v-row no-gutters>
489
589
  <v-col cols="6" class="text-body-1">Remarks</v-col>
@@ -634,6 +734,10 @@ const props = defineProps({
634
734
  title: "Date Modified",
635
735
  value: "date_modified",
636
736
  },
737
+ {
738
+ title: "Expiry Date",
739
+ value: "expiry_date",
740
+ },
637
741
  {
638
742
  title: "Remarks",
639
743
  value: "remarks",
@@ -733,6 +837,13 @@ const currentFolderPath = computed(() => {
733
837
  const fileTypes = ref(["all", "pdf", "csv", "doc"]);
734
838
  const selectedFileType = ref("all");
735
839
 
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
+
736
847
  const {
737
848
  data: getDocumentReq,
738
849
  refresh: getDocuments,
@@ -748,12 +859,20 @@ const {
748
859
  parentId: activeParentId.value,
749
860
  type:
750
861
  selectedFileType.value === "all" ? undefined : selectedFileType.value,
862
+ expiry: activeTab.value,
751
863
  }),
752
864
  {
753
- watch: [page, headerSearch, activeParentId, selectedFileType],
865
+ watch: [page, headerSearch, activeParentId, selectedFileType, activeTab],
754
866
  }
755
867
  );
756
868
 
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
+
757
876
  const loading = computed(() => getAllReqStatus.value === "pending");
758
877
 
759
878
  // The page totals are the server's count of the WHOLE list, read through the
@@ -788,6 +907,28 @@ function formatDate(value?: string | null) {
788
907
  });
789
908
  }
790
909
 
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
+
791
932
  function formatBytes(bytes = 0) {
792
933
  const b = Number(bytes) || 0;
793
934
  if (b === 0) return "0 B";
@@ -823,13 +964,20 @@ const tableItems = computed(() => {
823
964
  : formatBytes(Number(doc.file_size ?? doc.size ?? 0)),
824
965
  date_created: formatDate(doc.created_at || doc.createdAt),
825
966
  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),
826
972
  remarks: doc.remarks || "N/A",
827
973
  }));
828
974
  });
829
975
 
830
976
  const createDialog = ref(false);
831
977
  const editDialog = ref(false);
978
+ const updateDialog = ref(false);
832
979
  const previewDialog = ref(false);
980
+ const documentActionMenu = ref<Record<string, boolean>>({});
833
981
 
834
982
  // Create folder dialog state
835
983
  const createFolderDialog = ref(false);
@@ -880,6 +1028,10 @@ const previewDetails = computed(() => {
880
1028
  fileSize,
881
1029
  dateCreated: formatDate(document?.created_at || document?.createdAt),
882
1030
  dateModified: formatDate(document?.updated_at || document?.updatedAt),
1031
+ expiryDate:
1032
+ document?.type === "folder"
1033
+ ? "N/A"
1034
+ : formatDateOnly(document?.expiry_date || document?.expiryDate),
883
1035
  remarks: document?.remarks || "N/A",
884
1036
  };
885
1037
  });
@@ -1151,6 +1303,29 @@ function openDeleteDialog() {
1151
1303
  message.value = "";
1152
1304
  }
1153
1305
 
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
+
1154
1329
  function openEditDialog() {
1155
1330
  editDialog.value = true;
1156
1331
  }
@@ -1179,6 +1354,19 @@ function successUpdate() {
1179
1354
  showMessage("Document updated successfully!", "success");
1180
1355
  }
1181
1356
 
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
+
1182
1370
  function selectFileType(type: string) {
1183
1371
  selectedFileType.value = type;
1184
1372
  filterMenu.value = false;
@@ -1203,6 +1391,19 @@ function selectFileType(type: string) {
1203
1391
  color: var(--text);
1204
1392
  }
1205
1393
 
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
+
1206
1407
  .table-download-btn {
1207
1408
  min-width: 106px;
1208
1409
  height: 30px;
@@ -482,6 +482,55 @@
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
+
485
534
  <v-col cols="12" class="mt-10">
486
535
  <p class="header-style font-weight-bold mb-5 mt-md-0 ml-md-4">
487
536
  Other Settings
@@ -1020,6 +1069,20 @@ watchEffect(() => {
1020
1069
  }
1021
1070
  });
1022
1071
 
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
+
1023
1086
  function onToggleBookingUnitLimit(val: any) {
1024
1087
  if (val) {
1025
1088
  isBookingUnitLimitEnabled.value = true;
@@ -376,6 +376,7 @@ const visitorRecords = ref<Record<string, HidAccessRecord>>({});
376
376
  const logs = ref<HidAccessRecord[]>([]);
377
377
  const liveAccessLogs = ref<HidAccessRecord[]>([]);
378
378
  const administratorUserIds = ref<Set<number>>(new Set());
379
+ const { selectHidReader, resolveForSite } = useHidReaderSelection();
379
380
  const selectedReaderId = ref("");
380
381
  const activeTab = ref<AccessTab>("all");
381
382
  const search = ref("");
@@ -401,7 +402,10 @@ const exportForm = reactive({
401
402
  });
402
403
 
403
404
  const statusOptions = ["Authorized", "Not Authorized", "Unknown"];
404
- const accessMethodFilterOptions = ["Facial", "QR Code", "ID and Password", "PIN", "Card"];
405
+ const accessMethodFilterOptions = ["Facial", "QR Code", "ID and Password", "PIN", "Card", "Intercom"];
406
+
407
+ /** The persisted event the resident app records when it opens a door on a call. */
408
+ const INTERCOM_DOOR_OPEN_EVENT = "intercom_door_open";
405
409
  const accessMethodOptions = ["All", ...accessMethodFilterOptions];
406
410
 
407
411
  const readerOptions = computed(() =>
@@ -589,9 +593,11 @@ async function loadReaders() {
589
593
  if (!props.site) return;
590
594
  const response = await getReaders({ site: props.site, page: 1, limit: 100 });
591
595
  readers.value = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
592
- if (!selectedReaderId.value && readers.value.length) {
593
- selectedReaderId.value = readers.value[0]._id;
594
- }
596
+ /* Shared with every other HID screen — see `useHidReaderSelection`. The
597
+ resolver refuses a remembered reader that is not this site's, so a site
598
+ switch or a deleted reader falls back to the first rather than querying
599
+ an id the site does not own. */
600
+ selectedReaderId.value = resolveForSite(props.site, readers.value);
595
601
  }
596
602
 
597
603
  async function loadDashboard() {
@@ -606,7 +612,7 @@ async function loadDashboard() {
606
612
  resetDashboardData();
607
613
  try {
608
614
  const isVisitorTab = activeTab.value === "visitor";
609
- const [logResponse, hidAccessLogResponse, visitorResponse, readerUserResponse] = await Promise.all([
615
+ const [logResponse, intercomLogResponse, hidAccessLogResponse, visitorResponse, readerUserResponse] = await Promise.all([
610
616
  loadDashboardSource(
611
617
  getLogs(selectedReaderId.value, {
612
618
  page: page.value,
@@ -615,6 +621,20 @@ async function loadDashboard() {
615
621
  null,
616
622
  "persisted access logs",
617
623
  ),
624
+ // Door opens from the resident app during an intercom call. The reader
625
+ // never logs a DTMF door open, so these exist only as persisted events
626
+ // and are fetched by type: the general page above is shared with every
627
+ // other event the reader emits and is dropped whenever the reader has
628
+ // live logs, which it always has.
629
+ loadDashboardSource(
630
+ getLogs(selectedReaderId.value, {
631
+ page: page.value,
632
+ limit,
633
+ type: INTERCOM_DOOR_OPEN_EVENT,
634
+ }),
635
+ null,
636
+ "intercom door-open logs",
637
+ ),
618
638
  loadDashboardSource(
619
639
  getReaderAccessLogs(selectedReaderId.value, {
620
640
  page: page.value,
@@ -681,9 +701,14 @@ async function loadDashboard() {
681
701
  // identity metadata here: it can contain a stale photo from another user.
682
702
  allIdentities.value = readerUsers;
683
703
  identities.value = readerUsers;
684
- logs.value = liveItems.length
685
- ? []
686
- : logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
704
+ const intercomItems = intercomLogResponse?.items ?? intercomLogResponse?.data?.items ?? intercomLogResponse?.data?.logs ?? [];
705
+ logs.value = [
706
+ ...(liveItems.length
707
+ ? []
708
+ : logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? []),
709
+ // Always kept: the reader has no live counterpart for these.
710
+ ...intercomItems,
711
+ ];
687
712
  liveAccessLogs.value = liveItems;
688
713
  serverPages.value = Number(hidAccessLogResponse?.pages ?? hidAccessLogResponse?.data?.pages ?? 1);
689
714
  serverPageRange.value = String(
@@ -819,6 +844,8 @@ function getAccessMethod(identity: HidAccessRecord, log?: HidAccessRecord) {
819
844
  id_password: "ID and Password",
820
845
  pin: "PIN",
821
846
  card: "Card",
847
+ // Set by the API on intercom_door_open events; no reader field implies it.
848
+ intercom: "Intercom",
822
849
  };
823
850
  const normalizedMethod = String(payload.normalizedMethod || "");
824
851
  if (normalizedMethodLabels[normalizedMethod]) return normalizedMethodLabels[normalizedMethod];
@@ -833,11 +860,36 @@ function getAccessMethod(identity: HidAccessRecord, log?: HidAccessRecord) {
833
860
  return identity.metadata?.facialData || identity.metadata?.imageTimestamp ? "Facial" : "N/A";
834
861
  }
835
862
 
863
+ /**
864
+ * One shape for the whole Access Date and Time column: `15:18:43 23/9/2026`.
865
+ *
866
+ * Reader rows arrive as a number and went through `formatHidEpochDate`; an
867
+ * intercom door open never touches the reader, so the API writes it as an ISO
868
+ * string and this branch took `toLocaleString` instead - which put
869
+ * "9/23/2026, 2:15:29 PM" in the same column, one row below "15:18:43
870
+ * 23/9/2026". Both branches now build the same string.
871
+ *
872
+ * Local parts here, UTC parts there, on purpose: the reader encodes its own
873
+ * wall clock as if it were UTC, while an ISO string is a real instant. Reading
874
+ * each the way it was written is what makes both columns show the time that was
875
+ * on the clock at the door.
876
+ */
836
877
  function formatDate(value?: string | number) {
837
878
  if (!value) return "N/A";
838
879
  if (typeof value === "number") return formatHidEpochDate(value);
839
- const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
840
- return Number.isNaN(date.getTime()) ? "N/A" : date.toLocaleString();
880
+ const date = new Date(value);
881
+ if (Number.isNaN(date.getTime())) return "N/A";
882
+ return formatClock(
883
+ [date.getHours(), date.getMinutes(), date.getSeconds()],
884
+ date.getDate(),
885
+ date.getMonth() + 1,
886
+ date.getFullYear(),
887
+ );
888
+ }
889
+
890
+ function formatClock(time: number[], day: number, month: number, year: number) {
891
+ const clock = time.map((part) => String(part).padStart(2, "0")).join(":");
892
+ return `${clock} ${day}/${month}/${year}`;
841
893
  }
842
894
 
843
895
  function parseDate(value?: string | number) {
@@ -867,13 +919,12 @@ function formatHidEpochDate(value: number) {
867
919
  const date = new Date(value * 1000);
868
920
  if (Number.isNaN(date.getTime())) return "N/A";
869
921
 
870
- const time = [
871
- date.getUTCHours(),
872
- date.getUTCMinutes(),
873
- date.getUTCSeconds(),
874
- ].map((part) => String(part).padStart(2, "0")).join(":");
875
-
876
- return `${time} ${date.getUTCDate()}/${date.getUTCMonth() + 1}/${date.getUTCFullYear()}`;
922
+ return formatClock(
923
+ [date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()],
924
+ date.getUTCDate(),
925
+ date.getUTCMonth() + 1,
926
+ date.getUTCFullYear(),
927
+ );
877
928
  }
878
929
 
879
930
  function openHistoryDialog(row: AccessRow) {
@@ -1548,6 +1599,12 @@ function escapeHtml(value: unknown) {
1548
1599
  .replace(/"/g, "&quot;")
1549
1600
  .replace(/'/g, "&#039;");
1550
1601
  }
1602
+
1603
+ /* Publish every change, including the seed above: a screen that had to fall
1604
+ back to the first reader should leave the others agreeing with it. */
1605
+ watch(selectedReaderId, (value) => {
1606
+ if (value) selectHidReader(props.site, value);
1607
+ });
1551
1608
  </script>
1552
1609
 
1553
1610
  <style scoped>