@7365admin1/layer-common 4.97.0 → 4.97.1-staging.501
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +59 -6
- package/components/AccessCardPreviewDialog.vue +371 -53
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +532 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2242 -279
- package/components/InvitationClientForm.vue +19 -1
- package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
- package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
- package/components/ServiceProviderMain.vue +48 -0
- package/components/SiteProviderChange.vue +371 -0
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useCustomerSite.ts +29 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReport.ts +36 -32
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +10 -3
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/nfc-patrol-report.ts +0 -2
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +366 -0
- package/utils/hid-permission-assignments.ts +259 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/nfc-patrol-time.ts +99 -0
- package/utils/occupancy-role.ts +109 -0
- 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"
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
<!--
|
|
204
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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 =
|
|
840
|
-
|
|
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
|
-
|
|
871
|
-
date.getUTCHours(),
|
|
872
|
-
date.
|
|
873
|
-
date.
|
|
874
|
-
|
|
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, """)
|
|
1549
1600
|
.replace(/'/g, "'");
|
|
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>
|