@7365admin1/layer-common 4.94.0 → 4.96.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.
@@ -76,7 +76,34 @@
76
76
  Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
77
77
  dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
78
78
  </p>
79
- <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview" />
79
+ <div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
80
+ <p class="text-caption text-medium-emphasis mb-2">
81
+ Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365, and the
82
+ QR code stays the same size. Your choice is remembered on this computer.
83
+ </p>
84
+ <v-row dense data-test="vehicle-qr-design">
85
+ <v-col cols="12" sm="6" md="4" lg>
86
+ <v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
87
+ </v-col>
88
+ <v-col cols="12" sm="6" md="4" lg>
89
+ <v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
90
+ </v-col>
91
+ <v-col cols="12" sm="6" md="4" lg>
92
+ <v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
93
+ hide-details />
94
+ </v-col>
95
+ <v-col cols="12" sm="6" md="4" lg>
96
+ <v-select v-model="design.frame" :items="FRAME_OPTIONS" label="Sticker shape" density="compact" hide-details />
97
+ </v-col>
98
+ <v-col cols="12" sm="6" md="4" lg>
99
+ <v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
100
+ hide-details />
101
+ </v-col>
102
+ <v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
103
+ <v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
104
+ </v-col>
105
+ </v-row>
106
+ <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
80
107
  </template>
81
108
  </v-card-text>
82
109
 
@@ -114,6 +141,18 @@ import {
114
141
  type StickerItem,
115
142
  type StickerPlateRow,
116
143
  } from "../utils/vehicle-qr-sticker";
144
+ import {
145
+ DEFAULT_STICKER_DESIGN,
146
+ DOT_OPTIONS,
147
+ EYE_OPTIONS,
148
+ FRAME_OPTIONS,
149
+ LABEL_OPTIONS,
150
+ QR_ECC,
151
+ STICKER_THEMES,
152
+ normalizeDesign,
153
+ stickerFaceSvg,
154
+ type StickerDesign,
155
+ } from "../utils/vehicle-qr-design";
117
156
 
