@7365admin1/layer-common 4.35.1-staging.448 → 4.35.1-staging.450
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/AccessCardPreviewDialog.vue +286 -50
- package/components/InventoryHistoryDialog.vue +205 -0
- package/components/InventoryMain.vue +23 -2
- package/components/InventoryMyStockTab.vue +63 -8
- package/components/InventoryPhotoThumbs.vue +67 -0
- package/components/InventoryReportsTab.vue +154 -28
- package/components/InventoryUsageDialog.vue +119 -28
- package/composables/useInventory.ts +11 -3
- package/package.json +1 -1
- package/types/inventory.d.ts +18 -2
- package/utils/inventory-usage.ts +94 -0
- package/utils/service-type.ts +30 -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,6 +15,7 @@ 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",
|
|
20
21
|
handover: "Hand-over",
|
|
@@ -44,6 +45,13 @@ export default function useInventory() {
|
|
|
44
45
|
issue: (store: string, body: Record<string, any>) => api(`/stores/${store}/issue`, { method: "POST", body }),
|
|
45
46
|
receiveReturn: (store: string, body: Record<string, any>) => api(`/stores/${store}/returns`, { method: "POST", body }),
|
|
46
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 }),
|
|
47
55
|
|
|
48
56
|
getRequests: (store: string, query: { page?: number; limit?: number; status?: string; mine?: boolean }) =>
|
|
49
57
|
api<{ items: TInventoryRequest[]; total: number; pendingCount: number; page: number; pages: number }>(`/stores/${store}/requests`, { query }),
|
|
@@ -74,9 +82,9 @@ export default function useInventory() {
|
|
|
74
82
|
getPeople: (store: string, search = "") =>
|
|
75
83
|
api<{ _id: string; name: string; orgName: string; email?: string; company?: string; service?: string; serviceLabel?: string; label?: string }[]>(`/stores/${store}/people`, { query: { search } }),
|
|
76
84
|
|
|
77
|
-
getReport: (store: string, report: "movement" | "usage" | "technicians" | "purchases" | "handovers", query: TInventoryPeriod &
|
|
78
|
-
api<{ rows: any[]; totalQty?: number; totalCost?: number }>(`/stores/${store}/reports/${report}`, { query }),
|
|
79
|
-
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>) =>
|
|
80
88
|
api<Blob>(`/stores/${store}/reports/${report}/export`, { query, responseType: "blob" }),
|
|
81
89
|
checkUsage: (store: string, ids: string[], checked: boolean) =>
|
|
82
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.35.1-staging.
|
|
5
|
+
"version": "4.35.1-staging.450",
|
|
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
|
@@ -82,6 +82,11 @@ declare type TInventoryHolding = {
|
|
|
82
82
|
packing?: string;
|
|
83
83
|
/** Used by this holder since the start of the month (Singapore time). */
|
|
84
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;
|
|
85
90
|
};
|
|
86
91
|
|
|
87
92
|
declare type TInventoryTransaction = {
|
|
@@ -89,7 +94,7 @@ declare type TInventoryTransaction = {
|
|
|
89
94
|
item: string;
|
|
90
95
|
itemName: string;
|
|
91
96
|
unit: string;
|
|
92
|
-
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover";
|
|
97
|
+
type: "opening" | "receive" | "issue" | "use" | "return" | "adjust" | "handover" | "use_draft";
|
|
93
98
|
qty: number;
|
|
94
99
|
storeChange: number;
|
|
95
100
|
storeBalance: number;
|
|
@@ -97,7 +102,7 @@ declare type TInventoryTransaction = {
|
|
|
97
102
|
holderChange?: number;
|
|
98
103
|
holderBalance?: number;
|
|
99
104
|
site?: string;
|
|
100
|
-
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 };
|
|
101
106
|
reference?: string;
|
|
102
107
|
supplier?: string;
|
|
103
108
|
unitCost: number;
|
|
@@ -109,8 +114,19 @@ declare type TInventoryTransaction = {
|
|
|
109
114
|
receivedBy?: string;
|
|
110
115
|
date: string;
|
|
111
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;
|
|
112
125
|
};
|
|
113
126
|
|
|
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
|
+
|
|
114
130
|
declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string; usedBy?: string };
|
|
115
131
|
|
|
116
132
|
/** An organisation's supplier (shared by its stores). Only the name is required. */
|
|
@@ -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,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SERVICE PROVIDER'S LINE OF BUSINESS, AS PEOPLE NAME IT.
|
|
3
|
+
*
|
|
4
|
+
* `/api/service-providers` returns the service CODE (`mechanical_electrical_services`),
|
|
5
|
+
* which is a database value, not something to show an operator. This is the same
|
|
6
|
+
* mapping `API-core src/utils/converter.ts spmServiceNewToOld` applies — copied
|
|
7
|
+
* rather than imported, because that is a server package this layer does not
|
|
8
|
+
* depend on.
|
|
9
|
+
*
|
|
10
|
+
* NOT the same labels as `utils/module-applications.ts APPLICATIONS`. Those name
|
|
11
|
+
* the seven role-permission applications ("Mechanical & electrical", "Pest
|
|
12
|
+
* control") and are keyed `mechanical-electrical`, not `*_services`. These are
|
|
13
|
+
* the shorter names the service-provider screens use ("M&E", "Pest Control").
|
|
14
|
+
*
|
|
15
|
+
* An unknown code is returned untouched: a new service type shows as its raw
|
|
16
|
+
* code, which is visibly odd, rather than vanishing from the row.
|
|
17
|
+
*/
|
|
18
|
+
const SERVICE_TYPE_LABELS: Readonly<Record<string, string>> = Object.freeze({
|
|
19
|
+
security_agency: "Security",
|
|
20
|
+
cleaning_services: "Cleaning",
|
|
21
|
+
pool_maintenance_services: "Pool Maintenance",
|
|
22
|
+
pest_control_services: "Pest Control",
|
|
23
|
+
landscaping_services: "Landscape",
|
|
24
|
+
mechanical_electrical_services: "Mechanical & Electrical",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function serviceTypeLabel(service: string | null | undefined): string {
|
|
28
|
+
const code = String(service ?? "");
|
|
29
|
+
return SERVICE_TYPE_LABELS[code] ?? code;
|
|
30
|
+
}
|