@7365admin1/layer-common 4.90.2-staging.492 → 4.90.2-staging.494

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.
@@ -92,6 +92,18 @@
92
92
 
93
93
  <InputLabel :title="draft ? 'Date of the job (used when you submit)' : 'Date used'" class="mb-1 mt-3" />
94
94
  <v-text-field v-model="date" type="date" :max="today" density="comfortable" style="max-width: 200px" />
95
+ <template v-if="stepsLive">
96
+ <InputLabel title="Also attended (optional)" class="mb-1" />
97
+ <v-text-field
98
+ v-model="attendedWith"
99
+ density="comfortable"
100
+ maxlength="200"
101
+ placeholder="e.g. Ahmad, Kumar"
102
+ hint="Colleagues who did this job with you. The stock still comes from your own stock."
103
+ persistent-hint
104
+ class="mb-2"
105
+ />
106
+ </template>
95
107
  <InputLabel title="Fault reported by (optional)" class="mb-1" />
96
108
  <v-text-field v-model="reportedBy" density="comfortable" placeholder="e.g. tenant, security guard" maxlength="120" />
97
109
  <InputLabel title="Remarks (optional)" class="mb-1" />
@@ -122,6 +134,7 @@
122
134
  <script setup lang="ts">
123
135
  import useInventory, { pickedDate } from "../composables/useInventory";
124
136
  import useUtils from "../composables/useUtils";
137
+ import { inventoryStepsLive } from "../utils/inventory-ack";
125
138
 
126
139
  const show = defineModel<boolean>({ default: false });
