@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.
@@ -6,6 +6,41 @@
6
6
  <v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
7
7
  </div>
8
8
 
9
+ <!-- Give to colleague: what waits for me to confirm, and what I gave that waits for them. -->
10
+ <AppCard v-for="h in toConfirm" :key="h._id" class="pa-4 mb-3 d-flex flex-wrap align-center ga-3" style="border-left: 4px solid rgb(var(--v-theme-warning))">
11
+ <v-icon icon="mdi-hand-coin-outline" color="warning" />
12
+ <div class="flex-grow-1">
13
+ <div class="font-weight-medium">{{ h.fromLabel || h.fromName }} is giving you {{ h.qty }} {{ h.unit }} {{ h.itemName }}</div>
14
+ <div class="text-caption text-medium-emphasis">{{ standardFormatDate(h.createdAt) }}{{ h.note ? ` · ${h.note}` : "" }} · Confirm once you have it in hand.</div>
15
+ </div>
16
+ <v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" :loading="acting === h._id + 'd'" @click="decide(h, false)">Decline</v-btn>
17
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" :loading="acting === h._id + 'a'" @click="decide(h, true)">I received it</v-btn>
18
+ </AppCard>
19
+ <AppCard v-for="h in waiting" :key="h._id" class="pa-3 mb-3 d-flex flex-wrap align-center ga-3">
20
+ <v-icon icon="mdi-timer-sand" color="medium-emphasis" />
21
+ <div class="flex-grow-1 text-body-2">Waiting for {{ h.toName }} to confirm {{ h.qty }} {{ h.unit }} {{ h.itemName }}.</div>
22
+ <v-btn class="text-none" variant="text" size="small" :loading="acting === h._id + 'c'" @click="cancelHandover(h)">Cancel</v-btn>
23
+ </AppCard>
24
+
25
+ <!-- Usage drafts: before photo saved, job not finished yet. Nothing is taken off the stock until submitted. -->
26
+ <template v-if="drafts.length">
27
+ <div class="text-subtitle-2 mb-2">My drafts — in progress ({{ drafts.length }})</div>
28
+ <AppCard
29
+ v-for="d in drafts"
30
+ :key="d._id"
31
+ class="pa-3 mb-2 d-flex flex-wrap align-center ga-3"
32
+ :style="{ borderLeft: `4px solid rgb(var(--v-theme-${d.stale ? 'error' : 'info'}))` }"
33
+ >
34
+ <InventoryPhotoThumbs :photos="{ before: d.photos?.before ?? [] }" :before-at="d.photosBeforeAt" :size="44" />
35
+ <div class="flex-grow-1" style="min-width: 200px">
36
+ <div class="font-weight-medium">{{ d.itemName }} × {{ d.qty }} {{ d.unit }}<span v-if="d.reference" class="text-medium-emphasis"> · {{ d.reference }}</span></div>
37
+ <div class="text-body-2">{{ draftCardText(d, standardFormatDateTime, staleDays).line }}</div>
38
+ <div v-if="d.stale" class="text-caption text-error">{{ draftCardText(d, standardFormatDateTime, staleDays).warning }}</div>
39
+ </div>
40
+ <v-btn class="text-none screen-btn-primary" variant="flat" size="small" @click="continueDraft(d)">{{ d.photos?.after?.length ? "Review and submit" : "Add after photo" }}</v-btn>
41
+ </AppCard>
42
+ </template>
43
+
9
44
  <v-progress-linear v-if="loading" indeterminate color="primary" />
10
45
  <AppCard v-else-if="!holdings.length" class="pa-6 text-center">
11
46
  <div class="text-body-1 mb-1">You are not holding any stock.</div>
@@ -22,13 +57,22 @@
22
57
  <div class="flex-grow-1">
23
58
  <div class="font-weight-medium">{{ h.itemName }}</div>
24
59
  <div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
60
+ <div v-if="h.inDrafts" class="text-caption text-medium-emphasis">{{ h.inDrafts }} in drafts · {{ h.free ?? 0 }} free to use</div>
61
+ </div>
62
+ <div class="d-flex flex-column ga-1">
63
+ <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>
64
+ <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>
65
+ <v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-history" @click="openHistory(h)">History</v-btn>
25
66
  </div>
