@7365admin1/layer-common 4.73.0 → 4.73.1-staging.452

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 (45) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +43 -0
  3. package/components/AccessCardPreviewDialog.vue +286 -50
  4. package/components/AppSelect.vue +77 -3
  5. package/components/BuildingUnitFormEdit.vue +6 -0
  6. package/components/BuildingUnitManagement.vue +298 -0
  7. package/components/Dialog/UpdateMoreAction.vue +11 -1
  8. package/components/DocumentForm.vue +407 -112
  9. package/components/DocumentManagement.vue +213 -12
  10. package/components/Facility/BookingSetup.vue +63 -0
  11. package/components/HidAccessLogDashboard.vue +74 -17
  12. package/components/HidAccessPermissions.vue +424 -0
  13. package/components/HidIntercomManagement.vue +183 -14
  14. package/components/HidProfileQrCode.vue +332 -0
  15. package/components/HidQrCodeConfiguration.vue +17 -195
  16. package/components/HidReaderManagement.vue +398 -0
  17. package/components/HidReaderUserRoster.vue +44 -3
  18. package/components/HidUserEnrollment.vue +845 -155
  19. package/components/InvitationClientForm.vue +19 -1
  20. package/components/VisitorForm.vue +12 -0
  21. package/components/VisitorManagement.vue +267 -5
  22. package/composables/useDocument.ts +7 -0
  23. package/composables/useFacility.ts +11 -0
  24. package/composables/useHidAmico.ts +165 -0
  25. package/composables/useHidNavigation.ts +25 -1
  26. package/composables/useHidReaderSelection.ts +50 -0
  27. package/composables/useMember.ts +12 -0
  28. package/composables/usePeople.ts +19 -0
  29. package/composables/useSecurityPermission.ts +20 -0
  30. package/composables/useServiceProvider.ts +33 -2
  31. package/composables/useSiteCategory.ts +45 -0
  32. package/package.json +1 -1
  33. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  34. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  35. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  36. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  37. package/types/document.d.ts +14 -0
  38. package/types/facility.d.ts +4 -0
  39. package/types/member.d.ts +4 -0
  40. package/types/people.d.ts +28 -1
  41. package/types/service-provider.d.ts +5 -0
  42. package/utils/hid-permission-assignments.ts +190 -0
  43. package/utils/hid-reader-selection.ts +52 -0
  44. package/utils/occupancy-role.ts +109 -0
  45. package/utils/service-type.ts +30 -0
package/CHANGELOG.md CHANGED
@@ -1885,11 +1885,11 @@
1885
1885
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1886
1886
  own rule from two endpoints the console already calls, unprojected:
1887
1887
 
1888
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1889
- GET /api/roles/id/:role that membership's role document
1888
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1889
+ GET /api/roles/id/:role that membership's role document
1890
1890
 
1891
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1892
- staff = member.type === "admin" && role.type === "admin"
1891
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1892
+ staff = member.type === "admin" && role.type === "admin"
1893
1893
 
1894
1894
  `role.default` is the marker because it is the only property of a platform
1895
1895
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -775,6 +775,49 @@ select option {
775
775
  box-shadow: var(--shadow-menu);
776
776
  }
777
777
 
