@7365admin1/layer-common 4.82.1-staging.478 → 4.82.1-staging.480

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 && !isResidentSubject">
366
+ <template v-if="!selectedUser && isMemberSubject">
367
367
  <div class="field-label">Member <span>*</span></div>
368
368
  <AppSelect
369
369
  v-model="form.subjectId"
@@ -389,6 +389,59 @@
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
+
392
445
  <!-- The duplicate check. It is reader-scoped: this resident holding a
393
446
  HID user on another gate is normal and says nothing here. -->
394
447
  <p v-if="checkingResident" class="field-hint">
@@ -412,12 +465,12 @@
412
465
  whatever the reader already holds. -->
413
466
  <!-- A mirror of the chosen member's role, on the same rule as Name:
414
467
  restated from the record, never collected. -->
415
- <template v-if="!selectedUser && !isResidentSubject">
416
- <div class="field-label">Role</div>
468
+ <template v-if="!selectedUser && isPersonSubject">
469
+ <div class="field-label">{{ subjectMirrorLabel }}</div>
417
470
  <AppField
418
471
  v-model="form.subjectRole"
419
- aria-label="Role"
420
- placeholder="Select a member above"
472
+ :aria-label="subjectMirrorLabel"
473
+ :placeholder="subjectMirrorPlaceholder"
421
474
  readonly
422
475
  klass="mb-3"
423
476
  />
@@ -430,6 +483,8 @@
430
483
  :placeholder="
431
484
  isResidentSubject
432
485
  ? 'Select a resident above'
486
+ : isProviderSubject
487
+ ? 'Select a person above'
433
488
  : 'Select a member above'
434
489
  "
435
490
  readonly
@@ -567,6 +622,115 @@
567
622
  </v-card>
568
623
  </v-dialog>
569
624
 
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
+
570
734
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
571
735
 
572
736
  <v-dialog v-model="viewDialog" max-width="430">
@@ -804,9 +968,34 @@ import {
804
968
  hiddenSubjectNote,
805
969
  memberCandidateOf,
806
970
  memberHasAccount,
971
+ HID_ALL_SERVICES,
972
+ permissionCategoryOf,
973
+ providerAccountsKnown,
974
+ providerCandidateOf,
975
+ providerEnrollableRows,
807
976
  readMemberPage,
977
+ serviceFilterOf,
808
978
  subjectCategoryLabel as labelForSubjectCategory,
979
+ subjectLinkOf,
980
+ type HidEnrolmentSubject,
809
981
  } from "../utils/hid-enrolment-subject";
982
+ /* The SAME filter the Members > Service Providers screen applies, not a second
983
+ copy of the rule: a picker that disagrees with the screen the operator was
984
+ just looking at is a picker they cannot trust. Core applies it to the Excel
985
+ export too (`filterSiteProviderMembers`). */
986
+ import {
987
+ filterProviderMembers,
988
+ serviceTypeOptions,
989
+ } from "../utils/site-provider-members";
990
+ /* Bulk access is granted through the permissions document, and the PUT REPLACES
991
+ this reader's whole assignment set. These three are what keep a bulk add from
992
+ revoking everybody who was already on the door - see the file's own header. */
993
+ import {
994
+ seedAssignments,
995
+ setAssignment,
996
+ toAssignmentPayload,
997
+ type HidAssignmentState,
998
+ } from "../utils/hid-permission-assignments";
810
999
 