26
- <v-btn v-if="h.qty > 0 && ctl.recordUsage" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
27
67
  </AppCard>
28
68
  </v-col>
29
69
  </v-row>
30
70
 
31
- <div class="text-subtitle-2 mt-6 mb-2">My recent activity</div>
71
+ <div class="d-flex align-center mt-6 mb-2">
72
+ <div class="text-subtitle-2">My recent activity</div>
73
+ <v-spacer />
74
+ <v-btn class="text-none" variant="text" size="small" prepend-icon="mdi-map-marker-check-outline" @click="openMyUsage">My usage — where I used my stock</v-btn>
75
+ </div>
32
76
  <v-table density="compact">
33
77
  <thead>
34
78
  <tr>
@@ -44,10 +88,10 @@
44
88
  <tr v-for="t in activity" :key="t._id">
45
89
  <td class="text-no-wrap">{{ standardFormatDate(t.date) }}</td>
46
90
  <td>{{ t.itemName }}</td>
47
- <td>{{ t.type === "issue" ? "Taken from store" : t.type === "return" ? "Given back to store" : typeLabels[t.type] }}</td>
91
+ <td>{{ activityText(t) }}</td>
48
92
  <td class="text-right">{{ t.qty }}</td>
49
93
  <td class="text-caption">
50
- {{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.reference].filter(Boolean).join(" · ") }}
94
+ {{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.type === "handover" ? t.remarks : t.reference].filter(Boolean).join(" · ") }}
51
95
  </td>
52
96
  <td class="text-right">{{ t.holderBalance }}</td>
53
97
  </tr>
@@ -57,7 +101,56 @@
57
101
  </tbody>
58
102
  </v-table>
59
103
 
60
- <InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :sites="sites" @done="changed" />
104
+ <InventoryUsageDialog v-model="usage.show" :store="store" :site-id="siteId" :holding="usage.holding" :draft="usage.draft" :sites="sites" @done="changed" />
105
+ <InventoryHistoryDialog v-model="history.show" :store="store" :holder="me" :item="history.item" :title="history.title" :usage-only="history.usageOnly" />
106
+ <v-dialog v-model="give.show" max-width="520" persistent>
107
+ <v-card class="screen-modal">
108
+ <v-card-title class="d-flex align-center">
109
+ Give to colleague
110
+ <v-spacer />
111
+ <v-btn icon="mdi-close" variant="text" class="screen-modal__close" aria-label="Close" @click="give.show = false" />
112
+ </v-card-title>
113
+ <v-card-text class="screen-modal__body">
114
+ <p class="text-body-2 text-medium-emphasis mb-3">
115
+ {{ give.holding?.itemName }}: you hold {{ give.holding?.qty }} {{ give.holding?.unit }}. It moves to your colleague once they confirm they received it.
116
+ </p>
117
+ <v-form ref="giveForm">
118
+ <InputLabel title="Colleague" required class="mb-1" />
119
+ <v-autocomplete
120
+ v-model="give.to"
121
+ :items="colleagues"
122
+ :item-title="(p: any) => p.label || p.name"
123
+ item-value="_id"
124
+ :loading="colleaguesLoading"
125
+ placeholder="Search a name"
126
+ density="comfortable"
127
+ no-filter
128
+ :rules="[(v: string) => !!v || 'Choose who you are giving it to']"
129
+ @update:search="searchColleagues"
130
+ />
131
+ <InputLabel title="Quantity" required class="mb-1" />
132
+ <v-text-field
133
+ v-model.number="give.qty"
134
+ type="number"
135
+ min="1"
136
+ density="comfortable"
137
+ :suffix="give.holding?.unit"
138
+ :rules="[giveQtyRule]"
139
+ hint="Counted in the unit the store issues it in."
140
+ persistent-hint
141
+ />
142
+ <InputLabel title="Note (optional)" class="mb-1 mt-2" />
143
+ <v-text-field v-model="give.note" density="comfortable" maxlength="500" placeholder="e.g. for the lift lobby job" />
144
+ <InputLabel title="Photo (optional)" class="mb-1" />
145
+ <InventoryPhotoInput v-model="give.photos" :max="1" />
146
+ </v-form>
147
+ </v-card-text>
148
+ <div class="screen-modal__footer">
149
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="give.saving" @click="give.show = false">Cancel</v-btn>
150
+ <v-btn class="screen-btn-primary" variant="flat" :loading="give.saving" @click="submitGive">Give</v-btn>
151
+ </div>
152
+ </v-card>
153
+ </v-dialog>
61
154
  <InventoryLinesDialog v-model="showRequest" mode="request" :store="store" :site-id="siteId" @done="changed" />
