@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.
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 +532 -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 +2242 -279
  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 +366 -0
  58. package/utils/hid-permission-assignments.ts +259 -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
@@ -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
- <AppField v-model="contactForm.name" placeholder="Name shown on calls" class="my-3" />
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
- <AppField v-model="contactForm.serverAddress" :placeholder="contactAddressPlaceholder" />
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
- if (!contactReaderRow.value) {
981
- return "Extension number (SIP Client) or local IP address (Peer-to-Peer), depending on the reader's operating mode.";
982
- }
983
- return isContactReaderPeerToPeer.value
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(webPhoneForm.destination);
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>