@7365admin1/layer-common 4.73.1-staging.456 → 4.75.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +21 -4
  2. package/assets/css/primitives.css +0 -43
  3. package/components/AccessCardPreviewDialog.vue +50 -286
  4. package/components/AppSelect.vue +3 -77
  5. package/components/BuildingUnitFormEdit.vue +0 -6
  6. package/components/Dialog/UpdateMoreAction.vue +1 -11
  7. package/components/DocumentForm.vue +112 -407
  8. package/components/DocumentManagement.vue +12 -213
  9. package/components/Facility/BookingSetup.vue +0 -63
  10. package/components/HidAccessLogDashboard.vue +17 -74
  11. package/components/HidIntercomManagement.vue +14 -183
  12. package/components/HidQrCodeConfiguration.vue +195 -17
  13. package/components/HidReaderManagement.vue +0 -398
  14. package/components/HidReaderUserRoster.vue +3 -44
  15. package/components/HidUserEnrollment.vue +155 -845
  16. package/components/InvitationClientForm.vue +1 -19
  17. package/components/MemberMain.vue +3 -6
  18. package/components/VisitorForm.vue +0 -12
  19. package/components/VisitorManagement.vue +5 -267
  20. package/composables/useDocument.ts +0 -7
  21. package/composables/useFacility.ts +0 -11
  22. package/composables/useHidAmico.ts +0 -165
  23. package/composables/useHidNavigation.ts +1 -25
  24. package/composables/useMember.ts +0 -12
  25. package/composables/usePeople.ts +0 -19
  26. package/composables/useServiceProvider.ts +2 -33
  27. package/package.json +1 -1
  28. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  29. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  31. package/types/document.d.ts +0 -14
  32. package/types/facility.d.ts +0 -4
  33. package/types/member.d.ts +0 -4
  34. package/types/people.d.ts +1 -28
  35. package/types/service-provider.d.ts +0 -5
  36. package/utils/role.ts +1 -6
  37. package/components/BuildingUnitManagement.vue +0 -298
  38. package/components/HidAccessPermissions.vue +0 -424
  39. package/components/HidProfileQrCode.vue +0 -332
  40. package/composables/useHidReaderSelection.ts +0 -50
  41. package/composables/useSiteCategory.ts +0 -45
  42. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  43. package/utils/hid-permission-assignments.ts +0 -190
  44. package/utils/hid-reader-selection.ts +0 -52
  45. package/utils/occupancy-role.ts +0 -109
  46. package/utils/service-type.ts +0 -30
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.75.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aa8c658: Organization app Members: lists every person of the organisation, including people limited to specific sites (one row each, labelled "Limited to: Site A, Site B"; organisation-wide people read "All sites"); a Site access button on every row; the actions column stays pinned on the right; shorter dates; at organisation level the "role from another site" banner is gone and such a role reads "Security (The Loft)". Needs core with `GET /members?people=1` (older servers refuse the flag).
8
+
9
+ ## 4.74.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 92ff5ab: Members: the "role from another site" banner gets "Fix roles from other sites" — a preview of each member, their site, the current role and the role proposed at their own site (warnings for fewer permissions or several/no matches, never pre-ticked), Apply, an automatic undo file download and an Undo button. Uses `GET/POST /api/members/cross-site-roles/fix` and `POST /api/members/cross-site-roles/undo` (core). The list and the banner count refresh after it.
14
+ - 8cca55f: Security role editor: NFC Patrol now offers Add, Update and Delete routes, and Site Settings offers Manage NFC Management and Manage NFC Patrol Settings. The strings and descriptions are Property Management's, so no new spelling is introduced; the security web app's NFC Patrol pages and Site Settings panels gate on them.
15
+
16
+ ### Patch Changes
17
+
18
+ - 8df8f59: Robot maps: a robot that reports its floor but no position on the plan (Ounitech, now that its API reports the floor) is listed under the map ("can't be placed on the map, so it is listed but not drawn") instead of silently missing.
19
+
3
20
  ## 4.73.0
4
21
 
5
22
  ### Minor Changes
@@ -1885,11 +1902,11 @@
1885
1902
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1886
1903
  own rule from two endpoints the console already calls, unprojected:
1887
1904
 
1888
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1889
- GET /api/roles/id/:role that membership's role document
1905
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1906
+ GET /api/roles/id/:role that membership's role document
1890
1907
 
