@7365admin1/layer-common 4.97.1-staging.502 → 4.98.0

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.
@@ -363,7 +363,7 @@
363
363
  access is bound by user id, so there is nothing to bind. The note
364
364
  below says how many were left out rather than letting a missing
365
365
  colleague read as a bug. -->
366
- <template v-if="!selectedUser && isMemberSubject">
366
+ <template v-if="!selectedUser && !isResidentSubject">
367
367
  <div class="field-label">Member <span>*</span></div>
368
368
  <AppSelect
369
369
  v-model="form.subjectId"
@@ -389,59 +389,6 @@
389
389
  </p>
390
390
  </template>
391
391
 
392
- <!-- SERVICE PROVIDER STAFF. One person from a provider company, and
393
- never the company: granting a company puts every employee of the
394
- firm on the door, including the ones who do not attend this site.
395
- The list, the service filter and the filter's "All services"
396
- default are the Members > Service Providers screen's own, from the
397
- same endpoint and through the same `filterProviderMembers` rule,
398
- so the two screens cannot disagree about who works here.
399
- Unpaged - the endpoint returns the site's people in one response -
400
- so searching and filtering cost no request. -->
401
- <template v-if="!selectedUser && isProviderSubject">
402
- <div class="field-label">Service</div>
403
- <AppSelect
404
- v-model="providerService"
405
- :items="providerServiceOptions"
406
- klass="mb-3"
407
- @update:model-value="onProviderServiceChanged"
408
- />
409
-
410
- <div class="field-label">Person <span>*</span></div>
411
- <AppSelect
412
- v-model="form.subjectId"
413
- :items="providerOptions"
414
- searchable
415
- search-placeholder="Search by name, company or role"
416
- :placeholder="providerPlaceholder"
417
- klass="mb-3"
418
- @update:model-value="onProviderChanged"
419
- />
420
-
421
- <!-- ACCESS ONLY, and the button says so. Shown here and nowhere
422
- else: it is the one subject type that arrives a shift at a time,
423
- and the people it adds fill in their own credentials from their
424
- app afterwards. -->
425
- <div class="hid-bulk-cta mb-3">
426
- <AppButton
427
- variant="ghost"
428
- icon="mdi-account-multiple-plus-outline"
429
- :disabled="loadingProviders"
430
- @click="openBulkAdd"
431
- >
432
- Add Users in Bulk
433
- </AppButton>
434
- <span class="field-hint">
435
- Gives access to many people at once. They add their own face, QR
436
- or PIN from their app.
437
- </span>
438
- </div>
439
-
440
- <p v-if="hiddenProviderNote" class="field-hint mb-3">
441
- {{ hiddenProviderNote }}
442
- </p>
443
- </template>
444
-
445
392
  <!-- The duplicate check. It is reader-scoped: this resident holding a
446
393
  HID user on another gate is normal and says nothing here. -->
447
394
  <p v-if="checkingResident" class="field-hint">
@@ -465,12 +412,12 @@
465
412
  whatever the reader already holds. -->
466
413
  <!-- A mirror of the chosen member's role, on the same rule as Name:
467
414
  restated from the record, never collected. -->
468
- <template v-if="!selectedUser && isPersonSubject">
469
- <div class="field-label">{{ subjectMirrorLabel }}</div>
415
+ <template v-if="!selectedUser && !isResidentSubject">
416
+ <div class="field-label">Role</div>
470
417
  <AppField
471
418
  v-model="form.subjectRole"
472
- :aria-label="subjectMirrorLabel"
473
- :placeholder="subjectMirrorPlaceholder"
419
+ aria-label="Role"
420
+ placeholder="Select a member above"
474
421
  readonly
475
422
  klass="mb-3"
476
423
  />
@@ -483,8 +430,6 @@
483
430
  :placeholder="
484
431
  isResidentSubject
485
432
  ? 'Select a resident above'
486
- : isProviderSubject
487
- ? 'Select a person above'
488
433
  : 'Select a member above'
489
434
  "
490
435
  readonly
@@ -622,115 +567,6 @@
622
567
  </v-card>
623
568
  </v-dialog>
624
569
 