62
155
  </div>
63
156
  </template>
@@ -68,6 +161,7 @@ import useFile from "../composables/useFile";
68
161
  import useUtils from "../composables/useUtils";
69
162
  import useLocalAuth from "../composables/useLocalAuth";
70
163
  import { inventoryControls } from "../utils/inventory-presets";
164
+ import { draftCardText } from "../utils/inventory-usage";
71
165
 
72
166
  const props = defineProps({
73
167
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -80,14 +174,135 @@ const emit = defineEmits(["changed"]);
80
174
  const api = useInventory();
81
175
  const { typeLabels } = api;
82
176
  const { getFileUrl } = useFile();
83
- const { standardFormatDate } = useUtils();
177
+ const { standardFormatDate, standardFormatDateTime } = useUtils();
84
178
  const { currentUser } = useLocalAuth();
85
179
 
86
180
  const holdings = ref<TInventoryHolding[]>([]);
87
181
  const activity = ref<TInventoryTransaction[]>([]);
88
182
  const loading = ref(false);
89
183
  const showRequest = ref(false);
90
- const usage = reactive({ show: false, holding: null as TInventoryHolding | null });
184
+ const usage = reactive({ show: false, holding: null as TInventoryHolding | null, draft: null as TInventoryUsageDraft | null });
185
+ const drafts = ref<TInventoryUsageDraft[]>([]);
186
+ const staleDays = ref(3);
187
+ const history = reactive({ show: false, item: "", title: "", usageOnly: false });
188
+
189
+ function openHistory(h: TInventoryHolding) {
190
+ Object.assign(history, { show: true, item: String(h.item), title: `History — ${h.itemName}`, usageOnly: false });
191
+ }
192
+ function openMyUsage() {
193
+ Object.assign(history, { show: true, item: "", title: "My usage", usageOnly: true });
194
+ }
195
+ function continueDraft(d: TInventoryUsageDraft) {
196
+ // The draft's own item in hand (its qty is reserved there); a stand-in if the list has not loaded it.
197
+ const holding =
198
+ holdings.value.find((h) => String(h.item) === String(d.item)) ??
199
+ ({ _id: d._id, item: d.item, itemName: d.itemName, unit: d.unit, photo: "", holder: "", holderName: "", qty: d.qty, inDrafts: d.qty } as TInventoryHolding);
200
+ Object.assign(usage, { show: true, holding, draft: d });
201
+ }
202
+ async function loadDrafts() {
203
+ if (!ctl.value.recordUsage) return;
204
+ try {
205
+ const res = await api.getUsageDrafts(props.store._id);
206
+ drafts.value = res.items;
207
+ staleDays.value = res.staleAfterDays ?? 3;
208
+ } catch {
209
+ drafts.value = [];
210
+ }
211
+ }
212
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
213
+
214
+ const handovers = ref<TInventoryHandover[]>([]);
215
+ const me = computed(() => String(currentUser.value?._id ?? ""));
216
+ const toConfirm = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.to) === me.value));
217
+ const waiting = computed(() => handovers.value.filter((h) => h.status === "pending" && String(h.from) === me.value));
218
+ const acting = ref("");
219
+
220
+ function activityText(t: TInventoryTransaction) {
221
+ if (t.type === "issue") return "Taken from store";
222
+ if (t.type === "return") return "Given back to store";
223
+ if (t.type === "handover") return (t.holderChange ?? 0) < 0 ? "Given to colleague" : "Received from colleague";
224
+ return typeLabels[t.type];
225
+ }
226
+
227
+ async function loadHandovers() {
228
+ try {
229
+ handovers.value = (await api.getHandovers(props.store._id, { mine: true, status: "pending", limit: 50 })).items;
230
+ } catch {
231
+ handovers.value = [];
232
+ }
233
+ }
234
+
235
+ async function decide(h: TInventoryHandover, accept: boolean) {
236
+ acting.value = h._id + (accept ? "a" : "d");
237
+ try {
238
+ if (accept) await api.acceptHandover(h._id);
239
+ else await api.declineHandover(h._id, "");
240
+ notify(accept ? `You received ${h.qty} ${h.unit} ${h.itemName} from ${h.fromName}.` : `Declined. The ${h.itemName} stays with ${h.fromName}.`);
241
+ changed();
242
+ } catch (error: any) {
243
+ notify(error?.data?.message || "Could not update the hand-over.", "error");
244
+ } finally {
245
+ acting.value = "";
246
+ }
247
+ }
248
+
249
+ async function cancelHandover(h: TInventoryHandover) {
250
+ acting.value = h._id + "c";
251
+ try {
252
+ await api.cancelHandover(h._id);
253
+ notify("Hand-over cancelled.");
254
+ changed();
255
+ } catch (error: any) {
256
+ notify(error?.data?.message || "Could not cancel.", "error");
257
+ } finally {
258
+ acting.value = "";
259
+ }
260
+ }
261
+
262
+ const giveForm = ref();
263
+ const give = reactive({ show: false, holding: null as TInventoryHolding | null, to: "", qty: 1 as number | null, note: "", photos: [] as string[], saving: false });
264
+ const pendingOf = (item: string) => waiting.value.filter((h) => String(h.item) === item).reduce((s, h) => s + h.qty, 0);
265
+ const giveQtyRule = (v: number | null) => {
266
+ if (!v || v < 1 || !Number.isInteger(Number(v))) return "Enter a whole number";
267
+ const free = (give.holding?.qty ?? 0) - pendingOf(String(give.holding?.item ?? ""));
268
+ return Number(v) <= free || `You can give up to ${Math.max(0, free)}`;
269
+ };
270
+ const colleagues = ref<{ _id: string; name: string; label?: string }[]>([]);
271
+ const colleaguesLoading = ref(false);
272
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
273
+ function searchColleagues(q: string) {
274
+ clearTimeout(searchTimer);
275
+ searchTimer = setTimeout(async () => {
276
+ colleaguesLoading.value = true;
277
+ try {
278
+ colleagues.value = await api.getColleagues(props.store._id, q || "");
279
+ } catch {
280
+ colleagues.value = [];
281
+ } finally {
282
+ colleaguesLoading.value = false;
283
+ }
284
+ }, 300);
285
+ }
286
+ function openGive(h: TInventoryHolding) {
287
+ Object.assign(give, { show: true, holding: h, to: "", qty: 1, note: "", photos: [], saving: false });
288
+ searchColleagues("");
289
+ }
290
+ async function submitGive() {
291
+ const { valid } = await giveForm.value.validate();
292
+ if (!valid || !give.holding) return;
293
+ give.saving = true;
294
+ try {
295
+ await api.createHandover(props.store._id, { site: props.siteId, item: String(give.holding.item), to: give.to, qty: Number(give.qty), note: give.note, photos: give.photos });
296
+ const name = colleagues.value.find((c) => c._id === give.to)?.name ?? "your colleague";
297
+ notify(`Waiting for ${name} to confirm.`);
298
+ give.show = false;
299
+ changed();
300
+ } catch (error: any) {
301
+ notify(error?.data?.message || "Could not give it.", "error");
302
+ } finally {
303
+ give.saving = false;
304
+ }
305
+ }
91
306
 
