@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.
@@ -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">You have {{ holding.qty }} {{ holding.unit }} in hand.</div>
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="holding.qty"
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) <= holding!.qty) || `1 to ${holding!.qty}`]"
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="screen-btn-ghost" variant="outlined" :disabled="saving" @click="show = false">Cancel</v-btn>
95
- <v-btn class="screen-btn-primary" variant="flat" :loading="saving" @click="save">Save usage</v-btn>
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(false);
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 = true;
233
+ saving.value = "submit";
168
234
  try {
169
- await api.recordUsage(props.store._id, {
170
- site: site.value,
171
- item: props.holding.item,
172
- qty: Number(qty.value),
173
- locationType: where.value,
174
- ...(where.value === "tenant_unit" ? { unit: unit.value } : { area: area.value }),
175
- reference: reference.value,
176
- date: pickedDate(date.value),
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 = false;
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 & { unchecked?: boolean }) =>
78
- api<{ rows: any[]; totalQty?: number; totalCost?: number }>(`/stores/${store}/reports/${report}`, { query }),
79
- exportReport: (store: string, report: string, query: TInventoryPeriod & { appSlug?: string }) =>
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.448",
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.",
@@ -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
+ }