118
157
  const props = defineProps({
119
158
  modelValue: { type: Boolean, default: false },
@@ -143,6 +182,29 @@ const error = ref("");
143
182
  const docHtml = ref("");
144
183
  const frame = ref<HTMLIFrameElement | null>(null);
145
184
  const fromReissue = computed(() => props.vehicleIds.length > 0);
185
+ const siteName = ref("");
186
+
187
+ // The last design chosen in this browser. Storage can be blocked (private
188
+ // window): then it is simply the standard look each time.
189
+ const DESIGN_KEY = "vehicleQrStickerDesign";
190
+ function loadDesign(): StickerDesign {
191
+ try {
192
+ return normalizeDesign(JSON.parse(localStorage.getItem(DESIGN_KEY) || "null"));
193
+ } catch {
194
+ return { ...DEFAULT_STICKER_DESIGN };
195
+ }
196
+ }
197
+ const design = reactive<StickerDesign>(loadDesign());
198
+ watch(design, (d) => {
199
+ try {
200
+ localStorage.setItem(DESIGN_KEY, JSON.stringify(d));
201
+ } catch {
202
+ /* not saved - nothing else to do */
203
+ }
204
+ });
205
+ function resetDesign() {
206
+ Object.assign(design, DEFAULT_STICKER_DESIGN);
207
+ }
146
208
 
147
209
  const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
148
210
  const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
@@ -203,6 +265,7 @@ async function goReview() {
203
265
  const { ids, dropped: over } = idsForRequest(selected.value);
204
266
  const res = await getVehicleQrStickers(props.site, ids);
205
267
  items.value = (res?.items ?? []) as StickerItem[];
268
+ siteName.value = String((res as any)?.site?.name ?? "");
206
269
  missing.value = res?.missing ?? [];
207
270
  dropped.value = over;
208
271
  ticked.value = defaultStickerIds(items.value);
@@ -214,21 +277,25 @@ async function goReview() {
214
277
  }
215
278
  }
216
279
 
217
- // The same document is the preview and the print job. Error correction Q and a
218
- // 4-module quiet zone; the QR is drawn at 32 mm so it scans from arm's length.
219
- let renderToken = 0;
220
- watch([items, ticked], async () => {
221
- const mine = ++renderToken;
222
- const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
223
- if (!chosen.length) {
224
- docHtml.value = "";
225
- return;
226
- }
227
- const svgs = await Promise.all(
228
- chosen.map((i) => QRCode.toString(i.qr, { type: "svg", errorCorrectionLevel: "Q", margin: 4 })),
229
- );
230
- if (mine === renderToken) docHtml.value = buildStickerDocument(chosen, svgs);
231
- });
280
+ // The same document is the preview and the print job. The QR content, error
281
+ // correction (Q), 4-module quiet zone and 32 mm size are the same on every
282
+ // design; only the look of the dots, corners, colours and frame changes.
283
+ watch(
284
+ [items, ticked, design, siteName],
285
+ () => {
286
+ const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
287
+ if (!chosen.length) {
288
+ docHtml.value = "";
289
+ return;
290
+ }
291
+ const look = normalizeDesign(design);
292
+ const faces = chosen.map((i) =>
293
+ stickerFaceSvg(QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules, look),
294
+ );
295
+ docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
296
+ },
297
+ { deep: true },
298
+ );
232
299
 
233
300
  function print() {
234
301
  const win = frame.value?.contentWindow;
@@ -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.94.0",
5
+ "version": "4.96.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.",
@@ -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,172 @@
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
+ /** Singular / plural of the item units the store offers; any other unit is shown as typed. */
16
+ const UNIT_FORMS: Record<string, [string, string]> = {
17
+ pc: ["pc", "pcs"], pcs: ["pc", "pcs"], box: ["box", "boxes"], boxes: ["box", "boxes"], set: ["set", "sets"], sets: ["set", "sets"],
18
+ pack: ["pack", "packs"], packs: ["pack", "packs"], roll: ["roll", "rolls"], rolls: ["roll", "rolls"], can: ["can", "cans"], cans: ["can", "cans"],
19
+ bottle: ["bottle", "bottles"], bottles: ["bottle", "bottles"], litre: ["litre", "litres"], litres: ["litre", "litres"],
20
+ metre: ["metre", "metres"], metres: ["metre", "metres"],
21
+ };
22
+
23
+ /** "1 pc", "2 pcs", "1 box", "3 boxes"; kg and unknown units unchanged. */
24
+ export function qtyUnit(qty: number | string | null | undefined, unit?: string | null) {
25
+ const forms = UNIT_FORMS[String(unit ?? "").trim().toLowerCase()];
26
+ const word = forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
27
+ return `${qty ?? ""} ${word}`.trim();
28
+ }
29
+
30
+ /** "you hold 2 pcs" or, while some still wait for Received, "you hold 2 pcs (2 waiting for you to tap Received)". */
31
+ export function holdText(h: { qty?: number; unit?: string; unacknowledged?: number } | null | undefined) {
32
+ const locked = Math.min(Number(h?.unacknowledged ?? 0), Number(h?.qty ?? 0));
33
+ return `you hold ${qtyUnit(h?.qty ?? 0, h?.unit)}${locked > 0 ? ` (${locked} waiting for you to tap Received)` : ""}`;
34
+ }
35
+
36
+ /** What can still be requested: `available` from the API, else what is on the shelf. */
37
+ export function availableQty(item: { onHand: number; available?: number }) {
38
+ return item.available ?? item.onHand;
39
+ }
40
+
41
+ /** "Available: 8 pcs · 2 set aside for requests". */
42
+ export function availableText(item: { onHand: number; available?: number; reserved?: number; unit: string }) {
43
+ const reserved = item.reserved ?? 0;
44
+ return `Available: ${qtyUnit(availableQty(item), item.unit)}${reserved > 0 ? ` · ${reserved} set aside for requests` : ""}`;
45
+ }
46
+
47
+ /**
48
+ * The most a store keeper can give on one request line: what is free, plus what this request
49
+ * itself set aside (it is already counted out of "available").
50
+ */
51
+ export function approveCap(item: { onHand: number; available?: number } | undefined, reservedByThisRequest = 0) {
52
+ if (!item) return undefined;
53
+ if (item.available === undefined) return item.onHand;
54
+ return Math.min(item.onHand, item.available + reservedByThisRequest);
55
+ }
56
+
57
+ export type TAck = "awaiting" | "received" | "not_received";
58
+ type TAckRequest = { status: string; ackStatus?: TAck; acknowledgedVia?: string; notReceivedReason?: string; requestedByName?: string };
59
+
60
+ /** The receipt step of an approved request, as one plain line; "" when there is nothing to say. */
61
+ export function ackText(r: TAckRequest, mine: boolean) {
62
+ if (r.status !== "issued" || !r.ackStatus) return "";
63
+ const who = r.requestedByName || "the person";
64
+ if (r.ackStatus === "awaiting")
65
+ return mine
66
+ ? "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."
67
+ : `Waiting for ${who} to tap Received. They get a reminder every day until they do.`;
68
+ if (r.ackStatus === "not_received")
69
+ return mine
70
+ ? `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.`
71
+ : `${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.`;
72
+ return r.acknowledgedVia === "auto" ? "" : mine ? "You confirmed you received this." : `${who} confirmed receiving it.`;
73
+ }
74
+
75
+ export const ACK_CHIP: Record<TAck, { label: string; color: string }> = {
76
+ awaiting: { label: "Waiting for Received", color: "warning" },
77
+ not_received: { label: "Not received", color: "error" },
78
+ received: { label: "Received", color: "success" },
79
+ };
80
+
81
+ /** Write-off reasons: the API's value, the words on screen. */
82
+ export const WRITE_OFF_REASONS = [
83
+ { value: "expended", title: "Used up" },
84
+ { value: "spoilt", title: "Spoilt" },
85
+ { value: "damaged", title: "Damaged" },
86
+ { value: "lost", title: "Lost" },
87
+ { value: "other", title: "Other" },
88
+ ] as const;
89
+ export type TWriteOffReason = (typeof WRITE_OFF_REASONS)[number]["value"];
90
+
91
+ /** A store keeper writing off from the shelf picks the reason (nothing chosen first); a holder starts on "Used up". */
92
+ export const writeOffDefaultReason = (from: "holding" | "store") => (from === "store" ? "" : "expended");
93
+
94
+ export const writeOffLabel = (reason?: string) => WRITE_OFF_REASONS.find((r) => r.value === reason)?.title ?? (reason || "");
95
+
96
+ /** The same sentence the API uses, shown before saving. */
97
+ export function writeOffProblem(input: { reason: string; remarks: string }) {
98
+ if (!input.reason) return "Choose why the stock is being written off.";
99
+ if (input.reason === "other" && !input.remarks.trim()) return "Say what happened (remarks) when the reason is Other.";
100
+ return "";
101
+ }
102
+
103
+ /** `{ expended: 5, spoilt: 2 }` -> "5 used up, 2 spoilt" (reasons with 0 left out). */
104
+ export function writeOffReasonText(byReason?: Record<string, number> | null, unit = "") {
105
+ return Object.entries(byReason ?? {})
106
+ .filter(([, n]) => n > 0)
107
+ .map(([reason, n]) => `${qtyUnit(n, unit)} ${writeOffLabel(reason).toLowerCase()}`)
108
+ .join(", ");
109
+ }
110
+
111
+ type TLowStockAlert = {
112
+ since?: string;
113
+ acknowledgedByName?: string;
114
+ acknowledgedAt?: string;
115
+ poNumber?: string;
116
+ poRecordedByName?: string;
117
+ };
118
+
119
+ /** The line under a low item: since when, who saw it, and whether reminders still go out. */
120
+ export function lowStockAlertText(alert: TLowStockAlert | null | undefined, formatDay: (d: string) => string) {
121
+ if (!alert) return "";
122
+ const parts = [alert.since ? `Low since ${formatDay(alert.since)}` : "Low on stock"];
123
+ if (alert.poNumber) parts.push(`PO ${alert.poNumber} recorded${alert.poRecordedByName ? ` by ${alert.poRecordedByName}` : ""} - reminders stopped`);
124
+ else if (alert.acknowledgedByName) parts.push(`Seen by ${alert.acknowledgedByName} - reminders continue until a PO number is recorded`);
125
+ else parts.push("Not acknowledged yet");
126
+ return parts.join(" · ");
127
+ }
128
+
129
+ /** Store Settings: what the reminder number means. */
130
+ export function reminderDaysText(days: number | null | undefined) {
131
+ const n = Number(days ?? 1);
132
+ if (!n) return "Off: one alert when an item goes low, no reminders.";
133
+ return `Store staff are reminded ${n === 1 ? "every day" : `every ${n} days`} about low items until someone records a PO number for them.`;
134
+ }
135
+
136
+ /** Reconciliation: one person's line for an item in plain words. */
137
+ export function reconciliationPersonText(p: {
138
+ opening: number;
139
+ taken: number;
140
+ fromColleagues: number;
141
+ toColleagues: number;
142
+ used: number;
143
+ usedWhere?: { where: string; reference?: string; qty: number }[];
144
+ writtenOff: number;
145
+ writtenOffByReason?: Record<string, number>;
146
+ returned: number;
147
+ closing: number;
148
+ awaitingReceipt?: number;
149
+ }, unit: string) {
150
+ const parts: string[] = [];
151
+ if (p.opening) parts.push(`had ${p.opening} at the start`);
152
+ if (p.taken) parts.push(`took ${p.taken}`);
153
+ if (p.fromColleagues) parts.push(`got ${p.fromColleagues} from colleagues`);
154
+ if (p.used) {
155
+ const where = (p.usedWhere ?? []).map((w) => `${w.qty} at ${w.where}${w.reference ? ` (${w.reference})` : ""}`).join(", ");
156
+ parts.push(`used ${p.used}${where ? `: ${where}` : ""}`);
157
+ }
158
+ if (p.writtenOff) parts.push(writeOffReasonText(p.writtenOffByReason) || `${p.writtenOff} written off`);
159
+ if (p.toColleagues) parts.push(`gave ${p.toColleagues} to colleagues`);
160
+ if (p.returned) parts.push(`returned ${p.returned} to the store`);
161
+ parts.push(`holds ${qtyUnit(p.closing, unit)}`);
162
+ if (p.awaitingReceipt) parts.push(`${p.awaitingReceipt} not yet confirmed as received`);
163
+ return parts.join(" · ");
164
+ }
165
+
166
+ /** Reconciliation check, in words a store keeper can act on. */
167
+ export function reconciliationCheckText(check: { in: number; out: number; ok: boolean } | null | undefined) {
168
+ if (!check) return "";
169
+ return check.ok
170
+ ? `Tallies: ${check.in} in = ${check.out} accounted for.`
171
+ : `Does not tally: ${check.in} in, ${check.out} accounted for. Please do a stock count, or ask Seven365 support to check this item.`;
172
+ }
@@ -3,6 +3,8 @@
3
3
  * words, how many were handed over against how many were asked, and who
4
4
  * decided and when. Used by `InventoryRequestsTab`.
5
5
  */
6
+ import { qtyUnit } from "./inventory-ack.ts";
7
+
6
8
  type TLine = { itemName: string; unit: string; qty: number; issuedQty: number };
7
9
  type TRequest = { status: string; decidedByName?: string; decidedAt?: string; decisionRemarks?: string };
8
10
 
@@ -13,10 +15,10 @@ export const REQUEST_STATUS: Record<string, { label: string; color: string }> =
13
15
  cancelled: { label: "Cancelled", color: "grey" },
14
16
  };
15
17
 
18
+ /** "(asked N)" only when less was handed over than asked; a counter issue was never asked for. */
16
19
  export function requestLineText(status: string, l: TLine) {
17
- return status === "issued"
18
- ? `${l.itemName} — ${l.issuedQty} ${l.unit} handed over (asked ${l.qty})`
19
- : `${l.itemName} — ${l.qty} ${l.unit}`;
20
+ if (status !== "issued") return `${l.itemName} — ${qtyUnit(l.qty, l.unit)}`;
21
+ return `${l.itemName} — ${qtyUnit(l.issuedQty, l.unit)} handed over${l.issuedQty !== l.qty ? ` (asked ${l.qty})` : ""}`;
20
22
  }
21
23
 
22
24
  /** "Approved and handed over by X on <when> — remarks", or "" before a decision. */
@@ -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