@7365admin1/layer-common 4.71.2 → 4.73.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 +16 -4
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryItemDetail.vue +7 -4
- package/components/InventoryItemDialog.vue +21 -1
- package/components/InventoryLinesDialog.vue +25 -3
- package/components/InventoryMain.vue +49 -3
- package/components/InventoryMyStockTab.vue +227 -10
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +208 -26
- package/components/InventoryRequestsTab.vue +2 -2
- package/components/InventoryStockTab.vue +79 -1
- package/components/InventoryTechniciansTab.vue +3 -2
- package/components/InventoryUsageDialog.vue +119 -28
- package/composables/useInventory.ts +28 -5
- package/package.json +1 -1
- package/types/inventory.d.ts +51 -3
- package/utils/inventory-store-label.ts +14 -1
- package/utils/inventory-usage.ts +94 -0
- package/utils/inventory-used-by.ts +57 -0
|
@@ -2,24 +2,30 @@
|
|
|
2
2
|
<v-dialog v-model="show" max-width="620" persistent scrollable>
|
|
3
3
|
<v-card v-if="holding" class="screen-modal">
|
|
4
4
|
<v-card-title class="d-flex align-center">
|
|
5
|
-
Record Usage
|
|
5
|
+
{{ draft ? "Finish usage (draft)" : "Record Usage" }}
|
|
6
6
|
<v-spacer />
|
|
7
7
|
<v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="show = false" />
|
|
8
8
|
</v-card-title>
|
|
9
9
|
<v-card-text class="screen-modal__body">
|
|
10
10
|
<div class="font-weight-medium">{{ holding.itemName }}</div>
|
|
11
|
-
<div class="text-body-2 text-medium-emphasis mb-4">
|
|
11
|
+
<div class="text-body-2 text-medium-emphasis mb-4">
|
|
12
|
+
You have {{ holding.qty }} {{ holding.unit }} in hand<template v-if="otherDrafts"> · {{ otherDrafts }} already in your other drafts</template>.
|
|
13
|
+
<template v-if="!draft"> Took the before photo but the job is not done yet? Save a draft now and finish it later.</template>
|
|
14
|
+
</div>
|
|
15
|
+
<v-alert v-if="draft" type="info" variant="tonal" density="compact" class="mb-4">
|
|
16
|
+
Saved as a draft {{ draft.draftedAt ? standardFormatDateTime(draft.draftedAt) : "" }}. Nothing has been taken off your stock yet — add the after photo and submit when the job is done.
|
|
17
|
+
</v-alert>
|
|
12
18
|
<v-form ref="form">
|
|
13
19
|
<InputLabel title="How many did you use?" required class="mb-1" />
|
|
14
20
|
<v-text-field
|
|
15
21
|
v-model.number="qty"
|
|
16
22
|
type="number"
|
|
17
23
|
min="1"
|
|
18
|
-
:max="
|
|
24
|
+
:max="maxQty"
|
|
19
25
|
:suffix="holding.unit"
|
|
20
26
|
density="comfortable"
|
|
21
27
|
style="max-width: 200px"
|
|
22
|
-
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <=
|
|
28
|
+
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= maxQty) || `1 to ${maxQty}`]"
|
|
23
29
|
/>
|
|
24
30
|
|
|
25
31
|
<InputLabel title="Where?" required class="mb-1" />
|
|
@@ -74,15 +80,17 @@
|
|
|
74
80
|
<v-col cols="12" sm="6">
|
|
75
81
|
<InputLabel title="Photo before" required class="mb-1" />
|
|
76
82
|
<InventoryPhotoInput v-model="photosBefore" :max="3" />
|
|
83
|
+
<div v-if="draft?.photosBeforeAt" class="text-caption text-medium-emphasis mt-1">Taken {{ standardFormatDateTime(draft.photosBeforeAt) }}</div>
|
|
77
84
|
</v-col>
|
|
78
85
|
<v-col cols="12" sm="6">
|
|
79
86
|
<InputLabel title="Photo after" required class="mb-1" />
|
|
80
87
|
<InventoryPhotoInput v-model="photosAfter" :max="3" />
|
|
88
|
+
<div v-if="draft?.photosAfterAt" class="text-caption text-medium-emphasis mt-1">Taken {{ standardFormatDateTime(draft.photosAfterAt) }}</div>
|
|
81
89
|
</v-col>
|
|
82
90
|
</v-row>
|
|
83
91
|
<div v-if="photoError" class="text-error text-body-2 mt-1" role="alert">{{ photoError }}</div>
|
|
84
92
|
|
|
85
|
-
<InputLabel title="Date used" class="mb-1 mt-3" />
|
|
93
|
+
<InputLabel :title="draft ? 'Date of the job (used when you submit)' : 'Date used'" class="mb-1 mt-3" />
|
|
86
94
|
<v-text-field v-model="date" type="date" :max="today" density="comfortable" style="max-width: 200px" />
|
|
87
95
|
<InputLabel title="Fault reported by (optional)" class="mb-1" />
|
|
88
96
|
<v-text-field v-model="reportedBy" density="comfortable" placeholder="e.g. tenant, security guard" maxlength="120" />
|
|
@@ -91,8 +99,21 @@
|
|
|
91
99
|
</v-form>
|
|
92
100
|
</v-card-text>
|
|
93
101
|
<div class="screen-modal__footer">
|
|
94
|
-
<v-btn class="
|
|
95
|
-
<v-
|
|
102
|
+
<v-btn v-if="draft" class="text-none" variant="text" color="error" :disabled="!!saving" @click="confirmDiscard = true">Discard draft</v-btn>
|
|
103
|
+
<v-spacer v-if="draft" />
|
|
104
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="!!saving" @click="show = false">Cancel</v-btn>
|
|
105
|
+
<v-btn class="screen-btn-ghost text-none" variant="outlined" prepend-icon="mdi-content-save-outline" :loading="saving === 'draft'" :disabled="!!saving" @click="saveDraft">Save draft</v-btn>
|
|
106
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="saving === 'submit'" :disabled="!!saving" @click="save">{{ draft ? "Submit usage" : "Save usage" }}</v-btn>
|
|
107
|
+
</div>
|
|
108
|
+
</v-card>
|
|
109
|
+
</v-dialog>
|
|
110
|
+
<v-dialog v-model="confirmDiscard" max-width="420">
|
|
111
|
+
<v-card class="screen-modal">
|
|
112
|
+
<v-card-title>Discard this draft?</v-card-title>
|
|
113
|
+
<v-card-text>The draft and its photos are removed. Nothing was taken off your stock, so your stock stays as it is.</v-card-text>
|
|
114
|
+
<div class="screen-modal__footer">
|
|
115
|
+
<v-btn class="screen-btn-ghost" variant="outlined" @click="confirmDiscard = false">Keep it</v-btn>
|
|
116
|
+
<v-btn color="error" variant="flat" :loading="saving === 'discard'" @click="discard">Discard</v-btn>
|
|
96
117
|
</div>
|
|
97
118
|
</v-card>
|
|
98
119
|
</v-dialog>
|
|
@@ -107,16 +128,24 @@ const props = defineProps({
|
|
|
107
128
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
108
129
|
siteId: { type: String, required: true },
|
|
109
130
|
holding: { type: Object as PropType<TInventoryHolding | null>, default: null },
|
|
131
|
+
/** An existing draft to finish (edit, add the after photo, submit or discard). */
|
|
132
|
+
draft: { type: Object as PropType<TInventoryUsageDraft | null>, default: null },
|
|
110
133
|
sites: { type: Array as PropType<{ _id: string; name: string }[]>, default: () => [] },
|
|
111
134
|
});
|
|
112
135
|
const emit = defineEmits(["done"]);
|
|
113
136
|
|
|
114
137
|
const api = useInventory();
|
|
115
|
-
const { requiredRule, debounce } = useUtils();
|
|
138
|
+
const { requiredRule, debounce, standardFormatDateTime } = useUtils();
|
|
116
139
|
const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
|
|
117
140
|
|
|
118
141
|
const form = ref();
|
|
119
|
-
const saving = ref(
|
|
142
|
+
const saving = ref<"" | "draft" | "submit" | "discard">("");
|
|
143
|
+
const confirmDiscard = ref(false);
|
|
144
|
+
// Stock already promised to the person's OTHER drafts; this draft's own qty is free for it.
|
|
145
|
+
const otherDrafts = computed(() => Math.max(0, (props.holding?.inDrafts ?? 0) - (props.draft?.qty ?? 0)));
|
|
146
|
+
const maxQty = computed(() => Math.max(1, (props.holding?.qty ?? 0) - otherDrafts.value));
|
|
147
|
+
const USAGE_DRAFT_NEEDS_BEFORE = "Take the before photo to save a draft.";
|
|
148
|
+
let prefilling = false;
|
|
120
149
|
const today = new Date().toLocaleDateString("en-CA");
|
|
121
150
|
const qty = ref<number | null>(1);
|
|
122
151
|
const where = ref<"tenant_unit" | "common_area">("tenant_unit");
|
|
@@ -159,38 +188,81 @@ async function loadAreas() {
|
|
|
159
188
|
}
|
|
160
189
|
}
|
|
161
190
|
|
|
191
|
+
function body() {
|
|
192
|
+
return {
|
|
193
|
+
item: props.holding!.item,
|
|
194
|
+
qty: Number(qty.value),
|
|
195
|
+
locationType: where.value,
|
|
196
|
+
...(where.value === "tenant_unit" ? { unit: unit.value } : { area: area.value }),
|
|
197
|
+
reference: reference.value,
|
|
198
|
+
photosBefore: photosBefore.value,
|
|
199
|
+
photosAfter: photosAfter.value,
|
|
200
|
+
remarks: remarks.value,
|
|
201
|
+
reportedBy: reportedBy.value,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function finished(text: string) {
|
|
206
|
+
notify(text);
|
|
207
|
+
show.value = false;
|
|
208
|
+
emit("done");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Save draft: before photo required, after photo optional; nothing is deducted. */
|
|
212
|
+
async function saveDraft() {
|
|
213
|
+
const { valid } = await form.value.validate();
|
|
214
|
+
photoError.value = photosBefore.value.length ? "" : USAGE_DRAFT_NEEDS_BEFORE;
|
|
215
|
+
if (!valid || photoError.value || !props.holding) return;
|
|
216
|
+
saving.value = "draft";
|
|
217
|
+
try {
|
|
218
|
+
if (props.draft) await api.updateUsageDraft(props.draft._id, body());
|
|
219
|
+
else await api.createUsageDraft(props.store._id, { site: site.value, ...body() });
|
|
220
|
+
finished("Draft saved. Add the after photo and submit it when the job is done — it is under My drafts.");
|
|
221
|
+
} catch (error: any) {
|
|
222
|
+
notify(error?.data?.message || "Could not save the draft.", "error");
|
|
223
|
+
} finally {
|
|
224
|
+
saving.value = "";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
162
228
|
async function save() {
|
|
163
229
|
const { valid } = await form.value.validate();
|
|
164
230
|
// Owner decision 2026-09-27: a before AND an after photo on every usage record (the API refuses without).
|
|
165
231
|
photoError.value = photosBefore.value.length && photosAfter.value.length ? "" : USAGE_PHOTOS_REQUIRED;
|
|
166
232
|
if (!valid || photoError.value || !props.holding) return;
|
|
167
|
-
saving.value =
|
|
233
|
+
saving.value = "submit";
|
|
168
234
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
photosBefore: photosBefore.value,
|
|
178
|
-
photosAfter: photosAfter.value,
|
|
179
|
-
remarks: remarks.value,
|
|
180
|
-
reportedBy: reportedBy.value,
|
|
181
|
-
});
|
|
182
|
-
notify("Usage saved.");
|
|
183
|
-
show.value = false;
|
|
184
|
-
emit("done");
|
|
235
|
+
if (props.draft) {
|
|
236
|
+
// Keep any edits, then submit: the stock comes off now.
|
|
237
|
+
await api.updateUsageDraft(props.draft._id, body());
|
|
238
|
+
await api.submitUsageDraft(props.draft._id, { photosAfter: photosAfter.value, date: pickedDate(date.value) });
|
|
239
|
+
} else {
|
|
240
|
+
await api.recordUsage(props.store._id, { site: site.value, ...body(), date: pickedDate(date.value) });
|
|
241
|
+
}
|
|
242
|
+
finished("Usage saved.");
|
|
185
243
|
} catch (error: any) {
|
|
186
244
|
notify(error?.data?.message || "Could not save the usage.", "error");
|
|
187
245
|
} finally {
|
|
188
|
-
saving.value =
|
|
246
|
+
saving.value = "";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function discard() {
|
|
251
|
+
if (!props.draft) return;
|
|
252
|
+
saving.value = "discard";
|
|
253
|
+
try {
|
|
254
|
+
await api.discardUsageDraft(props.draft._id);
|
|
255
|
+
confirmDiscard.value = false;
|
|
256
|
+
finished("Draft discarded.");
|
|
257
|
+
} catch (error: any) {
|
|
258
|
+
notify(error?.data?.message || "Could not discard the draft.", "error");
|
|
259
|
+
} finally {
|
|
260
|
+
saving.value = "";
|
|
189
261
|
}
|
|
190
262
|
}
|
|
191
263
|
|
|
192
264
|
watch(site, () => {
|
|
193
|
-
unit.value = "";
|
|
265
|
+
if (!prefilling) unit.value = "";
|
|
194
266
|
searchUnits("");
|
|
195
267
|
loadAreas();
|
|
196
268
|
});
|
|
@@ -212,6 +284,25 @@ watch(show, (open) => {
|
|
|
212
284
|
reportedBy.value = "";
|
|
213
285
|
date.value = today;
|
|
214
286
|
site.value = props.store.sites.includes(props.siteId) ? props.siteId : props.store.sites[0];
|
|
287
|
+
const d = props.draft;
|
|
288
|
+
if (d) {
|
|
289
|
+
// Finish a draft: everything as saved; the site cannot change.
|
|
290
|
+
prefilling = true;
|
|
291
|
+
site.value = String(d.site ?? site.value);
|
|
292
|
+
qty.value = d.qty;
|
|
293
|
+
where.value = d.location?.type === "common_area" ? "common_area" : "tenant_unit";
|
|
294
|
+
area.value = d.location?.area ?? "";
|
|
295
|
+
unit.value = String(d.location?.unit ?? "");
|
|
296
|
+
if (unit.value) units.value = [{ _id: unit.value, name: d.location?.unitName ?? "", tenantName: d.location?.tenantName ?? "" }];
|
|
297
|
+
reference.value = d.reference ?? "";
|
|
298
|
+
remarks.value = d.remarks ?? "";
|
|
299
|
+
reportedBy.value = d.reportedBy ?? "";
|
|
300
|
+
photosBefore.value = [...(d.photos?.before ?? [])];
|
|
301
|
+
photosAfter.value = [...(d.photos?.after ?? [])];
|
|
302
|
+
nextTick(() => (prefilling = false));
|
|
303
|
+
loadAreas();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
215
306
|
searchUnits("");
|
|
216
307
|
loadAreas();
|
|
217
308
|
});
|
|
@@ -15,8 +15,10 @@ export default function useInventory() {
|
|
|
15
15
|
receive: "Received",
|
|
16
16
|
issue: "Issued",
|
|
17
17
|
use: "Used",
|
|
18
|
+
use_draft: "Draft",
|
|
18
19
|
return: "Returned",
|
|
19
20
|
adjust: "Stock count",
|
|
21
|
+
handover: "Hand-over",
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
return {
|
|
@@ -29,10 +31,13 @@ export default function useInventory() {
|
|
|
29
31
|
updateStore: (id: string, body: Partial<Pick<TInventoryStore, "name" | "sites" | "showInResidentApp" | "showCostInResidentApp">>) =>
|
|
30
32
|
api<TInventoryStore>(`/stores/${id}`, { method: "PATCH", body }),
|
|
31
33
|
|
|
32
|
-
getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean }) =>
|
|
34
|
+
getItems: (store: string, query: { page?: number; limit?: number; search?: string; lowStock?: boolean; includeInactive?: boolean; usedBy?: string }) =>
|
|
33
35
|
api<{ items: TInventoryItem[]; total: number; lowCount: number; page: number; pages: number }>(`/stores/${store}/items`, { query }),
|
|
34
36
|
createItem: (store: string, body: Record<string, any>) => api(`/stores/${store}/items`, { method: "POST", body }),
|
|
35
37
|
updateItem: (id: string, body: Record<string, any>) => api(`/items/${id}`, { method: "PATCH", body }),
|
|
38
|
+
/** Bulk edit: set "Used by" on several items (store owner). */
|
|
39
|
+
setUsedBy: (store: string, items: string[], usedBy: string[]) =>
|
|
40
|
+
api<{ updated: number }>(`/stores/${store}/items/used-by`, { method: "POST", body: { items, usedBy } }),
|
|
36
41
|
|
|
37
42
|
receive: (store: string, body: Record<string, any>) => api(`/stores/${store}/receive`, { method: "POST", body }),
|
|
38
43
|
count: (store: string, body: { item: string; countedQty: number; reason: string }) =>
|
|
@@ -40,6 +45,13 @@ export default function useInventory() {
|
|
|
40
45
|
issue: (store: string, body: Record<string, any>) => api(`/stores/${store}/issue`, { method: "POST", body }),
|
|
41
46
|
receiveReturn: (store: string, body: Record<string, any>) => api(`/stores/${store}/returns`, { method: "POST", body }),
|
|
42
47
|
recordUsage: (store: string, body: Record<string, any>) => api(`/stores/${store}/usage`, { method: "POST", body }),
|
|
48
|
+
/** 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 } : {} }),
|
|
51
|
+
createUsageDraft: (store: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/stores/${store}/usage-drafts`, { method: "POST", body }),
|
|
52
|
+
updateUsageDraft: (id: string, body: Record<string, any>) => api<TInventoryUsageDraft>(`/usage-drafts/${id}`, { method: "PATCH", body }),
|
|
53
|
+
discardUsageDraft: (id: string) => api(`/usage-drafts/${id}/discard`, { method: "POST" }),
|
|
54
|
+
submitUsageDraft: (id: string, body: { photosAfter?: string[]; date?: string }) => api<TInventoryTransaction>(`/usage-drafts/${id}/submit`, { method: "POST", body }),
|
|
43
55
|
|
|
44
56
|
getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean }) =>
|
|
45
57
|
api<{ items: TInventoryRequest[]; total: number; pendingCount: number; page: number; pages: number }>(`/stores/${store}/requests`, { query }),
|
|
@@ -51,17 +63,28 @@ export default function useInventory() {
|
|
|
51
63
|
|
|
52
64
|
getHoldings: (store: string, holder: string = "me", includeUsedUp = false) =>
|
|
53
65
|
api<TInventoryHolding[]>(`/stores/${store}/holdings`, { query: { holder, ...(includeUsedUp ? { includeUsedUp: true } : {}) } }),
|
|
66
|
+
/** Give to colleague. */
|
|
67
|
+
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 }),
|
|
69
|
+
createHandover: (store: string, body: { site: string; item: string; to: string; qty: number; note?: string; photos?: string[] }) =>
|
|
70
|
+
api<TInventoryHandover>(`/stores/${store}/handovers`, { method: "POST", body }),
|
|
71
|
+
acceptHandover: (id: string) => api(`/handovers/${id}/accept`, { method: "POST" }),
|
|
72
|
+
declineHandover: (id: string, reason = "") => api(`/handovers/${id}/decline`, { method: "POST", body: { reason } }),
|
|
73
|
+
cancelHandover: (id: string) => api(`/handovers/${id}/cancel`, { method: "POST" }),
|
|
74
|
+
getColleagues: (store: string, search = "") =>
|
|
75
|
+
api<{ _id: string; name: string; company?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/colleagues`, { query: { search } }),
|
|
54
76
|
getTransactions: (store: string, query: Record<string, any>) =>
|
|
55
77
|
api<{ items: TInventoryTransaction[]; total: number; page: number; pages: number }>(`/stores/${store}/transactions`, { query }),
|
|
56
78
|
|
|
57
79
|
getUnits: (store: string, site: string, search = "") =>
|
|
58
80
|
api<{ _id: string; name: string; tenantName: string }[]>(`/stores/${store}/units`, { query: { site, search } }),
|
|
59
81
|
getCommonAreas: (store: string, site: string) => api<string[]>(`/stores/${store}/common-areas`, { query: { site } }),
|
|
60
|
-
getPeople: (store: string, search = "") =>
|
|
82
|
+
getPeople: (store: string, search = "") =>
|
|
83
|
+
api<{ _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/people`, { query: { search } }),
|
|
61
84
|
|
|
62
|
-
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases", query: TInventoryPeriod &
|
|
63
|
-
api<{ rows: any[]; totalQty?: number; totalCost?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
64
|
-
exportReport: (store: string, report: string, query: TInventoryPeriod &
|
|
85
|
+
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod & Record<string, any>) =>
|
|
86
|
+
api<{ rows: any[]; totalQty?: number; totalCost?: number; scope?: "all" | "own_company"; staleAfterDays?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
87
|
+
exportReport: (store: string, report: string, query: TInventoryPeriod & Record<string, any>) =>
|
|
65
88
|
api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
|
|
66
89
|
checkUsage: (store: string, ids: string[], checked: boolean) =>
|
|
67
90
|
api<{ updated: number }>(`/stores/${store}/usage/check`, { method: "POST", body: { ids, checked } }),
|
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.73.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
|
@@ -23,6 +23,13 @@ declare type TInventoryStore = {
|
|
|
23
23
|
showInResidentApp: boolean;
|
|
24
24
|
showCostInResidentApp: boolean;
|
|
25
25
|
access: TInventoryAccess;
|
|
26
|
+
/** The property manager's store: the site's main store, which providers request from. */
|
|
27
|
+
main?: boolean;
|
|
28
|
+
/** The caller belongs to the organisation that owns (manages) this store. */
|
|
29
|
+
owner?: boolean;
|
|
30
|
+
ownerService?: string | null;
|
|
31
|
+
/** "Property management", "M&E", ... */
|
|
32
|
+
ownerServiceLabel?: string;
|
|
26
33
|
};
|
|
27
34
|
|
|
28
35
|
declare type TInventoryItem = {
|
|
@@ -40,6 +47,8 @@ declare type TInventoryItem = {
|
|
|
40
47
|
onHand: number;
|
|
41
48
|
status: "active" | "inactive";
|
|
42
49
|
lowStock: boolean;
|
|
50
|
+
/** Main store: which service providers see and may request it (["all"] or service keys). */
|
|
51
|
+
usedBy?: string[];
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
declare type TInventoryLine = { item: string; qty: number };
|
|
@@ -73,6 +82,11 @@ declare type TInventoryHolding = {
|
|
|
73
82
|
packing?: string;
|
|
74
83
|
/** Used by this holder since the start of the month (Singapore time). */
|
|
75
84
|
usedThisMonth?: number;
|
|
85
|
+
holderLabel?: string;
|
|
86
|
+
/** How many sit in the holder's own usage drafts (not deducted until submitted), and what is still free. */
|
|
87
|
+
inDrafts?: number;
|
|
88
|
+
draftCount?: number;
|
|
89
|
+
free?: number;
|
|
76
90
|
};
|
|
77
91
|
|
|
78
92
|
declare type TInventoryTransaction = {
|
|
@@ -80,7 +94,7 @@ declare type TInventoryTransaction = {
|
|
|
80
94
|
item: string;
|
|
81
95
|
itemName: string;
|
|
82
96
|
unit: string;
|
|
83
|
-
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust";
|
|
97
|
+
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover" | "use_draft";
|
|
84
98
|
qty: number;
|
|
85
99
|
storeChange: number;
|
|
86
100
|
storeBalance: number;
|
|
@@ -88,7 +102,7 @@ declare type TInventoryTransaction = {
|
|
|
88
102
|
holderChange?: number;
|
|
89
103
|
holderBalance?: number;
|
|
90
104
|
site?: string;
|
|
91
|
-
location?: { type: "common_area" | "tenant_unit"; area?: string; unitName?: string; tenantName?: string };
|
|
105
|
+
location?: { type: "common_area" | "tenant_unit"; area?: string; unit?: string; unitName?: string; tenantName?: string };
|
|
92
106
|
reference?: string;
|
|
93
107
|
supplier?: string;
|
|
94
108
|
unitCost: number;
|
|
@@ -100,9 +114,20 @@ declare type TInventoryTransaction = {
|
|
|
100
114
|
receivedBy?: string;
|
|
101
115
|
date: string;
|
|
102
116
|
createdByName: string;
|
|
117
|
+
holder?: string;
|
|
118
|
+
holderLabel?: string;
|
|
119
|
+
createdByLabel?: string;
|
|
120
|
+
/** Usage saved as a draft first. */
|
|
121
|
+
draftedAt?: string;
|
|
122
|
+
photosBeforeAt?: string | null;
|
|
123
|
+
photosAfterAt?: string | null;
|
|
124
|
+
submittedAt?: string;
|
|
103
125
|
};
|
|
104
126
|
|
|
105
|
-
|
|
127
|
+
/** A usage record saved with its before photo, waiting for the after photo (no stock deducted yet). */
|
|
128
|
+
declare type TInventoryUsageDraft = TInventoryTransaction & { type: "use_draft"; stale: boolean; mine: boolean; clientRef?: string };
|
|
129
|
+
|
|
130
|
+
declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string; usedBy?: string };
|
|
106
131
|
|
|
107
132
|
/** An organisation's supplier (shared by its stores). Only the name is required. */
|
|
108
133
|
declare type TInventorySupplier = {
|
|
@@ -116,3 +141,26 @@ declare type TInventorySupplier = {
|
|
|
116
141
|
notes: string;
|
|
117
142
|
status: "active" | "inactive";
|
|
118
143
|
};
|
|
144
|
+
|
|
145
|
+
/** Give to colleague: stock in hand passed to a colleague, pending until they confirm. */
|
|
146
|
+
declare type TInventoryHandover = {
|
|
147
|
+
_id: string;
|
|
148
|
+
store: string;
|
|
149
|
+
site: string;
|
|
150
|
+
item: string;
|
|
151
|
+
itemName: string;
|
|
152
|
+
unit: string;
|
|
153
|
+
qty: number;
|
|
154
|
+
from: string;
|
|
155
|
+
fromName: string;
|
|
156
|
+
fromLabel?: string;
|
|
157
|
+
to: string;
|
|
158
|
+
toName: string;
|
|
159
|
+
toLabel?: string;
|
|
160
|
+
note: string;
|
|
161
|
+
photos: string[];
|
|
162
|
+
status: "pending" | "accepted" | "declined" | "cancelled";
|
|
163
|
+
declineReason?: string;
|
|
164
|
+
createdAt: string;
|
|
165
|
+
decidedAt?: string;
|
|
166
|
+
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* same kind (e.g. staff of several organisations at one property), each one
|
|
5
5
|
* also names its organisation so the buttons can be told apart.
|
|
6
6
|
*/
|
|
7
|
-
type TStoreLike = { ownerType: string; orgName?: string };
|
|
7
|
+
type TStoreLike = { ownerType: string; orgName?: string; main?: boolean; owner?: boolean; ownerServiceLabel?: string };
|
|
8
8
|
|
|
9
9
|
/** "Supplies Winsland House I and Winsland House II" for a store that supplies several sites. */
|
|
10
10
|
export function inventorySuppliesText(names: string[]) {
|
|
@@ -14,11 +14,24 @@ export function inventorySuppliesText(names: string[]) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function inventoryStoreLabel(store: TStoreLike, all: TStoreLike[]) {
|
|
17
|
+
// Owner rule 2026-09-29: the property manager's store is the site's MAIN store,
|
|
18
|
+
// named with who runs it; a provider's own store is "Our store".
|
|
19
|
+
if (store.main !== undefined) {
|
|
20
|
+
if (store.main) return store.orgName ? `Main store · ${store.orgName}` : "Main store";
|
|
21
|
+
const own = all.filter((s) => !s.main).length;
|
|
22
|
+
return own > 1 && store.orgName ? `Our store · ${store.orgName}` : "Our store";
|
|
23
|
+
}
|
|
17
24
|
const kind = store.ownerType === "property" ? "Property store" : "Our store";
|
|
18
25
|
const sameKind = all.filter((s) => s.ownerType === store.ownerType).length;
|
|
19
26
|
return sameKind > 1 && store.orgName ? `${kind} · ${store.orgName}` : kind;
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
/** "Managed by Management Pte Ltd (Property management)" — who runs the store. */
|
|
30
|
+
export function inventoryManagedBy(store: TStoreLike) {
|
|
31
|
+
if (!store.orgName) return "";
|
|
32
|
+
return store.ownerServiceLabel ? `Managed by ${store.orgName} (${store.ownerServiceLabel})` : `Managed by ${store.orgName}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
/**
|
|
23
36
|
* The properties a store can be set to supply, by name. The organisation's own
|
|
24
37
|
* site list does not always contain the store's current property (a manager
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain words for usage drafts, usage history and the store owner's usage
|
|
3
|
+
* tracking (owner 2026-09-29). Used by the Record Usage dialog, My Stock and
|
|
4
|
+
* the Usage report. Pure, so it is tested without a browser.
|
|
5
|
+
*/
|
|
6
|
+
type TLocation = { type?: string; area?: string; unitName?: string; tenantName?: string } | undefined;
|
|
7
|
+
type TDraft = { location?: TLocation; photos?: { before?: string[]; after?: string[] }; photosBeforeAt?: string | null; stale?: boolean };
|
|
8
|
+
type TStep = { type: "issued" | "handed_over"; to: string; by: string; date: string };
|
|
9
|
+
|
|
10
|
+
export const USAGE_FLAG_WORDS: Record<string, string> = {
|
|
11
|
+
no_work_order: "No work order",
|
|
12
|
+
old_draft: "Draft waiting too long",
|
|
13
|
+
above_typical: "More than usual",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const USAGE_STATUS_CHOICES = [
|
|
17
|
+
{ value: "submitted", title: "Submitted" },
|
|
18
|
+
{ value: "draft", title: "In progress (drafts)" },
|
|
19
|
+
{ value: "all", title: "All" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** "Unit Tower A 05-12 (Tenant Co)" or the common area as typed. */
|
|
23
|
+
export function whereText(location: TLocation) {
|
|
24
|
+
if (!location) return "";
|
|
25
|
+
if (location.type === "tenant_unit") return `Unit ${location.unitName ?? ""}${location.tenantName ? ` (${location.tenantName})` : ""}`.trim();
|
|
26
|
+
return location.area ?? "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** "Before photo taken 10:32 · Unit 05-12 · Add the after photo to finish" — the draft card. */
|
|
30
|
+
export function draftCardText(d: TDraft, time: (iso: string) => string, staleDays = 3) {
|
|
31
|
+
const taken = d.photosBeforeAt ? `Before photo taken ${time(d.photosBeforeAt)}` : "Before photo taken";
|
|
32
|
+
const next = d.photos?.after?.length ? "Ready to submit" : "Add the after photo to finish";
|
|
33
|
+
return {
|
|
34
|
+
line: [taken, whereText(d.location), next].filter(Boolean).join(" · "),
|
|
35
|
+
warning: d.stale ? `Waiting more than ${staleDays} days — finish or discard it` : "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "Issued to Ali by Keeper (29/09) → Handed to Bala by Ali (29/09)". */
|
|
40
|
+
export function usagePathText(steps: TStep[] | undefined, day: (iso: string) => string) {
|
|
41
|
+
if (!steps?.length) return "";
|
|
42
|
+
return steps.map((s) => `${s.type === "issued" ? "Issued to" : "Handed to"} ${s.to} by ${s.by} (${day(s.date)})`).join(" → ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Query for the report/export: empty filters are left out, so the server applies no filter. */
|
|
46
|
+
export function usageQuery(filters: Record<string, string | boolean | undefined | null>) {
|
|
47
|
+
return Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== "" && v !== undefined && v !== null && v !== false));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Totals per item / person / company, of submitted records only (drafts are not usage yet). */
|
|
51
|
+
export function usageTotals(rows: { status?: string; qty: number; totalCost?: number; unit?: string; itemName?: string; holderLabel?: string; technician?: string; holderCompany?: string }[]) {
|
|
52
|
+
const group = (label: (r: (typeof rows)[number]) => string) => {
|
|
53
|
+
const map = new Map<string, { label: string; records: number; qty: number; totalCost: number; unit: string }>();
|
|
54
|
+
for (const r of rows) {
|
|
55
|
+
if (r.status === "draft") continue;
|
|
56
|
+
const k = label(r);
|
|
57
|
+
const t = map.get(k) ?? { label: k, records: 0, qty: 0, totalCost: 0, unit: r.unit ?? "" };
|
|
58
|
+
t.records++;
|
|
59
|
+
t.qty += r.qty;
|
|
60
|
+
t.totalCost = Math.round((t.totalCost + (r.totalCost ?? 0)) * 100) / 100;
|
|
61
|
+
// Different units are not added up in words.
|
|
62
|
+
if (t.unit !== (r.unit ?? "")) t.unit = "";
|
|
63
|
+
map.set(k, t);
|
|
64
|
+
}
|
|
65
|
+
return [...map.values()].sort((a, b) => b.records - a.records || a.label.localeCompare(b.label));
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
byItem: group((r) => r.itemName ?? ""),
|
|
69
|
+
byPerson: group((r) => r.holderLabel || r.technician || ""),
|
|
70
|
+
byCompany: group((r) => r.holderCompany || "(company not known)"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a history row says happened, from the person's side. */
|
|
75
|
+
export function historyText(t: { type: string; holderChange?: number }) {
|
|
76
|
+
switch (t.type) {
|
|
77
|
+
case "issue":
|
|
78
|
+
return "Taken from store";
|
|
79
|
+
case "return":
|
|
80
|
+
return "Given back to store";
|
|
81
|
+
case "handover":
|
|
82
|
+
return (t.holderChange ?? 0) < 0 ? "Given to colleague" : "Received from colleague";
|
|
83
|
+
case "use":
|
|
84
|
+
return "Used";
|
|
85
|
+
case "opening":
|
|
86
|
+
return "Opening stock";
|
|
87
|
+
case "receive":
|
|
88
|
+
return "Received into store";
|
|
89
|
+
case "adjust":
|
|
90
|
+
return "Stock count";
|
|
91
|
+
default:
|
|
92
|
+
return t.type;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Used by" (owner 2026-09-29): which service providers see and may request an
|
|
3
|
+
* item from the main store. Mirrors API-core `utils/inventory-used-by.ts`.
|
|
4
|
+
* Browser-safe, no Node globals.
|
|
5
|
+
*/
|
|
6
|
+
export const USED_BY_ALL = "all";
|
|
7
|
+
export const USED_BY_ALL_LABEL = "All service providers";
|
|
8
|
+
|
|
9
|
+
export const USED_BY_OPTIONS = [
|
|
10
|
+
{ value: "cleaning", title: "Cleaning" },
|
|
11
|
+
{ value: "security", title: "Security" },
|
|
12
|
+
{ value: "mne", title: "M&E" },
|
|
13
|
+
{ value: "pest", title: "Pest control" },
|
|
14
|
+
{ value: "pool", title: "Pool" },
|
|
15
|
+
{ value: "landscape", title: "Landscape" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const USED_BY_HINT = "Choose who can see and request this item. Leave as All if everyone uses it.";
|
|
19
|
+
|
|
20
|
+
/** Stored/sent value, cleaned: missing, empty or containing "all" means all. */
|
|
21
|
+
export function normaliseUsedBy(value?: readonly string[] | null): string[] {
|
|
22
|
+
const list = (value ?? []).map(String);
|
|
23
|
+
if (!list.length || list.includes(USED_BY_ALL)) return [USED_BY_ALL];
|
|
24
|
+
const keys = USED_BY_OPTIONS.map((o) => o.value).filter((k) => list.includes(k));
|
|
25
|
+
return keys.length ? keys : [USED_BY_ALL];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Chips for one item: nothing for "all" (the usual case says nothing), else one word per service. */
|
|
29
|
+
export function usedByChips(value?: readonly string[] | null): string[] {
|
|
30
|
+
const keys = normaliseUsedBy(value);
|
|
31
|
+
if (keys[0] === USED_BY_ALL) return [];
|
|
32
|
+
return keys.map((k) => USED_BY_OPTIONS.find((o) => o.value === k)!.title);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "All service providers" / "Cleaning, M&E" — for a table cell or a sentence. */
|
|
36
|
+
export function usedByText(value?: readonly string[] | null) {
|
|
37
|
+
const chips = usedByChips(value);
|
|
38
|
+
return chips.length ? chips.join(", ") : USED_BY_ALL_LABEL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The multi-select's model <-> the stored value. Picking a service clears
|
|
43
|
+
* "All"; picking "All" clears the services; clearing everything means All.
|
|
44
|
+
*/
|
|
45
|
+
export function nextUsedBy(previous: readonly string[], picked: readonly string[]): string[] {
|
|
46
|
+
const added = picked.filter((p) => !previous.includes(p));
|
|
47
|
+
if (added.includes(USED_BY_ALL) || !picked.length) return [USED_BY_ALL];
|
|
48
|
+
const services = picked.filter((p) => p !== USED_BY_ALL);
|
|
49
|
+
return services.length ? services : [USED_BY_ALL];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Filter choices for the owner's stock list and reports. */
|
|
53
|
+
export const USED_BY_FILTERS = [
|
|
54
|
+
{ value: "", title: "Everyone's items" },
|
|
55
|
+
{ value: USED_BY_ALL, title: "For all providers only" },
|
|
56
|
+
...USED_BY_OPTIONS.map((o) => ({ value: o.value, title: `Offered to ${o.title}` })),
|
|
57
|
+
];
|