@7365admin1/layer-common 4.60.0 → 4.61.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.61.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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".
8
+
3
9
  ## 4.60.0
4
10
 
5
11
  ### Minor Changes
@@ -1661,11 +1667,11 @@
1661
1667
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1662
1668
  own rule from two endpoints the console already calls, unprojected:
1663
1669
 
1664
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1665
- GET /api/roles/id/:role that membership's role document
1670
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1671
+ GET /api/roles/id/:role that membership's role document
1666
1672
 
1667
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1668
- staff = member.type === "admin" && role.type === "admin"
1673
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1674
+ staff = member.type === "admin" && role.type === "admin"
1669
1675
 
1670
1676
  `role.default` is the marker because it is the only property of a platform
1671
1677
  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">
@@ -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.0",
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
+ }