@7365admin1/layer-common 4.90.2-staging.495 → 4.90.2-staging.497

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.
@@ -60,8 +60,9 @@
60
60
  </template>
61
61
 
62
62
  <!-- Colleagues' open drafts (same company): any technician can take one over and finish it from their own stock. -->
63
- <template v-if="colleagueDrafts.length">
63
+ <template v-if="colleagueDraftsOffered">
64
64
  <div class="text-subtitle-2 mb-2">Colleagues' jobs in progress ({{ colleagueDrafts.length }})</div>
65
+ <div v-if="!colleagueDrafts.length" class="text-body-2 text-medium-emphasis mb-3">No unfinished jobs from your colleagues right now.</div>
65
66
  <AppCard v-for="d in colleagueDrafts" :key="d._id" class="pa-3 mb-2 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-secondary))">
66
67
  <InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
67
68
  <div class="flex-grow-1" style="min-width: 200px">
@@ -93,7 +94,7 @@
93
94
  </div>
94
95
  <div class="d-flex flex-column ga-1">
95
96
  <v-btn v-if="(h.free ?? h.qty) > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
96
- <v-btn v-if="h.qty > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
97
+ <v-btn v-if="freeOf(h) > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openGive(h)">Give to colleague</v-btn>
97
98
  <v-btn v-if="stepsLive && freeOf(h) > 0" class="text-none screen-btn-ghost" variant="outlined" size="small" @click="openReturn(h)">Return to store</v-btn>
98
99
  <v-btn v-if="stepsLive && ctl.recordUsage && freeOf(h) > 0" class="text-none" variant="text" size="small" prepend-icon="mdi-delete-alert-outline" @click="openWriteOff(h)">Write off</v-btn>
99
100
  <v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
@@ -146,7 +147,7 @@
146
147
  </v-card-title>
147
148
  <v-card-text class="screen-modal__body">
148
149
  <p class="text-body-2 text-medium-emphasis mb-3">
149
- {{ give.holding?.itemName }}: you hold {{ qtyUnit(give.holding?.qty, give.holding?.unit) }}. It moves to your colleague once they confirm they received it.
150
+ {{ give.holding?.itemName }}: {{ holdText(give.holding) }}. It moves to your colleague once they confirm they received it.
150
151
  </p>
151
152
  <v-form ref="giveForm" validate-on="blur invalid-input">
152
153
  <InputLabel title="Colleague" required class="mb-1" />
@@ -159,6 +160,7 @@
159
160
  placeholder="Search a name"
160
161
  density="comfortable"
161
162
  no-filter
163
+ no-data-text="No colleague from your company works at this property."
162
164
  :rules="[(v: string) => !!v || 'Choose who you are giving it to']"
163
165
  @update:search="searchColleagues"
164
166
  />
@@ -235,7 +237,7 @@ import useUtils from "../composables/useUtils";
235
237
  import useLocalAuth from "../composables/useLocalAuth";
236
238
  import { inventoryControls } from "../utils/inventory-presets";
237
239
  import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