1891
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1892
- staff = member.type === "admin" && role.type === "admin"
1908
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1909
+ staff = member.type === "admin" && role.type === "admin"
1893
1910
 
1894
1911
  `role.default` is the marker because it is the only property of a platform
1895
1912
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -775,49 +775,6 @@ 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
-
821
778
  .app-select__option {
822
779
  display: flex;
823
780
  align-items: center;
@@ -18,98 +18,50 @@
18
18
 
19
19
  <v-card-text class="pa-4">
20
20
  <div class="text-caption text-fontgray mb-4">
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>
21
+ Card: <strong class="text-body-2">{{ selectedCardInUnit?.cardNo }}</strong>
30
22
  </div>
31
23
 
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"
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"
43
29
  density="compact"
44
- item-title="label"
45
- item-value="value"
46
- hide-details
30
+ variant="outlined"
31
+ rounded="pill"
47
32
  class="mb-4"
48
- @update:model-value="onAssignCategoryChange"
49
- />
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> -->
50
39
 
51
40
  <!-- Person Selector -->
52
41
  <InputLabel title="Person" required class="mb-1" />
53
42
  <v-select
54
43
  v-model="assignPerson"
55
- :items="assignPeopleItems"
44
+ :items="filteredPeopleItems"
56
45
  density="compact"
57
46
  item-title="name"
58
- item-value="assigneeId"
47
+ item-value="_id"
59
48
  hide-details="auto"
60
49
  placeholder="Select person..."
61
50
  persistent-placeholder
62
51
  :loading="peopleLoading"
63
- :disabled="peopleLoading"
64
52
  return-object
65
- :no-data-text="assignNoDataText"
53
+ no-data-text="No residents or tenants found for this unit"
66
54
  >
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
- -->
73
55
  <template #item="{ props: itemProps, item }">
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>
56
+ <v-list-item v-bind="itemProps" :subtitle="personTypeLabel(item.raw)" />
84
57
  </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
- -->
90
58
  <template #selection="{ item }">
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>
59
+ <span>{{ item.raw.name }}</span>
60
+ <v-chip size="x-small" class="ml-2" variant="tonal">
61
+ {{ personTypeLabel(item.raw) }}
62
+ </v-chip>
100
63
  </template>
101
64
  </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>
113
65
  <p v-if="assignError" class="text-error text-caption mt-2">{{ assignError }}</p>
114
66
  </v-card-text>
115
67
 
@@ -124,7 +76,7 @@
124
76
  <v-btn
125
77
  variant="flat"
126
78
  class="text-none screen-btn-primary"
127
- :disabled="!assignPerson?.assigneeId || assignLoading"
79
+ :disabled="!assignPerson || assignLoading"
128
80
  :loading="assignLoading"
129
81
  @click="confirmAssign"
130
82
  >
@@ -452,54 +404,12 @@ const emit = defineEmits<{
452
404
 
453
405
  const { getResidents, assignUser } = useAccessManagement();
454
406
  const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
455
- const { getAll: getMembers } = useMember();
456
- const { getAll: getServiceProviders } = useServiceProvider();
457
407
 
458
408
  const historyDialog = ref(false);
459
409
  const showAssignForm = ref(false);
410
+ const assignPersonType = ref<string>("");
460
411
  const assignPerson = ref<Record<string, any> | null>(null);
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[]>([]);
412
+ const peopleItems = ref<Record<string, any>[]>([]);
503
413
  const peopleLoading = ref(false);
504
414
  const assignLoading = ref(false);
505
415
  const assignError = ref("");
@@ -542,6 +452,12 @@ const isSelectedCardDeletable = computed(() => {
542
452
  ].some((c) => c._id === id);
543
453
  });
544
454
 
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
+
545
461
  const personTypeLabel = (person: Record<string, any>) => {
546
462
  if (person.isOwner) return "Unit Owner";
547
463
  if (person.type === "resident") return "Resident";
@@ -549,168 +465,34 @@ const personTypeLabel = (person: Record<string, any>) => {
549
465
  return person.type ?? "";
550
466
  };
551
467
 
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
-
690
468
  async function openAssignForm() {
691
469
  showAssignForm.value = true;
692
470
  assignPerson.value = null;
693
- assignCategory.value = "residents";
471
+ assignPersonType.value = "";
694
472
  assignError.value = "";
695
- assignPeopleItems.value = [];
473
+ peopleItems.value = [];
696
474
  encryptedAcmUrl.value = "";
697
475
 
698
476
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
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();
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
+ }
707
490
  }
708
491
 
709
492
  function cancelAssign() {
710
493
  showAssignForm.value = false;
711
494
  assignPerson.value = null;
712
- assignCategory.value = "residents";
713
- assignPeopleItems.value = [];
495
+ assignPersonType.value = "";
714
496
  assignError.value = "";
715
497
  }
716
498
 
@@ -725,16 +507,12 @@ const selectedCardType = computed(() => {
725
507
  });
726
508
 
727
509
  async function confirmAssign() {
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;
510
+ if (!assignPerson.value || !props.unit?._id) return;
733
511
  assignLoading.value = true;
734
512
  assignError.value = "";
735
513
  try {
736
514
  await assignUser({
737
- assignees: [assigneeId],
515
+ assignees: [assignPerson.value._id],
738
516
  unit: props.unit._id,
739
517
  type: selectedCardType.value,
740
518
  acm_url: encryptedAcmUrl.value,
@@ -742,9 +520,7 @@ async function confirmAssign() {
742
520
  });
743
521
  emit("assign-to-person", {
744
522
  card: props.selectedCardInUnit,
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,
523
+ person: assignPerson.value,
748
524
  });
749
525
  showToast("Card assigned successfully.", "success");
750
526
  cancelAssign();
@@ -768,15 +544,3 @@ function toggleCard(card: Record<string, any>) {
768
544
  );
769
545
  }
770
546
  </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,8 +18,7 @@
18
18
  <template>
19
19
  <v-menu
20
20
  v-if="!multiple"
21
- v-model="singleOpen"
22
- :close-on-content-click="!searchable"
21
+ :close-on-content-click="true"
23
22
  :disabled="disabled"
24
23
  location="bottom start"
25
24
  offset="4"
@@ -54,21 +53,8 @@
54
53
  </template>
55
54
 
56
55
  <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>
70
56
  <button
71
- v-for="opt in visibleOptions"
57
+ v-for="opt in normalized"
72
58
  :key="String(opt.value)"
73
59
  type="button"
74
60
  class="app-select__option"
@@ -80,9 +66,6 @@
80
66
  <span class="app-select__option-text">{{ opt.title }}</span>
81
67
  <v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
82
68
  </button>
83
- <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
84
- {{ normalized.length ? "No matches" : "Nothing to choose from" }}
85
- </p>
86
69
  </div>
87
70
  </v-menu>
88
71
 
@@ -139,18 +122,8 @@
139
122
  </template>
140
123
 
141
124
  <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>
152
125
  <button
153
- v-for="opt in visibleOptions"
126
+ v-for="opt in normalized"
154
127
  :key="String(opt.value)"
155
128
  type="button"
156
129
  class="app-select__option"
@@ -165,9 +138,6 @@
165
138
  />
166
139
  <span class="app-select__option-text">{{ opt.title }}</span>
167
140
  </button>
168
- <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
169
- {{ normalized.length ? "No matches" : "Nothing to choose from" }}
170
- </p>
171
141
  </div>
172
142
  </v-menu>
173
143
  </template>
@@ -198,13 +168,6 @@ const props = defineProps({
198
168
  returnObject: { type: Boolean, default: false },
199
169
  clearable: { type: Boolean, default: false },
200
170
  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" },
208
171
  });
209
172
 
210
173
  const normalized = computed(() =>
@@ -215,38 +178,6 @@ const normalized = computed(() =>
215
178
  )
216
179
  );
217
180
 
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
-
250
181
  /* A model entry is either the plain value or - under `return-object` - the
251
182
  source item, so everything compares on this one key. */
252
183
  const keyOf = (v: any) =>
@@ -274,11 +205,6 @@ const isSingleOn = (opt: any) =>
274
205
  function selectSingle(opt: any) {
275
206
  if (props.disabled) return;
276
207
  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;
282
208
  }
283
209
 
284
210
  function clearSingle() {
@@ -10,7 +10,6 @@
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>
14
13
  <v-tab value="others">Others</v-tab>
15
14
  </v-tabs>
16
15
  </v-toolbar>
@@ -171,11 +170,6 @@
171
170
  </TableMain>
172
171
  </template>
173
172
 
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
-
179
173
  <template v-else-if="tab === 'others'">
180
174
  <v-row>
181
175
  <!-- <v-col cols="12" md="6" class="mt-2">