@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.
- package/components/InventoryHistoryDialog.vue +8 -1
- package/components/InventoryItemDetail.vue +17 -0
- package/components/InventoryLinesDialog.vue +26 -4
- package/components/InventoryMain.vue +101 -3
- package/components/InventoryMyStockTab.vue +157 -5
- package/components/InventoryReceiptActions.vue +66 -0
- package/components/InventoryReportsTab.vue +97 -6
- package/components/InventoryRequestsTab.vue +35 -13
- package/components/InventorySettingsTab.vue +27 -1
- package/components/InventoryStockTab.vue +51 -3
- package/components/InventoryTechniciansTab.vue +76 -4
- package/components/InventoryUsageDialog.vue +19 -0
- package/components/InventoryWriteOffDialog.vue +110 -0
- package/composables/useInventory.ts +24 -6
- package/package.json +1 -1
- package/types/inventory.d.ts +39 -2
- package/utils/inventory-ack.ts +148 -0
- package/utils/inventory-usage.ts +16 -1
- package/utils/notification-target.ts +9 -3
|
@@ -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
|
-
|
|
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.90.2-staging.
|
|
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.",
|
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,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
|
+
}
|
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
|
|