@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.
- package/CHANGELOG.md +20 -4
- package/components/InventoryHistoryDialog.vue +10 -3
- package/components/InventoryItemDetail.vue +18 -1
- package/components/InventoryItemDialog.vue +1 -1
- package/components/InventoryLinesDialog.vue +35 -9
- package/components/InventoryMain.vue +107 -5
- package/components/InventoryMyStockTab.vue +167 -12
- package/components/InventoryPoDialog.vue +2 -1
- package/components/InventoryReceiptActions.vue +67 -0
- package/components/InventoryReportsTab.vue +97 -6
- package/components/InventoryRequestsTab.vue +36 -14
- package/components/InventorySettingsTab.vue +27 -1
- package/components/InventoryStockTab.vue +52 -4
- package/components/InventoryTechniciansTab.vue +77 -4
- package/components/InventoryUsageDialog.vue +21 -2
- package/components/InventoryWriteOffDialog.vue +112 -0
- package/components/VehicleQrStickerDialog.vue +83 -16
- package/composables/useInventory.ts +24 -6
- package/package.json +1 -1
- package/types/inventory.d.ts +39 -2
- package/utils/inventory-ack.ts +172 -0
- package/utils/inventory-request-text.ts +5 -3
- package/utils/inventory-usage.ts +16 -1
- package/utils/notification-target.ts +9 -3
- package/utils/vehicle-qr-design.ts +268 -0
- package/utils/vehicle-qr-sticker.ts +26 -15
|
@@ -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
|
-
<
|
|
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.
|
|
218
|
-
// 4-module quiet zone
|
|
219
|
-
|
|
220
|
-
watch(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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.
|
|
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.",
|
package/types/inventory.d.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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. */
|
package/utils/inventory-usage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|