811
1000
  const props = defineProps({
812
1001
  site: {
@@ -847,8 +1036,13 @@ const {
847
1036
  createIdentity,
848
1037
  updateIdentity,
849
1038
  deleteIdentity,
1039
+ getSitePermissions,
1040
+ updateSitePermissions,
850
1041
  } = useHidAmico();
851
1042
  const { getAll: getAllMembers } = useMember();
1043
+ /* Members > Service Providers reads this same call, so the enrolment picker
1044
+ offers exactly the people that screen lists. Site-scoped by the server. */
1045
+ const { getSiteMembers } = useServiceProvider();
852
1046
  const runtimeConfig = useRuntimeConfig();
853
1047
 
854
1048
  type HidMetadata = Record<string, unknown> & {
@@ -1034,7 +1228,11 @@ type UnitPerson = {
1034
1228
  * the dialog, and `useHidReaderSelection` already set the precedent for where
1035
1229
  * that line sits.
1036
1230
  */
1037
- const REMEMBERED_SUBJECT_CATEGORY = ref<THidPermissionCategory>("resident");
1231
+ /* The dropdown's values, plus `service_provider` - which the dropdown does not
1232
+ offer but `openEdit` resolves from records that name a COMPANY. */
1233
+ type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
1234
+
1235
+ const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
1038
1236
 
1039
1237
  const form = reactive({
1040
1238
  reader: "",
@@ -1048,7 +1246,7 @@ const form = reactive({
1048
1246
  accessPin: "",
1049
1247
  password: "",
1050
1248
  photoPreview: "",
1051
- subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
1249
+ subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
1052
1250
  subjectId: "",
1053
1251
  /** A mirror of the chosen member's role. Never sent; display only. */
1054
1252
  subjectRole: "",
@@ -1170,6 +1368,28 @@ const unitResidentNote = computed(() => {
1170
1368
  const subjectCategoryOptions = HID_ENROLMENT_SUBJECTS;
1171
1369
 
1172
1370
  const isResidentSubject = computed(() => form.subjectCategory === "resident");
1371
+ /* Members and provider staff both pick ONE PERSON from a flat list, but from
1372
+ different lists, so they are separate branches rather than "not a resident". */
1373
+ const isMemberSubject = computed(
1374
+ () => form.subjectCategory === "property_management"
1375
+ );
1376
+ const isProviderSubject = computed(
1377
+ () => form.subjectCategory === "service_provider_staff"
1378
+ );
1379
+ /** Either of the two person pickers - what the Role mirror follows. */
1380
+ const isPersonSubject = computed(
1381
+ () => isMemberSubject.value || isProviderSubject.value
1382
+ );
1383
+
1384
+ /* The mirror's label follows the list it mirrors. A member's subtitle is their
1385
+ role at the site; a contractor's is the firm they work for, and calling that
1386
+ "Role" would read as a mistake. */
1387
+ const subjectMirrorLabel = computed(() =>
1388
+ isProviderSubject.value ? "Company" : "Role"
1389
+ );
1390
+ const subjectMirrorPlaceholder = computed(() =>
1391
+ isProviderSubject.value ? "Select a person above" : "Select a member above"
1392
+ );
1173
1393
 
1174
1394
  /* What an EDIT shows in place of the control, since the type cannot change.
1175
1395
  Looked up in the util, not in the switch's own options: records carry
@@ -1363,6 +1583,387 @@ function loadMoreMembers() {
1363
1583
  return fetchMemberPage(memberPage.value + 1, true);
1364
1584
  }
1365
1585
 
1586
+ /* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
1587
+ *
1588
+ * ONE PERSON from a provider company, never the company.
1589
+ *
1590
+ * Read from `GET /api/service-providers/site-members` - the same call behind
1591
+ * Members > Service Providers, with the same `filterProviderMembers` rule - so
1592
+ * the picker lists exactly the people that screen lists. The server scopes it to
1593
+ * the site; these are the contractors who attend THIS site, not every employee
1594
+ * of every firm engaged with the organisation.
1595
+ *
1596
+ * Unpaged, deliberately, unlike the staff picker above: this endpoint returns
1597
+ * the site's people in one response (capped at 1000 by the repository) and has
1598
+ * no per-page cache entry to disagree with. The service filter and the search
1599
+ * box both run over the loaded list, so neither costs a request.
1600
+ */
1601
+ const providerPeople = ref<Record<string, unknown>[]>([]);
1602
+ const providerCompanyCount = ref(0);
1603
+ const loadingProviders = ref(false);
1604
+ const providersLoaded = ref(false);
1605
+ /**
1606
+ * All services is the default, the same as the Members screen - but it carries a
1607
+ * REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
1608
+ * string, so an empty-valued option draws the placeholder in placeholder grey and
1609
+ * is never marked as chosen: the filter worked and the field looked blank. See
1610
+ * `serviceFilterOf`.
1611
+ */
1612
+ const providerService = ref<string>(HID_ALL_SERVICES);
1613
+
1614
+ /** What the filter actually receives: "" for All services. */
1615
+ const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
1616
+ let providerRequestSeq = 0;
1617
+
1618
+ /** Everyone who can actually be given a door: an app account, and active. */
1619
+ const providerEnrollable = computed(() =>
1620
+ providerEnrollableRows(providerPeople.value)
1621
+ );
1622
+
1623
+ /* Whether the endpoint told us who holds an app account. It only began
1624
+ returning `user` in core 3.130, and this layer can be deployed ahead of it. */
1625
+ const providerAccountsVisible = computed(() =>
1626
+ providerAccountsKnown(providerPeople.value)
1627
+ );
1628
+
1629
+ /** People the operator can see on the Members screen but cannot enrol here. */
1630
+ const hiddenProviderCount = computed(
1631
+ () => providerPeople.value.length - providerEnrollable.value.length
1632
+ );
1633
+
1634
+ const providerServiceOptions = computed(() => [
1635
+ { title: "All services", value: HID_ALL_SERVICES },
1636
+ // From the ENROLLABLE set, so choosing a service never lands on an empty list.
1637
+ ...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
1638
+ ]);
1639
+
1640
+ /** The filtered list - what both the picker and the bulk table draw. */
1641
+ const providerRows = computed(() =>
1642
+ filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
1643
+ type: providerServiceFilter.value,
1644
+ })
1645
+ );
1646
+
1647
+ const providerOptions = computed(() =>
1648
+ providerRows.value.map((row) => {
1649
+ const candidate = providerCandidateOf(row);
1650
+ return {
1651
+ // The firm in the title as well as the mirror: two contractors sharing a
1652
+ // first name is the normal case, and the popover is where they have to be
1653
+ // told apart.
1654
+ title: candidate.subtitle
1655
+ ? `${candidate.name} — ${candidate.subtitle}`
1656
+ : candidate.name,
1657
+ value: candidate.subjectId,
1658
+ };
1659
+ })
1660
+ );
1661
+
1662
+ const providerPlaceholder = computed(() => {
1663
+ if (loadingProviders.value) return "Loading people...";
1664
+ if (!providersLoaded.value) return "Select a person";
1665
+ if (providerOptions.value.length) return "Select a person";
1666
+ if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
1667
+ if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
1668
+ return providerCompanyCount.value
1669
+ ? "No service provider people at this site"
1670
+ : "No service providers work at this site yet";
1671
+ });
1672
+
1673
+ /* Say why somebody on the Members screen is not in this list, rather than
1674
+ letting a missing colleague read as a broken dialog. */
1675
+ const hiddenProviderNote = computed(() => {
1676
+ if (loadingProviders.value || !hiddenProviderCount.value) return "";
1677
+ const count = hiddenProviderCount.value;
1678
+ const subject = count === 1 ? "1 person" : `${count} people`;
1679
+ const verb = count === 1 ? "is" : "are";
1680
+ const holder = count === 1 ? "that person is" : "they are";
1681
+ const needs = providerAccountsVisible.value
1682
+ ? "an active membership with an app account"
1683
+ : "an active membership at this site";
1684
+ return `${subject} from this site's service providers ${verb} not listed:`
1685
+ + ` HID enrollment needs ${needs}, and ${holder} without one.`;
1686
+ });
1687
+
1688
+ /** Back to "nothing loaded", so the next open starts clean. */
1689
+ function resetProviderCandidates() {
1690
+ providerRequestSeq += 1;
1691
+ providerPeople.value = [];
1692
+ providerCompanyCount.value = 0;
1693
+ providerService.value = HID_ALL_SERVICES;
1694
+ providersLoaded.value = false;
1695
+ }
1696
+
1697
+ /**
1698
+ * This site's service provider people.
1699
+ *
1700
+ * NEVER THROWS, on the same rule as the staff loader: choosing Service provider
1701
+ * must not break the dialog. The placeholder carries the failure.
1702
+ */
1703
+ async function loadProviderCandidates() {
1704
+ if (!props.site) {
1705
+ resetProviderCandidates();
1706
+ providersLoaded.value = true;
1707
+ return;
1708
+ }
1709
+ const seq = (providerRequestSeq += 1);
1710
+ loadingProviders.value = true;
1711
+ try {
1712
+ const data = await getSiteMembers(props.site);
1713
+ // A response that arrived after the dialog was reopened belongs to a list
1714
+ // that no longer exists - the same guard the staff pager uses.
1715
+ if (seq !== providerRequestSeq) return;
1716
+ providerPeople.value = Array.isArray(data?.items)
1717
+ ? (data.items as unknown as Record<string, unknown>[])
1718
+ : [];
1719
+ providerCompanyCount.value = Array.isArray(data?.companies)
1720
+ ? data.companies.length
1721
+ : 0;
1722
+ providersLoaded.value = true;
1723
+ } catch (error: unknown) {
1724
+ if (seq !== providerRequestSeq) return;
1725
+ console.error("Unable to load service provider people for HID enrollment:", error);
1726
+ providerPeople.value = [];
1727
+ providerCompanyCount.value = 0;
1728
+ providersLoaded.value = true;
1729
+ } finally {
1730
+ loadingProviders.value = false;
1731
+ }
1732
+ }
1733
+
1734
+ /** The chosen person, restated into Name and the Company mirror. */
1735
+ function onProviderChanged() {
1736
+ // Compared on the TRIMMED id, because that is what the option's value carries
1737
+ // (`providerCandidateOf` trims) - matching the raw field would silently find
1738
+ // nobody and blank the name the operator just picked.
1739
+ const row = providerRows.value.find(
1740
+ (item) => String(item._id ?? "").trim() === form.subjectId
1741
+ );
1742
+ const candidate = row ? providerCandidateOf(row) : null;
1743
+ form.name = candidate?.name ?? "";
1744
+ form.subjectRole = candidate?.subtitle ?? "";
1745
+ void checkSubjectEnrollment();
1746
+ }
1747
+
1748
+ /* Changing the service can take the chosen person out of the list. Leaving a
1749
+ selection that is no longer on screen is how a save ends up naming somebody
1750
+ the operator cannot see. */
1751
+ function onProviderServiceChanged() {
1752
+ if (!form.subjectId) return;
1753
+ const stillThere = providerRows.value.some(
1754
+ (item) => String(item._id ?? "").trim() === form.subjectId
1755
+ );
1756
+ if (stillThere) return;
1757
+ form.subjectId = "";
1758
+ form.name = "";
1759
+ form.subjectRole = "";
1760
+ residentConflict.value = null;
1761
+ }
1762
+
1763
+ /* ── ADD USERS IN BULK ────────────────────────────────────────────────────
1764
+ *
1765
+ * ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
1766
+ * stops there - no face, no card, no PIN. Each person then adds their own
1767
+ * credential from their app, which attaches to the HID user this grant created
1768
+ * (`getProfileEnrollmentContext` reuses the existing profile identity), so
1769
+ * nobody has to be enrolled twice.
1770
+ *
1771
+ * It writes through `PUT /sites/:siteId/permissions`, the one call that writes
1772
+ * group membership, and the device users are created by the reconcile that call
1773
+ * triggers: `provisionPermissionUser` writes `{ name, registration }` and
1774
+ * nothing else. That is exactly "user record and group, credentials empty".
1775
+ *
1776
+ * THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
1777
+ * this reader's whole assignment set. Sending only the ticked contractors would
1778
+ * revoke every resident and every member on the door. The payload is always
1779
+ * built from the reader's existing assignments PLUS the selection.
1780
+ */
1781
+ const bulkDialog = ref(false);
1782
+ const bulkLoading = ref(false);
1783
+ const bulkSaving = ref(false);
1784
+ const bulkSearch = ref("");
1785
+ /** The reader's CURRENT assignments, across every category. The merge base. */
1786
+ const bulkSaved = ref<HidAssignmentState>(new Map());
1787
+ /** Who is being added now. Already-granted people are never in here. */
1788
+ const bulkSelected = ref<Set<string>>(new Set());
1789
+
1790
+ /* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
1791
+ screen behind two dialogs, so the confirmation says which one rather than
1792
+ leaving the operator to remember. */
1793
+ const bulkReaderLabel = computed(() => {
1794
+ const reader = selectedReader.value;
1795
+ const name = String(reader?.name ?? "").trim() || "this HID reader";
1796
+ const location = String(reader?.location ?? "").trim();
1797
+ return location ? `${name} · ${location}` : name;
1798
+ });
1799
+
1800
+ /** The same people the picker offers, narrowed by this dialog's own search. */
1801
+ const bulkRows = computed(() => {
1802
+ const rows = filterProviderMembers(
1803
+ providerRows.value as Array<Record<string, any>>,
1804
+ { search: bulkSearch.value }
1805
+ );
1806
+ return rows.map((row) => {
1807
+ const candidate = providerCandidateOf(row);
1808
+ const id = candidate.subjectId;
1809
+ return {
1810
+ subjectId: id,
1811
+ name: candidate.name,
1812
+ company: String(row.company ?? "").trim(),
1813
+ service: String(row.typeLabel ?? row.type ?? "").trim(),
1814
+ // Already on the door. Ticked and locked rather than hidden: hiding them
1815
+ // would make "select all" look like it had missed people, and unticking
1816
+ // one here would revoke access this dialog is not for revoking.
1817
+ granted: bulkSaved.value.has(id),
1818
+ };
1819
+ });
1820
+ });
1821
+
1822
+ const bulkSelectableRows = computed(() =>
1823
+ bulkRows.value.filter((row) => !row.granted)
1824
+ );
1825
+ const bulkSelectedCount = computed(() => bulkSelected.value.size);
1826
+ const bulkAllSelected = computed(
1827
+ () =>
1828
+ bulkSelectableRows.value.length > 0 &&
1829
+ bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
1830
+ );
1831
+ const bulkGrantedCount = computed(
1832
+ () => bulkRows.value.filter((row) => row.granted).length
1833
+ );
1834
+
1835
+ const bulkEmptyText = computed(() => {
1836
+ if (bulkLoading.value) return "Loading...";
1837
+ if (bulkSearch.value.trim()) return "No one matches this search.";
1838
+ if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
1839
+ return "No service provider people can be enrolled at this site yet.";
1840
+ });
1841
+
1842
+ function isBulkSelected(subjectId: string) {
1843
+ return bulkSelected.value.has(subjectId);
1844
+ }
1845
+
1846
+ function toggleBulkRow(subjectId: string, selected: boolean) {
1847
+ const next = new Set(bulkSelected.value);
1848
+ if (selected) next.add(subjectId);
1849
+ else next.delete(subjectId);
1850
+ bulkSelected.value = next;
1851
+ }
1852
+
1853
+ /* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
1854
+ and the search have left - never on the whole estate. A tick box cannot
1855
+ quietly grant a door to people who are not on screen. */
1856
+ function toggleBulkAll(selected: boolean) {
1857
+ const next = new Set(bulkSelected.value);
1858
+ for (const row of bulkSelectableRows.value) {
1859
+ if (selected) next.add(row.subjectId);
1860
+ else next.delete(row.subjectId);
1861
+ }
1862
+ bulkSelected.value = next;
1863
+ }
1864
+
1865
+ /**
1866
+ * Open the bulk dialog, after reading what the reader already holds.
1867
+ *
1868
+ * The saved set is fetched EVERY time rather than cached: it is the merge base
1869
+ * for a write that replaces the reader's assignments, and a stale one would
1870
+ * revoke whoever was granted in between.
1871
+ */
1872
+ async function openBulkAdd() {
1873
+ const readerId =
1874
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1875
+ if (!readerId) {
1876
+ showToast("Select a HID reader before adding people in bulk.", "error");
1877
+ return;
1878
+ }
1879
+ /* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
1880
+ has it as `required()`, unlike `createIdentity`, which falls back to the
1881
+ site's own. Both pages that mount this component pass one, so this is a
1882
+ guard against a 400 that would read as a server fault. */
1883
+ if (!props.org) {
1884
+ showToast(
1885
+ "This screen cannot grant access in bulk without an organization.",
1886
+ "error"
1887
+ );
1888
+ return;
1889
+ }
1890
+ bulkSearch.value = "";
1891
+ bulkSelected.value = new Set();
1892
+ bulkSaved.value = new Map();
1893
+ bulkDialog.value = true;
1894
+ bulkLoading.value = true;
1895
+ try {
1896
+ if (!providersLoaded.value) await loadProviderCandidates();
1897
+ const response = await getSitePermissions(props.site, props.org, readerId);
1898
+ bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
1899
+ } catch (error: unknown) {
1900
+ // Closed rather than left open on an empty merge base: saving from here
1901
+ // would send only the ticked people and revoke everybody else.
1902
+ bulkDialog.value = false;
1903
+ showToast(
1904
+ apiReason(error) || "Unable to read this reader's current access.",
1905
+ "error"
1906
+ );
1907
+ } finally {
1908
+ bulkLoading.value = false;
1909
+ }
1910
+ }
1911
+
1912
+ /** Grant the ticked people, keeping everyone the reader already admits. */
1913
+ async function saveBulkAdd() {
1914
+ if (bulkSaving.value || !bulkSelected.value.size) return;
1915
+ const readerId =
1916
+ selectedReaderId.value || form.reader || readers.value[0]?._id;
1917
+ if (!readerId) {
1918
+ showToast("Select a HID reader before adding people in bulk.", "error");
1919
+ return;
1920
+ }
1921
+
1922
+ bulkSaving.value = true;
1923
+ try {
1924
+ // Seeded from what the server holds, so every category the reader already
1925
+ // admits survives this write.
1926
+ let draft: HidAssignmentState = new Map(bulkSaved.value);
1927
+ for (const subjectId of bulkSelected.value) {
1928
+ draft = setAssignment(
1929
+ draft,
1930
+ bulkSaved.value,
1931
+ subjectId,
1932
+ // A provider employee's grant is stored under the STAFF category: core's
1933
+ // `permissionSubjectOfIdentity` reads the `member` link and cannot see
1934
+ // this dropdown, and that category's subject list already includes these
1935
+ // people. See `permissionCategoryOf`.
1936
+ permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
1937
+ true
1938
+ );
1939
+ }
1940
+ const added = bulkSelected.value.size;
1941
+ await updateSitePermissions(
1942
+ props.site,
1943
+ props.org,
1944
+ readerId,
1945
+ toAssignmentPayload(draft)
1946
+ );
1947
+ // Both dialogs close and the list is re-read: the reconcile has just created
1948
+ // a HID user for each person, so the roster behind this dialog is stale.
1949
+ bulkDialog.value = false;
1950
+ formDialog.value = false;
1951
+ await loadUsers();
1952
+ showToast(
1953
+ `${added} ${added === 1 ? "person" : "people"} can now use this reader. `
1954
+ + "They add their own face, QR or PIN from their app.",
1955
+ "success"
1956
+ );
1957
+ } catch (error: unknown) {
1958
+ showToast(
1959
+ apiReason(error) || "Unable to give these people access to this reader.",
1960
+ "error"
1961
+ );
1962
+ } finally {
1963
+ bulkSaving.value = false;
1964
+ }
1965
+ }
1966
+
1366
1967
  /**
1367
1968
  * Switching type clears the chosen subject, and nothing else.
1368
1969
  *
@@ -1372,15 +1973,20 @@ function loadMoreMembers() {
1372
1973
  * choice, so they clear with it.
1373
1974
  */
1374
1975
  async function onSubjectCategoryChanged() {
1375
- REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
1976
+ REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
1376
1977
  form.subjectId = "";
1377
1978
  form.subjectRole = "";
1378
1979
  form.name = "";
1379
1980
  residentConflict.value = null;
1380
1981
 
1381
- if (!isResidentSubject.value && !membersLoaded.value) {
1982
+ // Each list is fetched on first use and then kept, so flipping between the
1983
+ // three options costs one request each rather than one per flip.
1984
+ if (isMemberSubject.value && !membersLoaded.value) {
1382
1985
  await loadMemberCandidates();
1383
1986
  }
1987
+ if (isProviderSubject.value && !providersLoaded.value) {
1988
+ await loadProviderCandidates();
1989
+ }
1384
1990
  }
1385
1991
 
1386
1992
  /* The staff equivalent of `onResidentChanged`. */
@@ -1939,7 +2545,9 @@ async function openEnroll() {
1939
2545
  // the request must not delay it, and the picker carries its own loading
1940
2546
  // placeholder. Fresh each opening, because staff change between them.
1941
2547
  resetMemberCandidates();
1942
- if (!isResidentSubject.value) await loadMemberCandidates();
2548
+ resetProviderCandidates();
2549
+ if (isMemberSubject.value) await loadMemberCandidates();
2550
+ if (isProviderSubject.value) await loadProviderCandidates();
1943
2551
  }
1944
2552
 
1945
2553
  async function openEdit(user: HidUser) {
@@ -1961,10 +2569,19 @@ async function openEdit(user: HidUser) {
1961
2569
  removePinRequested.value = false;
1962
2570
  form.photoPreview = "";
1963
2571
  facialEnrolled.value = hasFacialEnrollment(user);
2572
+ /*
2573
+ * Read in the order `createIdentity` validates the links, with one extra
2574
+ * test. A `member` link is how BOTH a property-management member and a
2575
+ * service provider's own employee are stored - the person's membership at
2576
+ * this site - so the identity's `type` is what tells them apart. Without
2577
+ * that test a contractor's edit would be headed "Member".
2578
+ */
1964
2579
  form.subjectCategory = user.person
1965
2580
  ? "resident"
1966
2581
  : user.serviceProvider
1967
2582
  ? "service_provider"
2583
+ : user.member && String(user.type ?? "") === "contractor"
2584
+ ? "service_provider_staff"
1968
2585
  : "property_management";
1969
2586
  form.subjectId = String(
1970
2587
  user.person || user.member || user.serviceProvider || ""
@@ -2081,6 +2698,8 @@ async function saveUser() {
2081
2698
  : !selectedUser.value && !form.subjectId
2082
2699
  ? isResidentSubject.value
2083
2700
  ? "a resident"
2701
+ : isProviderSubject.value
2702
+ ? "a person"
2084
2703
  : "a member"
2085
2704
  : !form.name
2086
2705
  ? "a name"
@@ -2139,27 +2758,7 @@ async function saveUser() {
2139
2758
  // an empty string as a link being removed and then refuses the write for
2140
2759
  // having no subject at all. Omitting the keys is what makes it keep the
2141
2760
  // subject the record already has.
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
- : {};
2761
+ const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
2163
2762
 
2164
2763
  const payload = {
2165
2764
  hidUserId: String(hidUser.id),
@@ -3283,6 +3882,47 @@ watch(selectedReaderId, (value) => {
3283
3882
  color: var(--err);
3284
3883
  }
3285
3884
 
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
+
3286
3926
  .photo-wrap {
3287
3927
  display: grid;
3288
3928
  place-items: center;
@@ -77,10 +77,11 @@
77
77
  </v-tab>
78
78
  </v-tabs>
79
79
 
80
+ <!-- Wraps on narrow screens (was a fixed 60px row: on a phone the
81
+ "Not Checked Out" label stacked one letter per line). -->
80
82
  <v-card
81
- class="w-100 px-3 d-flex align-center ga-5 py-2"
83
+ class="w-100 px-3 d-flex flex-wrap align-center ga-3 py-2 visitor-filters"
82
84
  flat
83
- :height="60"
84
85
  >
85
86
  <v-text-field
86
87
  v-model="searchInput"
@@ -3421,4 +3422,13 @@ watchEffect(async () => {
3421
3422
  opacity: 0.55;
3422
3423
  cursor: not-allowed;
3423
3424
  }
3425
+
3426
+ .visitor-filters > *:not(.v-checkbox) {
3427
+ flex: 1 1 160px;
3428
+ min-width: 150px;
3429
+ }
3430
+
3431
+ .visitor-filters > .v-checkbox {
3432
+ flex: 0 0 auto;
3433
+ }
3424
3434
  </style>
@@ -19,10 +19,15 @@ export type TSiteProviderMember = {
19
19
  unit?: string | null;
20
20
  unitName?: string;
21
21
  /**
22
- * The person's user account. NOT yet returned by the endpoint — the
23
- * repository projects it but the service layer drops it. Anything acting on
24
- * the PERSON (assigning an access card writes it onto the card as `userId`)
25
- * needs this rather than `_id`, which identifies the membership.
22
+ * The person's user account. Anything acting on the PERSON needs this rather
23
+ * than `_id`, which identifies the membership: an access card carries it as
24
+ * `userId`, and HID reader access is bound by it -
25
+ * `resolvePermissionUserBindings` drops every subject without one.
26
+ *
27
+ * Returned from core 3.130. Before that the repository projected it and the
28
+ * service layer dropped it, so against an older API it is absent on every
29
+ * row - `providerAccountsKnown` is how the HID enrolment screen tells the two
30
+ * apart rather than reading an absence as "nobody has an account".
26
31
  */
27
32
  user?: string | null;
28
33
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.82.1-staging.478",
5
+ "version": "4.82.1-staging.480",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
package/types/site.d.ts CHANGED
@@ -5,7 +5,10 @@ declare type THidQrCodeFormat = "0" | "1" | "2";
5
5
  declare type THidPermissionCategory =
6
6
  | "resident"
7
7
  | "property_management"
8
- | "service_provider";
8
+ /** A provider COMPANY - granting one admits every active member of that org. */
9
+ | "service_provider"
10
+ /** ONE PERSON from a provider company, by their membership at this site. */
11
+ | "service_provider_member";
9
12
 
10
13
  declare type THidPermissionAssignment = {
11
14
  subjectId: string;
@@ -27,7 +30,10 @@ declare type THidSitePermissions = {
27
30
  counts: {
28
31
  resident: number;
29
32
  propertyManagement: number;
33
+ /** Provider COMPANIES granted. */
30
34
  serviceProvider: number;
35
+ /** Named provider people granted. Counted apart from the companies. */
36
+ serviceProviderMember: number;
31
37
  intercom: number;
32
38
  };
33
39
  };
@@ -8,14 +8,20 @@
8
8
  * in this repo.
9
9
  */
10
10
 
11
- export type HidEnrolmentSubject = "resident" | "property_management";
11
+ /**
12
+ * What the ENROLLING dropdown offers. Three of these four are the server's own
13
+ * permission categories; `service_provider_staff` is this screen's own value
14
+ * and deliberately not one of them - see `subjectLinkOf`.
15
+ */
16
+ export type HidEnrolmentSubject =
17
+ | "resident"
18
+ | "property_management"
19
+ | "service_provider_staff";
12
20
 
13
21
  /**
14
22
  * The dropdown's options, in the order they are drawn.
15
23
  *
16
- * `title`/`value` because `AppSelect` takes that shape. A service-provider
17
- * option is expected next; adding it here is most of the work, since the write
18
- * path already maps `service_provider` onto `serviceProvider`.
24
+ * `title`/`value` because `AppSelect` takes that shape.
19
25
  */
20
26
  export const HID_ENROLMENT_SUBJECTS: ReadonlyArray<{
21
27
  title: string;
@@ -23,6 +29,7 @@ export const HID_ENROLMENT_SUBJECTS: ReadonlyArray<{
23
29
  }> = [
24
30
  { title: "Resident", value: "resident" },
25
31
  { title: "Member", value: "property_management" },
32
+ { title: "Service provider", value: "service_provider_staff" },
26
33
  ];
27
34
 
28
35
  /**
@@ -38,6 +45,10 @@ export function subjectCategoryLabel(category: unknown): string {
38
45
  const labels: Record<string, string> = {
39
46
  resident: "Resident",
40
47
  property_management: "Member",
48
+ // The dropdown's own value: ONE PERSON from a provider company.
49
+ service_provider_staff: "Service provider",
50
+ // The server's category, which names a COMPANY. Records in the wild carry
51
+ // it, and an edit must not call such a row a resident.
41
52
  service_provider: "Service provider",
42
53
  visitor: "Visitor",
43
54
  };
@@ -142,3 +153,192 @@ export function hiddenSubjectNote(count: number, noun: "resident" | "member"): s
142
153
  return `${subject} at this site ${verb} not listed:`
143
154
  + ` HID enrollment needs an app account, and ${holder} none yet.`;
144
155
  }
156
+
157
+ /* ── THE LINK THAT REACHES THE DEVICE ──────────────────────────────────── */
158
+
159
+ /**
160
+ * WHICH SUBJECT FIELD AN ENROLMENT WRITES, and what the identity is called.
161
+ *
162
+ * This is the one decision in the dialog that changes who a door lets in, so it
163
+ * lives here with tests rather than inline in the template's script.
164
+ *
165
+ * `service_provider_staff` links through `member`, NOT `serviceProvider`, and
166
+ * that is the whole design:
167
+ *
168
+ * - `serviceProvider` names a `site.service-providers` row, which is a COMPANY.
169
+ * `resolvePermissionUserBindings` expands one into every active member of
170
+ * that organisation with NO site filter, so granting it puts a 200-person
171
+ * firm on a door that eight of them attend.
172
+ * - A provider employee's membership lives in `members` with `siteId` set to
173
+ * this site - `members` carries a unique index on `{org, siteId, user, type}`,
174
+ * so it cannot exist there without one. That makes the person site-scoped by
175
+ * construction, which is the whole point.
176
+ *
177
+ * The identity `type` is `"contractor"` rather than `"staff"`, and it is load
178
+ * bearing on the server as well as here: core's `permissionSubjectOfIdentity`
179
+ * reads it to tell a contractor's `member` link from a property-management one,
180
+ * and files the grant under `service_provider_member` or `property_management`
181
+ * accordingly. It is also what the Access Permissions tabs filter on.
182
+ *
183
+ * An earlier version filed provider staff under `property_management`, since that
184
+ * category's subject list already accepts these rows. It worked for access and
185
+ * failed for identity: `permissionIdentityType` derives the identity's type from
186
+ * the category, so every contractor came back as `staff` - and because the
187
+ * reconcile rewrites the identity it finds, the `"contractor"` written here was
188
+ * overwritten seconds later.
189
+ *
190
+ * An empty `subjectId` returns `{}` ON PURPOSE. Sending `person: ""` is not
191
+ * "leave this alone", it is "clear it": the server reads an empty string as the
192
+ * link being removed and then refuses the write for having no subject at all.
193
+ */
194
+ export function subjectLinkOf(
195
+ category: unknown,
196
+ subjectId: unknown,
197
+ ): {
198
+ person?: string;
199
+ member?: string;
200
+ serviceProvider?: string;
201
+ type?: "resident" | "staff" | "contractor";
202
+ } {
203
+ const id = String(subjectId ?? "").trim();
204
+ if (!id) return {};
205
+ const of = (
206
+ field: "person" | "member" | "serviceProvider",
207
+ type: "resident" | "staff" | "contractor",
208
+ ) => ({
209
+ person: field === "person" ? id : "",
210
+ member: field === "member" ? id : "",
211
+ serviceProvider: field === "serviceProvider" ? id : "",
212
+ type,
213
+ });
214
+ switch (String(category ?? "")) {
215
+ case "resident":
216
+ return of("person", "resident");
217
+ case "service_provider_staff":
218
+ return of("member", "contractor");
219
+ case "service_provider":
220
+ return of("serviceProvider", "contractor");
221
+ default:
222
+ return of("member", "staff");
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Which PERMISSION CATEGORY an enrolment's access grant is stored under.
228
+ *
229
+ * Mirrors `permissionSubjectOfIdentity` in core, which reads the stored links
230
+ * rather than this dropdown: a `member` link resolves to `property_management`
231
+ * whatever the identity's `type` says. Provider staff therefore share the staff
232
+ * category, which is correct - they are validated against the same subject list
233
+ * - and the identity `type` is what tells them apart on screen.
234
+ */
235
+ export function permissionCategoryOf(category: unknown): string {
236
+ const value = String(category ?? "");
237
+ if (value === "resident") return "resident";
238
+ if (value === "service_provider") return "service_provider";
239
+ if (value === "service_provider_staff") return "service_provider_member";
240
+ return "property_management";
241
+ }
242
+
243
+ /* ── PROVIDER STAFF AS PICKER ROWS ─────────────────────────────────────── */
244
+
245
+ /**
246
+ * Can this provider employee be enrolled?
247
+ *
248
+ * Two conditions, and both have to be checked here rather than trusted from the
249
+ * list. An app account, because reader access is bound by user id
250
+ * (`memberHasAccount`). And an ACTIVE membership: `getProviderMembersBySite`
251
+ * returns everyone who is not deleted, so a suspended person is in the list the
252
+ * Members screen draws - marked, deliberately, so the property manager can see
253
+ * them. Offering a suspended person a door is a different matter.
254
+ */
255
+ export function providerCanEnrol(
256
+ row: Record<string, unknown> | null | undefined,
257
+ ): boolean {
258
+ if (!memberHasAccount(row)) return false;
259
+ return String(row?.status ?? "").trim() === "active";
260
+ }
261
+
262
+ /**
263
+ * How a provider employee reads in the picker: their name, their company and
264
+ * the service beneath it.
265
+ *
266
+ * The company comes FIRST in the subtitle. Two contractors with the same first
267
+ * name are common, and which firm somebody belongs to is what the property
268
+ * manager actually recognises them by - the service type alone ("Security")
269
+ * describes half the list.
270
+ */
271
+ export function providerCandidateOf(row: Record<string, unknown>): {
272
+ subjectId: string;
273
+ name: string;
274
+ subtitle: string;
275
+ } {
276
+ const text = (value: unknown) => String(value ?? "").trim();
277
+ const parts = [text(row.company), text(row.typeLabel)].filter(Boolean);
278
+ return {
279
+ subjectId: text(row._id),
280
+ name: text(row.name) || text(row.email) || "Service provider",
281
+ subtitle: parts.join(" · ") || text(row.role) || text(row.email) || "",
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Does this list tell us who has an app account?
287
+ *
288
+ * `GET /api/service-providers/site-members` only began returning `user` in core
289
+ * 3.130; before that the service layer dropped it even though the repository
290
+ * projected it. Frontend and backend deploy separately, so this screen can run
291
+ * against either. Asked of the ROWS rather than of a version number: the key is
292
+ * present on every row or on none.
293
+ *
294
+ * It matters because the answer flips a filter from "cannot be bound to a door"
295
+ * to "unknown", and a screen that silently reports NOBODY can be enrolled is
296
+ * worse than one that offers somebody the server then refuses by name.
297
+ */
298
+ export function providerAccountsKnown(
299
+ rows: ReadonlyArray<Record<string, unknown>>,
300
+ ): boolean {
301
+ return rows.some((row) => Boolean(row) && typeof row === "object" && "user" in row);
302
+ }
303
+
304
+ /**
305
+ * Who may be offered a door, from whatever the endpoint returned.
306
+ *
307
+ * With accounts known, both conditions apply (`providerCanEnrol`). Without them,
308
+ * active membership is all that can be checked here — the server still refuses
309
+ * anyone it cannot bind, with a message naming them, which is a better failure
310
+ * than an empty picker.
311
+ */
312
+ export function providerEnrollableRows<T extends Record<string, unknown>>(
313
+ rows: ReadonlyArray<T>,
314
+ ): T[] {
315
+ const known = providerAccountsKnown(rows);
316
+ return rows.filter((row) =>
317
+ known ? providerCanEnrol(row) : String(row?.status ?? "").trim() === "active",
318
+ );
319
+ }
320
+
321
+ /* ── THE SERVICE FILTER'S "ALL" ────────────────────────────────────────── */
322
+
323
+ /**
324
+ * WHY "ALL SERVICES" CANNOT BE THE EMPTY STRING HERE.
325
+ *
326
+ * `filterProviderMembers` reads an empty `type` as "do not filter", and the
327
+ * Members screen's own filter uses `value: ""` for All services - but that screen
328
+ * draws a Vuetify `v-select`, which renders an empty value perfectly well.
329
+ *
330
+ * `AppSelect` does not. Its `hasSingleValue` is false for `""`, so the field
331
+ * falls back to the placeholder, draws in the placeholder's grey, and the option
332
+ * is never marked as the chosen one in the menu. The filter worked; it just
333
+ * looked like nothing was selected and like only the real services existed.
334
+ *
335
+ * So the option carries a real value here and is translated on the way to the
336
+ * filter. Changing `AppSelect` instead would touch every one of its callers.
337
+ */
338
+ export const HID_ALL_SERVICES = "all";
339
+
340
+ /** The `type` to filter by: "" for All services, the service code otherwise. */
341
+ export function serviceFilterOf(selected: unknown): string {
342
+ const value = String(selected ?? "").trim();
343
+ return value === HID_ALL_SERVICES ? "" : value;
344
+ }
@@ -27,11 +27,19 @@
27
27
  * through is a 400.
28
28
  */
29
29
 
30
- /** The three real categories. `intercom` is a candidate FILTER, never a category. */
30
+ /**
31
+ * The real categories. `intercom` is a candidate FILTER, never a category.
32
+ *
33
+ * Mirrors `HID_PERMISSION_CATEGORIES` in core, and has to: `seedAssignments`
34
+ * DROPS a row whose category is not in this list, so a category missing here
35
+ * silently loses that grant from the edit state - and the save that follows,
36
+ * being a whole-reader replace, would revoke it.
37
+ */
31
38
  export const HID_PERMISSION_CATEGORIES: THidPermissionCategory[] = [
32
39
  "resident",
33
40
  "property_management",
34
41
  "service_provider",
42
+ "service_provider_member",
35
43
  ];
36
44
 
37
45
  export type HidAssignmentState = Map<string, { category: THidPermissionCategory; intercom: boolean }>;