@7365admin1/layer-common 4.71.2 → 4.72.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.72.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eb2e98f: Inventory: "Main store · <company>" with who manages it, a request-first view for other companies, pending-approval banner for the owner; Used by on items (form, chips, column, filter, bulk edit, report filter); Give to colleague (My Stock) with confirm/decline; Hand-overs report; name · company · service on requests, holdings, history, reports; Issue Stock "Give to" grouped by company with a Used-by warning.
8
+
3
9
  ## 4.71.2
4
10
 
5
11
  ### Patch Changes
@@ -1873,11 +1879,11 @@
1873
1879
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1874
1880
  own rule from two endpoints the console already calls, unprojected:
1875
1881
 
1876
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1877
- GET /api/roles/id/:role that membership's role document
1882
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1883
+ GET /api/roles/id/:role that membership's role document
1878
1884
 
1879
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1880
- staff = member.type === "admin" && role.type === "admin"
1885
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1886
+ staff = member.type === "admin" && role.type === "admin"
1881
1887
 
1882
1888
  `role.default` is the marker because it is the only property of a platform
1883
1889
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -58,7 +58,7 @@
58
58
  </td>
59
59
  <td class="text-right">{{ t.type === "use" ? "" : t.storeBalance }}</td>
60
60
  <td class="text-caption">{{ describe(t) }}</td>
61
- <td class="text-caption">{{ t.createdByName }}</td>
61
+ <td class="text-caption">{{ (t as any).createdByLabel || t.createdByName }}</td>
62
62
  </tr>
63
63
  <tr v-if="!history.length && !loading">
64
64
  <td colspan="6" class="text-center text-medium-emphasis py-4">Nothing recorded yet.</td>
