@7365admin1/layer-common 4.60.0 → 4.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.61.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 598f6c1: Inventory store switcher: when a person can see several stores of the same kind, each is named with its organisation ("Property store · Margaret Ville"); more than three stores are chosen from a list instead of a row of identical buttons.
8
+
9
+ ## 4.61.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 872164d: Inventory is easier to use: a new store shows only **Add Items** until it has items; the item form explains Unit, Packing, the low-stock alert and unit price; the item picker is labelled; requests read "Waiting for approval" / "Approved" with "N handed over (asked M)" and who decided and when; cancelling a request asks first; item history shows who did each step; Record Usage says "Tenant unit"; the Usage report shows the before/after photos the supervisor checks; "Count diff." reads "Stock count +/-"; a technician's activity says "Taken from store".
14
+
3
15
  ## 4.60.0
4
16
 
5
17
  ### Minor Changes
@@ -1661,11 +1673,11 @@
1661
1673
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1662
1674
  own rule from two endpoints the console already calls, unprojected:
1663
1675
 
1664
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1665
- GET /api/roles/id/:role that membership's role document
1676
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1677
+ GET /api/roles/id/:role that membership's role document
1666
1678
 
1667
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1668
- staff = member.type === "admin" && role.type === "admin"
1679
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1680
+ staff = member.type === "admin" && role.type === "admin"
1669
1681
 
1670
1682
  `role.default` is the marker because it is the only property of a platform
1671
1683
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -46,6 +46,7 @@
46
46
  <th class="text-right">Store</th>
47
47
  <th class="text-right">Balance</th>
48
48
  <th>Details</th>
49
+ <th>By</th>
49
50
  </tr>
50
51
  </thead>
51
52
  <tbody>
@@ -57,9 +58,10 @@
57
58
  </td>
58
59
  <td class="text-right">{{ t.type === "use" ? "" : t.storeBalance }}</td>
59
60
  <td class="text-caption">{{ describe(t) }}</td>
61
+ <td class="text-caption">{{ t.createdByName }}</td>
60
62
  </tr>
61
63
  <tr v-if="!history.length && !loading">
62
- <td colspan="5" class="text-center text-medium-emphasis py-4">Nothing recorded yet.</td>
64
+ <td colspan="6" class="text-center text-medium-emphasis py-4">Nothing recorded yet.</td>
63
65
  </tr>
64
66
  </tbody>
65
67
  </v-table>
@@ -13,11 +13,11 @@
13
13
  <v-row dense>
14
14
  <v-col cols="12" sm="4">
15
15
  <InputLabel title="Unit" required class="mb-1" />
16
- <v-combobox v-model="f.unit" :items="units" :rules="[requiredRule]" density="comfortable" />
16
+ <v-combobox v-model="f.unit" :items="units" :rules="[requiredRule]" density="comfortable" hint="How you count it (pcs, box, can)" persistent-hint />
17
17
  </v-col>
18
18
  <v-col cols="12" sm="4">
19
19
  <InputLabel title="Packing" class="mb-1" />
20
- <v-text-field v-model="f.packing" density="comfortable" placeholder="e.g. 25 pcs / box" maxlength="60" />
20
+ <v-text-field v-model="f.packing" density="comfortable" placeholder="e.g. 25 pcs / box" maxlength="60" hint="How the supplier packs it (info only)" persistent-hint />
21
21
  </v-col>
22
22
  <v-col cols="12" sm="4">
23
23
  <InputLabel title="Item code" class="mb-1" />
@@ -29,11 +29,11 @@
29
29
  </v-col>
30
30
  <v-col cols="12" sm="4">
31
31
  <InputLabel title="Low stock alert at" class="mb-1" />
32
- <v-text-field v-model.number="f.reorderLevel" type="number" min="0" density="comfortable" :rules="[wholeRule]" hint="0 = no alert" persistent-hint />
32
+ <v-text-field v-model.number="f.reorderLevel" type="number" min="0" density="comfortable" :rules="[wholeRule]" hint="Alert when stock falls to this. 0 = no alert" persistent-hint />
33
33
  </v-col>
34
34
  <v-col cols="12" sm="4">
35
35
  <InputLabel title="Unit price" class="mb-1" />