238
- import { ackText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
240
+ import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
239
241
 
240
242
  const props = defineProps({
241
243
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -334,6 +336,7 @@ async function loadDrafts() {
334
336
  }
335
337
  }
336
338
  const colleagueDrafts = ref<TInventoryUsageDraft[]>([]);
339
+ const colleagueDraftsOffered = computed(() => ctl.value.recordUsage && stepsLive.value);
337
340
  async function loadColleagueDrafts() {
338
341
  // stepsLive = the API knows ?colleagues (an older API ignores it and returns my own, hence the mine/holder filter too).
339
342
  if (!ctl.value.recordUsage || !stepsLive.value) return (colleagueDrafts.value = []);
@@ -97,13 +97,13 @@
97
97
  </template>
98
98
  <template #item.onHand="{ item }">
99
99
  <!-- Owner 2026-10-03: what is requested comes off this number at once (set aside), so everyone sees what is really left. -->
100
- <span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ availableQty(item) }}</span>
101
- <span class="text-medium-emphasis ml-1">{{ item.unit }}</span>
100
+ <span :class="item.lowStock ? 'text-error font-weight-bold' : 'font-weight-medium'">{{ qtyUnit(availableQty(item), item.unit) }}</span>
102
101
  <v-chip v-if="item.lowStock" size="x-small" color="error" variant="tonal" class="ml-2">Low</v-chip>
103
102
  <v-chip v-if="item.status === 'inactive'" size="x-small" variant="tonal" class="ml-2">Not in use</v-chip>
104
103
  <div v-if="item.reserved" class="text-caption text-medium-emphasis">+ {{ item.reserved }} set aside for requests (still on the shelf)</div>
105
104
  <div v-if="item.awaitingReceipt" class="text-caption text-medium-emphasis">{{ item.awaitingReceipt }} handed out, waiting for Received</div>
106
- <div v-if="item.lowStockAlert" class="text-caption text-error" @click.stop>
105
+ <!-- Acknowledge / PO details are for the people who handle the store; a technician sees only the Low chip. -->
106
+ <div v-if="item.lowStockAlert && canAckLow" class="text-caption text-error" @click.stop>
107
107
  {{ lowStockAlertText(item.lowStockAlert, standardFormatDate) }}
108
108
  <a v-if="canAckLow && !item.lowStockAlert.poNumber" href="#" class="text-primary ml-1" @click.prevent.stop="openAck(item)">Acknowledge</a>
109
109
  </div>
@@ -36,6 +36,14 @@
36
36
  >
37
37
  Add
38
38
  </AppButton>
39
+ <AppButton
40
+ v-if="canInviteServiceProvider && siteId"
41
+ variant="ghost"
42
+ data-test="sp-provider-changes"
43
+ @click="providerChange?.openHistory()"
44
+ >
45
+ Provider changes
46
+ </AppButton>
39
47
  <AppButton
40
48
  v-if="canInviteServiceProvider"
41
49
  @click="
@@ -202,6 +210,17 @@
202
210
  the engagement itself, not a stored service-providers record, so
203
211
  the In-Active move (which acts on that record) does not apply.
204
212
  -->
213
+ <v-menu v-else-if="item.source === 'engagement' && canChangeProvider(item)" location="bottom end">
214
+ <template #activator="{ props: menuProps }">
215
+ <v-btn v-bind="menuProps" icon variant="text" density="comfortable" aria-label="Provider actions">
216
+ <v-icon>mdi-dots-vertical</v-icon>
217
+ </v-btn>
218
+ </template>
219
+ <v-list density="compact" min-width="200">
220
+ <v-list-item title="View provider" @click="openProvider(item)" />
221
+ <v-list-item title="Change or end provider" data-test="sp-change-provider" @click="openProviderChange(item)" />
222
+ </v-list>
223
+ </v-menu>
205
224
  <v-btn
206
225
  v-else-if="item.source === 'engagement'"
207
226
  icon
@@ -230,6 +249,12 @@
230
249
  title="View Members"
231
250
  @click="openView(item)"
232
251
  />
252
+ <v-list-item
253
+ v-if="canChangeProvider(item)"
254
+ title="Change or end provider"
255
+ data-test="sp-change-provider"
256
+ @click="openProviderChange(item)"
257
+ />
233
258
  <v-list-item
234
259
  :title="rowActionLabel(item)"
235
260
  :class="{
@@ -720,6 +745,15 @@
720
745
  </v-dialog>
721
746
 
722
747
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
748
+
749
+ <SiteProviderChange
750
+ v-if="siteId"
751
+ ref="providerChange"
752
+ :site-id="siteId"
753
+ :site-name="siteName || site?.name || ''"
754
+ :owner-org-id="String(site?.orgId || '')"
755
+ @changed="loadList()"
756
+ />
723
757
  </template>
724
758
 
725
759
  <script lang="ts" setup>
@@ -998,6 +1032,20 @@ const { data: site } = await useLazyAsyncData(
998
1032
 
999
1033
  const searchText = ref("");
1000
1034
 
1035
+ // Change or end a provider at this site (owner 2026-10-05). The server decides
1036
+ // who may; the menu only offers it to people who may invite providers, on a
1037
+ // live provider row that names its company.
1038
+ const providerChange = ref<{
1039
+ openChange: (row: { from: string; fromName: string }) => void;
1040
+ openHistory: () => void;
1041
+ } | null>(null);
1042
+ function canChangeProvider(item: TableRow) {
1043
+ return Boolean(canInviteServiceProvider.value && props.siteId && listTab.value === "active" && item.serviceProviderOrgId);
1044
+ }
1045
+ function openProviderChange(item: TableRow) {
1046
+ providerChange.value?.openChange({ from: item.serviceProviderOrgId, fromName: item.companyName });
1047
+ }
1048
+
1001
1049
  const createDialog = ref(false);
1002
1050
  const createInviteDialog = ref(false);
1003
1051
  // const loadingInviteRoles = ref(false);
@@ -0,0 +1,356 @@
1
+ <template>
2
+ <!--
3
+ Change provider at a site / change site owner (owner, Harris, 2026-10-05).
4
+
5
+ One dialog to plan the change - the server's preview says, in plain words,
6
+ exactly what will happen before anything does - and one to see the site's
7
+ changes, undo one within 14 days, or cancel one that is still planned.
8
+ Who may do what is decided by the server; a refusal is shown as it says it.
9
+ -->
10
+ <v-dialog v-model="changeOpen" max-width="620" persistent>
11
+ <v-card class="screen-modal" data-test="spc-dialog">
12
+ <v-card-title class="d-flex align-center">
13
+ {{ kind === "owner" ? "Change site owner" : `Change or end ${fromName}` }}
14
+ <v-spacer />
15
+ <v-btn variant="text" icon="mdi-close" class="screen-modal__close" aria-label="Close" :disabled="saving" @click="changeOpen = false" />
16
+ </v-card-title>
17
+ <v-card-text class="screen-modal__body">
18
+ <p class="spc-intro">
19
+ <template v-if="kind === 'owner'">
20
+ {{ siteName || "This site" }} moves to a new owner, who takes charge of all of its records.
21
+ Every service provider at the site keeps working as it is. Only the new owner must already be working at the site
22
+ (invite them first; Seven365 can pick any company).
23
+ </template>
24
+ <template v-else>
25
+ Only {{ preview?.service || "this service" }} at {{ siteName || "this site" }} changes. Other services at the site,
26
+ and {{ fromName }}'s other sites, are not touched.
27
+ </template>
28
+ </p>
29
+
30
+ <label class="spc-label" for="spc-to">{{ kind === "owner" ? "New owner" : "New company (optional)" }}</label>
31
+ <v-select
32
+ id="spc-to"
33
+ v-model="to"
34
+ :items="toItems"
35
+ item-title="title"
36
+ item-value="value"
37
+ density="comfortable"
38
+ variant="outlined"
39
+ class="filter-field"
40
+ :disabled="saving"
41
+ :no-data-text="kind === 'owner' ? 'Invite the new owner to the site first.' : 'Invite the new company to the site first.'"
42
+ data-test="spc-to"
43
+ @update:model-value="refresh"
44
+ />
45
+
46
+ <div class="spc-label">When</div>
47
+ <v-radio-group v-model="when" inline hide-details :disabled="saving" @update:model-value="refresh">
48
+ <v-radio label="Straight away" value="now" />
49
+ <v-radio label="On a date" value="date" />
50
+ </v-radio-group>
51
+ <input
52
+ v-if="when === 'date'"
53
+ v-model="endDate"
54
+ type="date"
55
+ class="spc-date"
56
+ :min="tomorrow"
57
+ aria-label="Change date"
58
+ data-test="spc-date"
59
+ @change="refresh"
60
+ />
61
+ <div v-if="when === 'date'" class="spc-sub">
62
+ Until then both companies work at the site. The change happens at the start of that day.
63
+ </div>
64
+
65
+ <div class="spc-preview" data-test="spc-preview">
66
+ <div class="spc-preview__title">What will happen</div>
67
+ <div v-if="loading" class="spc-sub">Checking...</div>
68
+ <div v-else-if="error" class="spc-error">{{ error }}</div>
69
+ <template v-else-if="preview">
70
+ <div v-if="!preview.allowed" class="spc-error">{{ preview.reason }}</div>
71
+ <ul class="spc-list">
72
+ <li v-for="line in preview.sentences" :key="line">{{ line }}</li>
73
+ </ul>
74
+ </template>
75
+ </div>
76
+ </v-card-text>
77
+ <div class="screen-modal__footer">
78
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="changeOpen = false">Cancel</v-btn>
79
+ <v-btn
80
+ class="screen-btn-primary"
81
+ variant="flat"
82
+ :loading="saving"
83
+ :disabled="!canConfirm"
84
+ data-test="spc-confirm"
85
+ @click="confirm"
86
+ >
87
+ {{ when === "date" ? "Plan this change" : kind === "owner" ? "Change owner" : to ? "Change provider" : "End provider" }}
88
+ </v-btn>
89
+ </div>
90
+ </v-card>
91
+ </v-dialog>
92
+
93
+ <v-dialog v-model="historyOpen" max-width="720">
94
+ <v-card class="screen-modal" data-test="spc-history">
95
+ <v-card-title class="d-flex align-center">
96
+ Provider changes at {{ siteName || "this site" }}
97
+ <v-spacer />
98
+ <v-btn
99
+ v-if="ownerOrgId"
100
+ variant="outlined"
101
+ class="screen-btn-ghost mr-2"
102
+ data-test="spc-change-owner"
103
+ @click="openOwnerChange"
104
+ >
105
+ Change owner
106
+ </v-btn>
107
+ <v-btn variant="text" icon="mdi-close" class="screen-modal__close" aria-label="Close" @click="historyOpen = false" />
108
+ </v-card-title>
109
+ <v-card-text class="screen-modal__body">
110
+ <div v-if="historyLoading" class="spc-sub">Loading...</div>
111
+ <div v-else-if="historyError" class="spc-error">{{ historyError }}</div>
112
+ <div v-else-if="!history.length" class="spc-sub">No changes yet.</div>
113
+ <div v-for="c in history" :key="c._id" class="spc-item" data-test="spc-item">
114
+ <div class="d-flex align-center">
115
+ <strong class="spc-item__title">{{ title(c) }}</strong>
116
+ <v-spacer />
117
+ <span class="spc-chip" :class="`spc-chip--${c.status}`">{{ statusText(c) }}</span>
118
+ </div>
119
+ <div class="spc-sub">{{ whenText(c) }}</div>
120
+ <ul class="spc-list">
121
+ <li v-for="line in c.sentences || []" :key="line">{{ line }}</li>
122
+ </ul>
123
+ <div v-if="c.status === 'failed'" class="spc-error">It could not be done: {{ c.failure }}</div>
124
+ <div class="d-flex justify-end ga-2">
125
+ <v-btn v-if="c.canCancel" size="small" variant="outlined" :loading="busy === c._id" data-test="spc-cancel" @click="cancelChange(c)">
126
+ Cancel this plan
127
+ </v-btn>
128
+ <v-btn v-if="c.canUndo" size="small" variant="outlined" :loading="busy === c._id" data-test="spc-undo" @click="undoChange(c)">
129
+ Undo (until {{ day(c.undoUntil) }})
130
+ </v-btn>
131
+ </div>
132
+ </div>
133
+ </v-card-text>
134
+ </v-card>
135
+ </v-dialog>
136
+
137
+ <Snackbar v-model="snack" :text="snackText" :color="snackColor" />
138
+ </template>
139
+
140
+ <script lang="ts" setup>
141
+ const props = defineProps({
142
+ siteId: { type: String, required: true },
143
+ siteName: { type: String, default: "" },
144
+ /** The site's owner organisation; offers "Change owner" in the history dialog. */
145
+ ownerOrgId: { type: String, default: "" },
146
+ });
147
+ const emit = defineEmits<{ (e: "changed"): void }>();
148
+
149
+ const { previewProviderChange, applyProviderChange, listProviderChanges, undoProviderChange, cancelProviderChange } = useCustomerSite();
150
+
151
+ const changeOpen = ref(false);
152
+ const kind = ref<"provider" | "owner">("provider");
153
+ const from = ref("");
154
+ const fromName = ref("");
155
+ const to = ref<string | null>(null);
156
+ const when = ref<"now" | "date">("now");
157
+ const endDate = ref("");
158
+ const preview = ref<Record<string, any> | null>(null);
159
+ const loading = ref(false);
160
+ const saving = ref(false);
161
+ const error = ref("");
162
+
163
+ const history = ref<Record<string, any>[]>([]);
164
+ const historyOpen = ref(false);
165
+ const historyLoading = ref(false);
166
+ const historyError = ref("");
167
+ const busy = ref("");
168
+
169
+ const snack = ref(false);
170
+ const snackText = ref("");
171
+ const snackColor = ref("success");
172
+
173
+ const tomorrow = computed(() => new Date(Date.now() + 864e5).toISOString().slice(0, 10));
174
+ const replacements = ref<Array<{ org: string; name: string }>>([]);
175
+ const toItems = computed(() => [
176
+ ...(kind.value === "provider" ? [{ title: "No new company yet - just end", value: null }] : []),
177
+ ...replacements.value.map((r) => ({ title: r.name, value: r.org })),
178
+ ]);
179
+ const canConfirm = computed(
180
+ () =>
181
+ !loading.value &&
182
+ !saving.value &&
183
+ Boolean(preview.value?.allowed) &&
184
+ (kind.value === "provider" || Boolean(to.value)) &&
185
+ (when.value === "now" || Boolean(endDate.value)),
186
+ );
187
+
188
+ function message(e: any) {
189
+ return e?.data?.message || e?.message || "Something went wrong. Please try again.";
190
+ }
191
+ function say(text: string, color = "success") {
192
+ snackText.value = text;
193
+ snackColor.value = color;
194
+ snack.value = true;
195
+ }
196
+ function body() {
197
+ return {
198
+ site: props.siteId,
199
+ kind: kind.value,
200
+ from: from.value,
201
+ to: to.value || null,
202
+ endDate: when.value === "date" ? endDate.value || null : null,
203
+ };
204
+ }
205
+
206
+ async function refresh() {
207
+ if (!from.value) return;
208
+ loading.value = true;
209
+ error.value = "";
210
+ try {
211
+ preview.value = await previewProviderChange(body());
212
+ replacements.value = preview.value?.replacements ?? [];
213
+ } catch (e: any) {
214
+ preview.value = null;
215
+ error.value = message(e);
216
+ } finally {
217
+ loading.value = false;
218
+ }
219
+ }
220
+
221
+ function reset() {
222
+ to.value = null;
223
+ when.value = "now";
224
+ endDate.value = "";
225
+ preview.value = null;
226
+ error.value = "";
227
+ replacements.value = [];
228
+ }
229
+
230
+ function openChange(row: { from: string; fromName: string }) {
231
+ reset();
232
+ kind.value = "provider";
233
+ from.value = row.from;
234
+ fromName.value = row.fromName;
235
+ changeOpen.value = true;
236
+ refresh();
237
+ }
238
+
239
+ function openOwnerChange() {
240
+ reset();
241
+ kind.value = "owner";
242
+ from.value = props.ownerOrgId;
243
+ fromName.value = "the owner";
244
+ changeOpen.value = true;
245
+ refresh();
246
+ }
247
+
248
+ async function confirm() {
249
+ saving.value = true;
250
+ try {
251
+ const res = await applyProviderChange(body());
252
+ changeOpen.value = false;
253
+ say(
254
+ res?.status === "scheduled"
255
+ ? "The change is planned. You can cancel it until that day."
256
+ : res?.alreadyDone
257
+ ? "This change was already done."
258
+ : "Done. You can undo it within 14 days from Provider changes.",
259
+ );
260
+ emit("changed");
261
+ if (historyOpen.value) loadHistory();
262
+ } catch (e: any) {
263
+ error.value = message(e);
264
+ } finally {
265
+ saving.value = false;
266
+ }
267
+ }
268
+
269
+ async function loadHistory() {
270
+ historyLoading.value = true;
271
+ historyError.value = "";
272
+ try {
273
+ history.value = (await listProviderChanges(props.siteId)) ?? [];
274
+ } catch (e: any) {
275
+ history.value = [];
276
+ historyError.value = message(e);
277
+ } finally {
278
+ historyLoading.value = false;
279
+ }
280
+ }
281
+
282
+ function openHistory() {
283
+ historyOpen.value = true;
284
+ loadHistory();
285
+ }
286
+
287
+ async function undoChange(c: Record<string, any>) {
288
+ if (!window.confirm(`Undo "${title(c)}"? Everything this change did at ${props.siteName || "the site"} is put back.`)) return;
289
+ busy.value = c._id;
290
+ try {
291
+ const res = await undoProviderChange(c._id);
292
+ say(
293
+ res?.doorAccessToReEnrol
294
+ ? `Undone. ${res.doorAccessToReEnrol} face or card enrolment(s) on the door readers must be added again.`
295
+ : "Undone. Everything is back as it was.",
296
+ );
297
+ emit("changed");
298
+ await loadHistory();
299
+ } catch (e: any) {
300
+ say(message(e), "error");
301
+ } finally {
302
+ busy.value = "";
303
+ }
304
+ }
305
+
306
+ async function cancelChange(c: Record<string, any>) {
307
+ busy.value = c._id;
308
+ try {
309
+ await cancelProviderChange(c._id);
310
+ say("The planned change is cancelled. Nothing changed.");
311
+ await loadHistory();
312
+ } catch (e: any) {
313
+ say(message(e), "error");
314
+ } finally {
315
+ busy.value = "";
316
+ }
317
+ }
318
+
319
+ function title(c: Record<string, any>) {
320
+ if (c.kind === "owner") return `Owner: ${c.fromName} to ${c.toName}`;
321
+ return c.toName ? `${c.fromName} replaced by ${c.toName}` : `${c.fromName} ended`;
322
+ }
323
+ function day(v: unknown) {
324
+ if (!v) return "";
325
+ return new Date(String(v)).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
326
+ }
327
+ function statusText(c: Record<string, any>) {
328
+ return (
329
+ { scheduled: "Planned", applying: "In progress", applied: "Done", undoing: "Undoing", undone: "Undone", cancelled: "Cancelled", failed: "Failed" } as Record<string, string>
330
+ )[c.status] ?? c.status;
331
+ }
332
+ function whenText(c: Record<string, any>) {
333
+ if (c.status === "scheduled") return `Planned for ${day(c.endAt)}`;
334
+ if (c.status === "undone") return `Done ${day(c.appliedAt)}, undone ${day(c.undoneAt)}`;
335
+ if (c.status === "cancelled") return `Cancelled ${day(c.cancelledAt)}`;
336
+ return c.appliedAt ? `Done ${day(c.appliedAt)}` : `Asked ${day(c.createdAt)}`;
337
+ }
338
+
339
+ defineExpose({ openChange, openOwnerChange, openHistory });
340
+ </script>
341
+
342
+ <style scoped>
343
+ .spc-intro { margin: 0 0 12px; color: var(--muted, inherit); }
344
+ .spc-label { display: block; font-weight: 600; margin: 8px 0 4px; }
345
+ .spc-date { border: 1px solid var(--line, #ccc); border-radius: 6px; padding: 8px 10px; color: inherit; background: transparent; margin-top: 4px; }
346
+ .spc-sub { color: var(--muted, inherit); font-size: 13px; margin-top: 4px; }
347
+ .spc-error { color: var(--err, #b00020); margin: 4px 0; }
348
+ .spc-preview { margin-top: 16px; padding: 12px 14px; border: 1px solid var(--line, #ddd); border-radius: 8px; }
349
+ .spc-preview__title { font-weight: 600; margin-bottom: 6px; }
350
+ .spc-list { margin: 4px 0 0 18px; padding: 0; }
351
+ .spc-list li { margin: 3px 0; }
352
+ .spc-item { padding: 12px 0; border-bottom: 1px solid var(--line, #eee); }
353
+ .spc-item__title { font-weight: 600; }
354
+ .spc-chip { font-size: 12px; padding: 2px 10px; border-radius: 999px; border: 1px solid currentColor; }
355
+ .spc-chip--failed { color: var(--err, #b00020); }
356
+ </style>
@@ -86,7 +86,36 @@ async function deleteById(id: string) {
86
86
  );
87
87
  }
88
88
 
89
+ // Change provider at a site / change site owner (owner 2026-10-05).
90
+ type TProviderChangeInput = {
91
+ site: string;
92
+ kind: "provider" | "owner";
93
+ from: string;
94
+ to?: string | null;
95
+ endDate?: string | null;
96
+ };
97
+ function previewProviderChange(body: TProviderChangeInput) {
98
+ return useNuxtApp().$api<Record<string, any>>("/api/customer-sites/provider-changes/preview", { method: "POST", body });
99
+ }
100
+ function applyProviderChange(body: TProviderChangeInput) {
101
+ return useNuxtApp().$api<Record<string, any>>("/api/customer-sites/provider-changes", { method: "POST", body });
102
+ }
103
+ function listProviderChanges(site: string) {
104
+ return useNuxtApp().$api<Record<string, any>[]>("/api/customer-sites/provider-changes", { method: "GET", query: { site } });
105
+ }
106
+ function undoProviderChange(id: string) {
107
+ return useNuxtApp().$api<Record<string, any>>(`/api/customer-sites/provider-changes/${id}/undo`, { method: "POST" });
108
+ }
109
+ function cancelProviderChange(id: string) {
110
+ return useNuxtApp().$api<Record<string, any>>(`/api/customer-sites/provider-changes/${id}/cancel`, { method: "POST" });
111
+ }
112
+
89
113
  return {
114
+ previewProviderChange,
115
+ applyProviderChange,
116
+ listProviderChanges,
117
+ undoProviderChange,
118
+ cancelProviderChange,
90
119
  add,
91
120
  getAll,
92
121
  addViaInvite,
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.495",
5
+ "version": "4.90.2-staging.497",
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.",
@@ -27,6 +27,12 @@ export function qtyUnit(qty: number | string | null | undefined, unit?: string |
27
27
  return `${qty ?? ""} ${word}`.trim();
28
28
  }
29
29
 
30
+ /** "you hold 2 pcs" or, while some still wait for Received, "you hold 2 pcs (2 waiting for you to tap Received)". */
31
+ export function holdText(h: { qty?: number; unit?: string; unacknowledged?: number } | null | undefined) {
32
+ const locked = Math.min(Number(h?.unacknowledged ?? 0), Number(h?.qty ?? 0));
33
+ return `you hold ${qtyUnit(h?.qty ?? 0, h?.unit)}${locked > 0 ? ` (${locked} waiting for you to tap Received)` : ""}`;
34
+ }
35
+
30
36
  /** What can still be requested: `available` from the API, else what is on the shelf. */
31
37
  export function availableQty(item: { onHand: number; available?: number }) {
32
38
  return item.available ?? item.onHand;