@@ -108,11 +108,14 @@ const formatDate = (d: string) => standardFormatDate(d);
108
108
  function describe(t: TInventoryTransaction) {
109
109
  const parts: string[] = [];
110
110
  if (t.type === "receive") parts.push(t.supplier || "", t.reference ? `PO ${t.reference}` : "");
111
- if (t.type === "issue") parts.push(`${t.qty} to ${t.holderName}`, t.reference || "");
112
- if (t.type === "return") parts.push(`${t.qty} from ${t.holderName}`);
111
+ const who = (t as any).holderLabel || t.holderName;
112
+ if (t.type === "issue") parts.push(`${t.qty} to ${who}`, t.reference || "");
113
+ if (t.type === "return") parts.push(`${t.qty} from ${who}`);
114
+ // Give to colleague: one row per holder.
115
+ if (t.type === "handover") parts.push(who || "", t.remarks || "");
113
116
  if (t.type === "use")
114
117
  parts.push(
115
- `${t.holderName} used ${t.qty}`,
118
+ `${who} used ${t.qty}`,
116
119
  t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area || "",
117
120
  t.reference || "",
118
121
  );
@@ -36,6 +36,21 @@
36
36
  <v-text-field v-model.number="f.unitCost" type="number" min="0" step="0.01" prefix="$" density="comfortable" :rules="[moneyRule]" hint="Price of one unit" persistent-hint />
37
37
  </v-col>
38
38
  </v-row>
39
+ <!-- Main store only: which service providers see and may request it (owner 2026-09-29). -->
40
+ <template v-if="store.ownerType === 'property'">
41
+ <InputLabel title="Used by" class="mb-1 mt-2" />
42
+ <v-select
43
+ :model-value="f.usedBy"
44
+ :items="usedByItems"
45
+ multiple
46
+ chips
47
+ closable-chips
48
+ density="comfortable"
49
+ :hint="USED_BY_HINT"
50
+ persistent-hint
51
+ @update:model-value="(v: string[]) => (f.usedBy = nextUsedBy(f.usedBy, v))"
52
+ />
53
+ </template>
39
54
  <!-- Stock already on the shelf, recorded like a delivery (one "Opening stock" entry in the item's history). -->
40
55
  <template v-if="!item">
41
56
  <div class="text-subtitle-2 mt-4">Opening stock</div>
@@ -93,6 +108,9 @@
93
108
  <script setup lang="ts">
94
109
  import useInventory, { pickedDate } from "../composables/useInventory";
95
110
  import useUtils from "../composables/useUtils";
111
+ import { normaliseUsedBy, nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_HINT, USED_BY_OPTIONS } from "../utils/inventory-used-by";
112
+
113
+ const usedByItems = [{ value: USED_BY_ALL, title: USED_BY_ALL_LABEL }, ...USED_BY_OPTIONS];
96
114
 
97
115
  const show = defineModel<boolean>({ default: false });
98
116
  const props = defineProps({
@@ -126,6 +144,7 @@ const blank = () => ({
126
144
  openingReceivedBy: "",
127
145
  openingRemarks: "",
128
146
  description: "",
147
+ usedBy: [USED_BY_ALL] as string[],
129
148
  });
130
149
  const today = new Date().toLocaleDateString("en-CA");
131
150
  const f = reactive(blank());
@@ -135,7 +154,7 @@ const inUse = ref(true);
135
154
  watch(show, (open) => {
136
155
  if (!open) return;
137
156
  const i = props.item;
138
- Object.assign(f, blank(), i ? { name: i.name, unit: i.unit, packing: i.packing ?? "", code: i.code, category: i.category, reorderLevel: i.reorderLevel, unitCost: i.unitCost ?? 0, description: i.description } : {});
157
+ Object.assign(f, blank(), i ? { name: i.name, unit: i.unit, packing: i.packing ?? "", code: i.code, category: i.category, reorderLevel: i.reorderLevel, unitCost: i.unitCost ?? 0, description: i.description, usedBy: normaliseUsedBy(i.usedBy) } : {});
139
158
  photo.value = i?.photo ? [i.photo] : [];
140
159
  inUse.value = i ? i.status === "active" : true;
141
160
  });
@@ -154,6 +173,7 @@ async function save() {
154
173
  unitCost: Number(f.unitCost || 0),
155
174
  description: f.description,
156
175
  photo: photo.value[0] ?? "",
176
+ ...(props.store.ownerType === "property" ? { usedBy: normaliseUsedBy(f.usedBy) } : {}),
157
177
  };
158
178
  try {
159
179
  if (props.item) {
@@ -16,19 +16,23 @@
16
16
  v-if="mode === 'issue'"
17
17
  v-model="holder"
18
18
  :items="people"
19
- item-title="name"
19
+ :item-title="(p: any) => p.label || p.name"
20
20
  item-value="_id"
21
21
  :loading="peopleLoading"
22
22
  placeholder="Search a name"
23
23
  density="comfortable"
24
24
  :rules="[requiredRule]"
25
25
  no-filter
26
+ hint="Everyone at this property who can take stock: your staff and the service providers' people."
27
+ persistent-hint
26
28
  @update:search="searchPeople"
27
29
  >
28
30
  <template #item="{ props: p, item }">
29
- <v-list-item v-bind="p" :subtitle="item.raw.orgName" />
31
+ <v-list-subheader v-if="groupStarts(item.raw)">{{ item.raw.company || "Other" }}</v-list-subheader>
32
+ <v-list-item v-bind="p" :title="item.raw.name" :subtitle="[item.raw.serviceLabel, item.raw.email].filter(Boolean).join(' · ')" />
30
33
  </template>
31
34
  </v-autocomplete>
35
+ <v-alert v-if="usedByWarning" type="warning" variant="tonal" density="compact" class="mt-2">{{ usedByWarning }}</v-alert>
32
36
  <v-text-field v-else :model-value="holderName" density="comfortable" readonly />
33
37
  </template>
34
38
 
@@ -129,6 +133,7 @@
129
133
  <script setup lang="ts">
130
134
  import useInventory, { pickedDate } from "../composables/useInventory";
131
135
  import useUtils from "../composables/useUtils";
136
+ import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
132
137
 
133
138
  type TMode = "receive" | "issue" | "request" | "return";
134
139
  type TRow = { item: string; qty: number | null; unitCost?: number | null };
@@ -230,7 +235,24 @@ async function loadItems(search = "") {
230
235
  }
231
236
  const searchItems = debounce((q: string) => loadItems(q || ""), 300);
232
237
 
233
- const people = ref<{ _id: string; name: string; orgName: string }[]>([]);
238
+ type TPerson = { _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string };
239
+ const people = ref<TPerson[]>([]);
240
+ /** A company heading above the first person of each company (the server sorts by company). */
241
+ const groupStarts = (p: TPerson) => {
242
+ const i = people.value.indexOf(p);
243
+ return i === 0 || (i > 0 && people.value[i - 1].company !== p.company);
244
+ };
245
+ /** Used by: warn before the server refuses an item meant for another service. */
246
+ const usedByWarning = computed(() => {
247
+ if (props.mode !== "issue" || !holder.value) return "";
248
+ const person = people.value.find((p) => p._id === holder.value);
249
+ if (!person?.service || person.service === "property") return "";
250
+ const blocked = rows.value
251
+ .map((r) => itemChoices.value.find((c) => c._id === r.item))
252
+ .filter((i): i is TInventoryItem => Boolean(i) && usedByChips(i!.usedBy).length > 0 && !normaliseUsedBy(i!.usedBy).includes(person.service!));
253
+ if (!blocked.length) return "";
254
+ return `${blocked.map((i) => i.name).join(", ")} ${blocked.length === 1 ? "is" : "are"} for ${usedByText(blocked[0].usedBy)} only — ${person.name} works for ${person.serviceLabel || "another service"}.`;
255
+ });
234
256
  const peopleLoading = ref(false);
235
257
  const searchPeople = debounce(async (q: string) => {
236
258
  peopleLoading.value = true;
@@ -82,6 +82,23 @@
82
82
  </AppCard>
83
83
 
84
84
  <template v-else>
85
+ <!-- Owner rule 2026-09-29: another organisation's store is for requesting only. -->
86
+ <v-alert v-if="requestOnly" type="info" variant="tonal" density="compact" class="mb-4" icon="mdi-cart-arrow-down">
87
+ <div class="font-weight-medium">Request items from the main store</div>
88
+ <div class="text-body-2">Pick the items you need and press Request Stock. You are told when the store keeper approves it.</div>
89
+ </v-alert>
90
+ <v-alert
91
+ v-else-if="store.access.canApprove && pendingCount > 0 && tab !== 'requests'"
92
+ type="warning"
93
+ variant="tonal"
94
+ density="compact"
95
+ class="mb-4"
96
+ >
97
+ <div class="d-flex align-center flex-wrap ga-2">
98
+ <span>{{ pendingCount === 1 ? "1 request is" : `${pendingCount} requests are` }} waiting for your approval.</span>
99
+ <v-btn size="small" variant="flat" color="warning" class="text-none" @click="tab = 'requests'">Review requests</v-btn>
100
+ </div>
101
+ </v-alert>
85
102
  <v-tabs v-model="tab" color="primary" class="mb-4" show-arrows>
86
103
  <v-tab v-for="t in tabs" :key="t.value" :value="t.value" class="text-none">
87
104
  {{ t.label }}
@@ -123,7 +140,7 @@ import useInventory from "../composables/useInventory";
123
140
  import useWorkerTerm from "../composables/useWorkerTerm";
124
141
  import useSite from "../composables/useSite";
125
142
  import useUtils from "../composables/useUtils";
126
- import { inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
143
+ import { inventoryManagedBy, inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
127
144
  import { inventoryControls } from "../utils/inventory-presets";
128
145
 
129
146
  const props = defineProps({
@@ -167,8 +184,16 @@ const storeSites = computed(() =>
167
184
  })),
168
185
  );
169
186
 
187
+ // Another organisation's (main) store: the person requests from it and manages nothing there.
188
+ const requestOnly = computed(() => Boolean(store.value && store.value.owner === false));
189
+ const managedBy = computed(() => (store.value ? inventoryManagedBy(store.value) : ""));
190
+
170
191
  const subtitle = computed(() => {
171
192
  if (!store.value) return "";
193
+ if (store.value.main && store.value.owner === false) {
194
+ // "Main store · managed by Management Pte Ltd (Property management)"
195
+ return managedBy.value ? `Main store · ${managedBy.value.replace(/^Managed/, "managed")}` : "Main store";
196
+ }
172
197
  if (storeSites.value.length > 1) {
173
198
  const supplies = inventorySuppliesText(storeSites.value.map((s) => s.name));
174
199
  return stores.value.length > 1 ? `${inventoryStoreLabel(store.value, stores.value)} · ${supplies}` : supplies;
@@ -6,6 +6,22 @@
6
6
  <v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
7
7
  </div>
8
8
 
9
+ <!-- Give to colleague: what waits for me to confirm, and what I gave that waits for them. -->
10
+ <AppCard v-for="h in toConfirm" :key="h._id" class="pa-4 mb-3 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-warning))">
11
+ <v-icon icon="mdi-hand-coin-outline" color="warning" />
12
+ <div class="flex-grow-1">
13
+ <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
14
+ <div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
15
+ </div>
16
+ <v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :loading="acting === h._id + 'd'" @click="decide(h, false)">Decline</v-btn>
17
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === h._id + 'a'" @click="decide(h, true)">I received it</v-btn>
18
+ </AppCard>
19
+ <AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
20
+ <v-icon icon="mdi-timer-sand" color="medium-emphasis" />
21
+ <div class="flex-grow-1 text-body-2">Waiting for {{ h.toName }} to confirm {{ h.qty }} {{ h.unit }} {{ h.itemName }}.</div>
22
+ <v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
23
+ </AppCard>
24
+
9
25
  <v-progress-linear v-if="loading" indeterminate color="primary" />
10
26
  <AppCard v-else-if="!holdings.length" class="pa-6 text-center">
11
27
  <div class="text-body-1 mb-1">You are not holding any stock.</div>
@@ -23,7 +39,10 @@
23
39
  <div class="font-weight-medium">{{ h.itemName }}</div>
24
40
  <div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
25
41
  </div>
26
- <v-btn v-if="h.qty > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
42
+ <div class="d-flex flex-column ga-1">
43
+ <v-btn v-if="h.qty > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
44
+ <v-btn v-if="h.qty > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
45
+ </div>
27
46
  </AppCard>
28
47
  </v-col>
29
48
  </v-row>
@@ -44,10 +63,10 @@
44
63
  <tr v-for="t in activity" :key="t._id">
45
64
  <td class="text-no-wrap">{{ standardFormatDate(t.date) }}</td>
46
65
  <td>{{ t.itemName }}</td>
47
- <td>{{ t.type === "issue" ? "Taken from store" : t.type === "return" ? "Given back to store" : typeLabels[t.type] }}</td>
66
+ <td>{{ activityText(t) }}</td>
48
67
  <td class="text-right">{{ t.qty }}</td>
49
68
  <td class="text-caption">
50
- {{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.reference].filter(Boolean).join(" · ") }}
69
+ {{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.type === "handover" ? t.remarks : t.reference].filter(Boolean).join(" · ") }}
51
70
  </td>
52
71
  <td class="text-right">{{ t.holderBalance }}</td>
53
72
  </tr>
@@ -58,6 +77,54 @@
58
77
  </v-table>
59
78
 
60
79
  <InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :sites="sites" @done="changed" />
80
+ <v-dialog v-model="give.show" max-width="520" persistent>
81
+ <v-card class="screen-modal">
82
+ <v-card-title class="d-flex align-center">
83
+ Give to colleague
84
+ <v-spacer />
85
+ <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="give.show = false" />
86
+ </v-card-title>
87
+ <v-card-text class="screen-modal__body">
88
+ <p class="text-body-2 text-medium-emphasis mb-3">
89
+ {{ give.holding?.itemName }}: you hold {{ give.holding?.qty }} {{ give.holding?.unit }}. It moves to your colleague once they confirm they received it.
90
+ </p>
91
+ <v-form ref="giveForm">
92
+ <InputLabel title="Colleague" required class="mb-1" />
93
+ <v-autocomplete
94
+ v-model="give.to"
95
+ :items="colleagues"
96
+ :item-title="(p: any) => p.label || p.name"
97
+ item-value="_id"
98
+ :loading="colleaguesLoading"
99
+ placeholder="Search a name"
100
+ density="comfortable"
101
+ no-filter
102
+ :rules="[(v: string) => !!v || 'Choose who you are giving it to']"
103
+ @update:search="searchColleagues"
104
+ />
105
+ <InputLabel title="Quantity" required class="mb-1" />
106
+ <v-text-field
107
+ v-model.number="give.qty"
108
+ type="number"
109
+ min="1"
110
+ density="comfortable"
111
+ :suffix="give.holding?.unit"
112
+ :rules="[giveQtyRule]"
113
+ hint="Counted in the unit the store issues it in."
114
+ persistent-hint
115
+ />
116
+ <InputLabel title="Note (optional)" class="mb-1 mt-2" />
117
+ <v-text-field v-model="give.note" density="comfortable" maxlength="500" placeholder="e.g. for the lift lobby job" />
118
+ <InputLabel title="Photo (optional)" class="mb-1" />
119
+ <InventoryPhotoInput v-model="give.photos" :max="1" />
120
+ </v-form>
121
+ </v-card-text>
122
+ <div class="screen-modal__footer">
123
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="give.saving" @click="give.show = false">Cancel</v-btn>
124
+ <v-btn class="screen-btn-primary" variant="flat" :loading="give.saving" @click="submitGive">Give</v-btn>
125
+ </div>
126
+ </v-card>
127
+ </v-dialog>
61
128
  <InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
62
129
  </div>
63
130
  </template>
@@ -88,6 +155,100 @@ const activity = ref<TInventoryTransaction[]>([]);
88
155
  const loading = ref(false);
89
156
  const showRequest = ref(false);
90
157
  const usage = reactive({ show: false, holding: null as TInventoryHolding | null });
158
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
159
+
160
+ const handovers = ref<TInventoryHandover[]>([]);
161
+ const me = computed(() => String(currentUser.value?._id ?? ""));
162
+ const toConfirm = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.to) === me.value));
163
+ const waiting = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.from) === me.value));
164
+ const acting = ref("");
165
+
166
+ function activityText(t: TInventoryTransaction) {
167
+ if (t.type === "issue") return "Taken from store";
168
+ if (t.type === "return") return "Given back to store";
169
+ if (t.type === "handover") return (t.holderChange ?? 0) < 0 ? "Given to colleague" : "Received from colleague";
170
+ return typeLabels[t.type];
171
+ }
172
+
173
+ async function loadHandovers() {
174
+ try {
175
+ handovers.value = (await api.getHandovers(props.store._id, { mine: true, status: "pending", limit: 50 })).items;
176
+ } catch {
177
+ handovers.value = [];
178
+ }
179
+ }
180
+
181
+ async function decide(h: TInventoryHandover, accept: boolean) {
182
+ acting.value = h._id + (accept ? "a" : "d");
183
+ try {
184
+ if (accept) await api.acceptHandover(h._id);
185
+ else await api.declineHandover(h._id, "");
186
+ notify(accept ? `You received ${h.qty} ${h.unit} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
187
+ changed();
188
+ } catch (error: any) {
189
+ notify(error?.data?.message || "Could not update the hand-over.", "error");
190
+ } finally {
191
+ acting.value = "";
192
+ }
193
+ }
194
+
195
+ async function cancelHandover(h: TInventoryHandover) {
196
+ acting.value = h._id + "c";
197
+ try {
198
+ await api.cancelHandover(h._id);
199
+ notify("Hand-over cancelled.");
200
+ changed();
201
+ } catch (error: any) {
202
+ notify(error?.data?.message || "Could not cancel.", "error");
203
+ } finally {
204
+ acting.value = "";
205
+ }
206
+ }
207
+
208
+ const giveForm = ref();
209
+ const give = reactive({ show: false, holding: null as TInventoryHolding | null, to: "", qty: 1 as number | null, note: "", photos: [] as string[], saving: false });
210
+ const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
211
+ const giveQtyRule = (v: number | null) => {
212
+ if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
213
+ const free = (give.holding?.qty ?? 0) - pendingOf(String(give.holding?.item ?? ""));
214
+ return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
215
+ };
216
+ const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
217
+ const colleaguesLoading = ref(false);
218
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
219
+ function searchColleagues(q: string) {
220
+ clearTimeout(searchTimer);
221
+ searchTimer = setTimeout(async () => {
222
+ colleaguesLoading.value = true;
223
+ try {
224
+ colleagues.value = await api.getColleagues(props.store._id, q || "");
225
+ } catch {
226
+ colleagues.value = [];
227
+ } finally {
228
+ colleaguesLoading.value = false;
229
+ }
230
+ }, 300);
231
+ }
232
+ function openGive(h: TInventoryHolding) {
233
+ Object.assign(give, { show: true, holding: h, to: "", qty: 1, note: "", photos: [], saving: false });
234
+ searchColleagues("");
235
+ }
236
+ async function submitGive() {
237
+ const { valid } = await giveForm.value.validate();
238
+ if (!valid || !give.holding) return;
239
+ give.saving = true;
240
+ try {
241
+ await api.createHandover(props.store._id, { site: props.siteId, item: String(give.holding.item), to: give.to, qty: Number(give.qty), note: give.note, photos: give.photos });
242
+ const name = colleagues.value.find((c) => c._id === give.to)?.name ?? "your colleague";
243
+ notify(`Waiting for ${name} to confirm.`);
244
+ give.show = false;
245
+ changed();
246
+ } catch (error: any) {
247
+ notify(error?.data?.message || "Could not give it.", "error");
248
+ } finally {
249
+ give.saving = false;
250
+ }
251
+ }
91
252
 
92
253
  async function load() {
93
254
  loading.value = true;
@@ -108,9 +269,10 @@ function use(h: TInventoryHolding) {
108
269
 
109
270
  function changed() {
110
271
  load();
272
+ loadHandovers();
111
273
  emit("changed");
112
274
  }
113
275
 
114
- watch(() => props.store._id, load);
115
- onMounted(load);
276
+ watch(() => props.store._id, () => (load(), loadHandovers()));
277
+ onMounted(() => (load(), loadHandovers()));
116
278
  </script>
@@ -6,6 +6,7 @@
6
6
  <v-btn value="usage" class="text-none">Usage</v-btn>
7
7
  <v-btn v-if="ctl.fullReports" value="technicians" class="text-none">{{ worker.many }}</v-btn>
8
8
  <v-btn v-if="ctl.fullReports" value="purchases" class="text-none">Purchases</v-btn>
9
+ <v-btn value="handovers" class="text-none">Hand-overs</v-btn>
9
10
  </v-btn-toggle>
10
11
  <v-select
11
12
  v-if="report === 'purchases'"
@@ -41,6 +42,24 @@
41
42
  hide-details
42
43
  style="max-width: 260px"
43
44
  />
45
+ <v-select
46
+ v-if="store.ownerType === 'property'"
47
+ v-model="usedBy"
48
+ :items="USED_BY_FILTERS"
49
+ label="Used by"
50
+ density="compact"
51
+ hide-details
52
+ style="max-width: 220px"
53
+ />
54
+ <v-select
55
+ v-if="serviceChoices.length > 1"
56
+ v-model="serviceFilter"
57
+ :items="[{ value: '', title: 'All companies' }, ...serviceChoices]"
58
+ label="Company / service"
59
+ density="compact"
60
+ hide-details
61
+ style="max-width: 260px"
62
+ />
44
63
  <v-checkbox v-if="report === 'usage'" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
45
64
  <v-spacer />
46
65
  <v-btn v-if="ctl.fullReports" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
@@ -99,7 +118,7 @@
99
118
  <v-data-table
100
119
  v-model="selected"
101
120
  :headers="tableHeaders"
102
- :items="rows"
121
+ :items="shownRows"
103
122
  :loading="loading"
104
123
  :show-select="report === 'usage' && canCheck"
105
124
  item-value="_id"
@@ -109,6 +128,11 @@
109
128
  no-data-text="Nothing recorded in this period."
110
129
  >
111
130
  <template #item.date="{ value }">{{ standardFormatDate(value) }}</template>
131
+ <template #item.createdAt="{ value }">{{ standardFormatDate(value) }}</template>
132
+ <template #item.decidedAt="{ value }">{{ value ? standardFormatDate(value) : "—" }}</template>
133
+ <template #item.status="{ value }">
134
+ <v-chip size="x-small" variant="tonal" :color="value === 'accepted' ? 'success' : value === 'pending' ? 'warning' : undefined">{{ handoverStatus[value] ?? value }}</v-chip>
135
+ </template>
112
136
  <template #item.locationType="{ value }">{{ value === "tenant_unit" ? "Tenant unit" : "Common area" }}</template>
113
137
  <template #item.unitCost="{ value }">{{ money(value) }}</template>
114
138
  <template #item.totalCost="{ value }">{{ money(value) }}</template>
@@ -144,6 +168,7 @@ import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
144
168
  import useUtils from "../composables/useUtils";
145
169
  import useFile from "../composables/useFile";
146
170
  import { inventoryControls } from "../utils/inventory-presets";
171
+ import { USED_BY_FILTERS } from "../utils/inventory-used-by";
147
172
 
148
173
  const props = defineProps({
149
174
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -166,11 +191,12 @@ const appSlug = workerAppKey();
166
191
  const now = new Date();
167
192
  const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
168
193
  // An approver without `view-reports` gets only the usage check screen.
169
- const report = ref<"movement" | "usage" | "technicians" | "purchases">(ctl.value.fullReports ? "movement" : "usage");
194
+ const report = ref<"movement" | "usage" | "technicians" | "purchases" | "handovers">(ctl.value.fullReports ? "movement" : "usage");
170
195
  const span = ref<"month" | "year">("year");
171
196
  const site = ref("");
172
197
  const locationType = ref("");
173
198
  const uncheckedOnly = ref(false);
199
+ const usedBy = ref("");
174
200
  const rows = ref<any[]>([]);
175
201
  const selected = ref<string[]>([]);
176
202
  const purchases = reactive({ byMonth: [] as { month: string; deliveries: number; totalCost: number }[], bySupplier: [] as { supplier: string; deliveries: number; totalCost: number }[] });
@@ -191,8 +217,19 @@ const help: Record<string, string> = {
191
217
  usage: "Every recorded usage: where it was used, for which tenant, by whom, and the cost. Use this for tenant billing. Tick records and mark them checked once verified.",
192
218
  technicians: "For each person: stock they held at the start, took, used, returned, and hold at the end of the month.",
193
219
  purchases: "Every delivery received, and opening stock entered with new items (Type: Opening stock), with totals by month and by supplier.",
220
+ handovers: "Stock passed from one person to a colleague: who gave it, who received it, and whether they confirmed.",
194
221
  };
195
222
 
223
+ const handoverStatus: Record<string, string> = { pending: "Waiting to confirm", accepted: "Confirmed", declined: "Declined", cancelled: "Cancelled" };
224
+
225
+ // "Company · Service" of the person on each row, for the filter (usage, staff and hand-over reports).
226
+ const companyOf = (r: any) => [r.holderCompany ?? r.fromCompany, r.holderServiceLabel ?? r.fromServiceLabel].filter(Boolean).join(" · ");
227
+ const serviceFilter = ref("");
228
+ const serviceChoices = computed(() =>
229
+ [...new Set(rows.value.map(companyOf).filter(Boolean))].sort().map((v) => ({ value: v, title: v })),
230
+ );
231
+ const shownRows = computed(() => (serviceFilter.value ? rows.value.filter((r) => companyOf(r) === serviceFilter.value || [r.toCompany, r.toServiceLabel].filter(Boolean).join(" · ") === serviceFilter.value) : rows.value));
232
+
196
233
  const headers: Record<string, any[]> = {
197
234
  movement: [
198
235
  { title: "Item", key: "name" },
@@ -219,6 +256,8 @@ const headers: Record<string, any[]> = {
219
256
  { title: "Unit price", key: "unitCost" },
220
257
  { title: "Total", key: "totalCost" },
221
258
  { title: worker.one, key: "technician" },
259
+ { title: "Company", key: "holderCompany" },
260
+ { title: "Service", key: "holderServiceLabel" },
222
261
  { title: "Photos", key: "photos", sortable: false },
223
262
  { title: "Reference", key: "reference" },
224
263
  { title: "Reported by", key: "reportedBy" },
@@ -226,15 +265,30 @@ const headers: Record<string, any[]> = {
226
265
  ],
227
266
  technicians: [
228
267
  { title: "Name", key: "technician" },
268
+ { title: "Company", key: "holderCompany" },
269
+ { title: "Service", key: "holderServiceLabel" },
229
270
  { title: "Item", key: "itemName" },
230
271
  { title: "Unit", key: "unit" },
231
272
  { title: "Holding at start", key: "opening" },
232
273
  { title: "Taken", key: "taken" },
274
+ { title: "From colleagues", key: "gotFromColleagues" },
233
275
  { title: "Used", key: "used" },
234
276
  { title: "Returned", key: "returned" },
277
+ { title: "To colleagues", key: "gaveToColleagues" },
235
278
  { title: "Holding at end", key: "closing" },
236
279
  { title: "In hand now", key: "inHandNow" },
237
280
  ],
281
+ handovers: [
282
+ { title: "Date", key: "createdAt" },
283
+ { title: "Item", key: "itemName" },
284
+ { title: "Qty", key: "qty" },
285
+ { title: "Unit", key: "unit" },
286
+ { title: "Given by", key: "fromLabel" },
287
+ { title: "Given to", key: "toLabel" },
288
+ { title: "Status", key: "status" },
289
+ { title: "Confirmed / declined", key: "decidedAt" },
290
+ { title: "Note", key: "note" },
291
+ ],
238
292
  purchases: [
239
293
  { title: "Date", key: "date" },
240
294
  { title: "Type", key: "kind" },
@@ -271,6 +325,7 @@ function period(): TInventoryPeriod & { unchecked?: boolean } {
271
325
  ...((report.value === "usage" || report.value === "movement") && site.value ? { site: site.value } : {}),
272
326
  ...(report.value === "usage" && locationType.value ? { locationType: locationType.value } : {}),
273
327
  ...(report.value === "usage" && uncheckedOnly.value ? { unchecked: true } : {}),
328
+ ...(usedBy.value ? { usedBy: usedBy.value } : {}),
274
329
  };
275
330
  }
276
331
 
@@ -319,6 +374,7 @@ async function exportExcel() {
319
374
  }
320
375
  }
321
376
 
322
- watch([report, month, span, site, locationType, uncheckedOnly, () => props.store._id], load);
377
+ watch([report, month, span, site, locationType, uncheckedOnly, usedBy, () => props.store._id], load);
378
+ watch(report, () => (serviceFilter.value = ""));
323
379
  onMounted(load);
324
380
  </script>
@@ -20,7 +20,7 @@
20
20
 
21
21
  <AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
22
22
  <div class="d-flex flex-wrap align-center ga-2 mb-2">
23
- <div class="font-weight-medium">{{ r.requestedByName || "Someone" }}</div>
23
+ <div class="font-weight-medium">{{ r.requestedByLabel || r.requestedByName || "Someone" }}</div>
24
24
  <div class="text-caption text-medium-emphasis">{{ standardFormatDateTime(r.createdAt) }}</div>
25
25
  <v-chip size="small" :color="chip[r.status].color" variant="tonal">{{ chip[r.status].label }}</v-chip>
26
26
  <!-- One store can supply several properties: say which one this request is for. -->
@@ -47,7 +47,7 @@
47
47
  <v-dialog v-model="decision.show" max-width="620" persistent scrollable>
48
48
  <v-card v-if="decision.request" class="screen-modal">
49
49
  <v-card-title class="d-flex align-center">
50
- Request from {{ decision.request.requestedByName }}
50
+ Request from {{ decision.request.requestedByLabel || decision.request.requestedByName }}
51
51
  <v-spacer />
52
52
  <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="decision.show = false" />
53
53
  </v-card-title>
@@ -35,7 +35,25 @@
35
35
  <v-list-item v-if="legacyToCopy" prepend-icon="mdi-content-copy" title="Copy from old Equipment list" @click="showLegacy = true" />
36
36
  </v-list>
37
37
  </v-menu>
38
+ <v-btn
39
+ v-if="usedByOwner && selected.length"
40
+ class="text-none screen-btn-ghost"
41
+ variant="outlined"
42
+ prepend-icon="mdi-account-group-outline"
43
+ @click="openBulk"
44
+ >
45
+ Set "Used by" for {{ selected.length }} item{{ selected.length === 1 ? "" : "s" }}
46
+ </v-btn>
38
47
  <v-spacer />
48
+ <v-select
49
+ v-if="usedByOwner"
50
+ v-model="usedByFilter"
51
+ :items="USED_BY_FILTERS"
52
+ label="Used by"
53
+ density="compact"
54
+ hide-details
55
+ style="min-width: 200px; max-width: 240px"
56
+ />
39
57
  <v-checkbox v-model="lowOnly" label="Low stock only" density="compact" hide-details class="flex-grow-0" />
40
58
  <v-text-field
41
59
  v-model="search"
@@ -56,6 +74,8 @@
56
74
  v-model:page="page"
57
75
  :pages="pages"
58
76
  :page-range="pageRange"
77
+ :show-select="usedByOwner"
78
+ v-model:selected="selected"
59
79
  @refresh="load"
60
80
  @row-click="(row: any) => openItem(row.item)"
61
81
  >
@@ -68,6 +88,9 @@
68
88
  <div>
69
89
  <div class="font-weight-medium">{{ item.name }}</div>
70
90
  <div class="text-caption text-medium-emphasis">{{ inventoryItemMeta(item) }}</div>
91
+ <div v-if="isMain && usedByChips(item.usedBy).length" class="d-flex flex-wrap ga-1 mt-1">
92
+ <v-chip v-for="c in usedByChips(item.usedBy)" :key="c" size="x-small" variant="tonal" color="primary">{{ c }}</v-chip>
93
+ </div>
71
94
  </div>
72
95
  </div>
73
96
  </template>
@@ -78,6 +101,7 @@
78
101
  <v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
79
102
  </template>
80
103
  <template #item.reorderLevel="{ value }">{{ value || "—" }}</template>
104
+ <template #item.usedBy="{ item }">{{ usedByText(item.usedBy) }}</template>
81
105
  <template #item.unitCost="{ value }">{{ value !== undefined ? money(value) : "—" }}</template>
82
106
  <template #no-data>
83
107
  <div class="py-8 text-center">
@@ -94,6 +118,29 @@
94
118
  <InventoryImportDialog v-model="showImport" :store="store" @done="done" />
95
119
  <InventoryImportDialog v-model="showReceiveImport" :store="store" kind="receipts" @done="done" />
96
120
  <InventoryLegacyImportDialog v-model="showLegacy" :store="store" @done="done" />
121
+ <v-dialog v-model="bulk.show" max-width="480">
122
+ <v-card class="screen-modal">
123
+ <v-card-title>Set "Used by"</v-card-title>
124
+ <v-card-text class="screen-modal__body">
125
+ <p class="text-body-2 mb-3">For the {{ selected.length }} selected item{{ selected.length === 1 ? "" : "s" }}.</p>
126
+ <v-select
127
+ :model-value="bulk.usedBy"
128
+ :items="usedByItems"
129
+ multiple
130
+ chips
131
+ closable-chips
132
+ density="comfortable"
133
+ :hint="USED_BY_HINT"
134
+ persistent-hint
135
+ @update:model-value="(v: string[]) => (bulk.usedBy = nextUsedBy(bulk.usedBy, v))"
136
+ />
137
+ </v-card-text>
138
+ <div class="screen-modal__footer">
139
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="bulk.saving" @click="bulk.show = false">Cancel</v-btn>
140
+ <v-btn class="screen-btn-primary" variant="flat" :loading="bulk.saving" @click="saveBulk">Save</v-btn>
141
+ </div>
142
+ </v-card>
143
+ </v-dialog>
97
144
  <InventoryItemDetail v-model="detail.show" :store="store" :item="detail.item" @edit="editItem" @done="done" />
98
145
  </div>
99
146
  </template>
@@ -105,6 +152,7 @@ import useUtils from "../composables/useUtils";
105
152
  import { inventoryItemMeta } from "../utils/inventory-store-label";
106
153
  import { inventoryControls } from "../utils/inventory-presets";
107
154
  import { hasLegacyEquipmentToCopy } from "../utils/inventory-legacy";
155
+ import { nextUsedBy, USED_BY_ALL, USED_BY_ALL_LABEL, USED_BY_FILTERS, USED_BY_HINT, USED_BY_OPTIONS, usedByChips, usedByText } from "../utils/inventory-used-by";
108
156
 
109
157
  const props = defineProps({
110
158
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -114,7 +162,34 @@ const props = defineProps({
114
162
  const ctl = computed(() => inventoryControls(props.store.access));
115
163
  const emit = defineEmits(["changed"]);
116
164
 
117
- const { getItems, getLegacyEquipment } = useInventory();
165
+ const { getItems, getLegacyEquipment, setUsedBy } = useInventory();
166
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
167
+
168
+ // Used by belongs to the main (property) store; its owner sees the column, the filter and bulk edit.
169
+ const isMain = computed(() => props.store.ownerType === "property");
170
+ const usedByOwner = computed(() => isMain.value && props.store.access.canManage);
171
+ const usedByFilter = ref("");
172
+ const selected = ref<string[]>([]);
173
+ const usedByItems = [{ value: USED_BY_ALL, title: USED_BY_ALL_LABEL }, ...USED_BY_OPTIONS];
174
+ const bulk = reactive({ show: false, usedBy: [USED_BY_ALL] as string[], saving: false });
175
+ function openBulk() {
176
+ Object.assign(bulk, { show: true, usedBy: [USED_BY_ALL], saving: false });
177
+ }
178
+ async function saveBulk() {
179
+ bulk.saving = true;
180
+ try {
181
+ const ids = selected.value.map((s: any) => (typeof s === "string" ? s : s._id));
182
+ const res = await setUsedBy(props.store._id, ids, bulk.usedBy);
183
+ notify(`"Used by" set to ${usedByText(bulk.usedBy)} for ${res?.updated ?? ids.length} item(s).`);
184
+ bulk.show = false;
185
+ selected.value = [];
186
+ load();
187
+ } catch (error: any) {
188
+ notify(error?.data?.message || "Could not save.", "error");
189
+ } finally {
190
+ bulk.saving = false;
191
+ }
192
+ }
118
193
  const { getFileUrl } = useFile();
119
194
  const { debounce } = useUtils();
120
195
 
@@ -135,6 +210,7 @@ const headers = computed(() => [
135
210
  { title: "Item", value: "name" },
136
211
  { title: "In store", value: "onHand" },
137
212
  { title: "Low stock alert at", value: "reorderLevel" },
213
+ ...(usedByOwner.value ? [{ title: "Used by", value: "usedBy" }] : []),
138
214
  ...(ctl.value.unitPrice ? [{ title: "Unit price", value: "unitCost" }] : []),
139
215
  ]);
140
216
 
@@ -149,6 +225,7 @@ async function load() {
149
225
  search: search.value || "",
150
226
  lowStock: lowOnly.value || undefined,
151
227
  includeInactive: props.store.access.canManage || undefined,
228
+ usedBy: (usedByOwner.value && usedByFilter.value) || undefined,
152
229
  });
153
230
  items.value = res.items;
154
231
  pages.value = res.pages;
@@ -199,6 +276,7 @@ function done() {
199
276
 
200
277
  watch(page, load);
201
278
  watch(lowOnly, () => (page.value === 1 ? load() : (page.value = 1)));
279
+ watch(usedByFilter, () => (page.value === 1 ? load() : (page.value = 1)));
202
280
  watch(search, debounce(() => (page.value === 1 ? load() : (page.value = 1)), 400));
203
281
  watch(() => props.store._id, () => (checkLegacy(), page.value === 1 ? load() : (page.value = 1)));
204
282
  onMounted(() => (load(), checkLegacy()));
@@ -12,6 +12,7 @@
12
12
  <AppCard v-for="g in groups" :key="g.holder" class="pa-4 mb-3">
13
13
  <div class="d-flex align-center mb-2">
14
14
  <div class="font-weight-medium">{{ g.holderName || "Unnamed" }}</div>
15
+ <div v-if="g.company || g.serviceLabel" class="text-caption text-medium-emphasis">{{ [g.company, g.serviceLabel].filter(Boolean).join(" · ") }}</div>
15
16
  <v-spacer />
16
17
  <v-btn v-if="ctl.takeReturn" class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
17
18
  </div>
@@ -47,11 +48,11 @@ const search = ref("");
47
48
 
48
49
  const groups = computed(() => {
49
50
  const q = (search.value || "").toLowerCase();
50
- const map = new Map<string, { holder: string; holderName: string; items: TInventoryHolding[] }>();
51
+ const map = new Map<string, { holder: string; holderName: string; company?: string; serviceLabel?: string; items: TInventoryHolding[] }>();
51
52
  for (const h of holdings.value) {
52
53
  if (q && !h.holderName.toLowerCase().includes(q) && !h.itemName.toLowerCase().includes(q)) continue;
53
54
  const key = String(h.holder);
54
- if (!map.has(key)) map.set(key, { holder: key, holderName: h.holderName, items: [] });
55
+ if (!map.has(key)) map.set(key, { holder: key, holderName: h.holderName, company: (h as any).holderCompany, serviceLabel: (h as any).holderServiceLabel, items: [] });
55
56
  map.get(key)!.items.push(h);
56
57
  }
57
58
  return [...map.values()];
@@ -17,6 +17,7 @@ export default function useInventory() {
17
17
  use: "Used",
18
18
  return: "Returned",
19
19
  adjust: "Stock count",
20
+ handover: "Hand-over",
20
21
  };
21
22
 
22
23
  return {
@@ -29,10 +30,13 @@ export default function useInventory() {
29
30
  updateStore: (id: string, body: Partial<Pick<TInventoryStore, "name" | "sites" | "showInResidentApp" | "showCostInResidentApp">>) =>
30
31
  api<TInventoryStore>(`/stores/${id}`, { method: "PATCH", body }),
31
32
 
32
- getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean }) =>
33
+ getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean; usedBy?: string }) =>
33
34
  api<{ items: TInventoryItem[]; total: number; lowCount: number; page: number; pages: number }>(`/stores/${store}/items`, { query }),
34
35
  createItem: (store: string, body: Record<string, any>) => api(`/stores/${store}/items`, { method: "POST", body }),
35
36
  updateItem: (id: string, body: Record<string, any>) => api(`/items/${id}`, { method: "PATCH", body }),
37
+ /** Bulk edit: set "Used by" on several items (store owner). */
38
+ setUsedBy: (store: string, items: string[], usedBy: string[]) =>
39
+ api<{ updated: number }>(`/stores/${store}/items/used-by`, { method: "POST", body: { items, usedBy } }),
36
40
 
37
41
  receive: (store: string, body: Record<string, any>) => api(`/stores/${store}/receive`, { method: "POST", body }),
38
42
  count: (store: string, body: { item: string; countedQty: number; reason: string }) =>
@@ -51,15 +55,26 @@ export default function useInventory() {
51
55
 
52
56
  getHoldings: (store: string, holder: string = "me", includeUsedUp = false) =>
53
57
  api<TInventoryHolding[]>(`/stores/${store}/holdings`, { query: { holder, ...(includeUsedUp ? { includeUsedUp: true } : {}) } }),
58
+ /** Give to colleague. */
59
+ getHandovers: (store: string, query: { mine?: boolean; status?: string; item?: string; page?: number; limit?: number }) =>
60
+ api<{ items: TInventoryHandover[]; total: number; waitingForMe: number }>(`/stores/${store}/handovers`, { query }),
61
+ createHandover: (store: string, body: { site: string; item: string; to: string; qty: number; note?: string; photos?: string[] }) =>
62
+ api<TInventoryHandover>(`/stores/${store}/handovers`, { method: "POST", body }),
63
+ acceptHandover: (id: string) => api(`/handovers/${id}/accept`, { method: "POST" }),
64
+ declineHandover: (id: string, reason = "") => api(`/handovers/${id}/decline`, { method: "POST", body: { reason } }),
65
+ cancelHandover: (id: string) => api(`/handovers/${id}/cancel`, { method: "POST" }),
66
+ getColleagues: (store: string, search = "") =>
67
+ api<{ _id: string; name: string; company?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/colleagues`, { query: { search } }),
54
68
  getTransactions: (store: string, query: Record<string, any>) =>
55
69
  api<{ items: TInventoryTransaction[]; total: number; page: number; pages: number }>(`/stores/${store}/transactions`, { query }),
56
70
 
57
71
  getUnits: (store: string, site: string, search = "") =>
58
72
  api<{ _id: string; name: string; tenantName: string }[]>(`/stores/${store}/units`, { query: { site, search } }),
59
73
  getCommonAreas: (store: string, site: string) => api<string[]>(`/stores/${store}/common-areas`, { query: { site } }),
60
- getPeople: (store: string, search = "") => api<{ _id: string; name: string; orgName: string }[]>(`/stores/${store}/people`, { query: { search } }),
74
+ getPeople: (store: string, search = "") =>
75
+ api<{ _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/people`, { query: { search } }),
61
76
 
62
- getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases", query: TInventoryPeriod & { unchecked?: boolean }) =>
77
+ getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & { unchecked?: boolean }) =>
63
78
  api<{ rows: any[]; totalQty?: number; totalCost?: number }>(`/stores/${store}/reports/${report}`, { query }),
64
79
  exportReport: (store: string, report: string, query: TInventoryPeriod & { appSlug?: string }) =>
65
80
  api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.71.2",
5
+ "version": "4.72.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -23,6 +23,13 @@ declare type TInventoryStore = {
23
23
  showInResidentApp: boolean;
24
24
  showCostInResidentApp: boolean;
25
25
  access: TInventoryAccess;
26
+ /** The property manager's store: the site's main store, which providers request from. */
27
+ main?: boolean;
28
+ /** The caller belongs to the organisation that owns (manages) this store. */
29
+ owner?: boolean;
30
+ ownerService?: string | null;
31
+ /** "Property management", "M&E", ... */
32
+ ownerServiceLabel?: string;
26
33
  };
27
34
 
28
35
  declare type TInventoryItem = {
@@ -40,6 +47,8 @@ declare type TInventoryItem = {
40
47
  onHand: number;
41
48
  status: "active" | "inactive";
42
49
  lowStock: boolean;
50
+ /** Main store: which service providers see and may request it (["all"] or service keys). */
51
+ usedBy?: string[];
43
52
  };
44
53
 
45
54
  declare type TInventoryLine = { item: string; qty: number };
@@ -80,7 +89,7 @@ declare type TInventoryTransaction = {
80
89
  item: string;
81
90
  itemName: string;
82
91
  unit: string;
83
- type: "opening" | "receive" | "issue" | "use" | "return" | "adjust";
92
+ type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover";
84
93
  qty: number;
85
94
  storeChange: number;
86
95
  storeBalance: number;
@@ -102,7 +111,7 @@ declare type TInventoryTransaction = {
102
111
  createdByName: string;
103
112
  };
104
113
 
105
- declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string };
114
+ declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string; usedBy?: string };
106
115
 
107
116
  /** An organisation's supplier (shared by its stores). Only the name is required. */
108
117
  declare type TInventorySupplier = {
@@ -116,3 +125,26 @@ declare type TInventorySupplier = {
116
125
  notes: string;
117
126
  status: "active" | "inactive";
118
127
  };
128
+
129
+ /** Give to colleague: stock in hand passed to a colleague, pending until they confirm. */
130
+ declare type TInventoryHandover = {
131
+ _id: string;
132
+ store: string;
133
+ site: string;
134
+ item: string;
135
+ itemName: string;
136
+ unit: string;
137
+ qty: number;
138
+ from: string;
139
+ fromName: string;
140
+ fromLabel?: string;
141
+ to: string;
142
+ toName: string;
143
+ toLabel?: string;
144
+ note: string;
145
+ photos: string[];
146
+ status: "pending" | "accepted" | "declined" | "cancelled";
147
+ declineReason?: string;
148
+ createdAt: string;
149
+ decidedAt?: string;
150
+ };
@@ -4,7 +4,7 @@
4
4
  * same kind (e.g. staff of several organisations at one property), each one
5
5
  * also names its organisation so the buttons can be told apart.
6
6
  */
7
- type TStoreLike = { ownerType: string; orgName?: string };
7
+ type TStoreLike = { ownerType: string; orgName?: string; main?: boolean; owner?: boolean; ownerServiceLabel?: string };
8
8
 
9
9
  /** "Supplies Winsland House I and Winsland House II" for a store that supplies several sites. */
10
10
  export function inventorySuppliesText(names: string[]) {
@@ -14,11 +14,24 @@ export function inventorySuppliesText(names: string[]) {
14
14
  }
15
15
 
16
16
  export function inventoryStoreLabel(store: TStoreLike, all: TStoreLike[]) {
17
+ // Owner rule 2026-09-29: the property manager's store is the site's MAIN store,
18
+ // named with who runs it; a provider's own store is "Our store".
19
+ if (store.main !== undefined) {
20
+ if (store.main) return store.orgName ? `Main store · ${store.orgName}` : "Main store";
21
+ const own = all.filter((s) => !s.main).length;
22
+ return own > 1 && store.orgName ? `Our store · ${store.orgName}` : "Our store";
23
+ }
17
24
  const kind = store.ownerType === "property" ? "Property store" : "Our store";
18
25
  const sameKind = all.filter((s) => s.ownerType === store.ownerType).length;
19
26
  return sameKind > 1 && store.orgName ? `${kind} · ${store.orgName}` : kind;
20
27
  }
21
28
 
29
+ /** "Managed by Management Pte Ltd (Property management)" — who runs the store. */
30
+ export function inventoryManagedBy(store: TStoreLike) {
31
+ if (!store.orgName) return "";
32
+ return store.ownerServiceLabel ? `Managed by ${store.orgName} (${store.ownerServiceLabel})` : `Managed by ${store.orgName}`;
33
+ }
34
+
22
35
  /**
23
36
  * The properties a store can be set to supply, by name. The organisation's own
24
37
  * site list does not always contain the store's current property (a manager
@@ -0,0 +1,57 @@
1
+ /**
2
+ * "Used by" (owner 2026-09-29): which service providers see and may request an
3
+ * item from the main store. Mirrors API-core `utils/inventory-used-by.ts`.
4
+ * Browser-safe, no Node globals.
5
+ */
6
+ export const USED_BY_ALL = "all";
7
+ export const USED_BY_ALL_LABEL = "All service providers";
8
+
9
+ export const USED_BY_OPTIONS = [
10
+ { value: "cleaning", title: "Cleaning" },
11
+ { value: "security", title: "Security" },
12
+ { value: "mne", title: "M&E" },
13
+ { value: "pest", title: "Pest control" },
14
+ { value: "pool", title: "Pool" },
15
+ { value: "landscape", title: "Landscape" },
16
+ ];
17
+
18
+ export const USED_BY_HINT = "Choose who can see and request this item. Leave as All if everyone uses it.";
19
+
20
+ /** Stored/sent value, cleaned: missing, empty or containing "all" means all. */
21
+ export function normaliseUsedBy(value?: readonly string[] | null): string[] {
22
+ const list = (value ?? []).map(String);
23
+ if (!list.length || list.includes(USED_BY_ALL)) return [USED_BY_ALL];
24
+ const keys = USED_BY_OPTIONS.map((o) => o.value).filter((k) => list.includes(k));
25
+ return keys.length ? keys : [USED_BY_ALL];
26
+ }
27
+
28
+ /** Chips for one item: nothing for "all" (the usual case says nothing), else one word per service. */
29
+ export function usedByChips(value?: readonly string[] | null): string[] {
30
+ const keys = normaliseUsedBy(value);
31
+ if (keys[0] === USED_BY_ALL) return [];
32
+ return keys.map((k) => USED_BY_OPTIONS.find((o) => o.value === k)!.title);
33
+ }
34
+
35
+ /** "All service providers" / "Cleaning, M&E" — for a table cell or a sentence. */
36
+ export function usedByText(value?: readonly string[] | null) {
37
+ const chips = usedByChips(value);
38
+ return chips.length ? chips.join(", ") : USED_BY_ALL_LABEL;
39
+ }
40
+
41
+ /**
42
+ * The multi-select's model <-> the stored value. Picking a service clears
43
+ * "All"; picking "All" clears the services; clearing everything means All.
44
+ */
45
+ export function nextUsedBy(previous: readonly string[], picked: readonly string[]): string[] {
46
+ const added = picked.filter((p) => !previous.includes(p));
47
+ if (added.includes(USED_BY_ALL) || !picked.length) return [USED_BY_ALL];
48
+ const services = picked.filter((p) => p !== USED_BY_ALL);
49
+ return services.length ? services : [USED_BY_ALL];
50
+ }
51
+
52
+ /** Filter choices for the owner's stock list and reports. */
53
+ export const USED_BY_FILTERS = [
54
+ { value: "", title: "Everyone's items" },
55
+ { value: USED_BY_ALL, title: "For all providers only" },
56
+ ...USED_BY_OPTIONS.map((o) => ({ value: o.value, title: `Offered to ${o.title}` })),
57
+ ];