625
- <!-- ADD USERS IN BULK.
626
- Over the enrolment form, not instead of it: the operator came here to
627
- enrol, and this is the shortcut for the case where a whole shift arrives
628
- at once. It grants ACCESS only - the people it adds are recognised by the
629
- reader once they add a face, QR or PIN from their own app - which is why
630
- it collects no photo, PIN or password.
631
- The table is the same list as the picker behind it, under the same
632
- service filter, with Company / Name / Service and nothing else: the
633
- Members screen's other columns say nothing about whether to open a door
634
- for somebody. -->
635
- <v-dialog v-model="bulkDialog" max-width="640" persistent>
636
- <v-card class="screen-modal">
637
- <v-card-title>Add Users in Bulk</v-card-title>
638
- <v-card-text class="screen-modal__body">
639
- <p class="field-hint mb-3">
640
- Gives these people access to
641
- <strong>{{ bulkReaderLabel }}</strong>. They add their own face, QR
642
- or PIN from their app - nothing is enrolled on the reader here.
643
- </p>
644
-
645
- <AppField
646
- v-model="bulkSearch"
647
- aria-label="Search people"
648
- placeholder="Search by name, company or role"
649
- klass="mb-3"
650
- />
651
-
652
- <v-progress-linear v-if="bulkLoading" indeterminate class="mb-3" />
653
-
654
- <template v-else-if="bulkRows.length">
655
- <v-table density="compact" class="hid-bulk-table mb-2">
656
- <thead>
657
- <tr>
658
- <th class="hid-bulk-table__check">
659
- <!-- Acts on the rows in front of you - what the service
660
- filter and the search have left - never on the whole
661
- estate. -->
662
- <v-checkbox
663
- :model-value="bulkAllSelected"
664
- :disabled="!bulkSelectableRows.length"
665
- hide-details
666
- density="compact"
667
- aria-label="Select everyone listed"
668
- @update:model-value="toggleBulkAll"
669
- />
670
- </th>
671
- <th>Company</th>
672
- <th>Name</th>
673
- <th>Service</th>
674
- </tr>
675
- </thead>
676
- <tbody>
677
- <tr v-for="row in bulkRows" :key="row.subjectId">
678
- <td class="hid-bulk-table__check">
679
- <!-- Already on the door: ticked and locked. Unticking here
680
- would revoke access, and this dialog does not revoke. -->
681
- <v-checkbox
682
- :model-value="row.granted || isBulkSelected(row.subjectId)"
683
- :disabled="row.granted"
684
- hide-details
685
- density="compact"
686
- :aria-label="`Select ${row.name}`"
687
- @update:model-value="toggleBulkRow(row.subjectId, $event)"
688
- />
689
- </td>
690
- <td>{{ row.company || "—" }}</td>
691
- <td>
692
- {{ row.name }}
693
- <span v-if="row.granted" class="hid-bulk-granted">
694
- Already has access
695
- </span>
696
- </td>
697
- <td>{{ row.service || "—" }}</td>
698
- </tr>
699
- </tbody>
700
- </v-table>
701
- <p class="field-hint">
702
- {{ bulkSelectedCount }} selected<template v-if="bulkGrantedCount">
703
- · {{ bulkGrantedCount }} already have access</template
704
- >
705
- </p>
706
- </template>
707
-
708
- <v-alert v-else type="info" variant="tonal" density="compact">
709
- {{ bulkEmptyText }}
710
- </v-alert>
711
- </v-card-text>
712
- <v-card-actions class="screen-modal__footer">
713
- <v-btn
714
- class="text-none screen-btn-ghost"
715
- variant="flat"
716
- :disabled="bulkSaving"
717
- @click="bulkDialog = false"
718
- >
719
- Cancel
720
- </v-btn>
721
- <v-btn
722
- class="text-none screen-btn-primary"
723
- variant="flat"
724
- :loading="bulkSaving"
725
- :disabled="!bulkSelectedCount"
726
- @click="saveBulkAdd"
727
- >
728
- Give access
729
- </v-btn>
730
- </v-card-actions>
731
- </v-card>
732
- </v-dialog>
733
-
734
570
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
735
571
 
736
572
  <v-dialog v-model="viewDialog" max-width="430">
@@ -968,36 +804,9 @@ import {
968
804
  hiddenSubjectNote,
969
805
  memberCandidateOf,
970
806
  memberHasAccount,
971
- HID_ALL_SERVICES,
972
- enrolmentSubjectOfCategory,
973
- permissionCategoryOf,
974
- providerAccountsKnown,
975
- providerCandidateOf,
976
- providerEnrollableRows,
977
807
  readMemberPage,
978
- serviceFilterOf,
979
808
  subjectCategoryLabel as labelForSubjectCategory,
980
- subjectLinkOf,
981
- type HidEnrolmentSubject,
982
809
  } from "../utils/hid-enrolment-subject";
