@7365admin1/layer-common 4.35.1-staging.448 → 4.35.1-staging.450

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.
@@ -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>
@@ -0,0 +1,205 @@
1
+ <template>
2
+ <v-dialog v-model="show" max-width="1100" scrollable>
3
+ <v-card class="screen-modal">
4
+ <v-card-title class="d-flex align-center">
5
+ {{ title }}
6
+ <v-spacer />
7
+ <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
8
+ </v-card-title>
9
+ <v-card-text class="screen-modal__body">
10
+ <p class="text-body-2 text-medium-emphasis mb-3">{{ intro }}</p>
11
+
12
+ <!-- A supervisor opening a person: what they hold now, then everything that happened to their stock. -->
13
+ <template v-if="holder && holder !== 'me' && holder !== selfId && (store.access.canApprove || store.access.canHandle)">
14
+ <div class="text-subtitle-2 mb-1">Holding now</div>
15
+ <v-table density="compact" class="mb-4 border rounded">
16
+ <thead>
17
+ <tr>
18
+ <th>Item</th>
19
+ <th class="text-right">In hand</th>
20
+ <th class="text-right">In drafts</th>
21
+ <th class="text-right">Used this month</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ <tr v-for="h in holdings" :key="h._id">
26
+ <td>{{ h.itemName }}</td>
27
+ <td class="text-right">{{ h.qty }} {{ h.unit }}</td>
28
+ <td class="text-right">{{ h.inDrafts || "—" }}</td>
29
+ <td class="text-right">{{ h.usedThisMonth ?? 0 }}</td>
30
+ </tr>
31
+ <tr v-if="!holdings.length">
32
+ <td colspan="4" class="text-center text-medium-emphasis py-3">Not holding any stock now.</td>
33
+ </tr>
34
+ </tbody>
35
+ </v-table>
36
+ </template>
37
+
38
+ <div class="d-flex flex-wrap ga-2 mb-2">
39
+ <v-text-field v-model="from" type="date" label="From" density="compact" hide-details style="max-width: 170px" />
40
+ <v-text-field v-model="to" type="date" label="To" density="compact" hide-details style="max-width: 170px" />
41
+ <v-select v-model="type" :items="typeChoices" label="What" density="compact" hide-details style="max-width: 200px" />
42
+ <v-select v-if="!item" v-model="itemPick" :items="[{ value: '', title: 'All items' }, ...itemChoices]" label="Item" density="compact" hide-details style="max-width: 240px" />
43
+ <v-text-field v-model="location" label="Where (unit, tenant or area)" density="compact" hide-details clearable style="max-width: 240px" />
44
+ </div>
45
+
46
+ <v-data-table-server
47
+ :headers="headers"
48
+ :items="rows"
49
+ :items-length="total"
50
+ :loading="loading"
51
+ :items-per-page="limit"
52
+ :page="page"
53
+ density="compact"
54
+ class="border rounded"
55
+ :no-data-text="emptyText"
56
+ @update:page="(p: number) => ((page = p), load())"
57
+ >
58
+ <template #item.date="{ value }">{{ standardFormatDateTime(value) }}</template>
59
+ <template #item.what="{ item }">{{ historyText(item) }}</template>
60
+ <template #item.person="{ item }">{{ item.holderLabel || item.holderName || "—" }}</template>
61
+ <template #item.qty="{ item }">{{ item.qty }} {{ item.unit }}</template>
62
+ <template #item.where="{ item }">{{ whereText(item.location) || (item.type === "handover" ? item.remarks : "") || "—" }}</template>
63
+ <template #item.reference="{ item }">{{ item.type === "handover" ? "—" : item.reference || "—" }}</template>
64
+ <template #item.photos="{ item }">
65
+ <InventoryPhotoThumbs :photos="item.photos" :before-at="item.photosBeforeAt" :after-at="item.photosAfterAt" />
66
+ </template>
67
+ <template #item.by="{ item }">{{ item.createdByLabel || item.createdByName }}</template>
68
+ <template #item.holderBalance="{ value }">{{ value ?? "—" }}</template>
69
+ </v-data-table-server>
70
+ </v-card-text>
71
+ </v-card>
72
+ </v-dialog>
73
+ </template>
74
+
75
+ <script setup lang="ts">
76
+ import useInventory from "../composables/useInventory";
77
+ import useUtils from "../composables/useUtils";
78
+ import useLocalAuth from "../composables/useLocalAuth";
79
+ import { historyText, whereText } from "../utils/inventory-usage";
80
+
81
+ /**
82
+ * History of stock (owner 2026-09-29, "where they used their stock"): one person's (`holder`, "me" =
83
+ * the signed-in person), one item's (`item`: its whole chain across people), or both. Received from the
84
+ * store, hand-overs in and out, usage with where + work order + photos, and returns. Drafts are not history.
85
+ */
86
+ const show = defineModel<boolean>({ default: false });
87
+ const props = defineProps({
88
+ store: { type: Object as PropType<TInventoryStore>, required: true },
89
+ holder: { type: String, default: "" },
90
+ item: { type: String, default: "" },
91
+ title: { type: String, default: "History" },
92
+ /** Open on "Used" only (My usage). */
93
+ usageOnly: { type: Boolean, default: false },
94
+ });
95
+
96
+ const api = useInventory();
97
+ const { standardFormatDateTime } = useUtils();
98
+ const { currentUser } = useLocalAuth();
99
+ const selfId = computed(() => String(currentUser.value?._id ?? ""));
100
+ const rows = ref<TInventoryTransaction[]>([]);
101
+ const holdings = ref<TInventoryHolding[]>([]);
102
+ const total = ref(0);
103
+ const page = ref(1);
104
+ const limit = 50;
105
+ const loading = ref(false);
106
+ const from = ref("");
107
+ const to = ref("");
108
+ const type = ref("");
109
+ const location = ref("");
110
+ const itemPick = ref("");
111
+ const itemChoices = ref<{ value: string; title: string }[]>([]);
112
+
113
+ const typeChoices = [
114
+ { value: "", title: "Everything" },
115
+ { value: "use", title: "Used" },
116
+ { value: "issue", title: "Taken from store" },
117
+ { value: "handover", title: "Hand-overs" },
118
+ { value: "return", title: "Given back" },
119
+ ];
120
+
121
+ const intro = computed(() =>
122
+ props.usageOnly
123
+ ? "Where the stock was used: date, place, work order and the before / after photos. Filter by date, item or place."
124
+ : props.item && !props.holder
125
+ ? "Every movement of this item: into the store, to each person, between colleagues, where it was used and what came back."
126
+ : "Everything that happened to this stock: taken from the store, hand-overs, where it was used (with the work order and photos) and returns.",
127
+ );
128
+ const emptyText = computed(() => (from.value || to.value || type.value || location.value || itemPick.value ? "Nothing matches these filters." : "Nothing recorded yet."));
129
+ const headers = computed(() => [
130
+ { title: "Date", key: "date", sortable: false },
131
+ { title: "What happened", key: "what", sortable: false },
132
+ ...(props.item ? [] : [{ title: "Item", key: "itemName", sortable: false }]),
133
+ ...(props.holder ? [] : [{ title: "Person", key: "person", sortable: false }]),
134
+ { title: "Qty", key: "qty", sortable: false },
135
+ { title: "Where", key: "where", sortable: false },
136
+ { title: "Work order / ref", key: "reference", sortable: false },
137
+ { title: "Photos", key: "photos", sortable: false },
138
+ { title: "Recorded by", key: "by", sortable: false },
139
+ ...(props.holder ? [{ title: "In hand after", key: "holderBalance", sortable: false }] : []),
140
+ ]);
141
+
142
+ async function load() {
143
+ loading.value = true;
144
+ try {
145
+ const res = await api.getTransactions(props.store._id, {
146
+ page: page.value,
147
+ limit,
148
+ ...(props.holder ? { holder: props.holder } : {}),
149
+ ...(props.item || itemPick.value ? { item: props.item || itemPick.value } : {}),
150
+ ...(type.value ? { type: type.value } : {}),
151
+ ...(location.value ? { location: location.value } : {}),
152
+ ...(from.value ? { from: new Date(`${from.value}T00:00:00`).toISOString() } : {}),
153
+ ...(to.value ? { to: new Date(`${to.value}T23:59:59.999`).toISOString() } : {}),
154
+ });
155
+ rows.value = res.items;
156
+ total.value = res.total;
157
+ } catch (error: any) {
158
+ rows.value = [];
159
+ total.value = 0;
160
+ notify(error?.data?.message || "Could not load the history.", "error");
161
+ } finally {
162
+ loading.value = false;
163
+ }
164
+ }
165
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
166
+
167
+ async function loadHoldings() {
168
+ holdings.value = [];
169
+ // The API gives another person's holdings to the store's approvers / storekeepers only.
170
+ if (!props.holder || props.holder === "me" || props.holder === selfId.value || !(props.store.access.canApprove || props.store.access.canHandle)) return;
171
+ try {
172
+ holdings.value = await api.getHoldings(props.store._id, props.holder);
173
+ } catch {
174
+ holdings.value = [];
175
+ }
176
+ }
177
+
178
+ let resetting = false;
179
+ watch([from, to, type, location, itemPick], () => {
180
+ if (resetting) return;
181
+ page.value = 1;
182
+ load();
183
+ });
184
+ watch(show, async (open) => {
185
+ if (!open) return;
186
+ resetting = true;
187
+ page.value = 1;
188
+ from.value = "";
189
+ to.value = "";
190
+ location.value = "";
191
+ itemPick.value = "";
192
+ type.value = props.usageOnly ? "use" : "";
193
+ await nextTick();
194
+ resetting = false;
195
+ load();
196
+ loadHoldings();
197
+ if (!props.item) {
198
+ try {
199
+ itemChoices.value = (await api.getItems(props.store._id, { limit: 200 })).items.map((i) => ({ value: i._id, title: i.name }));
200
+ } catch {
201
+ itemChoices.value = [];
202
+ }
203
+ }
204
+ });
205
+ </script>