778
+ /* The filter box of a `searchable` AppSelect. Sticky rather than scrolled
779
+ away with the options: the list it filters is long enough to need scrolling,
780
+ which is the only reason the box is there. */
781
+ .app-select__search {
782
+ position: sticky;
783
+ top: -6px;
784
+ z-index: 1;
785
+ display: flex;
786
+ align-items: center;
787
+ gap: 6px;
788
+ margin: -6px -6px 4px;
789
+ padding: 8px 10px;
790
+ border-bottom: 1px solid var(--border);
791
+ background: var(--card);
792
+ color: var(--muted);
793
+ }
794
+
795
+ .app-select__search-input {
796
+ flex: 1;
797
+ min-width: 0;
798
+ border: none;
799
+ outline: none;
800
+ background: none;
801
+ font-family: var(--font-sans);
802
+ font-size: var(--fs-cell);
803
+ font-weight: 500;
804
+ color: var(--text);
805
+ }
806
+
807
+ .app-select__search-input::placeholder {
808
+ color: var(--muted);
809
+ font-weight: 500;
810
+ }
811
+
812
+ .app-select__empty {
813
+ margin: 0;
814
+ padding: 10px 8px;
815
+ font-family: var(--font-sans);
816
+ font-size: var(--fs-cell);
817
+ color: var(--muted);
818
+ text-align: center;
819
+ }
820
+
778
821
  .app-select__option {
779
822
  display: flex;
780
823
  align-items: center;
@@ -18,50 +18,98 @@
18
18
 
19
19
  <v-card-text class="pa-4">
20
20
  <div class="text-caption text-fontgray mb-4">
21
- Card: <strong class="text-body-2">{{ selectedCardInUnit?.cardNo }}</strong>
21
+ <!-- The card number is the one fact this dialog is about, so it is
22
+ not greyed out with the "Card:" label around it. `text-high-emphasis`
23
+ rather than a literal black: every app has a light/dark toggle
24
+ (plugins/vuetify.ts), and #000 here would be black text on a dark
25
+ panel. It resolves to black in the light theme. -->
26
+ Card:
27
+ <strong class="text-body-2 font-weight-bold text-high-emphasis">{{
28
+ selectedCardInUnit?.cardNo
29
+ }}</strong>
22
30
  </div>
23
31
 
24
- <!-- Person Type Filter -->
25
- <!-- <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">Person Type</div>
26
- <v-btn-toggle
27
- v-model="assignPersonType"
28
- color="primary"
32
+ <!--
33
+ WHO the card is being given to. The dialog only ever offered the
34
+ unit's residents and tenants; Management and Service Providers are
35
+ the other two groups of people who hold cards on a site. A select
36
+ rather than a button toggle: three labels this long overflow a 420px
37
+ dialog, and this stays legible at phone width.
38
+ -->
39
+ <InputLabel title="Assign to" required class="mb-1" />
40
+ <v-select
41
+ v-model="assignCategory"
42
+ :items="assignCategories"
29
43
  density="compact"
30
- variant="outlined"
31
- rounded="pill"
44
+ item-title="label"
45
+ item-value="value"
46
+ hide-details
32
47
  class="mb-4"
33
- >
34
- <v-btn value="" size="small" class="text-none px-3">All</v-btn>
35
- <v-btn value="owner" size="small" class="text-none px-3">Unit Owner</v-btn>
36
- <v-btn value="resident" size="small" class="text-none px-3">Resident</v-btn>
37
- <v-btn value="tenant" size="small" class="text-none px-3">Tenant</v-btn>
38
- </v-btn-toggle> -->
48
+ @update:model-value="onAssignCategoryChange"
49
+ />
39
50
 
40
51
  <!-- Person Selector -->
41
52
  <InputLabel title="Person" required class="mb-1" />
42
53
  <v-select
43
54
  v-model="assignPerson"
44
- :items="filteredPeopleItems"
55
+ :items="assignPeopleItems"
45
56
  density="compact"
46
57
  item-title="name"
47
- item-value="_id"
58
+ item-value="assigneeId"
48
59
  hide-details="auto"
49
60
  placeholder="Select person..."
50
61
  persistent-placeholder
51
62
  :loading="peopleLoading"
63
+ :disabled="peopleLoading"
52
64
  return-object
53
- no-data-text="No residents or tenants found for this unit"
65
+ :no-data-text="assignNoDataText"
54
66
  >
67
+ <!--
68
+ Three lines: who they are, their email, and what they are here -
69
+ the member's role, or the provider's line of business. `title` is
70
+ bound rather than left to `itemProps` so the subtitle slot can add
71
+ the third line under it.
72
+ -->
55
73
  <template #item="{ props: itemProps, item }">
56
- <v-list-item v-bind="itemProps" :subtitle="personTypeLabel(item.raw)" />
74
+ <v-list-item v-bind="itemProps" :title="item.raw.name">
75
+ <template v-if="item.raw.subtitle || item.raw.meta" #subtitle>
76
+ <div v-if="item.raw.subtitle" class="text-truncate">
77
+ {{ item.raw.subtitle }}
78
+ </div>
79
+ <div v-if="item.raw.meta" class="text-caption text-medium-emphasis">
80
+ {{ item.raw.meta }}
81
+ </div>
82
+ </template>
83
+ </v-list-item>
57
84
  </template>
85
+ <!--
86
+ The same two lines once a person is chosen. A member's name is
87
+ often their email, so a selection showing the name alone would drop
88
+ the one thing that tells two of them apart - their role.
89
+ -->
58
90
  <template #selection="{ item }">
59
- <span>{{ item.raw.name }}</span>
60
- <v-chip size="x-small" class="ml-2" variant="tonal">
61
- {{ personTypeLabel(item.raw) }}
62
- </v-chip>
91
+ <div class="assign-selection">
92
+ <div class="text-truncate">{{ item.raw.name }}</div>
93
+ <div
94
+ v-if="item.raw.subtitle || item.raw.meta"
95
+ class="text-caption text-medium-emphasis text-truncate"
96
+ >
97
+ {{ [item.raw.subtitle, item.raw.meta].filter(Boolean).join(" · ") }}
98
+ </div>
99
+ </div>
63
100
  </template>
64
101
  </v-select>
102
+
103
+ <!--
104
+ A card is assigned by writing the person's USER id onto it, so a row
105
+ the list cannot give one for must not be sent - the server stores
106
+ whatever id it is handed without checking, which would silently bind
107
+ the card to nobody. Data-driven rather than per-group, so this
108
+ disappears by itself once the endpoint carries the id.
109
+ -->
110
+ <p v-if="assignPerson && !assignPerson.assigneeId" class="text-warning text-caption mt-2">
111
+ {{ assignUnassignableText }}
112
+ </p>
65
113
  <p v-if="assignError" class="text-error text-caption mt-2">{{ assignError }}</p>
66
114
  </v-card-text>
67
115
 
@@ -76,7 +124,7 @@
76
124
  <v-btn
77
125
  variant="flat"
78
126
  class="text-none screen-btn-primary"
79
- :disabled="!assignPerson || assignLoading"
127
+ :disabled="!assignPerson?.assigneeId || assignLoading"
80
128
  :loading="assignLoading"
81
129
  @click="confirmAssign"
82
130
  >
@@ -404,12 +452,54 @@ const emit = defineEmits<{
404
452
 
405
453
  const { getResidents, assignUser } = useAccessManagement();
406
454
  const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
455
+ const { getAll: getMembers } = useMember();
456
+ const { getAll: getServiceProviders } = useServiceProvider();
407
457
 
408
458
  const historyDialog = ref(false);
409
459
  const showAssignForm = ref(false);
410
- const assignPersonType = ref<string>("");
411
460
  const assignPerson = ref<Record<string, any> | null>(null);
412
- const peopleItems = ref<Record<string, any>[]>([]);
461
+
462
+ /**
463
+ * WHO A CARD CAN BE GIVEN TO.
464
+ *
465
+ * The dialog only ever listed the unit's residents and tenants. Management
466
+ * (the site's own members) and Service Providers hold cards on a site too, so
467
+ * the operator picks the group first and then the person.
468
+ *
469
+ * `value` is only ever read here; nothing is sent to the server, which takes a
470
+ * flat list of user ids and does not know about these groups.
471
+ */
472
+ type AssignCategory = "residents" | "management" | "providers";
473
+
474
+ const assignCategories: { value: AssignCategory; label: string }[] = [
475
+ { value: "residents", label: "Resident / Tenant" },
476
+ { value: "management", label: "Management" },
477
+ { value: "providers", label: "Service Provider" },
478
+ ];
479
+
480
+ const assignCategory = ref<AssignCategory>("residents");
481
+
482
+ /**
483
+ * One row shape for all three groups, so the picker does not care which it is
484
+ * showing.
485
+ *
486
+ * `assigneeId` is the person's USER id - the id `assignUser` sends and the
487
+ * server writes onto the card. Residents already come from the users
488
+ * collection, and a member row carries its `user`. A row that cannot supply
489
+ * one keeps `assigneeId: ""`, which is what disables Assign for it rather than
490
+ * sending an id that binds the card to nobody.
491
+ */
492
+ type AssignRow = {
493
+ assigneeId: string;
494
+ name: string;
495
+ /** Second line - the email, or what kind of resident this is. */
496
+ subtitle: string;
497
+ /** Third line, under the subtitle - the member's role or the provider's service. */
498
+ meta?: string;
499
+ raw: Record<string, any>;
500
+ };
501
+
502
+ const assignPeopleItems = ref<AssignRow[]>([]);
413
503
  const peopleLoading = ref(false);
414
504
  const assignLoading = ref(false);
415
505
  const assignError = ref("");
@@ -452,12 +542,6 @@ const isSelectedCardDeletable = computed(() => {
452
542
  ].some((c) => c._id === id);
453
543
  });
454
544
 
455
- const filteredPeopleItems = computed(() => {
456
- if (!assignPersonType.value) return peopleItems.value;
457
- if (assignPersonType.value === "owner") return peopleItems.value.filter((p) => p.isOwner);
458
- return peopleItems.value.filter((p) => p.type === assignPersonType.value && !p.isOwner);
459
- });
460
-
461
545
  const personTypeLabel = (person: Record<string, any>) => {
462
546
  if (person.isOwner) return "Unit Owner";
463
547
  if (person.type === "resident") return "Resident";
@@ -465,34 +549,168 @@ const personTypeLabel = (person: Record<string, any>) => {
465
549
  return person.type ?? "";
466
550
  };
467
551
 
552
+ const assignNoDataText = computed(() => {
553
+ if (peopleLoading.value) return "Loading...";
554
+ if (assignCategory.value === "management") return "No management members assigned to this unit";
555
+ if (assignCategory.value === "providers") return "No service providers assigned to this unit";
556
+ return "No residents or tenants found for this unit";
557
+ });
558
+
559
+ const assignUnassignableText = computed(() =>
560
+ assignCategory.value === "providers"
561
+ ? "A card is held by a person, and this is the provider company. Assigning to service providers needs the server to say which account the card belongs to."
562
+ : "This person has no user account, so a card cannot be assigned to them.",
563
+ );
564
+
565
+ /** Residents and tenants of THIS UNIT - the list the dialog has always shown. */
566
+ async function loadResidentRows(): Promise<AssignRow[]> {
567
+ if (!props.unit?._id || !props.orgId || !props.siteId) return [];
568
+ const res = await getResidents({
569
+ orgId: props.orgId,
570
+ siteId: props.siteId,
571
+ unitId: props.unit._id,
572
+ });
573
+ const rows = res?.data ?? [];
574
+ return rows.map((person: Record<string, any>) => ({
575
+ // Already the users collection's own `_id` - see core's residents repo.
576
+ assigneeId: String(person._id ?? ""),
577
+ name: person.name ?? "",
578
+ subtitle: person.email ?? "",
579
+ meta: personTypeLabel(person),
580
+ raw: person,
581
+ }));
582
+ }
583
+
584
+ /**
585
+ * Management - the site's own members ATTACHED TO THIS UNIT.
586
+ *
587
+ * `unit` comes from core #2515 (Unit Management tab), which gave members a
588
+ * `unit`/`unitName` and taught `GET /api/members` to filter on it. Narrowing
589
+ * to the unit matches what the card is: it belongs to a unit, so the people
590
+ * offered are the ones that unit has.
591
+ *
592
+ * `type` is the member type of whichever app this layer is running in
593
+ * (`property_management_agency`, `security_agency`, ...) - the endpoint
594
+ * requires it and rejects an empty string, and `limit` is capped at 50 there.
595
+ */
596
+ async function loadManagementRows(): Promise<AssignRow[]> {
597
+ if (!props.orgId || !props.siteId || !props.unit?._id) return [];
598
+ const res: any = await getMembers({
599
+ org: props.orgId,
600
+ siteId: props.siteId,
601
+ unit: props.unit._id,
602
+ type: String(useRuntimeConfig().public.APP ?? ""),
603
+ status: "active",
604
+ page: 1,
605
+ limit: 50,
606
+ });
607
+ const rows: Record<string, any>[] = res?.items ?? res?.data ?? [];
608
+ return rows.map((member) => ({
609
+ // `user` is the account; `_id` is the membership record and is NOT it.
610
+ assigneeId: String(member.user ?? ""),
611
+ name: member.name ?? member.email ?? "",
612
+ // One line under the name: "Access Mgmt Only · towefa4573@aghism.com".
613
+ // The role first, because that is what distinguishes two members of the
614
+ // same unit from each other. Either half is dropped if the row lacks it,
615
+ // so the separator never dangles.
616
+ subtitle: [member.roleName, member.email].filter(Boolean).join(" · "),
617
+ raw: member,
618
+ }));
619
+ }
620
+
621
+ /**
622
+ * Service Providers ATTACHED TO THIS UNIT.
623
+ *
624
+ * `GET /api/service-providers?siteId&status=active&unit=<id>` - the same list
625
+ * and the same unit filter the Unit Management tab uses (core #2515), so the
626
+ * providers offered are the ones this unit actually has.
627
+ *
628
+ * These rows are COMPANIES, not people: the list has `name`, `email` and the
629
+ * service `type`, and there is no user account behind a company. See
630
+ * `assigneeId` below.
631
+ */
632
+ async function loadProviderRows(): Promise<AssignRow[]> {
633
+ if (!props.siteId || !props.unit?._id) return [];
634
+ const res: any = await getServiceProviders({
635
+ siteId: props.siteId,
636
+ unit: props.unit._id,
637
+ status: "active",
638
+ page: 1,
639
+ limit: 50,
640
+ });
641
+ const rows: Record<string, any>[] = res?.items ?? [];
642
+ return rows.map((provider) => ({
643
+ /*
644
+ * Empty on purpose, and not a missing field: a card is assigned by writing
645
+ * a USER id onto it, and a provider company has no user account. Which id a
646
+ * company-held card should carry - the `service-providers` row, the
647
+ * provider `orgId`, or a person from that company - is a backend decision
648
+ * that has not been made. Sending one anyway would write an id the server
649
+ * stores without checking and that matches no user.
650
+ */
651
+ assigneeId: "",
652
+ name: provider.name ?? "",
653
+ subtitle: provider.email ?? "",
654
+ // The service code is a database value; show what people call it.
655
+ meta: serviceTypeLabel(provider.type),
656
+ raw: provider,
657
+ }));
658
+ }
659
+
660
+ /** Load the chosen group, dropping a response that a later switch superseded. */
661
+ let assignLoadSeq = 0;
662
+ async function loadAssignPeople() {
663
+ const seq = ++assignLoadSeq;
664
+ const category = assignCategory.value;
665
+ peopleLoading.value = true;
666
+ assignPeopleItems.value = [];
667
+ try {
668
+ const rows =
669
+ category === "management"
670
+ ? await loadManagementRows()
671
+ : category === "providers"
672
+ ? await loadProviderRows()
673
+ : await loadResidentRows();
674
+ if (seq !== assignLoadSeq) return;
675
+ assignPeopleItems.value = rows;
676
+ } catch {
677
+ if (seq !== assignLoadSeq) return;
678
+ assignPeopleItems.value = [];
679
+ } finally {
680
+ if (seq === assignLoadSeq) peopleLoading.value = false;
681
+ }
682
+ }
683
+
684
+ function onAssignCategoryChange() {
685
+ assignPerson.value = null;
686
+ assignError.value = "";
687
+ loadAssignPeople();
688
+ }
689
+
468
690
  async function openAssignForm() {
469
691
  showAssignForm.value = true;
470
692
  assignPerson.value = null;
471
- assignPersonType.value = "";
693
+ assignCategory.value = "residents";
472
694
  assignError.value = "";
473
- peopleItems.value = [];
695
+ assignPeopleItems.value = [];
474
696
  encryptedAcmUrl.value = "";
475
697
 
476
698
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
477
- peopleLoading.value = true;
478
- try {
479
- const [residentsRes, acmUrl] = await Promise.all([
480
- getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
481
- getEncryptedAcmUrl(props.siteId),
482
- ]);
483
- peopleItems.value = residentsRes?.data ?? [];
484
- encryptedAcmUrl.value = acmUrl ?? "";
485
- } catch {
486
- peopleItems.value = [];
487
- } finally {
488
- peopleLoading.value = false;
489
- }
699
+
700
+ // The ACM URL is needed whichever group the card goes to, so it is fetched
701
+ // once here rather than with each list.
702
+ getEncryptedAcmUrl(props.siteId)
703
+ .then((url) => { encryptedAcmUrl.value = url ?? ""; })
704
+ .catch(() => { encryptedAcmUrl.value = ""; });
705
+
706
+ await loadAssignPeople();
490
707
  }
491
708
 
492
709
  function cancelAssign() {
493
710
  showAssignForm.value = false;
494
711
  assignPerson.value = null;
495
- assignPersonType.value = "";
712
+ assignCategory.value = "residents";
713
+ assignPeopleItems.value = [];
496
714
  assignError.value = "";
497
715
  }
498
716
 
@@ -507,12 +725,16 @@ const selectedCardType = computed(() => {
507
725
  });
508
726
 
509
727
  async function confirmAssign() {
510
- if (!assignPerson.value || !props.unit?._id) return;
728
+ // `assigneeId` rather than `_id`: the rows now come from three different
729
+ // lists and only this field is the user account the card is written to. The
730
+ // Assign button is already disabled without one; this is the guard behind it.
731
+ const assigneeId = assignPerson.value?.assigneeId;
732
+ if (!assigneeId || !props.unit?._id) return;
511
733
  assignLoading.value = true;
512
734
  assignError.value = "";
513
735
  try {
514
736
  await assignUser({
515
- assignees: [assignPerson.value._id],
737
+ assignees: [assigneeId],
516
738
  unit: props.unit._id,
517
739
  type: selectedCardType.value,
518
740
  acm_url: encryptedAcmUrl.value,
@@ -520,7 +742,9 @@ async function confirmAssign() {
520
742
  });
521
743
  emit("assign-to-person", {
522
744
  card: props.selectedCardInUnit,
523
- person: assignPerson.value,
745
+ // The original record, not the picker's row, so listeners see the same
746
+ // shape they always have.
747
+ person: assignPerson.value?.raw ?? assignPerson.value,
524
748
  });
525
749
  showToast("Card assigned successfully.", "success");
526
750
  cancelAssign();
@@ -544,3 +768,15 @@ function toggleCard(card: Record<string, any>) {
544
768
  );
545
769
  }
546
770
  </script>
771
+
772
+ <style scoped>
773
+ /* The chosen person, two lines inside the field. `min-width: 0` is what lets
774
+ `text-truncate` work at all here - the selection sits in a flex row, and a
775
+ flex child will not shrink below its content without it, so a long email
776
+ would push the field wide instead of ellipsing. */
777
+ .assign-selection {
778
+ min-width: 0;
779
+ line-height: 1.25;
780
+ padding-block: 2px;
781
+ }
782
+ </style>
@@ -18,7 +18,8 @@
18
18
  <template>
19
19
  <v-menu
20
20
  v-if="!multiple"
21
- :close-on-content-click="true"
21
+ v-model="singleOpen"
22
+ :close-on-content-click="!searchable"
22
23
  :disabled="disabled"
23
24
  location="bottom start"
24
25
  offset="4"
@@ -53,8 +54,21 @@
53
54
  </template>
54
55
 
55
56
  <div class="app-select__menu" role="listbox">
57
+ <div v-if="searchable" class="app-select__search">
58
+ <v-icon icon="mdi-magnify" size="16" />
59
+ <input
60
+ ref="singleSearchInput"
61
+ v-model="search"
62
+ type="text"
63
+ class="app-select__search-input"
64
+ :placeholder="searchPlaceholder"
65
+ aria-label="Search options"
66
+ @keydown.enter.prevent="selectFirstMatch"
67
+ @keydown.esc.stop="singleOpen = false"
68
+ >
69
+ </div>
56
70
  <button
57
- v-for="opt in normalized"
71
+ v-for="opt in visibleOptions"
58
72
  :key="String(opt.value)"
59
73
  type="button"
60
74
  class="app-select__option"
@@ -66,6 +80,9 @@
66
80
  <span class="app-select__option-text">{{ opt.title }}</span>
67
81
  <v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
68
82
  </button>
83
+ <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
84
+ {{ normalized.length ? "No matches" : "Nothing to choose from" }}
85
+ </p>
69
86
  </div>
70
87
  </v-menu>
71
88
 
@@ -122,8 +139,18 @@
122
139
  </template>
123
140
 
124
141
  <div class="app-select__menu">
142
+ <div v-if="searchable" class="app-select__search">
143
+ <v-icon icon="mdi-magnify" size="16" />
144
+ <input
145
+ v-model="search"
146
+ type="text"
147
+ class="app-select__search-input"
148
+ :placeholder="searchPlaceholder"
149
+ aria-label="Search options"
150
+ >
151
+ </div>
125
152
  <button
126
- v-for="opt in normalized"
153
+ v-for="opt in visibleOptions"
127
154
  :key="String(opt.value)"
128
155
  type="button"
129
156
  class="app-select__option"
@@ -138,6 +165,9 @@
138
165
  />
139
166
  <span class="app-select__option-text">{{ opt.title }}</span>
140
167
  </button>
168
+ <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
169
+ {{ normalized.length ? "No matches" : "Nothing to choose from" }}
170
+ </p>
141
171
  </div>
142
172
  </v-menu>
143
173
  </template>
@@ -168,6 +198,13 @@ const props = defineProps({
168
198
  returnObject: { type: Boolean, default: false },
169
199
  clearable: { type: Boolean, default: false },
170
200
  placeholder: { type: String, default: "" },
201
+ /**
202
+ * A filter box at the top of the popover. Opt-in, so the 44 consumers that
203
+ * do not ask for it render exactly the DOM they render today - a list short
204
+ * enough to read does not want a search field in front of it.
205
+ */
206
+ searchable: { type: Boolean, default: false },
207
+ searchPlaceholder: { type: String, default: "Search" },
171
208
  });
172
209
 
173
210
  const normalized = computed(() =>
@@ -178,6 +215,38 @@ const normalized = computed(() =>
178
215
  )
179
216
  );
180
217
 
218
+ /* ---- search ---------------------------------------------------------- */
219
+
220
+ const search = ref("");
221
+ const singleOpen = ref(false);
222
+ const singleSearchInput = ref<HTMLInputElement | null>(null);
223
+
224
+ /* Case-insensitive substring over what the option READS AS, not its value:
225
+ the title is the only part the user can see to type. */
226
+ const visibleOptions = computed(() => {
227
+ const term = search.value.trim().toLowerCase();
228
+ if (!props.searchable || !term) return normalized.value;
229
+ return normalized.value.filter((opt) =>
230
+ String(opt.title ?? "").toLowerCase().includes(term)
231
+ );
232
+ });
233
+
234
+ /* A stale term would hide the list the next time the popover opens, so it is
235
+ dropped on every open - and the caret goes into the box, since opening a
236
+ searchable select is almost always a prelude to typing. */
237
+ watch(singleOpen, async (open) => {
238
+ if (!props.searchable) return;
239
+ search.value = "";
240
+ if (!open) return;
241
+ await nextTick();
242
+ singleSearchInput.value?.focus();
243
+ });
244
+
245
+ function selectFirstMatch() {
246
+ const [first] = visibleOptions.value;
247
+ if (first) selectSingle(first);
248
+ }
249
+
181
250
  /* A model entry is either the plain value or - under `return-object` - the
182
251
  source item, so everything compares on this one key. */
183
252
  const keyOf = (v: any) =>
@@ -205,6 +274,11 @@ const isSingleOn = (opt: any) =>
205
274
  function selectSingle(opt: any) {
206
275
  if (props.disabled) return;
207
276
  model.value = props.returnObject ? opt.raw : String(opt.value);
277
+ // `close-on-content-click` has to be OFF while searching - it fires on the
278
+ // click that puts the caret in the search box, so the popover would shut the
279
+ // moment it was typed into. With it off, picking an option has to close the
280
+ // popover by hand.
281
+ if (props.searchable) singleOpen.value = false;
208
282
  }
209
283
 
210
284
  function clearSingle() {
@@ -10,6 +10,7 @@
10
10
  <v-tab value="general">General</v-tab>
11
11
  <v-tab value="residents">Residents/Tenants</v-tab>
12
12
  <v-tab value="vehicles">Vehicles</v-tab>
13
+ <v-tab value="management">Management</v-tab>
13
14
  <v-tab value="others">Others</v-tab>
14
15
  </v-tabs>
15
16
  </v-toolbar>
@@ -170,6 +171,11 @@
170
171
  </TableMain>
171
172
  </template>
172
173
 
174
+ <template v-else-if="tab === 'management'">
175
+ <BuildingUnitManagement :unit-id="buildingUnit._id" :site-id="buildingUnit?.site?._id"
176
+ :can-update="canUpdateUnit" />
177
+ </template>
178
+
173
179
  <template v-else-if="tab === 'others'">
174
180
  <v-row>
175
181
  <!-- <v-col cols="12" md="6" class="mt-2">