983
- /* The SAME filter the Members > Service Providers screen applies, not a second
984
- copy of the rule: a picker that disagrees with the screen the operator was
985
- just looking at is a picker they cannot trust. Core applies it to the Excel
986
- export too (`filterSiteProviderMembers`). */
987
- import {
988
- filterProviderMembers,
989
- serviceTypeOptions,
990
- } from "../utils/site-provider-members";
991
- /* Bulk access is granted through the permissions document, and the PUT REPLACES
992
- this reader's whole assignment set. These three are what keep a bulk add from
993
- revoking everybody who was already on the door - see the file's own header. */
994
- import {
995
- seedAssignments,
996
- setAssignment,
997
- subjectOfIdentity,
998
- toAssignmentPayload,
999
- type HidAssignmentState,
1000
- } from "../utils/hid-permission-assignments";
1001
810
 
1002
811
  const props = defineProps({
1003
812
  site: {
@@ -1038,13 +847,8 @@ const {
1038
847
  createIdentity,
1039
848
  updateIdentity,
1040
849
  deleteIdentity,
1041
- getSitePermissions,
1042
- updateSitePermissions,
1043
850
  } = useHidAmico();
1044
851
  const { getAll: getAllMembers } = useMember();
1045
- /* Members > Service Providers reads this same call, so the enrolment picker
1046
- offers exactly the people that screen lists. Site-scoped by the server. */
1047
- const { getSiteMembers } = useServiceProvider();
1048
852
  const runtimeConfig = useRuntimeConfig();
1049
853
 
1050
854
  type HidMetadata = Record<string, unknown> & {
@@ -1230,11 +1034,7 @@ type UnitPerson = {
1230
1034
  * the dialog, and `useHidReaderSelection` already set the precedent for where
1231
1035
  * that line sits.
1232
1036
  */
1233
- /* The dropdown's values, plus `service_provider` - which the dropdown does not
1234
- offer but `openEdit` resolves from records that name a COMPANY. */
1235
- type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
1236
-
1237
- const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
1037
+ const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
1238
1038
 
1239
1039
  const form = reactive({
1240
1040
  reader: "",
@@ -1248,7 +1048,7 @@ const form = reactive({
1248
1048
  accessPin: "",
1249
1049
  password: "",
1250
1050
  photoPreview: "",
1251
- subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
1051
+ subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
1252
1052
  subjectId: "",
1253
1053
  /** A mirror of the chosen member's role. Never sent; display only. */
1254
1054
  subjectRole: "",
@@ -1370,28 +1170,6 @@ const unitResidentNote = computed(() => {
1370
1170
  const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1371
1171
 
1372
1172
  const isResidentSubject = computed(() => form.subjectCategory === "resident");
1373
- /* Members and provider staff both pick ONE PERSON from a flat list, but from
1374
- different lists, so they are separate branches rather than "not a resident". */
1375
- const isMemberSubject = computed(
1376
- () => form.subjectCategory === "property_management"
1377
- );
1378
- const isProviderSubject = computed(
1379
- () => form.subjectCategory === "service_provider_staff"
1380
- );
1381
- /** Either of the two person pickers - what the Role mirror follows. */
1382
- const isPersonSubject = computed(
1383
- () => isMemberSubject.value || isProviderSubject.value
1384
- );
1385
-
1386
- /* The mirror's label follows the list it mirrors. A member's subtitle is their
1387
- role at the site; a contractor's is the firm they work for, and calling that
1388
- "Role" would read as a mistake. */
1389
- const subjectMirrorLabel = computed(() =>
1390
- isProviderSubject.value ? "Company" : "Role"
1391
- );
1392
- const subjectMirrorPlaceholder = computed(() =>
1393
- isProviderSubject.value ? "Select a person above" : "Select a member above"
1394
- );
1395
1173
 
1396
1174
  /* What an EDIT shows in place of the control, since the type cannot change.
1397
1175
  Looked up in the util, not in the switch's own options: records carry
@@ -1585,387 +1363,6 @@ function loadMoreMembers() {
1585
1363
  return fetchMemberPage(memberPage.value + 1, true);
1586
1364
  }
1587
1365
 
1588
- /* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
1589
- *
1590
- * ONE PERSON from a provider company, never the company.
1591
- *
1592
- * Read from `GET /api/service-providers/site-members` - the same call behind
1593
- * Members > Service Providers, with the same `filterProviderMembers` rule - so
1594
- * the picker lists exactly the people that screen lists. The server scopes it to
1595
- * the site; these are the contractors who attend THIS site, not every employee
1596
- * of every firm engaged with the organisation.
1597
- *
1598
- * Unpaged, deliberately, unlike the staff picker above: this endpoint returns
1599
- * the site's people in one response (capped at 1000 by the repository) and has
1600
- * no per-page cache entry to disagree with. The service filter and the search
1601
- * box both run over the loaded list, so neither costs a request.
1602
- */
1603
- const providerPeople = ref<Record<string, unknown>[]>([]);
1604
- const providerCompanyCount = ref(0);
1605
- const loadingProviders = ref(false);
1606
- const providersLoaded = ref(false);
1607
- /**
1608
- * All services is the default, the same as the Members screen - but it carries a
1609
- * REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
1610
- * string, so an empty-valued option draws the placeholder in placeholder grey and
1611
- * is never marked as chosen: the filter worked and the field looked blank. See
1612
- * `serviceFilterOf`.
1613
- */
1614
- const providerService = ref<string>(HID_ALL_SERVICES);
1615
-
1616
- /** What the filter actually receives: "" for All services. */
1617
- const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
1618
- let providerRequestSeq = 0;
1619
-
1620
- /** Everyone who can actually be given a door: an app account, and active. */
1621
- const providerEnrollable = computed(() =>
1622
- providerEnrollableRows(providerPeople.value)
1623
- );
1624
-
1625
- /* Whether the endpoint told us who holds an app account. It only began
1626
- returning `user` in core 3.130, and this layer can be deployed ahead of it. */
1627
- const providerAccountsVisible = computed(() =>
1628
- providerAccountsKnown(providerPeople.value)
1629
- );
1630
-
1631
- /** People the operator can see on the Members screen but cannot enrol here. */
1632
- const hiddenProviderCount = computed(
1633
- () => providerPeople.value.length - providerEnrollable.value.length
1634
- );
1635
-
1636
- const providerServiceOptions = computed(() => [
1637
- { title: "All services", value: HID_ALL_SERVICES },
1638
- // From the ENROLLABLE set, so choosing a service never lands on an empty list.
1639
- ...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
1640
- ]);
1641
-
1642
- /** The filtered list - what both the picker and the bulk table draw. */
1643
- const providerRows = computed(() =>
1644
- filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
1645
- type: providerServiceFilter.value,
1646
- })
1647
- );
1648
-
1649
- const providerOptions = computed(() =>
1650
- providerRows.value.map((row) => {
1651
- const candidate = providerCandidateOf(row);
1652
- return {
1653
- // The firm in the title as well as the mirror: two contractors sharing a
1654
- // first name is the normal case, and the popover is where they have to be
1655
- // told apart.
1656
- title: candidate.subtitle
1657
- ? `${candidate.name} — ${candidate.subtitle}`
1658
- : candidate.name,
1659
- value: candidate.subjectId,
1660
- };
1661
- })
1662
- );
1663
-
1664
- const providerPlaceholder = computed(() => {
1665
- if (loadingProviders.value) return "Loading people...";
1666
- if (!providersLoaded.value) return "Select a person";
1667
- if (providerOptions.value.length) return "Select a person";
1668
- if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
1669
- if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
1670
- return providerCompanyCount.value
1671
- ? "No service provider people at this site"
1672
- : "No service providers work at this site yet";
1673
- });
1674
-
1675
- /* Say why somebody on the Members screen is not in this list, rather than
1676
- letting a missing colleague read as a broken dialog. */
1677
- const hiddenProviderNote = computed(() => {
1678
- if (loadingProviders.value || !hiddenProviderCount.value) return "";
1679
- const count = hiddenProviderCount.value;
1680
- const subject = count === 1 ? "1 person" : `${count} people`;
1681
- const verb = count === 1 ? "is" : "are";
1682
- const holder = count === 1 ? "that person is" : "they are";
1683
- const needs = providerAccountsVisible.value
1684
- ? "an active membership with an app account"
1685
- : "an active membership at this site";
1686
- return `${subject} from this site's service providers ${verb} not listed:`
1687
- + ` HID enrollment needs ${needs}, and ${holder} without one.`;
1688
- });
1689
-
1690
- /** Back to "nothing loaded", so the next open starts clean. */
1691
- function resetProviderCandidates() {
1692
- providerRequestSeq += 1;
1693
- providerPeople.value = [];
1694
- providerCompanyCount.value = 0;
1695
- providerService.value = HID_ALL_SERVICES;
1696
- providersLoaded.value = false;
1697
- }
1698
-
1699
- /**
1700
- * This site's service provider people.
1701
- *
1702
- * NEVER THROWS, on the same rule as the staff loader: choosing Service provider
1703
- * must not break the dialog. The placeholder carries the failure.
1704
- */
1705
- async function loadProviderCandidates() {
1706
- if (!props.site) {
1707
- resetProviderCandidates();
1708
- providersLoaded.value = true;
1709
- return;
1710
- }
1711
- const seq = (providerRequestSeq += 1);
1712
- loadingProviders.value = true;
1713
- try {
1714
- const data = await getSiteMembers(props.site);
1715
- // A response that arrived after the dialog was reopened belongs to a list
1716
- // that no longer exists - the same guard the staff pager uses.
1717
- if (seq !== providerRequestSeq) return;
1718
- providerPeople.value = Array.isArray(data?.items)
1719
- ? (data.items as unknown as Record<string, unknown>[])
1720
- : [];
1721
- providerCompanyCount.value = Array.isArray(data?.companies)
1722
- ? data.companies.length
1723
- : 0;
1724
- providersLoaded.value = true;
1725
- } catch (error: unknown) {
1726
- if (seq !== providerRequestSeq) return;
1727
- console.error("Unable to load service provider people for HID enrollment:", error);
1728
- providerPeople.value = [];
1729
- providerCompanyCount.value = 0;
1730
- providersLoaded.value = true;
1731
- } finally {
1732
- loadingProviders.value = false;
1733
- }
1734
- }
1735
-
1736
- /** The chosen person, restated into Name and the Company mirror. */
1737
- function onProviderChanged() {
1738
- // Compared on the TRIMMED id, because that is what the option's value carries
1739
- // (`providerCandidateOf` trims) - matching the raw field would silently find
1740
- // nobody and blank the name the operator just picked.
1741
- const row = providerRows.value.find(
1742
- (item) => String(item._id ?? "").trim() === form.subjectId
1743
- );
1744
- const candidate = row ? providerCandidateOf(row) : null;
1745
- form.name = candidate?.name ?? "";
1746
- form.subjectRole = candidate?.subtitle ?? "";
1747
- void checkSubjectEnrollment();
1748
- }
1749
-
1750
- /* Changing the service can take the chosen person out of the list. Leaving a
1751
- selection that is no longer on screen is how a save ends up naming somebody
1752
- the operator cannot see. */
1753
- function onProviderServiceChanged() {
1754
- if (!form.subjectId) return;
1755
- const stillThere = providerRows.value.some(
1756
- (item) => String(item._id ?? "").trim() === form.subjectId
1757
- );
1758
- if (stillThere) return;
1759
- form.subjectId = "";
1760
- form.name = "";
1761
- form.subjectRole = "";
1762
- residentConflict.value = null;
1763
- }
1764
-
1765
- /* ── ADD USERS IN BULK ────────────────────────────────────────────────────
1766
- *
1767
- * ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
1768
- * stops there - no face, no card, no PIN. Each person then adds their own
1769
- * credential from their app, which attaches to the HID user this grant created
1770
- * (`getProfileEnrollmentContext` reuses the existing profile identity), so
1771
- * nobody has to be enrolled twice.
1772
- *
1773
- * It writes through `PUT /sites/:siteId/permissions`, the one call that writes
1774
- * group membership, and the device users are created by the reconcile that call
1775
- * triggers: `provisionPermissionUser` writes `{ name, registration }` and
1776
- * nothing else. That is exactly "user record and group, credentials empty".
1777
- *
1778
- * THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
1779
- * this reader's whole assignment set. Sending only the ticked contractors would
1780
- * revoke every resident and every member on the door. The payload is always
1781
- * built from the reader's existing assignments PLUS the selection.
1782
- */
1783
- const bulkDialog = ref(false);
1784
- const bulkLoading = ref(false);
1785
- const bulkSaving = ref(false);
1786
- const bulkSearch = ref("");
1787
- /** The reader's CURRENT assignments, across every category. The merge base. */
1788
- const bulkSaved = ref<HidAssignmentState>(new Map());
1789
- /** Who is being added now. Already-granted people are never in here. */
1790
- const bulkSelected = ref<Set<string>>(new Set());
1791
-
1792
- /* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
1793
- screen behind two dialogs, so the confirmation says which one rather than
1794
- leaving the operator to remember. */
1795
- const bulkReaderLabel = computed(() => {
1796
- const reader = selectedReader.value;
1797
- const name = String(reader?.name ?? "").trim() || "this HID reader";
1798
- const location = String(reader?.location ?? "").trim();
1799
- return location ? `${name} · ${location}` : name;
1800
- });
1801
-
1802
- /** The same people the picker offers, narrowed by this dialog's own search. */
1803
- const bulkRows = computed(() => {
1804
- const rows = filterProviderMembers(
1805
- providerRows.value as Array<Record<string, any>>,
1806
- { search: bulkSearch.value }
1807
- );
1808
- return rows.map((row) => {
1809
- const candidate = providerCandidateOf(row);
1810
- const id = candidate.subjectId;
1811
- return {
1812
- subjectId: id,
1813
- name: candidate.name,
1814
- company: String(row.company ?? "").trim(),
1815
- service: String(row.typeLabel ?? row.type ?? "").trim(),
1816
- // Already on the door. Ticked and locked rather than hidden: hiding them
1817
- // would make "select all" look like it had missed people, and unticking
1818
- // one here would revoke access this dialog is not for revoking.
1819
- granted: bulkSaved.value.has(id),
1820
- };
1821
- });
1822
- });
1823
-
1824
- const bulkSelectableRows = computed(() =>
1825
- bulkRows.value.filter((row) => !row.granted)
1826
- );
1827
- const bulkSelectedCount = computed(() => bulkSelected.value.size);
1828
- const bulkAllSelected = computed(
1829
- () =>
1830
- bulkSelectableRows.value.length > 0 &&
1831
- bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
1832
- );
1833
- const bulkGrantedCount = computed(
1834
- () => bulkRows.value.filter((row) => row.granted).length
1835
- );
1836
-
1837
- const bulkEmptyText = computed(() => {
1838
- if (bulkLoading.value) return "Loading...";
1839
- if (bulkSearch.value.trim()) return "No one matches this search.";
1840
- if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
1841
- return "No service provider people can be enrolled at this site yet.";
1842
- });
1843
-
1844
- function isBulkSelected(subjectId: string) {
1845
- return bulkSelected.value.has(subjectId);
1846
- }
1847
-
1848
- function toggleBulkRow(subjectId: string, selected: boolean) {
1849
- const next = new Set(bulkSelected.value);
1850
- if (selected) next.add(subjectId);
1851
- else next.delete(subjectId);
1852
- bulkSelected.value = next;
1853
- }
1854
-
1855
- /* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
1856
- and the search have left - never on the whole estate. A tick box cannot
1857
- quietly grant a door to people who are not on screen. */
1858
- function toggleBulkAll(selected: boolean) {
1859
- const next = new Set(bulkSelected.value);
1860
- for (const row of bulkSelectableRows.value) {
1861
- if (selected) next.add(row.subjectId);
1862
- else next.delete(row.subjectId);
1863
- }
1864
- bulkSelected.value = next;
1865
- }
1866
-
1867
- /**
1868
- * Open the bulk dialog, after reading what the reader already holds.
1869
- *
1870
- * The saved set is fetched EVERY time rather than cached: it is the merge base
1871
- * for a write that replaces the reader's assignments, and a stale one would
1872
- * revoke whoever was granted in between.
1873
- */
1874
- async function openBulkAdd() {
1875
- const readerId =
1876
- selectedReaderId.value || form.reader || readers.value[0]?._id;
1877
- if (!readerId) {
1878
- showToast("Select a HID reader before adding people in bulk.", "error");
1879
- return;
1880
- }
1881
- /* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
1882
- has it as `required()`, unlike `createIdentity`, which falls back to the
1883
- site's own. Both pages that mount this component pass one, so this is a
1884
- guard against a 400 that would read as a server fault. */
1885
- if (!props.org) {
1886
- showToast(
1887
- "This screen cannot grant access in bulk without an organization.",
1888
- "error"
1889
- );
1890
- return;
1891
- }
1892
- bulkSearch.value = "";
1893
- bulkSelected.value = new Set();
1894
- bulkSaved.value = new Map();
1895
- bulkDialog.value = true;
1896
- bulkLoading.value = true;
1897
- try {
1898
- if (!providersLoaded.value) await loadProviderCandidates();
1899
- const response = await getSitePermissions(props.site, props.org, readerId);
1900
- bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
1901
- } catch (error: unknown) {
1902
- // Closed rather than left open on an empty merge base: saving from here
1903
- // would send only the ticked people and revoke everybody else.
1904
- bulkDialog.value = false;
1905
- showToast(
1906
- apiReason(error) || "Unable to read this reader's current access.",
1907
- "error"
1908
- );
1909
- } finally {
1910
- bulkLoading.value = false;
1911
- }
1912
- }
1913
-
1914
- /** Grant the ticked people, keeping everyone the reader already admits. */
1915
- async function saveBulkAdd() {
1916
- if (bulkSaving.value || !bulkSelected.value.size) return;
1917
- const readerId =
1918
- selectedReaderId.value || form.reader || readers.value[0]?._id;
1919
- if (!readerId) {
1920
- showToast("Select a HID reader before adding people in bulk.", "error");
1921
- return;
1922
- }
1923
-
1924
- bulkSaving.value = true;
1925
- try {
1926
- // Seeded from what the server holds, so every category the reader already
1927
- // admits survives this write.
1928
- let draft: HidAssignmentState = new Map(bulkSaved.value);
1929
- for (const subjectId of bulkSelected.value) {
1930
- draft = setAssignment(
1931
- draft,
1932
- bulkSaved.value,
1933
- subjectId,
1934
- // A provider employee's grant is stored under the STAFF category: core's
1935
- // `permissionSubjectOfIdentity` reads the `member` link and cannot see
1936
- // this dropdown, and that category's subject list already includes these
1937
- // people. See `permissionCategoryOf`.
1938
- permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
1939
- true
1940
- );
1941
- }
1942
- const added = bulkSelected.value.size;
1943
- await updateSitePermissions(
1944
- props.site,
1945
- props.org,
1946
- readerId,
1947
- toAssignmentPayload(draft)
1948
- );
1949
- // Both dialogs close and the list is re-read: the reconcile has just created
1950
- // a HID user for each person, so the roster behind this dialog is stale.
1951
- bulkDialog.value = false;
1952
- formDialog.value = false;
1953
- await loadUsers();
1954
- showToast(
1955
- `${added} ${added === 1 ? "person" : "people"} can now use this reader. `
1956
- + "They add their own face, QR or PIN from their app.",
1957
- "success"
1958
- );
1959
- } catch (error: unknown) {
1960
- showToast(
1961
- apiReason(error) || "Unable to give these people access to this reader.",
1962
- "error"
1963
- );
1964
- } finally {
1965
- bulkSaving.value = false;
1966
- }
1967
- }
1968
-
1969
1366
  /**
1970
1367
  * Switching type clears the chosen subject, and nothing else.
1971
1368
  *
@@ -1975,20 +1372,15 @@ async function saveBulkAdd() {
1975
1372
  * choice, so they clear with it.
1976
1373
  */
1977
1374
  async function onSubjectCategoryChanged() {
1978
- REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
1375
+ REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
1979
1376
  form.subjectId = "";
1980
1377
  form.subjectRole = "";
1981
1378
  form.name = "";
1982
1379
  residentConflict.value = null;
1983
1380
 
1984
- // Each list is fetched on first use and then kept, so flipping between the
1985
- // three options costs one request each rather than one per flip.
1986
- if (isMemberSubject.value && !membersLoaded.value) {
1381
+ if (!isResidentSubject.value && !membersLoaded.value) {
1987
1382
  await loadMemberCandidates();
1988
1383
  }
1989
- if (isProviderSubject.value && !providersLoaded.value) {
1990
- await loadProviderCandidates();
1991
- }
1992
1384
  }
1993
1385
 
1994
1386
  /* The staff equivalent of `onResidentChanged`. */
@@ -2547,9 +1939,7 @@ async function openEnroll() {
2547
1939
  // the request must not delay it, and the picker carries its own loading
2548
1940
  // placeholder. Fresh each opening, because staff change between them.
2549
1941
  resetMemberCandidates();
2550
- resetProviderCandidates();
2551
- if (isMemberSubject.value) await loadMemberCandidates();
2552
- if (isProviderSubject.value) await loadProviderCandidates();
1942
+ if (!isResidentSubject.value) await loadMemberCandidates();
2553
1943
  }
2554
1944
 
2555
1945
  async function openEdit(user: HidUser) {
@@ -2571,21 +1961,14 @@ async function openEdit(user: HidUser) {
2571
1961
  removePinRequested.value = false;
2572
1962
  form.photoPreview = "";
2573
1963
  facialEnrolled.value = hasFacialEnrollment(user);
2574
- /*
2575
- * ONE reading of the links, shared with the Access Permissions screen.
2576
- * `subjectOfIdentity` knows that a `member` link is how BOTH a
2577
- * property-management member and a service provider's own employee are stored,
2578
- * and uses the identity's `type` to tell them apart - without which a
2579
- * contractor's edit would be headed "Member".
2580
- *
2581
- * This was the same ternary written out inline. The other copy of it went
2582
- * stale, so neither is written out any more.
2583
- */
2584
- const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
2585
- form.subjectCategory = enrolmentSubjectOfCategory(
2586
- editedSubject?.category
2587
- ) as TEnrolmentSubjectValue;
2588
- form.subjectId = editedSubject?.subjectId ?? "";
1964
+ form.subjectCategory = user.person
1965
+ ? "resident"
1966
+ : user.serviceProvider
1967
+ ? "service_provider"
1968
+ : "property_management";
1969
+ form.subjectId = String(
1970
+ user.person || user.member || user.serviceProvider || ""
1971
+ );
2589
1972
  // The resident this user already holds is not a conflict with itself. A real
2590
1973
  // one only appears if the operator reassigns the row to somebody else.
2591
1974
  residentConflict.value = null;
@@ -2698,8 +2081,6 @@ async function saveUser() {
2698
2081
  : !selectedUser.value && !form.subjectId
2699
2082
  ? isResidentSubject.value
2700
2083
  ? "a resident"
2701
- : isProviderSubject.value
2702
- ? "a person"
2703
2084
  : "a member"
2704
2085
  : !form.name
2705
2086
  ? "a name"
@@ -2758,7 +2139,27 @@ async function saveUser() {
2758
2139
  // an empty string as a link being removed and then refuses the write for
2759
2140
  // having no subject at all. Omitting the keys is what makes it keep the
2760
2141
  // subject the record already has.
2761
- const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
2142
+ const subjectLink = form.subjectId
2143
+ ? {
2144
+ person: form.subjectCategory === "resident" ? form.subjectId : "",
2145
+ member:
2146
+ form.subjectCategory === "property_management"
2147
+ ? form.subjectId
2148
+ : "",
2149
+ serviceProvider:
2150
+ form.subjectCategory === "service_provider" ? form.subjectId : "",
2151
+ type: (form.subjectCategory === "resident"
2152
+ ? "resident"
2153
+ : form.subjectCategory === "service_provider"
2154
+ ? "contractor"
2155
+ : "staff") as
2156
+ | "resident"
2157
+ | "staff"
2158
+ | "contractor"
2159
+ | "visitor"
2160
+ | "unknown",
2161
+ }
2162
+ : {};
2762
2163
 
2763
2164
  const payload = {
2764
2165
  hidUserId: String(hidUser.id),
@@ -3882,47 +3283,6 @@ watch(selectedReaderId, (value) => {
3882
3283
  color: var(--err);
3883
3284
  }
3884
3285
 
3885
- /* The bulk button and the sentence that says what it does. Stacked rather than
3886
- side by side: the sentence is the part that stops somebody expecting this to
3887
- enrol faces, so it must not be squeezed into a margin at narrow widths. */
3888
- .hid-bulk-cta {
3889
- display: flex;
3890
- flex-direction: column;
3891
- align-items: flex-start;
3892
- gap: 4px;
3893
- }
3894
-
3895
- .hid-bulk-cta .field-hint {
3896
- margin: 0;
3897
- }
3898
-
3899
- /* Checkbox column only as wide as the control, so the three columns that carry
3900
- meaning keep the width. */
3901
- .hid-bulk-table__check {
3902
- width: 44px;
3903
- padding-right: 0 !important;
3904
- }
3905
-
3906
- .hid-bulk-table :deep(th) {
3907
- font-size: 12px;
3908
- font-weight: 700;
3909
- color: var(--muted);
3910
- white-space: nowrap;
3911
- }
3912
-
3913
- .hid-bulk-table :deep(td) {
3914
- font-size: 13px;
3915
- }
3916
-
3917
- /* Why this row cannot be unticked, on the row itself. A disabled checkbox with
3918
- no reason beside it reads as a broken control. */
3919
- .hid-bulk-granted {
3920
- display: block;
3921
- color: var(--muted);
3922
- font-size: 11px;
3923
- font-weight: 600;
3924
- }
3925
-
3926
3286
  .photo-wrap {
3927
3287
  display: grid;
3928
3288
  place-items: center;