92
307
  async function load() {
93
308
  loading.value = true;
@@ -103,14 +318,16 @@ async function load() {
103
318
  }
104
319
 
105
320
  function use(h: TInventoryHolding) {
106
- Object.assign(usage, { show: true, holding: h });
321
+ Object.assign(usage, { show: true, holding: h, draft: null });
107
322
  }
108
323
 
109
324
  function changed() {
110
325
  load();
326
+ loadHandovers();
327
+ loadDrafts();
111
328
  emit("changed");
112
329
  }
113
330
 
114
- watch(() => props.store._id, load);
115
- onMounted(load);
331
+ watch(() => props.store._id, () => (load(), loadHandovers(), loadDrafts()));
332
+ onMounted(() => (load(), loadHandovers(), loadDrafts()));
116
333
  </script>
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <div v-if="list.length" class="d-flex flex-wrap ga-1 py-1">
3
+ <button v-for="ph in list" :key="ph.id + ph.label" type="button" class="inventory-thumb" :title="ph.title" :aria-label="`Open ${ph.title}`" @click="open = ph">
4
+ <v-img :src="getFileUrl(ph.id)" :width="size" :height="size" cover class="rounded" :alt="ph.title" />
5
+ <span class="inventory-thumb__tag">{{ ph.label }}</span>
6
+ </button>
7
+ </div>
8
+ <span v-else class="text-medium-emphasis">—</span>
9
+ <v-dialog :model-value="!!open" max-width="820" @update:model-value="open = null">
10
+ <v-card v-if="open" class="screen-modal">
11
+ <v-card-title class="d-flex align-center">
12
+ {{ open.title }}
13
+ <v-spacer />
14
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="open = null" />
15
+ </v-card-title>
16
+ <v-card-text>
17
+ <v-img :src="getFileUrl(open.id)" max-height="70vh" contain />
18
+ <div class="d-flex mt-2">
19
+ <a :href="getFileUrl(open.id)" target="_blank" rel="noopener" class="text-body-2">Open full size</a>
20
+ </div>
21
+ </v-card-text>
22
+ </v-card>
23
+ </v-dialog>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import useFile from "../composables/useFile";
28
+ import useUtils from "../composables/useUtils";
29
+
30
+ /** Before / after (and other) photos of a usage or hand-over: small thumbnails, click to enlarge with the time taken. */
31
+ const props = defineProps({
32
+ photos: { type: Object as PropType<{ before?: string[]; after?: string[]; other?: string[] } | undefined>, default: undefined },
33
+ beforeAt: { type: String as PropType<string | null | undefined>, default: null },
34
+ afterAt: { type: String as PropType<string | null | undefined>, default: null },
35
+ size: { type: Number, default: 36 },
36
+ });
37
+ const { getFileUrl } = useFile();
38
+ const { standardFormatDateTime } = useUtils();
39
+ const open = ref<{ id: string; label: string; title: string } | null>(null);
40
+ const at = (d?: string | null) => (d ? ` — taken ${standardFormatDateTime(d)}` : "");
41
+ const list = computed(() => [
42
+ ...(props.photos?.before ?? []).map((id) => ({ id, label: "Before", title: `Before photo${at(props.beforeAt)}` })),
43
+ ...(props.photos?.after ?? []).map((id) => ({ id, label: "After", title: `After photo${at(props.afterAt)}` })),
44
+ ...(props.photos?.other ?? []).map((id) => ({ id, label: "Photo", title: "Photo" })),
45
+ ]);
46
+ </script>
47
+
48
+ <style scoped>
49
+ .inventory-thumb {
50
+ position: relative;
51
+ border: 0;
52
+ padding: 0;
53
+ background: none;
54
+ cursor: zoom-in;
55
+ }
56
+ .inventory-thumb__tag {
57
+ position: absolute;
58
+ left: 2px;
59
+ bottom: 2px;
60
+ font-size: 9px;
61
+ line-height: 1;
62
+ padding: 1px 3px;
63
+ border-radius: 3px;
64
+ background: rgba(0, 0, 0, 0.6);
65
+ color: #fff;
66
+ }
67
+ </style>