127
140
  const props = defineProps({
@@ -155,6 +168,8 @@ const area = ref("");
155
168
  const reference = ref("");
156
169
  const remarks = ref("");
157
170
  const reportedBy = ref("");
171
+ const attendedWith = ref("");
172
+ const stepsLive = computed(() => inventoryStepsLive(props.store));
158
173
  const date = ref(today);
159
174
  const photosBefore = ref<string[]>([]);
160
175
  const photosAfter = ref<string[]>([]);
@@ -199,6 +214,8 @@ function body() {
199
214
  photosAfter: photosAfter.value,
200
215
  remarks: remarks.value,
201
216
  reportedBy: reportedBy.value,
217
+ // Only to an API that knows it (an older one refuses an unknown field).
218
+ ...(stepsLive.value && attendedWith.value.trim() ? { attendedWith: attendedWith.value.trim() } : {}),
202
219
  };
203
220
  }
204
221
 
@@ -282,6 +299,7 @@ watch(show, (open) => {
282
299
  reference.value = "";
283
300
  remarks.value = "";
284
301
  reportedBy.value = "";
302
+ attendedWith.value = "";
285
303
  date.value = today;
286
304
  site.value = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
287
305
  const d = props.draft;
@@ -297,6 +315,7 @@ watch(show, (open) => {
297
315
  reference.value = d.reference ?? "";
298
316
  remarks.value = d.remarks ?? "";
299
317
  reportedBy.value = d.reportedBy ?? "";
318
+ attendedWith.value = d.attendedWith ?? "";
300
319
  photosBefore.value = [...(d.photos?.before ?? [])];
301
320
  photosAfter.value = [...(d.photos?.after ?? [])];
302
321
  nextTick(() => (prefilling = false));
@@ -0,0 +1,110 @@
1
+ <template>
2
+ <v-dialog v-model="show" max-width="560" persistent scrollable>
3
+ <v-card class="screen-modal">
4
+ <v-card-title class="d-flex align-center">
5
+ Write off {{ itemName }}
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">
11
+ {{
12
+ from === "store"
13
+ ? `For stock on the store shelf that can no longer be used. ${max} ${unit} on the shelf.`
14
+ : `For stock in your hands that is used up, spoilt, damaged or lost. It does not go back to the store and is not counted as usage. You can write off up to ${max} ${unit}.`
15
+ }}
16
+ </p>
17
+ <v-form ref="form">
18
+ <InputLabel title="How many?" required class="mb-1" />
19
+ <v-text-field
20
+ v-model.number="qty"
21
+ type="number"
22
+ min="1"
23
+ :max="max"
24
+ density="comfortable"
25
+ :suffix="unit"
26
+ style="max-width: 200px"
27
+ :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1) || 'Enter a whole number', (v: any) => Number(v) <= max || `You can write off up to ${max}`]"
28
+ />
29
+ <InputLabel title="Why?" required class="mb-1" />
30
+ <v-chip-group v-model="reason" mandatory selected-class="text-primary" class="mb-2">
31
+ <v-chip v-for="r in WRITE_OFF_REASONS" :key="r.value" :value="r.value" variant="outlined" filter>{{ r.title }}</v-chip>
32
+ </v-chip-group>
33
+ <InputLabel :title="reason === 'other' ? 'What happened?' : 'Remarks (optional)'" :required="reason === 'other'" class="mb-1" />
34
+ <v-textarea v-model="remarks" rows="2" auto-grow density="comfortable" maxlength="1000" placeholder="e.g. fell into the drain at block 3" />
35
+ <InputLabel title="Photos (optional)" class="mb-1" />
36
+ <InventoryPhotoInput v-model="photos" :max="5" />
37
+ <v-alert v-if="problem" type="warning" variant="tonal" density="compact" class="mt-2">{{ problem }}</v-alert>
38
+ </v-form>
39
+ </v-card-text>
40
+ <div class="screen-modal__footer">
41
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="show = false">Cancel</v-btn>
42
+ <v-btn class="screen-btn-primary" variant="flat" :loading="saving" @click="save">Write off</v-btn>
43
+ </div>
44
+ </v-card>
45
+ </v-dialog>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ import useInventory from "../composables/useInventory";
50
+ import { WRITE_OFF_REASONS, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
51
+
52
+ /** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
53
+ const show = defineModel<boolean>({ default: false });
54
+ const props = defineProps({
55
+ store: { type: Object as PropType<TInventoryStore>, required: true },
56
+ from: { type: String as PropType<"holding" | "store">, default: "holding" },
57
+ item: { type: String, default: "" },
58
+ itemName: { type: String, default: "" },
59
+ unit: { type: String, default: "" },
60
+ max: { type: Number, default: 0 },
61
+ siteId: { type: String, default: "" },
62
+ });
63
+ const emit = defineEmits(["done"]);
64
+
65
+ const api = useInventory();
66
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
67
+
68
+ const form = ref();
69
+ const qty = ref<number | null>(1);
70
+ const reason = ref("expended");
71
+ const remarks = ref("");
72
+ const photos = ref<string[]>([]);
73
+ const saving = ref(false);
74
+ const tried = ref(false);
75
+ const problem = computed(() => (tried.value ? writeOffProblem({ reason: reason.value ?? "", remarks: remarks.value }) : ""));
76
+
77
+ async function save() {
78
+ tried.value = true;
79
+ const { valid } = await form.value.validate();
80
+ if (!valid || writeOffProblem({ reason: reason.value ?? "", remarks: remarks.value })) return;
81
+ saving.value = true;
82
+ try {
83
+ await api.writeOff(props.store._id, {
84
+ from: props.from,
85
+ ...(props.siteId && props.store.sites.includes(props.siteId) ? { site: props.siteId } : {}),
86
+ item: props.item,
87
+ qty: Number(qty.value),
88
+ reason: reason.value,
89
+ remarks: remarks.value.trim(),
90
+ photos: photos.value,
91
+ });
92
+ notify(`${qty.value} ${props.unit} of ${props.itemName} written off as ${writeOffLabel(reason.value).toLowerCase()}.${props.from === "holding" ? " The store keeper has been told." : ""}`);
93
+ show.value = false;
94
+ emit("done");
95
+ } catch (error: any) {
96
+ notify(error?.data?.message || "Could not write it off. Please try again.", "error");
97
+ } finally {
98
+ saving.value = false;
99
+ }
100
+ }
101
+
102
+ watch(show, (open) => {
103
+ if (!open) return;
104
+ qty.value = 1;
105
+ reason.value = "expended";
106
+ remarks.value = "";
107
+ photos.value = [];
108
+ tried.value = false;
109
+ });
110
+ </script>
@@ -7,8 +7,10 @@ import { inventoryCaps } from "../utils/inventory-presets";
7
7
  * or hide actions from that, never from a guess on the client.
8
8
  */
9
9
  export default function useInventory() {
10
+ // `X-Inventory-Ack: 1`: this screen shows the Received step, so the API may hold stock that is not
11
+ // confirmed yet (API contract 2026-10-03). An API without the step ignores the header.
10
12
  const api = <T = any>(url: string, options: Record<string, any> = {}) =>
11
- useNuxtApp().$api<T>(`/api/inventory${url}`, options);
13
+ useNuxtApp().$api<T>(`/api/inventory${url}`, { ...options, headers: { ...(options.headers ?? {}), "X-Inventory-Ack": "1" } });
12
14
 
13
15
  const typeLabels: Record<string, string> = {
14
16
  opening: "Opening stock",
@@ -19,6 +21,7 @@ export default function useInventory() {
19
21
  return: "Returned",
20
22
  adjust: "Stock count",
21
23
  handover: "Hand-over",
24
+ write_off: "Written off",
22
25
  };
23
26
 
24
27
  return {
@@ -28,8 +31,10 @@ export default function useInventory() {
28
31
  ((await api<TInventoryStore[]>("/stores", { query: { site } })) ?? []).map((s) => ({ ...s, access: inventoryCaps(s.access) })),
29
32
  createStore: (body: { org: string; sites: string[]; name: string }) =>
30
33
  api<TInventoryStore>("/stores", { method: "POST", body }),
31
- updateStore: (id: string, body: Partial<Pick<TInventoryStore, "name" | "sites" | "showInResidentApp" | "showCostInResidentApp">>) =>
34
+ updateStore: (id: string, body: Partial<Pick<TInventoryStore, "name" | "sites" | "showInResidentApp" | "showCostInResidentApp" | "lowStockReminderDays">>) =>
32
35
  api<TInventoryStore>(`/stores/${id}`, { method: "PATCH", body }),
36
+ /** One store with its settings (`lowStockReminderDays` only from an API that sends reminders). */
37
+ getStore: (id: string) => api<TInventoryStore>(`/stores/${id}`),
33
38
 
34
39
  getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean; usedBy?: string }) =>
35
40
  api<{ items: TInventoryItem[]; total: number; lowCount: number; page: number; pages: number }>(`/stores/${store}/items`, { query }),
@@ -46,8 +51,9 @@ export default function useInventory() {
46
51
  receiveReturn: (store: string, body: Record<string, any>) => api(`/stores/${store}/returns`, { method: "POST", body }),
47
52
  recordUsage: (store: string, body: Record<string, any>) => api(`/stores/${store}/usage`, { method: "POST", body }),
48
53
  /** Usage drafts: before photo now, after photo and submit after the job (stock is deducted on submit). */
49
- getUsageDrafts: (store: string, everyone = false) =>
50
- api<{ items: TInventoryUsageDraft[]; staleAfterDays: number }>(`/stores/${store}/usage-drafts`, { query: everyone ? { everyone: true } : {} }),
54
+ /** Own drafts; "colleagues" = same-company colleagues' open drafts (anyone who records usage); "everyone" = supervisors. */
55
+ getUsageDrafts: (store: string, scope: "" | "colleagues" | "everyone" = "") =>
56
+ api<{ items: TInventoryUsageDraft[]; staleAfterDays: number }>(`/stores/${store}/usage-drafts`, { query: scope ? { [scope]: true } : {} }),
51
57
  createUsageDraft: (store: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/stores/${store}/usage-drafts`, { method: "POST", body }),
52
58
  updateUsageDraft: (id: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/usage-drafts/${id}`, { method: "PATCH", body }),
53
59
  discardUsageDraft: (id: string) => api(`/usage-drafts/${id}/discard`, { method: "POST" }),
@@ -60,12 +66,24 @@ export default function useInventory() {
60
66
  api(`/requests/${id}/approve`, { method: "POST", body }),
61
67
  rejectRequest: (id: string, remarks: string) => api(`/requests/${id}/reject`, { method: "POST", body: { remarks } }),
62
68
  cancelRequest: (id: string) => api(`/requests/${id}/cancel`, { method: "POST" }),
69
+ /** The person confirms the issued stock reached them (or did not, with a reason). */
70
+ acknowledgeRequest: (id: string) => api<TInventoryRequest>(`/requests/${id}/acknowledge`, { method: "POST", body: {} }),
71
+ notReceived: (id: string, reason: string) => api<TInventoryRequest>(`/requests/${id}/not-received`, { method: "POST", body: { reason } }),
72
+ /** Used up / spoilt / damaged / lost / other: from the person's own stock, or from the store shelf (store keeper). */
73
+ writeOff: (store: string, body: { from: "holding" | "store"; site?: string; item: string; qty: number; reason: string; remarks?: string; photos?: string[] }) =>
74
+ api<TInventoryTransaction>(`/stores/${store}/write-offs`, { method: "POST", body }),
75
+ /** The holder asks to give stock back; a store keeper accepts it (acceptHandover) when it arrives. */
76
+ requestReturn: (store: string, body: { site: string; item: string; qty: number; note?: string }) =>
77
+ api<TInventoryHandover>(`/stores/${store}/returns/request`, { method: "POST", body }),
78
+ takeOverDraft: (id: string) => api<TInventoryUsageDraft>(`/usage-drafts/${id}/take-over`, { method: "POST", body: {} }),
79
+ acknowledgeLowStock: (itemId: string, poNumber = "") =>
80
+ api<TInventoryItem>(`/items/${itemId}/low-stock/acknowledge`, { method: "POST", body: poNumber ? { poNumber } : {} }),
63
81
 
64
82
  getHoldings: (store: string, holder: string = "me", includeUsedUp = false) =>
65
83
  api<TInventoryHolding[]>(`/stores/${store}/holdings`, { query: { holder, ...(includeUsedUp ? { includeUsedUp: true } : {}) } }),
66
84
  /** Give to colleague. */
67
85
  getHandovers: (store: string, query: { mine?: boolean; status?: string; item?: string; page?: number; limit?: number }) =>
68
- api<{ items: TInventoryHandover[]; total: number; waitingForMe: number }>(`/stores/${store}/handovers`, { query }),
86
+ api<{ items: TInventoryHandover[]; total: number; waitingForMe: number; returnsWaiting?: number }>(`/stores/${store}/handovers`, { query }),
69
87
  createHandover: (store: string, body: { site: string; item: string; to: string; qty: number; note?: string; photos?: string[] }) =>
70
88
  api<TInventoryHandover>(`/stores/${store}/handovers`, { method: "POST", body }),
71
89
  acceptHandover: (id: string) => api(`/handovers/${id}/accept`, { method: "POST" }),
@@ -82,7 +100,7 @@ export default function useInventory() {
82
100
  getPeople: (store: string, search = "") =>
83
101
  api<{ _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/people`, { query: { search } }),
84
102
 
85
- getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & Record<string, any>) =>
103
+ getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers" | "reconciliation", query: TInventoryPeriod & Record<string, any>) =>
86
104
  api<{ rows: any[]; totalQty?: number; totalCost?: number; scope?: "all" | "own_company"; staleAfterDays?: number }>(`/stores/${store}/reports/${report}`, { query }),
87
105
  /** One PO across all its deliveries: ordered vs received per item (report viewers and store keepers). */
88
106
  getPurchaseOrder: (store: string, po: string) =>
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.90.2-staging.492",
5
+ "version": "4.90.2-staging.494",
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.",
@@ -30,6 +30,8 @@ declare type TInventoryStore = {
30
30
  ownerService?: string | null;
31
31
  /** "Property management", "M&E", ... */
32
32
  ownerServiceLabel?: string;
33
+ /** Low-stock reminders every N days until a PO is recorded (0 = off). Only from GET /stores/:id of an API that sends them. */
34
+ lowStockReminderDays?: number;
33
35
  };
34
36
 
35
37
  declare type TInventoryItem = {
@@ -49,6 +51,22 @@ declare type TInventoryItem = {
49
51
  lowStock: boolean;
50
52
  /** Main store: which service providers see and may request it (["all"] or service keys). */
51
53
  usedBy?: string[];
54
+ /** Set aside by pending requests (2026-10-03); `available` = onHand - reserved. Missing on an older API. */
55
+ reserved?: number;
56
+ available?: number;
57
+ /** Issued but not yet confirmed (Received) by the person. */
58
+ awaitingReceipt?: number;
59
+ /** Only while the item is low. */
60
+ lowStockAlert?: {
61
+ since?: string;
62
+ nextRemindAt?: string | null;
63
+ lastRemindedAt?: string;
64
+ acknowledgedByName?: string;
65
+ acknowledgedAt?: string;
66
+ poNumber?: string;
67
+ poRecordedByName?: string;
68
+ poRecordedAt?: string;
69
+ };
52
70
  };
53
71
 
54
72
  declare type TInventoryLine = { item: string; qty: number };
@@ -60,7 +78,7 @@ declare type TInventoryRequest = {
60
78
  siteName?: string;
61
79
  requestedBy: string;
62
80
  requestedByName: string;
63
- lines: { item: string; itemName: string; unit: string; qty: number; issuedQty: number }[];
81
+ lines: { item: string; itemName: string; unit: string; qty: number; issuedQty: number; reservedQty?: number }[];
64
82
  reference: string;
65
83
  remarks: string;
66
84
  status: "pending" | "issued" | "rejected" | "cancelled";
@@ -68,6 +86,13 @@ declare type TInventoryRequest = {
68
86
  decidedAt?: string;
69
87
  decisionRemarks?: string;
70
88
  createdAt: string;
89
+ requestedByLabel?: string;
90
+ /** Receipt step (missing = received, as before). */
91
+ ackStatus?: "awaiting" | "received" | "not_received";
92
+ acknowledgedAt?: string;
93
+ acknowledgedVia?: "app" | "auto";
94
+ notReceivedAt?: string;
95
+ notReceivedReason?: string;
71
96
  };
72
97
 
73
98
  declare type TInventoryHolding = {
@@ -87,6 +112,8 @@ declare type TInventoryHolding = {
87
112
  inDrafts?: number;
88
113
  draftCount?: number;
89
114
  free?: number;
115
+ /** Issued but not yet confirmed with Received: locked until then. */
116
+ unacknowledged?: number;
90
117
  };
91
118
 
92
119
  declare type TInventoryTransaction = {
@@ -94,7 +121,7 @@ declare type TInventoryTransaction = {
94
121
  item: string;
95
122
  itemName: string;
96
123
  unit: string;
97
- type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover" | "use_draft";
124
+ type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover" | "use_draft" | "write_off";
98
125
  qty: number;
99
126
  storeChange: number;
100
127
  storeBalance: number;
@@ -128,6 +155,13 @@ declare type TInventoryTransaction = {
128
155
  photosBeforeAt?: string | null;
129
156
  photosAfterAt?: string | null;
130
157
  submittedAt?: string;
158
+ writeOffReason?: string;
159
+ /** "Also attended" on a usage record. */
160
+ attendedWith?: string;
161
+ /** A draft taken over from a colleague. */
162
+ startedBy?: string;
163
+ startedByName?: string;
164
+ takenOverAt?: string;
131
165
  };
132
166
 
133
167
  /** A usage record saved with its before photo, waiting for the after photo (no stock deducted yet). */
@@ -169,6 +203,9 @@ declare type TInventoryHandover = {
169
203
  declineReason?: string;
170
204
  createdAt: string;
171
205
  decidedAt?: string;
206
+ /** "return": the holder giving stock back to the store (`to` empty, `toName` = the store). */
207
+ kind?: "return";
208
+ decidedByName?: string;
172
209
  };
173
210
 
174
211
  /** GET /stores/:id/reports/purchase-order: one item of a PO, ordered (null = not recorded) vs received. */
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Inventory words for the October 2026 steps (owner 2026-10-03): stock set aside at request time,
3
+ * the person tapping Received / Not received, write-offs, returns started by the holder, low-stock
4
+ * reminders and the reconciliation check. Browser-safe, no Nuxt imports, so the tests run on node.
5
+ *
6
+ * Every new field is optional: an API without these steps (production before the release) leaves
7
+ * them out and the screens fall back to what they showed before.
8
+ */
9
+
10
+ /** The store answers with its reminder setting only once these steps exist on the server. */
11
+ export function inventoryStepsLive(store?: { lowStockReminderDays?: number | null } | null) {
12
+ return typeof store?.lowStockReminderDays === "number";
13
+ }
14
+
15
+ /** What can still be requested: `available` from the API, else what is on the shelf. */
16
+ export function availableQty(item: { onHand: number; available?: number }) {
17
+ return item.available ?? item.onHand;
18
+ }
19
+
20
+ /** "Available: 8 pcs · 2 set aside for requests". */
21
+ export function availableText(item: { onHand: number; available?: number; reserved?: number; unit: string }) {
22
+ const reserved = item.reserved ?? 0;
23
+ return `Available: ${availableQty(item)} ${item.unit}${reserved > 0 ? ` · ${reserved} set aside for requests` : ""}`;
24
+ }
25
+
26
+ /**
27
+ * The most a store keeper can give on one request line: what is free, plus what this request
28
+ * itself set aside (it is already counted out of "available").
29
+ */
30
+ export function approveCap(item: { onHand: number; available?: number } | undefined, reservedByThisRequest = 0) {
31
+ if (!item) return undefined;
32
+ if (item.available === undefined) return item.onHand;
33
+ return Math.min(item.onHand, item.available + reservedByThisRequest);
34
+ }
35
+
36
+ export type TAck = "awaiting" | "received" | "not_received";
37
+ type TAckRequest = { status: string; ackStatus?: TAck; acknowledgedVia?: string; notReceivedReason?: string; requestedByName?: string };
38
+
39
+ /** The receipt step of an approved request, as one plain line; "" when there is nothing to say. */
40
+ export function ackText(r: TAckRequest, mine: boolean) {
41
+ if (r.status !== "issued" || !r.ackStatus) return "";
42
+ const who = r.requestedByName || "the person";
43
+ if (r.ackStatus === "awaiting")
44
+ return mine
45
+ ? "Handed over to you. Tap Received once the items are in your hands, or Not received if you did not get them. You can use the items after you tap Received."
46
+ : `Waiting for ${who} to tap Received. They get a reminder every day until they do.`;
47
+ if (r.ackStatus === "not_received")
48
+ return mine
49
+ ? `You said you did not receive this${r.notReceivedReason ? ` ("${r.notReceivedReason}")` : ""}. The store keeper has been told and will follow up. Got it after all? Tap Received.`
50
+ : `${who} says they did NOT receive this${r.notReceivedReason ? `: "${r.notReceivedReason}"` : ""}. Please check. If it was not handed over, use Receive return next to their name (staff tab) to put it back in the store.`;
51
+ return r.acknowledgedVia === "auto" ? "" : mine ? "You confirmed you received this." : `${who} confirmed receiving it.`;
52
+ }
53
+
54
+ export const ACK_CHIP: Record<TAck, { label: string; color: string }> = {
55
+ awaiting: { label: "Waiting for Received", color: "warning" },
56
+ not_received: { label: "Not received", color: "error" },
57
+ received: { label: "Received", color: "success" },
58
+ };
59
+
60
+ /** Write-off reasons: the API's value, the words on screen. */
61
+ export const WRITE_OFF_REASONS = [
62
+ { value: "expended", title: "Used up" },
63
+ { value: "spoilt", title: "Spoilt" },
64
+ { value: "damaged", title: "Damaged" },
65
+ { value: "lost", title: "Lost" },
66
+ { value: "other", title: "Other" },
67
+ ] as const;
68
+ export type TWriteOffReason = (typeof WRITE_OFF_REASONS)[number]["value"];
69
+
70
+ export const writeOffLabel = (reason?: string) => WRITE_OFF_REASONS.find((r) => r.value === reason)?.title ?? (reason || "");
71
+
72
+ /** The same sentence the API uses, shown before saving. */
73
+ export function writeOffProblem(input: { reason: string; remarks: string }) {
74
+ if (!input.reason) return "Choose why the stock is being written off.";
75
+ if (input.reason === "other" && !input.remarks.trim()) return "Say what happened (remarks) when the reason is Other.";
76
+ return "";
77
+ }
78
+
79
+ /** `{ expended: 5, spoilt: 2 }` -> "5 used up, 2 spoilt" (reasons with 0 left out). */
80
+ export function writeOffReasonText(byReason?: Record<string, number> | null, unit = "") {
81
+ return Object.entries(byReason ?? {})
82
+ .filter(([, n]) => n > 0)
83
+ .map(([reason, n]) => `${n}${unit ? ` ${unit}` : ""} ${writeOffLabel(reason).toLowerCase()}`)
84
+ .join(", ");
85
+ }
86
+
87
+ type TLowStockAlert = {
88
+ since?: string;
89
+ acknowledgedByName?: string;
90
+ acknowledgedAt?: string;
91
+ poNumber?: string;
92
+ poRecordedByName?: string;
93
+ };
94
+
95
+ /** The line under a low item: since when, who saw it, and whether reminders still go out. */
96
+ export function lowStockAlertText(alert: TLowStockAlert | null | undefined, formatDay: (d: string) => string) {
97
+ if (!alert) return "";
98
+ const parts = [alert.since ? `Low since ${formatDay(alert.since)}` : "Low on stock"];
99
+ if (alert.poNumber) parts.push(`PO ${alert.poNumber} recorded${alert.poRecordedByName ? ` by ${alert.poRecordedByName}` : ""} - reminders stopped`);
100
+ else if (alert.acknowledgedByName) parts.push(`Seen by ${alert.acknowledgedByName} - reminders continue until a PO number is recorded`);
101
+ else parts.push("Not acknowledged yet");
102
+ return parts.join(" · ");
103
+ }
104
+
105
+ /** Store Settings: what the reminder number means. */
106
+ export function reminderDaysText(days: number | null | undefined) {
107
+ const n = Number(days ?? 1);
108
+ if (!n) return "Off: one alert when an item goes low, no reminders.";
109
+ return `Store staff are reminded ${n === 1 ? "every day" : `every ${n} days`} about low items until someone records a PO number for them.`;
110
+ }
111
+
112
+ /** Reconciliation: one person's line for an item in plain words. */
113
+ export function reconciliationPersonText(p: {
114
+ opening: number;
115
+ taken: number;
116
+ fromColleagues: number;
117
+ toColleagues: number;
118
+ used: number;
119
+ usedWhere?: { where: string; reference?: string; qty: number }[];
120
+ writtenOff: number;
121
+ writtenOffByReason?: Record<string, number>;
122
+ returned: number;
123
+ closing: number;
124
+ awaitingReceipt?: number;
125
+ }, unit: string) {
126
+ const parts: string[] = [];
127
+ if (p.opening) parts.push(`had ${p.opening} at the start`);
128
+ if (p.taken) parts.push(`took ${p.taken}`);
129
+ if (p.fromColleagues) parts.push(`got ${p.fromColleagues} from colleagues`);
130
+ if (p.used) {
131
+ const where = (p.usedWhere ?? []).map((w) => `${w.qty} at ${w.where}${w.reference ? ` (${w.reference})` : ""}`).join(", ");
132
+ parts.push(`used ${p.used}${where ? `: ${where}` : ""}`);
133
+ }
134
+ if (p.writtenOff) parts.push(writeOffReasonText(p.writtenOffByReason) || `${p.writtenOff} written off`);
135
+ if (p.toColleagues) parts.push(`gave ${p.toColleagues} to colleagues`);
136
+ if (p.returned) parts.push(`returned ${p.returned} to the store`);
137
+ parts.push(`holds ${p.closing} ${unit}`.trim());
138
+ if (p.awaitingReceipt) parts.push(`${p.awaitingReceipt} not yet confirmed as received`);
139
+ return parts.join(" · ");
140
+ }
141
+
142
+ /** Reconciliation check, in words a store keeper can act on. */
143
+ export function reconciliationCheckText(check: { in: number; out: number; ok: boolean } | null | undefined) {
144
+ if (!check) return "";
145
+ return check.ok
146
+ ? `Tallies: ${check.in} in = ${check.out} accounted for.`
147
+ : `Does not tally: ${check.in} in, ${check.out} accounted for. Please do a stock count, or ask Seven365 support to check this item.`;
148
+ }
@@ -36,6 +36,17 @@ export function draftCardText(d: TDraft, time: (iso: string) => string, staleDay
36
36
  };
37
37
  }
38
38
 
39
+ /** "Kevin started this job at Unit 05-12 at 9:40 - take it over to finish it with your own stock." (a colleague's open draft) */
40
+ export function colleagueDraftText(
41
+ d: { startedByName?: string; holderName?: string; location?: TLocation; reference?: string; draftedAt?: string; photosBeforeAt?: string | null },
42
+ time: (iso: string) => string,
43
+ ) {
44
+ const who = d.holderName || d.startedByName || "A colleague";
45
+ const where = whereText(d.location) || d.reference || "";
46
+ const at = d.draftedAt || d.photosBeforeAt;
47
+ return `${who} started this job${where ? ` at ${where}` : ""}${at ? ` at ${time(at)}` : ""} - take it over to finish it with your own stock.`;
48
+ }
49
+
39
50
  /** "Issued to Ali by Keeper (29/09) → Handed to Bala by Ali (29/09)". */
40
51
  export function usagePathText(steps: TStep[] | undefined, day: (iso: string) => string) {
41
52
  if (!steps?.length) return "";
@@ -71,9 +82,13 @@ export function usageTotals(rows: { status?: string; qty: number; totalCost?: nu
71
82
  };
72
83
  }
73
84
 
85
+ const WRITE_OFF_WORDS: Record<string, string> = { expended: "used up", spoilt: "spoilt", damaged: "damaged", lost: "lost", other: "other" };
86
+
74
87
  /** What a history row says happened, from the person's side. */
75
- export function historyText(t: { type: string; holderChange?: number }) {
88
+ export function historyText(t: { type: string; holderChange?: number; writeOffReason?: string }) {
76
89
  switch (t.type) {
90
+ case "write_off":
91
+ return `Written off${t.writeOffReason ? ` (${WRITE_OFF_WORDS[t.writeOffReason] ?? t.writeOffReason})` : ""}${(t.holderChange ?? 0) < 0 ? "" : " from the store"}`;
77
92
  case "issue":
78
93
  return "Taken from store";
79
94
  case "return":
@@ -45,7 +45,7 @@ export type TTargetRow = {
45
45
  * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
46
  * unscoped on purpose. Older rows have neither and are read from `module`.
47
47
  */
48
- params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string } | null;
48
+ params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string; requestId?: string } | null;
49
49
  };
50
50
 
51
51
  /** What the surrounding app knows that the stored row does not. */
@@ -144,7 +144,7 @@ export function notificationKind(item: TTargetRow | null | undefined): string {
144
144
  */
145
145
  export function inventoryTabForAlert(title: string): "requests" | "stock" | "mine" | "" {
146
146
  if (/stock handed to you|hand-over (confirmed|declined)/i.test(title)) return "mine";
147
- if (/request waiting|request (was )?(approved|rejected)/i.test(title)) return "requests";
147
+ if (/request waiting|request (was )?(approved|rejected)|confirm you received/i.test(title)) return "requests";
148
148
  if (/low (on )?stock/i.test(title)) return "stock";
149
149
  return "";
150
150
  }
@@ -237,7 +237,13 @@ function sitePageTarget(
237
237
  // Open the alert's own store on the tab it is about (InventoryMain reads ?storeId=&tab=).
238
238
  const storeId = String(item.params?.storeId ?? item.params?.id ?? "").trim();
239
239
  const tab = inventoryTabForAlert(String(item.title ?? ""));
240
- const query: Record<string, string> = { ...(storeId ? { storeId } : {}), ...(tab ? { tab } : {}) };
240
+ // Approved / please confirm / reminder alerts name their request (API 2026-10-03): open that request.
241
+ const requestId = String(item.params?.requestId ?? "").trim();
242
+ const query: Record<string, string> = {
243
+ ...(storeId ? { storeId } : {}),
244
+ ...(tab || requestId ? { tab: tab || "requests" } : {}),
245
+ ...(requestId ? { requestId } : {}),
246
+ };
241
247
  return Object.keys(query).length ? { candidates, query } : { candidates };
242
248
  }
243
249