36
- <v-text-field v-model.number="f.unitCost" type="number" min="0" step="0.01" prefix="$" density="comfortable" :rules="[moneyRule]" />
36
+ <v-text-field v-model.number="f.unitCost" type="number" min="0" step="0.01" prefix="$" density="comfortable" :rules="[moneyRule]" hint="Price of one unit" persistent-hint />
37
37
  </v-col>
38
38
  <v-col v-if="!item" cols="12" sm="4">
39
39
  <InputLabel title="Current stock" class="mb-1" />
@@ -39,7 +39,8 @@
39
39
  :items="choices"
40
40
  item-title="name"
41
41
  item-value="_id"
42
- placeholder="Choose item"
42
+ label="Item"
43
+ placeholder="Type to search"
43
44
  density="comfortable"
44
45
  :rules="[requiredRule]"
45
46
  class="flex-grow-1"
@@ -75,7 +76,9 @@
75
76
  />
76
77
  <v-btn v-if="rows.length > 1" icon="mdi-delete-outline" variant="text" aria-label="Remove line" @click="rows.splice(i, 1)" />
77
78
  </div>
78
- <v-btn variant="text" class="text-none mb-4" prepend-icon="mdi-plus" @click="rows.push(blank())">Add another item</v-btn>
79
+ <div class="mb-4">
80
+ <v-btn variant="text" class="text-none" prepend-icon="mdi-plus" @click="rows.push(blank())">Add another item</v-btn>
81
+ </div>
79
82
 
80
83
  <v-row v-if="mode === 'receive'" dense>
81
84
  <v-col cols="12" md="6">
@@ -2,8 +2,20 @@
2
2
  <div class="inventory">
3
3
  <PageHeader :title="store ? store.name : 'Inventory'" :subtitle="subtitle">
4
4
  <template #actions>
5
+ <!-- Many stores (staff of several organisations at one property): a list, not a row of buttons. -->
6
+ <v-select
7
+ v-if="stores.length > 3"
8
+ v-model="storeId"
9
+ :items="stores"
10
+ :item-title="(s: TInventoryStore) => inventoryStoreLabel(s, stores)"
11
+ item-value="_id"
12
+ label="Store"
13
+ density="compact"
14
+ hide-details
15
+ style="min-width: 300px"
16
+ />
5
17
  <v-btn-toggle
6
- v-if="stores.length > 1"
18
+ v-else-if="stores.length > 1"
7
19
  v-model="storeId"
8
20
  mandatory
9
21
  density="comfortable"
@@ -11,7 +23,7 @@
11
23
  divided
12
24
  >
13
25
  <v-btn v-for="s in stores" :key="s._id" :value="s._id" class="text-none">
14
- {{ s.ownerType === "property" ? "Property store" : "Our store" }}
26
+ {{ inventoryStoreLabel(s, stores) }}
15
27
  </v-btn>
16
28
  </v-btn-toggle>
17
29
  </template>
@@ -102,6 +114,7 @@
102
114
  import useInventory from "../composables/useInventory";
103
115
  import useSite from "../composables/useSite";
104
116
  import useUtils from "../composables/useUtils";
117
+ import { inventoryStoreLabel } from "../utils/inventory-store-label";
105
118
 
