@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
|
@@ -162,11 +162,8 @@
|
|
|
162
162
|
/>
|
|
163
163
|
<v-list-item class="screen-menu__item" title="Call Settings" prepend-icon="mdi-cog" @click="openCallDialog(item)" />
|
|
164
164
|
<v-list-item class="screen-menu__item" title="Dial Code" prepend-icon="mdi-dialpad" @click="openDialCodeDialog(item)" />
|
|
165
|
-
<v-list-item class="screen-menu__item" title="Make a call" prepend-icon="mdi-phone" @click="openMakeCallDialog(item)" />
|
|
166
|
-
<v-list-item class="screen-menu__item" title="Web call" prepend-icon="mdi-headset" @click="openWebPhoneDialog(item)" />
|
|
167
165
|
</v-list>
|
|
168
166
|
<v-list v-else density="compact" class="screen-menu">
|
|
169
|
-
<v-list-item class="screen-menu__item" title="Call" prepend-icon="mdi-phone" @click="openContactCall(item)" />
|
|
170
167
|
<v-list-item class="screen-menu__item" title="Check reader SIP status" prepend-icon="mdi-lan-check" @click="testContactSipStatus(item)" />
|
|
171
168
|
<v-list-item class="screen-menu__item" title="View" prepend-icon="mdi-eye" @click="openContactView(item)" />
|
|
172
169
|
<v-list-item class="screen-menu__item" title="Edit" prepend-icon="mdi-pencil" @click="openContactDialog(item)" />
|
|
@@ -589,9 +586,36 @@
|
|
|
589
586
|
:disabled="Boolean(contactForm.id)"
|
|
590
587
|
/>
|
|
591
588
|
<FieldLabel text="Name" required />
|
|
592
|
-
|
|
589
|
+
<!-- The name is picked from the site's SIP accounts (`hid-sip-accounts`)
|
|
590
|
+
rather than typed, so a contact always names a person who holds an
|
|
591
|
+
extension. The model stays the plain name string the reader stores,
|
|
592
|
+
so `saveContact` is unchanged; an existing contact whose name is not
|
|
593
|
+
in the list still displays it (AppSelect falls back to the raw value). -->
|
|
594
|
+
<!-- Searchable: this is the one list on the screen that grows with
|
|
595
|
+
the estate, and the unit is part of every option's text, so the
|
|
596
|
+
box finds a person by name or by where they live. -->
|
|
597
|
+
<AppSelect
|
|
598
|
+
v-model="contactForm.name"
|
|
599
|
+
:items="sipAccountOptions"
|
|
600
|
+
:disabled="sipAccountsLoading"
|
|
601
|
+
:placeholder="sipAccountsLoading ? 'Loading SIP accounts…' : 'Name shown on calls'"
|
|
602
|
+
searchable
|
|
603
|
+
search-placeholder="Search name or unit"
|
|
604
|
+
class="my-3"
|
|
605
|
+
@update:model-value="applySipAccountToContact"
|
|
606
|
+
/>
|
|
607
|
+
<p v-if="sipAccountsError" class="field-hint field-hint--error">{{ sipAccountsError }}</p>
|
|
608
|
+
<p v-else-if="!sipAccountsLoading && !sipAccounts.length" class="field-hint">
|
|
609
|
+
No SIP accounts on this site yet. An account is created the first time a user opens Web Call.
|
|
610
|
+
</p>
|
|
593
611
|
<FieldLabel text="Address" required />
|
|
594
|
-
|
|
612
|
+
<!-- Always read-only: it shows the extension of the chosen SIP account
|
|
613
|
+
and is never typed into. -->
|
|
614
|
+
<AppField
|
|
615
|
+
v-model="contactForm.serverAddress"
|
|
616
|
+
:placeholder="contactAddressPlaceholder"
|
|
617
|
+
disabled
|
|
618
|
+
/>
|
|
595
619
|
<p class="field-hint">{{ contactAddressHint }}</p>
|
|
596
620
|
</v-card-text>
|
|
597
621
|
<v-card-actions class="dialog-actions pa-0">
|
|
@@ -636,6 +660,8 @@
|
|
|
636
660
|
</template>
|
|
637
661
|
|
|
638
662
|
<script setup lang="ts">
|
|
663
|
+
import type { HidSipAccountListItem } from "../composables/useHidAmico";
|
|
664
|
+
|
|
639
665
|
const props = defineProps({
|
|
640
666
|
org: {
|
|
641
667
|
type: String,
|
|
@@ -732,6 +758,8 @@ const {
|
|
|
732
758
|
makeIntercomCall,
|
|
733
759
|
finalizeIntercomCall,
|
|
734
760
|
ensureSipAccount,
|
|
761
|
+
getSipAccounts,
|
|
762
|
+
notifyIntercomCall,
|
|
735
763
|
} = useHidAmico();
|
|
736
764
|
|
|
737
765
|
const activeTab = ref<IntercomTab>("intercom");
|
|
@@ -760,6 +788,11 @@ const webPhoneActionLoading = ref(false);
|
|
|
760
788
|
const sipAccount = ref<SipAccount | null>(null);
|
|
761
789
|
const sipAccountLoading = ref(false);
|
|
762
790
|
const sipAccountError = ref("");
|
|
791
|
+
// The site's SIP account list for the contact dialog's name picker. Distinct
|
|
792
|
+
// from `sipAccount` above, which is the CALLER's own account for the web phone.
|
|
793
|
+
const sipAccounts = ref<HidSipAccountListItem[]>([]);
|
|
794
|
+
const sipAccountsLoading = ref(false);
|
|
795
|
+
const sipAccountsError = ref("");
|
|
763
796
|
const remoteVideoEl = ref<HTMLVideoElement | null>(null);
|
|
764
797
|
const remoteAudioEl = ref<HTMLAudioElement | null>(null);
|
|
765
798
|
const webPhone = useSipWebPhone();
|
|
@@ -864,6 +897,58 @@ const readerOptions = computed(() => readers.value.map((reader) => ({
|
|
|
864
897
|
value: reader._id,
|
|
865
898
|
})));
|
|
866
899
|
|
|
900
|
+
/**
|
|
901
|
+
* "Harris - Blk 1/Level 1/Unit 1" - the name a contact is stored under.
|
|
902
|
+
*
|
|
903
|
+
* The unit belongs in the name itself, not only in the picker's label: the
|
|
904
|
+
* reader's contact list is what a visitor reads off the door panel, and
|
|
905
|
+
* "Harris" alone does not say which home it rings. An account whose owner has
|
|
906
|
+
* no address, and every account until the API ships the `unitLabel` field,
|
|
907
|
+
* falls back to the bare name rather than trailing a separator.
|
|
908
|
+
*/
|
|
909
|
+
function buildContactName(account: HidSipAccountListItem) {
|
|
910
|
+
const unit = String(account.unitLabel ?? "").trim();
|
|
911
|
+
return unit ? `${account.name} - ${unit}` : account.name;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The contact name picker. `value` is the composed name because that is what
|
|
916
|
+
* the reader stores and what `saveContact` sends; the extension goes into the
|
|
917
|
+
* Address field, not the label. A name that already sits on the contact being
|
|
918
|
+
* edited but is no longer in the SIP list is kept as an option so the picker
|
|
919
|
+
* does not silently drop it.
|
|
920
|
+
*/
|
|
921
|
+
const sipAccountOptions = computed(() => {
|
|
922
|
+
const options: Array<{ title: string; value: string }> = sipAccounts.value
|
|
923
|
+
.filter((account: HidSipAccountListItem) => account.name)
|
|
924
|
+
.map((account: HidSipAccountListItem) => {
|
|
925
|
+
const name = buildContactName(account);
|
|
926
|
+
return { title: name, value: name };
|
|
927
|
+
});
|
|
928
|
+
const current = contactForm.name.trim();
|
|
929
|
+
if (current && !options.some((option) => option.value === current)) {
|
|
930
|
+
options.unshift({ title: current, value: current });
|
|
931
|
+
}
|
|
932
|
+
return options;
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The SIP account behind a stored contact name, or null when the name is not
|
|
937
|
+
* one. The bare name is matched as well as the composed one, so a contact
|
|
938
|
+
* created before the unit was part of the name still resolves to its account
|
|
939
|
+
* on edit instead of looking unrecognised.
|
|
940
|
+
*/
|
|
941
|
+
function findSipAccount(name: string) {
|
|
942
|
+
const value = name.trim();
|
|
943
|
+
if (!value) return null;
|
|
944
|
+
const match = sipAccounts.value.find((account: HidSipAccountListItem) => buildContactName(account) === value)
|
|
945
|
+
?? sipAccounts.value.find((account: HidSipAccountListItem) => account.name === value);
|
|
946
|
+
return (match ?? null) as HidSipAccountListItem | null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** The SIP account behind the picked name, or null for a legacy / unpicked name. */
|
|
950
|
+
const selectedSipAccount = computed(() => findSipAccount(contactForm.name));
|
|
951
|
+
|
|
867
952
|
const visibleRows = computed(() => activeTab.value === "intercom" ? filteredIntercomRows.value : filteredContacts.value);
|
|
868
953
|
const pages = computed(() => Math.max(1, Math.ceil(visibleRows.value.length / limit)));
|
|
869
954
|
const pageRange = computed(() => {
|
|
@@ -976,14 +1061,11 @@ const contactReaderRow = computed(() =>
|
|
|
976
1061
|
);
|
|
977
1062
|
const isContactReaderPeerToPeer = computed(() => contactReaderRow.value?.operatingMode === "Peer-to-Peer");
|
|
978
1063
|
const contactAddressPlaceholder = computed(() => (isContactReaderPeerToPeer.value ? "192.168.0.129" : "7100"));
|
|
979
|
-
const contactAddressHint = computed(() =>
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
? "Peer-to-Peer mode: enter the contact's local IP address."
|
|
985
|
-
: "SIP Client mode: enter the contact's extension number.";
|
|
986
|
-
});
|
|
1064
|
+
const contactAddressHint = computed(() => (
|
|
1065
|
+
selectedSipAccount.value
|
|
1066
|
+
? "Extension of the selected SIP account."
|
|
1067
|
+
: "Filled from the SIP account of the name selected above."
|
|
1068
|
+
));
|
|
987
1069
|
const areSipCredentialFieldsDisabled = computed(() => !sipForm.enabled || isPeerToPeerMode.value);
|
|
988
1070
|
const intercomDetailSections = computed(() => {
|
|
989
1071
|
const row = selectedRow.value;
|
|
@@ -1399,6 +1481,40 @@ async function loadSipAccount() {
|
|
|
1399
1481
|
}
|
|
1400
1482
|
}
|
|
1401
1483
|
|
|
1484
|
+
async function loadSipAccounts() {
|
|
1485
|
+
// Site only. The contact list belongs to the door, not to an organization,
|
|
1486
|
+
// so the picker loads wherever a site is known - including the screens that
|
|
1487
|
+
// are opened without an `org` prop.
|
|
1488
|
+
if (!props.site) return;
|
|
1489
|
+
|
|
1490
|
+
sipAccountsLoading.value = true;
|
|
1491
|
+
sipAccountsError.value = "";
|
|
1492
|
+
try {
|
|
1493
|
+
const response = await getSipAccounts(props.site);
|
|
1494
|
+
const rows = toRecord(response).data;
|
|
1495
|
+
sipAccounts.value = Array.isArray(rows) ? rows as HidSipAccountListItem[] : [];
|
|
1496
|
+
// An existing contact opened before the list arrived: once its name is
|
|
1497
|
+
// matched to an account, show that account's extension in the locked field.
|
|
1498
|
+
applySipAccountToContact(contactForm.name.trim());
|
|
1499
|
+
} catch (error: unknown) {
|
|
1500
|
+
sipAccounts.value = [];
|
|
1501
|
+
sipAccountsError.value = getErrorMessage(error);
|
|
1502
|
+
} finally {
|
|
1503
|
+
sipAccountsLoading.value = false;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Picking a SIP account sets the address to its extension. The Address field
|
|
1509
|
+
* is always disabled, so this is the only way it gets a value; a name that is
|
|
1510
|
+
* not an account (a legacy contact) leaves whatever address it already had.
|
|
1511
|
+
*/
|
|
1512
|
+
function applySipAccountToContact(name: string) {
|
|
1513
|
+
const account = findSipAccount(name);
|
|
1514
|
+
if (!account) return;
|
|
1515
|
+
contactForm.serverAddress = account.extension;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1402
1518
|
async function connectWebPhone() {
|
|
1403
1519
|
const account = sipAccount.value;
|
|
1404
1520
|
if (!account) {
|
|
@@ -1442,8 +1558,10 @@ async function disconnectWebPhone() {
|
|
|
1442
1558
|
|
|
1443
1559
|
async function startWebCall() {
|
|
1444
1560
|
webPhoneActionLoading.value = true;
|
|
1561
|
+
const destination = webPhoneForm.destination;
|
|
1445
1562
|
try {
|
|
1446
|
-
await webPhone.call(
|
|
1563
|
+
await webPhone.call(destination);
|
|
1564
|
+
void ringResidentPhone(destination);
|
|
1447
1565
|
} catch (error: unknown) {
|
|
1448
1566
|
showMessage(getErrorMessage(error), "error");
|
|
1449
1567
|
} finally {
|
|
@@ -1451,6 +1569,50 @@ async function startWebCall() {
|
|
|
1451
1569
|
}
|
|
1452
1570
|
}
|
|
1453
1571
|
|
|
1572
|
+
/**
|
|
1573
|
+
* Wake the called resident's mobile so they are there to answer.
|
|
1574
|
+
*
|
|
1575
|
+
* Deliberately fired AFTER the INVITE and never awaited. The SIP leg is the
|
|
1576
|
+
* call: it is already ringing by the time this runs, and a resident whose
|
|
1577
|
+
* phone is asleep is the only thing this fixes. So a failure here is logged
|
|
1578
|
+
* and dropped rather than shown — telling the operator "call failed" for a
|
|
1579
|
+
* call that is ringing would be worse than a silent miss.
|
|
1580
|
+
*
|
|
1581
|
+
* A destination with no resident behind it (a staff extension, an external SIP
|
|
1582
|
+
* address) comes back `notified: false` with a reason and is not an error.
|
|
1583
|
+
*/
|
|
1584
|
+
async function ringResidentPhone(destination: string) {
|
|
1585
|
+
if (!props.org || !props.site) return;
|
|
1586
|
+
|
|
1587
|
+
try {
|
|
1588
|
+
// No `readerId`: this call came from the web phone, not a door station.
|
|
1589
|
+
// The reader selected in the table above is whatever the operator happens
|
|
1590
|
+
// to be looking at, and sending it would tell the resident's app the call
|
|
1591
|
+
// arrived at a door it never touched.
|
|
1592
|
+
await notifyIntercomCall(props.site, {
|
|
1593
|
+
orgId: props.org,
|
|
1594
|
+
destination,
|
|
1595
|
+
callId: createCallId(),
|
|
1596
|
+
});
|
|
1597
|
+
} catch (error: unknown) {
|
|
1598
|
+
console.warn("[intercom] could not send the incoming-call push", error);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* A per-call id, so a second push for the same call replaces the first on the
|
|
1604
|
+
* resident's lock screen rather than stacking a second ringing card.
|
|
1605
|
+
* `randomUUID` needs a secure context, which the web phone already requires —
|
|
1606
|
+
* getUserMedia does not run without one — but the fallback keeps a call from
|
|
1607
|
+
* failing on that account alone.
|
|
1608
|
+
*/
|
|
1609
|
+
function createCallId() {
|
|
1610
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
1611
|
+
return crypto.randomUUID();
|
|
1612
|
+
}
|
|
1613
|
+
return `call-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1454
1616
|
async function answerWebCall() {
|
|
1455
1617
|
webPhoneActionLoading.value = true;
|
|
1456
1618
|
try {
|
|
@@ -1508,6 +1670,9 @@ function openContactDialog(contact?: ContactRow) {
|
|
|
1508
1670
|
contactForm.location = contact?.location === "N/A" ? "" : contact?.location || selectedReader?.location || "";
|
|
1509
1671
|
contactForm.serverAddress = contact?.serverAddress === "N/A" ? "" : contact?.serverAddress || "";
|
|
1510
1672
|
contactDialog.value = true;
|
|
1673
|
+
// Fetched on every open, not cached: an account is provisioned the first
|
|
1674
|
+
// time a user opens Web Call, so the list can grow between two dialogs.
|
|
1675
|
+
void loadSipAccounts();
|
|
1511
1676
|
}
|
|
1512
1677
|
|
|
1513
1678
|
function openContactView(contact: ContactRow) {
|
|
@@ -2096,6 +2261,10 @@ export default {
|
|
|
2096
2261
|
line-height: 1.4;
|
|
2097
2262
|
}
|
|
2098
2263
|
|
|
2264
|
+
.field-hint--error {
|
|
2265
|
+
color: var(--err);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2099
2268
|
.sip-settings-dialog .field-label {
|
|
2100
2269
|
margin-top: 14px;
|
|
2101
2270
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MY HID QR CODE.
|
|
3
|
+
|
|
4
|
+
The signed-in member's own pass for one reader. Every other HID screen acts on
|
|
5
|
+
somebody else - a resident being enrolled, a visitor being issued a pass. This
|
|
6
|
+
one has no subject picker at all, because the server takes the caller from the
|
|
7
|
+
session and there is no user id to send.
|
|
8
|
+
|
|
9
|
+
Issuing a code does NOT grant access. A member's door permission comes from the
|
|
10
|
+
HID permissions screen, and minting an access rule here would let anyone who
|
|
11
|
+
can press Generate admit themselves. So the screen reports what the permissions
|
|
12
|
+
screen says (`assigned`) and warns when a code would be refused at the door,
|
|
13
|
+
rather than handing out something that silently fails.
|
|
14
|
+
-->
|
|
15
|
+
<template>
|
|
16
|
+
<div class="hid-profile-qr">
|
|
17
|
+
<div class="hid-profile-qr__header">
|
|
18
|
+
<h2 class="hid-profile-qr__title">HID QR Code</h2>
|
|
19
|
+
<AppSelect
|
|
20
|
+
v-if="readerOptions.length"
|
|
21
|
+
v-model="selectedReaderId"
|
|
22
|
+
class="reader-select"
|
|
23
|
+
:items="readerOptions"
|
|
24
|
+
@update:model-value="loadProfile"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<v-card class="screen-modal hid-profile-qr__card">
|
|
29
|
+
<v-card-text class="screen-modal__body">
|
|
30
|
+
<div v-if="loading" class="hid-profile-qr__state">Loading...</div>
|
|
31
|
+
|
|
32
|
+
<div v-else-if="!readerOptions.length" class="hid-profile-qr__state">
|
|
33
|
+
No HID reader is set up for this site yet.
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<template v-else>
|
|
37
|
+
<p class="hid-profile-qr__reader">
|
|
38
|
+
{{ profile?.readerName || "HID reader" }}
|
|
39
|
+
<span>{{ profile?.portalName || "Portal not configured" }}</span>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<!-- A code this reader cannot read is worse than no code, so the
|
|
43
|
+
capability is checked before anything is offered. -->
|
|
44
|
+
<v-alert
|
|
45
|
+
v-if="profile && !profile.qrEnabled"
|
|
46
|
+
type="warning"
|
|
47
|
+
variant="tonal"
|
|
48
|
+
density="compact"
|
|
49
|
+
class="mb-4"
|
|
50
|
+
>
|
|
51
|
+
This reader is not set up to read QR codes.
|
|
52
|
+
</v-alert>
|
|
53
|
+
|
|
54
|
+
<!-- The honest part: a code opens the door only for a member the
|
|
55
|
+
permissions screen already admits. `assigned` is null when the
|
|
56
|
+
lookup failed, which is not the same as "no". -->
|
|
57
|
+
<v-alert
|
|
58
|
+
v-else-if="profile && profile.assigned === false"
|
|
59
|
+
type="warning"
|
|
60
|
+
variant="tonal"
|
|
61
|
+
density="compact"
|
|
62
|
+
class="mb-4"
|
|
63
|
+
>
|
|
64
|
+
You are not assigned to this reader, so this code will be refused at
|
|
65
|
+
the door. Ask a property manager to add you on the HID permissions
|
|
66
|
+
screen.
|
|
67
|
+
</v-alert>
|
|
68
|
+
|
|
69
|
+
<div v-if="qrImage" class="hid-profile-qr__code">
|
|
70
|
+
<img :src="qrImage" alt="Your HID QR code" width="220" height="220" />
|
|
71
|
+
<p class="hid-profile-qr__issued">
|
|
72
|
+
Issued {{ formatIssuedAt(profile?.issuedAt) }}
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
<p v-else class="hid-profile-qr__state">
|
|
76
|
+
You have no QR code on this reader yet.
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
<div class="hid-profile-qr__actions">
|
|
80
|
+
<AppButton
|
|
81
|
+
:disabled="working || !profile?.qrEnabled"
|
|
82
|
+
@click="generate"
|
|
83
|
+
>
|
|
84
|
+
{{ profile?.issued ? "Regenerate" : "Generate QR Code" }}
|
|
85
|
+
</AppButton>
|
|
86
|
+
<AppButton
|
|
87
|
+
v-if="qrImage"
|
|
88
|
+
variant="ghost"
|
|
89
|
+
icon="mdi-download"
|
|
90
|
+
@click="downloadQr"
|
|
91
|
+
>
|
|
92
|
+
Download
|
|
93
|
+
</AppButton>
|
|
94
|
+
<AppButton
|
|
95
|
+
v-if="profile?.issued"
|
|
96
|
+
variant="ghost"
|
|
97
|
+
:disabled="working"
|
|
98
|
+
@click="remove"
|
|
99
|
+
>
|
|
100
|
+
Remove
|
|
101
|
+
</AppButton>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<p class="field-hint">
|
|
105
|
+
Regenerating replaces the previous code. The old one stops working
|
|
106
|
+
immediately.
|
|
107
|
+
</p>
|
|
108
|
+
</template>
|
|
109
|
+
</v-card-text>
|
|
110
|
+
</v-card>
|
|
111
|
+
|
|
112
|
+
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="4000">
|
|
113
|
+
{{ snackbar.message }}
|
|
114
|
+
</v-snackbar>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<script setup lang="ts">
|
|
119
|
+
import QRCode from "qrcode";
|
|
120
|
+
import type { HidProfileQr } from "../composables/useHidAmico";
|
|
121
|
+
|
|
122
|
+
const props = defineProps<{ site: string }>();
|
|
123
|
+
|
|
124
|
+
const { getReaders, getProfileQr, issueProfileQr, deleteProfileQr } = useHidAmico();
|
|
125
|
+
|
|
126
|
+
type HidReaderOption = { _id: string; name?: string; portalName?: string };
|
|
127
|
+
|
|
128
|
+
const readers = ref<HidReaderOption[]>([]);
|
|
129
|
+
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
130
|
+
const selectedReaderId = ref("");
|
|
131
|
+
const profile = ref<HidProfileQr | null>(null);
|
|
132
|
+
const qrImage = ref("");
|
|
133
|
+
const loading = ref(false);
|
|
134
|
+
const working = ref(false);
|
|
135
|
+
const snackbar = reactive({ show: false, color: "success", message: "" });
|
|
136
|
+
|
|
137
|
+
const readerOptions = computed(() => readers.value.map((reader) => ({
|
|
138
|
+
title: `${reader.name || reader._id} — ${reader.portalName || "Portal not configured"}`,
|
|
139
|
+
value: reader._id,
|
|
140
|
+
})));
|
|
141
|
+
|
|
142
|
+
function showToast(message: string, color: "success" | "error" = "success") {
|
|
143
|
+
snackbar.message = message;
|
|
144
|
+
snackbar.color = color;
|
|
145
|
+
snackbar.show = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toRecord(value: unknown): Record<string, any> {
|
|
149
|
+
return value && typeof value === "object" ? (value as Record<string, any>) : {};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function errorMessage(error: unknown) {
|
|
153
|
+
const record = toRecord(error);
|
|
154
|
+
return String(toRecord(record.data).message || record.message || "Something went wrong.");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatIssuedAt(value?: string) {
|
|
158
|
+
if (!value) return "";
|
|
159
|
+
const date = new Date(value);
|
|
160
|
+
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The image is drawn here, never fetched. The value is the credential itself,
|
|
165
|
+
* so keeping it on this page and out of any URL is the point.
|
|
166
|
+
*/
|
|
167
|
+
async function renderQr(value: string) {
|
|
168
|
+
if (!value) {
|
|
169
|
+
qrImage.value = "";
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
qrImage.value = await QRCode.toDataURL(value, { width: 440, margin: 1 });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error("Unable to render the HID QR code:", error);
|
|
176
|
+
qrImage.value = "";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Saves the rendered code as a PNG.
|
|
182
|
+
*
|
|
183
|
+
* Straight off the data URL already in the page - no request, so the credential
|
|
184
|
+
* never travels again to be downloaded. Same anchor-click approach `VisitorForm`
|
|
185
|
+
* uses for a visitor pass, and the reader name is in the file name because a
|
|
186
|
+
* member can hold one code per reader.
|
|
187
|
+
*/
|
|
188
|
+
function downloadQr() {
|
|
189
|
+
if (!qrImage.value) return;
|
|
190
|
+
const slug = String(profile.value?.readerName || "reader")
|
|
191
|
+
.trim()
|
|
192
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
193
|
+
.replace(/^-|-$/g, "")
|
|
194
|
+
.toLowerCase();
|
|
195
|
+
const link = document.createElement("a");
|
|
196
|
+
link.href = qrImage.value;
|
|
197
|
+
link.download = `hid-qr-${slug || "reader"}.png`;
|
|
198
|
+
link.click();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function loadReaders() {
|
|
202
|
+
if (!props.site) return;
|
|
203
|
+
const response = toRecord(await getReaders({ site: props.site, page: 1, limit: 100 }));
|
|
204
|
+
const data = toRecord(response.data);
|
|
205
|
+
const items = response.items ?? data.items ?? data.readers ?? [];
|
|
206
|
+
readers.value = (Array.isArray(items) ? items : []).filter((reader) => reader?._id);
|
|
207
|
+
/* Shared with every other HID screen — see `useHidReaderSelection`. The
|
|
208
|
+
resolver refuses a remembered reader that is not this site's, so a site
|
|
209
|
+
switch or a deleted reader falls back to the first rather than querying
|
|
210
|
+
an id the site does not own. */
|
|
211
|
+
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function loadProfile() {
|
|
215
|
+
if (!selectedReaderId.value) return;
|
|
216
|
+
loading.value = true;
|
|
217
|
+
try {
|
|
218
|
+
const response = await getProfileQr(selectedReaderId.value);
|
|
219
|
+
profile.value = response?.data ?? null;
|
|
220
|
+
await renderQr(profile.value?.qrValue || "");
|
|
221
|
+
} catch (error: unknown) {
|
|
222
|
+
profile.value = null;
|
|
223
|
+
qrImage.value = "";
|
|
224
|
+
showToast(errorMessage(error), "error");
|
|
225
|
+
} finally {
|
|
226
|
+
loading.value = false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function generate() {
|
|
231
|
+
if (!selectedReaderId.value) return;
|
|
232
|
+
working.value = true;
|
|
233
|
+
try {
|
|
234
|
+
const response = await issueProfileQr(selectedReaderId.value);
|
|
235
|
+
profile.value = response?.data ?? null;
|
|
236
|
+
await renderQr(profile.value?.qrValue || "");
|
|
237
|
+
showToast("QR code generated.");
|
|
238
|
+
} catch (error: unknown) {
|
|
239
|
+
showToast(errorMessage(error), "error");
|
|
240
|
+
} finally {
|
|
241
|
+
working.value = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function remove() {
|
|
246
|
+
if (!selectedReaderId.value) return;
|
|
247
|
+
if (!window.confirm("Remove your QR code from this reader?")) return;
|
|
248
|
+
working.value = true;
|
|
249
|
+
try {
|
|
250
|
+
const response = await deleteProfileQr(selectedReaderId.value);
|
|
251
|
+
profile.value = response?.data ?? null;
|
|
252
|
+
await renderQr("");
|
|
253
|
+
showToast("QR code removed.");
|
|
254
|
+
} catch (error: unknown) {
|
|
255
|
+
showToast(errorMessage(error), "error");
|
|
256
|
+
} finally {
|
|
257
|
+
working.value = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
watch(
|
|
262
|
+
() => props.site,
|
|
263
|
+
async () => {
|
|
264
|
+
await loadReaders();
|
|
265
|
+
await loadProfile();
|
|
266
|
+
},
|
|
267
|
+
{ immediate: true },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
/* Publish every change, including the seed above: a screen that had to fall
|
|
271
|
+
back to the first reader should leave the others agreeing with it. */
|
|
272
|
+
watch(selectedReaderId, (value) => {
|
|
273
|
+
if (value) selectHidReader(props.site, value);
|
|
274
|
+
});
|
|
275
|
+
</script>
|
|
276
|
+
|
|
277
|
+
<style scoped>
|
|
278
|
+
.hid-profile-qr__header {
|
|
279
|
+
display: flex;
|
|
280
|
+
align-items: center;
|
|
281
|
+
gap: 12px;
|
|
282
|
+
flex-wrap: wrap;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.hid-profile-qr__title {
|
|
286
|
+
margin: 0;
|
|
287
|
+
font-size: 18px;
|
|
288
|
+
font-weight: 600;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.hid-profile-qr__card {
|
|
292
|
+
margin-top: 16px;
|
|
293
|
+
max-width: 460px;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.hid-profile-qr__reader {
|
|
297
|
+
margin: 0 0 14px;
|
|
298
|
+
font-size: 14px;
|
|
299
|
+
font-weight: 600;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.hid-profile-qr__reader span {
|
|
303
|
+
display: block;
|
|
304
|
+
color: var(--muted);
|
|
305
|
+
font-size: 12px;
|
|
306
|
+
font-weight: 500;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.hid-profile-qr__code {
|
|
310
|
+
display: grid;
|
|
311
|
+
place-items: center;
|
|
312
|
+
margin-bottom: 14px;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.hid-profile-qr__issued {
|
|
316
|
+
margin: 8px 0 0;
|
|
317
|
+
color: var(--muted);
|
|
318
|
+
font-size: 12px;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.hid-profile-qr__state {
|
|
322
|
+
color: var(--muted);
|
|
323
|
+
font-size: 13.5px;
|
|
324
|
+
padding: 8px 0 14px;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.hid-profile-qr__actions {
|
|
328
|
+
display: flex;
|
|
329
|
+
gap: 8px;
|
|
330
|
+
flex-wrap: wrap;
|
|
331
|
+
}
|
|
332
|
+
</style>
|