@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.
- package/components/AccessCardPreviewDialog.vue +286 -50
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryMain.vue +23 -2
- package/components/InventoryMyStockTab.vue +63 -8
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +154 -28
- package/components/InventoryUsageDialog.vue +119 -28
- package/composables/useInventory.ts +11 -3
- package/package.json +1 -1
- package/types/inventory.d.ts +18 -2
- package/utils/inventory-usage.ts +94 -0
- package/utils/service-type.ts +30 -0
|
@@ -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
|
-
|
|
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
|
-
<!--
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
44
|
+
item-title="label"
|
|
45
|
+
item-value="value"
|
|
46
|
+
hide-details
|
|
32
47
|
class="mb-4"
|
|
33
|
-
|
|
34
|
-
|
|
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="
|
|
55
|
+
:items="assignPeopleItems"
|
|
45
56
|
density="compact"
|
|
46
57
|
item-title="name"
|
|
47
|
-
item-value="
|
|
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="
|
|
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" :
|
|
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
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
+
assignCategory.value = "residents";
|
|
472
694
|
assignError.value = "";
|
|
473
|
-
|
|
695
|
+
assignPeopleItems.value = [];
|
|
474
696
|
encryptedAcmUrl.value = "";
|
|
475
697
|
|
|
476
698
|
if (!props.unit?._id || !props.orgId || !props.siteId) return;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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>
|