106
119
  const props = defineProps({
107
120
  orgId: { type: String, required: true },
@@ -144,7 +157,7 @@ const storeSites = computed(() =>
144
157
  const subtitle = computed(() => {
145
158
  if (!store.value) return "";
146
159
  if (storeSites.value.length > 1) return `Supplies ${storeSites.value.map((s) => s.name).join(", ")}`;
147
- return store.value.ownerType === "property" ? "Property store" : "Our own store";
160
+ return inventoryStoreLabel(store.value, stores.value);
148
161
  });
149
162
 
150
163
  const tabs = computed(() => {
@@ -44,7 +44,7 @@
44
44
  <tr v-for="t in activity" :key="t._id">
45
45
  <td class="text-no-wrap">{{ standardFormatDate(t.date) }}</td>
46
46
  <td>{{ t.itemName }}</td>
47
- <td>{{ typeLabels[t.type] }}</td>
47
+ <td>{{ t.type === "issue" ? "Taken from store" : t.type === "return" ? "Given back to store" : typeLabels[t.type] }}</td>
48
48
  <td class="text-right">{{ t.qty }}</td>
49
49
  <td class="text-caption">
50
50
  {{ [t.location?.type === "tenant_unit" ? `${t.location.unitName} ${t.location.tenantName || ""}` : t.location?.area, t.reference].filter(Boolean).join(" · ") }}
@@ -111,6 +111,15 @@
111
111
  <template #item.totalCost="{ value }">{{ money(value) }}</template>
112
112
  <template #item.receivedCost="{ value }">{{ money(value) }}</template>
113
113
  <template #item.usedCost="{ value }">{{ money(value) }}</template>
114
+ <!-- The supervisor checks usage against the technician's before/after photos. -->
115
+ <template #item.photos="{ item }">
116
+ <div v-if="usagePhotos(item).length" class="d-flex ga-1 py-1">
117
+ <a v-for="ph in usagePhotos(item)" :key="ph.id" :href="getFileUrl(ph.id)" target="_blank" rel="noopener" :title="ph.label">
118
+ <v-img :src="getFileUrl(ph.id)" width="36" height="36" cover class="rounded" :alt="ph.label" />
119
+ </a>
120
+ </div>
121
+ <span v-else class="text-medium-emphasis">—</span>
122
+ </template>
114
123
  <template #item.checkedByName="{ item }">
115
124
  <v-chip v-if="item.checkedByName" size="x-small" color="success" variant="tonal">{{ item.checkedByName }}</v-chip>
116
125
  <span v-else class="text-medium-emphasis">—</span>
@@ -125,6 +134,7 @@
125
134
  <script setup lang="ts">
126
135
  import useInventory, { saveBlob } from "../composables/useInventory";
127
136
  import useUtils from "../composables/useUtils";
137
+ import useFile from "../composables/useFile";
128
138
 
129
139
  const props = defineProps({
130
140
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -133,6 +143,11 @@ const props = defineProps({
133
143
 
134
144
  const api = useInventory();
135
145
  const { standardFormatDate } = useUtils();
146
+ const { getFileUrl } = useFile();
147
+ const usagePhotos = (row: any) => [
148
+ ...(row.photos?.before ?? []).map((id: string) => ({ id, label: "Before" })),
149
+ ...(row.photos?.after ?? []).map((id: string) => ({ id, label: "After" })),
150
+ ];
136
151
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
137
152
 
138
153
  const now = new Date();
@@ -174,7 +189,7 @@ const headers: Record<string, any[]> = {
174
189
  { title: "Purchase cost", key: "receivedCost" },
175
190
  { title: "Issued", key: "issued" },
176
191
  { title: "Returned", key: "returned" },
177
- { title: "Count diff.", key: "adjusted" },
192
+ { title: "Stock count +/-", key: "adjusted" },
178
193
  { title: "End", key: "closing" },
179
194
  { title: "Used", key: "usedByTechnicians" },
180
195
  { title: "Usage cost", key: "usedCost" },
@@ -185,11 +200,12 @@ const headers: Record<string, any[]> = {
185
200
  { title: "Where", key: "locationType" },
186
201
  { title: "Unit / Area", key: "location" },
187
202
  { title: "Tenant", key: "tenantName" },
188
- { title: "Item", key: "itemName" },
203
+ { title: "Item", key: "itemName", minWidth: "180px" },
189
204
  { title: "Qty", key: "qty" },
190
205
  { title: "Unit price", key: "unitCost" },
191
206
  { title: "Total", key: "totalCost" },
192
207
  { title: "Technician", key: "technician" },
208
+ { title: "Photos", key: "photos", sortable: false },
193
209
  { title: "Reference", key: "reference" },
194
210
  { title: "Reported by", key: "reportedBy" },
195
211
  { title: "Checked by", key: "checkedByName" },
@@ -3,7 +3,7 @@
3
3
  <div class="d-flex flex-wrap ga-2 align-center mb-3">
4
4
  <v-btn-toggle v-model="status" mandatory density="comfortable" variant="outlined" divided>
5
5
  <v-btn value="pending" class="text-none">Waiting</v-btn>
6
- <v-btn value="issued" class="text-none">Issued</v-btn>
6
+ <v-btn value="issued" class="text-none">Approved</v-btn>
7
7
  <v-btn value="rejected" class="text-none">Rejected</v-btn>
8
8
  <v-btn value="" class="text-none">All</v-btn>
9
9
  </v-btn-toggle>
@@ -15,7 +15,7 @@
15
15
 
16
16
  <v-progress-linear v-if="loading" indeterminate color="primary" />
17
17
  <div v-else-if="!requests.length" class="text-center text-medium-emphasis py-10">
18
- {{ status === "pending" ? "No requests waiting." : "No requests to show." }}
18
+ {{ status === "pending" ? (store.access.canApprove ? "No requests waiting for approval." : "You have no requests waiting. Use Request Stock to ask the store for items.") : "No requests to show." }}
19
19
  </div>
20
20
 
21
21
  <AppCard v-for="r in requests" :key="r._id" class="pa-4 mb-3">
@@ -30,11 +30,11 @@
30
30
  </template>
31
31
  </div>
32
32
  <div v-for="l in r.lines" :key="String(l.item)" class="text-body-2">
33
- {{ l.itemName }} — {{ r.status === "issued" ? `${l.issuedQty} of ${l.qty}` : l.qty }} {{ l.unit }}
33
+ {{ requestLineText(r.status, l) }}
34
34
  </div>
35
35
  <div v-if="r.reference || r.remarks" class="text-caption text-medium-emphasis mt-1">{{ [r.reference, r.remarks].filter(Boolean).join(" · ") }}</div>
36
36
  <div v-if="r.decidedByName" class="text-caption mt-1">
37
- {{ r.status === "issued" ? "Issued" : "Rejected" }} by {{ r.decidedByName }}{{ r.decisionRemarks ? `: ${r.decisionRemarks}` : "" }}
37
+ {{ requestOutcomeText(r, standardFormatDateTime) }}
38
38
  </div>
39
39
  </AppCard>
40
40
 
@@ -86,6 +86,7 @@
86
86
  <script setup lang="ts">
87
87
  import useInventory from "../composables/useInventory";
88
88
  import useUtils from "../composables/useUtils";
89
+ import { REQUEST_STATUS, requestLineText, requestOutcomeText } from "../utils/inventory-request-text";
89
90
  import useLocalAuth from "../composables/useLocalAuth";
90
91
 
91
92
  const props = defineProps({
@@ -99,12 +100,7 @@ const { standardFormatDateTime } = useUtils();
99
100
  const { currentUser } = useLocalAuth();
100
101
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
101
102
 
102
- const chip: Record<string, { label: string; color: string }> = {
103
- pending: { label: "Waiting", color: "warning" },
104
- issued: { label: "Issued", color: "success" },
105
- rejected: { label: "Rejected", color: "error" },
106
- cancelled: { label: "Cancelled", color: "grey" },
107
- };
103
+ const chip = REQUEST_STATUS;
108
104
 
109
105
  const status = ref("pending");
110
106
  const requests = ref<TInventoryRequest[]>([]);
@@ -136,6 +132,7 @@ function changed() {
136
132
  }
137
133
 
138
134
  async function cancel(r: TInventoryRequest) {
135
+ if (!confirm("Cancel this request? The store keeper will no longer see it.")) return;
139
136
  busyId.value = r._id;
140
137
  try {
141
138
  await api.cancelRequest(r._id);
@@ -10,10 +10,10 @@
10
10
  </v-row>
11
11
 
12
12
  <div class="d-flex flex-wrap ga-2 align-center mb-3">
13
- <v-btn v-if="store.access.canManage" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-truck-delivery-outline" @click="open('receive')">
13
+ <v-btn v-if="store.access.canManage && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-truck-delivery-outline" @click="open('receive')">
14
14
  Receive Stock
15
15
  </v-btn>
16
- <v-btn v-if="store.access.canApprove" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
16
+ <v-btn v-if="store.access.canApprove && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
17
17
  Issue Stock
18
18
  </v-btn>
19
19
  <v-btn v-if="store.access.canRequest && !store.access.canManage" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="open('request')">
@@ -21,7 +21,7 @@
21
21
  </v-btn>
22
22
  <v-menu v-if="store.access.canManage">
23
23
  <template #activator="{ props: menu }">
24
- <v-btn v-bind="menu" class="text-none screen-btn-ghost" variant="outlined" append-icon="mdi-chevron-down">Add Items</v-btn>
24
+ <v-btn v-bind="menu" class="text-none" :class="storeEmpty ? 'screen-btn-primary' : 'screen-btn-ghost'" :variant="storeEmpty ? 'flat' : 'outlined'" append-icon="mdi-chevron-down">Add Items</v-btn>
25
25
  </template>
26
26
  <v-list density="compact">
27
27
  <v-list-item prepend-icon="mdi-plus" title="Add one item" @click="editItem(null)" />
@@ -116,6 +116,8 @@ const lowCount = ref(0);
116
116
  const search = ref("");
117
117
  const lowOnly = ref(false);
118
118
  const limit = 20;
119
+ // A new store has nothing to receive or hand out yet: its first step is Add Items.
120
+ const storeEmpty = computed(() => !loading.value && total.value === 0 && !search.value && !lowOnly.value);
119
121
  const pageRange = computed(() => (total.value ? `${(page.value - 1) * limit + 1}-${Math.min(page.value * limit, total.value)} of ${total.value}` : "0 of 0"));
120
122
 
121
123
  const headers = computed(() => [
@@ -45,7 +45,7 @@
45
45
  :items="units"
46
46
  item-title="name"
47
47
  item-value="_id"
48
- label="Unit"
48
+ label="Tenant unit (type the unit no. or tenant name)"
49
49
  placeholder="Search unit or tenant name"
50
50
  density="comfortable"
51
51
  no-filter
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.60.0",
5
+ "version": "4.61.1",
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.",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How a stock request reads to a technician and a store keeper: plain status
3
+ * words, how many were handed over against how many were asked, and who
4
+ * decided and when. Used by `InventoryRequestsTab`.
5
+ */
6
+ type TLine = { itemName: string; unit: string; qty: number; issuedQty: number };
7
+ type TRequest = { status: string; decidedByName?: string; decidedAt?: string; decisionRemarks?: string };
8
+
9
+ export const REQUEST_STATUS: Record<string, { label: string; color: string }> = {
10
+ pending: { label: "Waiting for approval", color: "warning" },
11
+ issued: { label: "Approved", color: "success" },
12
+ rejected: { label: "Rejected", color: "error" },
13
+ cancelled: { label: "Cancelled", color: "grey" },
14
+ };
15
+
16
+ export function requestLineText(status: string, l: TLine) {
17
+ return status === "issued"
18
+ ? `${l.itemName} — ${l.issuedQty} ${l.unit} handed over (asked ${l.qty})`
19
+ : `${l.itemName} — ${l.qty} ${l.unit}`;
20
+ }
21
+
22
+ /** "Approved and handed over by X on <when> — remarks", or "" before a decision. */
23
+ export function requestOutcomeText(r: TRequest, formatWhen: (d: string) => string) {
24
+ if (!r.decidedByName) return "";
25
+ const what = r.status === "issued" ? "Approved and handed over" : "Rejected";
26
+ const when = r.decidedAt ? ` on ${formatWhen(r.decidedAt)}` : "";
27
+ const why = r.decisionRemarks ? ` — ${r.decisionRemarks}` : "";
28
+ return `${what} by ${r.decidedByName}${when}${why}`;
29
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The store switcher's button text. "Property store" / "Our store" is enough
3
+ * when each kind appears once; when a person can see several stores of the
4
+ * same kind (e.g. staff of several organisations at one property), each one
5
+ * also names its organisation so the buttons can be told apart.
6
+ */
7
+ type TStoreLike = { ownerType: string; orgName?: string };
8
+
9
+ export function inventoryStoreLabel(store: TStoreLike, all: TStoreLike[]) {
10
+ const kind = store.ownerType === "property" ? "Property store" : "Our store";
11
+ const sameKind = all.filter((s) => s.ownerType === store.ownerType).length;
12
+ return sameKind > 1 && store.orgName ? `${kind} · ${store.orgName}` : kind;
13
+ }