@7365admin1/layer-common 4.69.0 → 4.70.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 +22 -4
- package/components/InventoryItemDetail.vue +5 -3
- package/components/InventoryMain.vue +16 -17
- package/components/InventoryMyStockTab.vue +5 -3
- package/components/InventoryReportsTab.vue +9 -6
- package/components/InventoryRequestsTab.vue +4 -2
- package/components/InventoryStockTab.vue +7 -5
- package/components/InventorySupplierField.vue +3 -5
- package/components/InventoryTechniciansTab.vue +3 -1
- package/components/RolePermissionFormCreate.vue +36 -12
- package/components/RolePermissionFormPreviewUpdate.vue +39 -16
- package/components/RolePermissionPresets.vue +62 -0
- package/composables/useCommonPermission.ts +23 -4
- package/composables/useInventory.ts +3 -1
- package/composables/useInventoryPermission.ts +5 -1
- package/constants/permissions.ts +9 -1
- package/package.json +1 -1
- package/types/inventory.d.ts +6 -0
- package/types/permission.d.ts +2 -0
- package/utils/inventory-presets.ts +145 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.70.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ba6869d: Create Role / Edit Role: a refused or failed save now shows "Couldn't save: <reason>" above the buttons (it was printed below the whole module list, out of sight). Catalogue snapshot test ties the dialog catalogues to core's full-catalogue create test.
|
|
8
|
+
|
|
9
|
+
## 4.70.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 13bbaf1: Inventory roles: one permission per job (view stock, request items, record usage, approve requests, issue and receive stock, view reports, inventory admin) with plain names in the role editor, and one-click presets ("Staff: request & use", "Supervisor", "Storekeeper", "Inventory admin"). The Inventory screen shows only the tabs and buttons the role allows.
|
|
14
|
+
|
|
15
|
+
## 4.69.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 3eaaf7e: Inventory supplier picker: one "Add ... as a new supplier" row (it showed twice when nothing matched).
|
|
20
|
+
|
|
3
21
|
## 4.69.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
|
@@ -1837,11 +1855,11 @@
|
|
|
1837
1855
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1838
1856
|
own rule from two endpoints the console already calls, unprojected:
|
|
1839
1857
|
|
|
1840
|
-
|
|
1841
|
-
|
|
1858
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1859
|
+
GET /api/roles/id/:role that membership's role document
|
|
1842
1860
|
|
|
1843
|
-
|
|
1844
|
-
|
|
1861
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1862
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1845
1863
|
|
|
1846
1864
|
`role.default` is the marker because it is the only property of a platform
|
|
1847
1865
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
</div>
|
|
19
19
|
</div>
|
|
20
20
|
|
|
21
|
-
<div class="d-flex flex-wrap ga-2 mb-4" v-if="
|
|
22
|
-
<v-btn class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-pencil-outline" @click="emit('edit', item)">Edit item</v-btn>
|
|
23
|
-
<v-btn class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-counter" @click="counting = !counting">Stock count</v-btn>
|
|
21
|
+
<div class="d-flex flex-wrap ga-2 mb-4" v-if="ctl.editItem || ctl.count">
|
|
22
|
+
<v-btn v-if="ctl.editItem" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-pencil-outline" @click="emit('edit', item)">Edit item</v-btn>
|
|
23
|
+
<v-btn v-if="ctl.count" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-counter" @click="counting = !counting">Stock count</v-btn>
|
|
24
24
|
</div>
|
|
25
25
|
|
|
26
26
|
<v-form v-if="counting" ref="countForm" class="mb-4">
|
|
@@ -78,12 +78,14 @@ import useInventory from "../composables/useInventory";
|
|
|
78
78
|
import useFile from "../composables/useFile";
|
|
79
79
|
import useUtils from "../composables/useUtils";
|
|
80
80
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
81
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
81
82
|
|
|
82
83
|
const show = defineModel<boolean>({ default: false });
|
|
83
84
|
const props = defineProps({
|
|
84
85
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
85
86
|
item: { type: Object as PropType<TInventoryItem | null>, default: null },
|
|
86
87
|
});
|
|
88
|
+
const ctl = computed(() => inventoryControls(props.store.access));
|
|
87
89
|
const emit = defineEmits(["edit", "done"]);
|
|
88
90
|
|
|
89
91
|
const { getTransactions, count, typeLabels } = useInventory();
|
|
@@ -93,22 +93,22 @@
|
|
|
93
93
|
<v-window-item value="stock">
|
|
94
94
|
<InventoryStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
|
|
95
95
|
</v-window-item>
|
|
96
|
-
<v-window-item v-if="
|
|
96
|
+
<v-window-item v-if="ctl.tabs.mine" value="mine">
|
|
97
97
|
<InventoryMyStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
|
|
98
98
|
</v-window-item>
|
|
99
|
-
<v-window-item value="requests">
|
|
99
|
+
<v-window-item v-if="ctl.tabs.requests" value="requests">
|
|
100
100
|
<InventoryRequestsTab :store="store" :site-id="siteId" @changed="refreshCounts" />
|
|
101
101
|
</v-window-item>
|
|
102
|
-
<v-window-item v-if="
|
|
102
|
+
<v-window-item v-if="ctl.tabs.people" value="technicians">
|
|
103
103
|
<InventoryTechniciansTab :store="store" @changed="refreshCounts" />
|
|
104
104
|
</v-window-item>
|
|
105
|
-
<v-window-item v-if="
|
|
105
|
+
<v-window-item v-if="ctl.tabs.reports" value="reports">
|
|
106
106
|
<InventoryReportsTab :store="store" :sites="storeSites" />
|
|
107
107
|
</v-window-item>
|
|
108
|
-
<v-window-item v-if="
|
|
108
|
+
<v-window-item v-if="ctl.tabs.suppliers" value="suppliers">
|
|
109
109
|
<InventorySuppliersTab :store="store" />
|
|
110
110
|
</v-window-item>
|
|
111
|
-
<v-window-item v-if="
|
|
111
|
+
<v-window-item v-if="ctl.tabs.settings" value="settings">
|
|
112
112
|
<InventorySettingsTab :store="store" :org-sites="orgSites" @saved="loadStores" />
|
|
113
113
|
</v-window-item>
|
|
114
114
|
</v-window>
|
|
@@ -124,6 +124,7 @@ import useWorkerTerm from "../composables/useWorkerTerm";
|
|
|
124
124
|
import useSite from "../composables/useSite";
|
|
125
125
|
import useUtils from "../composables/useUtils";
|
|
126
126
|
import { inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
|
|
127
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
127
128
|
|
|
128
129
|
const props = defineProps({
|
|
129
130
|
orgId: { type: String, required: true },
|
|
@@ -156,11 +157,6 @@ function notify(text: string, color = "success") {
|
|
|
156
157
|
}
|
|
157
158
|
provide("inventoryNotify", notify);
|
|
158
159
|
|
|
159
|
-
const isStaff = computed(() => {
|
|
160
|
-
const a = store.value?.access;
|
|
161
|
-
return !!a && (a.canManage || a.canApprove || (a.canView && !a.canRequest));
|
|
162
|
-
});
|
|
163
|
-
|
|
164
160
|
const storeSites = computed(() =>
|
|
165
161
|
(store.value?.sites ?? []).map((id) => ({
|
|
166
162
|
_id: id,
|
|
@@ -183,17 +179,20 @@ const subtitle = computed(() => {
|
|
|
183
179
|
// Who holds this store's stock: the property manager's store lends to service providers' workers.
|
|
184
180
|
const worker = useWorkerTerm({ providers: true });
|
|
185
181
|
|
|
182
|
+
const ctl = computed(() => inventoryControls(store.value?.access));
|
|
183
|
+
|
|
186
184
|
const tabs = computed(() => {
|
|
187
185
|
const a = store.value?.access;
|
|
188
186
|
if (!a) return [];
|
|
189
187
|
return [
|
|
190
188
|
{ value: "stock", label: "Stock" },
|
|
191
|
-
|
|
192
|
-
{ value: "
|
|
193
|
-
...(
|
|
194
|
-
...(
|
|
195
|
-
...(
|
|
196
|
-
...(
|
|
189
|
+
// Hide, never grey: each tab only for the job that uses it (API-core decides `access`).
|
|
190
|
+
...(ctl.value.tabs.mine ? [{ value: "mine", label: "My Stock" }] : []),
|
|
191
|
+
...(ctl.value.tabs.requests ? [{ value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 }] : []),
|
|
192
|
+
...(ctl.value.tabs.people ? [{ value: "technicians", label: worker.many }] : []),
|
|
193
|
+
...(ctl.value.tabs.reports ? [{ value: "reports", label: a.canReports ? "Reports" : "Usage check" }] : []),
|
|
194
|
+
...(ctl.value.tabs.suppliers ? [{ value: "suppliers", label: "Suppliers" }] : []),
|
|
195
|
+
...(ctl.value.tabs.settings ? [{ value: "settings", label: "Settings" }] : []),
|
|
197
196
|
];
|
|
198
197
|
});
|
|
199
198
|
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
<div class="d-flex flex-wrap ga-2 align-center mb-3">
|
|
4
4
|
<div class="text-body-2 text-medium-emphasis">Stock you have taken from the store: how many are left, and how many you used this month.</div>
|
|
5
5
|
<v-spacer />
|
|
6
|
-
<v-btn class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">Request Stock</v-btn>
|
|
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
9
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
10
10
|
<AppCard v-else-if="!holdings.length" class="pa-6 text-center">
|
|
11
11
|
<div class="text-body-1 mb-1">You are not holding any stock.</div>
|
|
12
|
-
<div class="text-body-2 text-medium-emphasis">Request stock from the store. Once approved, it shows here and you can record where you used it
|
|
12
|
+
<div class="text-body-2 text-medium-emphasis">{{ ctl.request ? "Request stock from the store. Once approved, it shows here" : "Stock the storekeeper hands you shows here" }}{{ ctl.recordUsage ? " and you can record where you used it." : "." }}</div>
|
|
13
13
|
</AppCard>
|
|
14
14
|
|
|
15
15
|
<v-row v-else dense>
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<div class="font-weight-medium">{{ h.itemName }}</div>
|
|
24
24
|
<div class="text-body-2">{{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</div>
|
|
25
25
|
</div>
|
|
26
|
-
<v-btn v-if="h.qty > 0" class="text-none screen-btn-primary" variant="flat" size="small" @click="use(h)">Record usage</v-btn>
|
|
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
27
|
</AppCard>
|
|
28
28
|
</v-col>
|
|
29
29
|
</v-row>
|
|
@@ -67,12 +67,14 @@ import useInventory from "../composables/useInventory";
|
|
|
67
67
|
import useFile from "../composables/useFile";
|
|
68
68
|
import useUtils from "../composables/useUtils";
|
|
69
69
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
70
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
70
71
|
|
|
71
72
|
const props = defineProps({
|
|
72
73
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
73
74
|
siteId: { type: String, required: true },
|
|
74
75
|
sites: { type: Array as PropType<{ _id: string; name: string }[]>, default: () => [] },
|
|
75
76
|
});
|
|
77
|
+
const ctl = computed(() => inventoryControls(props.store.access));
|
|
76
78
|
const emit = defineEmits(["changed"]);
|
|
77
79
|
|
|
78
80
|
const api = useInventory();
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
<div>
|
|
3
3
|
<div class="d-flex flex-wrap ga-3 align-center mb-3">
|
|
4
4
|
<v-btn-toggle v-model="report" mandatory density="comfortable" variant="outlined" divided>
|
|
5
|
-
<v-btn value="movement" class="text-none">Stock Movement</v-btn>
|
|
5
|
+
<v-btn v-if="ctl.fullReports" value="movement" class="text-none">Stock Movement</v-btn>
|
|
6
6
|
<v-btn value="usage" class="text-none">Usage</v-btn>
|
|
7
|
-
<v-btn value="technicians" class="text-none">{{ worker.many }}</v-btn>
|
|
8
|
-
<v-btn value="purchases" class="text-none">Purchases</v-btn>
|
|
7
|
+
<v-btn v-if="ctl.fullReports" value="technicians" class="text-none">{{ worker.many }}</v-btn>
|
|
8
|
+
<v-btn v-if="ctl.fullReports" value="purchases" class="text-none">Purchases</v-btn>
|
|
9
9
|
</v-btn-toggle>
|
|
10
10
|
<v-select
|
|
11
11
|
v-if="report === 'purchases'"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
/>
|
|
44
44
|
<v-checkbox v-if="report === 'usage'" v-model="uncheckedOnly" label="Not checked yet" density="compact" hide-details class="flex-grow-0" />
|
|
45
45
|
<v-spacer />
|
|
46
|
-
<v-btn class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
|
|
46
|
+
<v-btn v-if="ctl.fullReports" class="text-none screen-btn-ghost" variant="outlined" prepend-icon="mdi-file-excel-outline" :loading="exporting" @click="exportExcel">
|
|
47
47
|
Export Excel
|
|
48
48
|
</v-btn>
|
|
49
49
|
</div>
|
|
@@ -143,11 +143,13 @@ import useInventory, { saveBlob } from "../composables/useInventory";
|
|
|
143
143
|
import useWorkerTerm, { workerAppKey } from "../composables/useWorkerTerm";
|
|
144
144
|
import useUtils from "../composables/useUtils";
|
|
145
145
|
import useFile from "../composables/useFile";
|
|
146
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
146
147
|
|
|
147
148
|
const props = defineProps({
|
|
148
149
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
149
150
|
sites: { type: Array as PropType<{ _id: string; name: string }[]>, default: () => [] },
|
|
150
151
|
});
|
|
152
|
+
const ctl = computed(() => inventoryControls(props.store.access));
|
|
151
153
|
|
|
152
154
|
const api = useInventory();
|
|
153
155
|
const { standardFormatDate } = useUtils();
|
|
@@ -163,7 +165,8 @@ const appSlug = workerAppKey();
|
|
|
163
165
|
|
|
164
166
|
const now = new Date();
|
|
165
167
|
const month = ref(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
|
|
166
|
-
|
|
168
|
+
// An approver without `view-reports` gets only the usage check screen.
|
|
169
|
+
const report = ref<"movement" | "usage" | "technicians" | "purchases">(ctl.value.fullReports ? "movement" : "usage");
|
|
167
170
|
const span = ref<"month" | "year">("year");
|
|
168
171
|
const site = ref("");
|
|
169
172
|
const locationType = ref("");
|
|
@@ -176,7 +179,7 @@ const loading = ref(false);
|
|
|
176
179
|
const exporting = ref(false);
|
|
177
180
|
const checking = ref<"" | "on" | "off">("");
|
|
178
181
|
|
|
179
|
-
const canCheck = computed(() =>
|
|
182
|
+
const canCheck = computed(() => ctl.value.checkUsage);
|
|
180
183
|
const money = (v: number) => (v === undefined || v === null ? "—" : `$${Number(v).toFixed(2)}`);
|
|
181
184
|
const barWidth = (v: number) => {
|
|
182
185
|
const max = Math.max(...purchases.byMonth.map((m) => m.totalCost), 0);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<v-btn value="" class="text-none">All</v-btn>
|
|
9
9
|
</v-btn-toggle>
|
|
10
10
|
<v-spacer />
|
|
11
|
-
<v-btn v-if="
|
|
11
|
+
<v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">
|
|
12
12
|
Request Stock
|
|
13
13
|
</v-btn>
|
|
14
14
|
</div>
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
<v-chip v-if="store.sites.length > 1 && r.siteName" size="small" variant="outlined" prepend-icon="mdi-office-building-outline">For {{ r.siteName }}</v-chip>
|
|
28
28
|
<v-spacer />
|
|
29
29
|
<template v-if="r.status === 'pending'">
|
|
30
|
-
<v-btn v-if="
|
|
30
|
+
<v-btn v-if="ctl.review" class="text-none screen-btn-primary" variant="flat" @click="openDecision(r)">Review</v-btn>
|
|
31
31
|
<v-btn v-else-if="isMine(r)" class="text-none screen-btn-ghost" variant="outlined" :loading="busyId === r._id" @click="cancel(r)">Cancel</v-btn>
|
|
32
32
|
</template>
|
|
33
33
|
</div>
|
|
@@ -90,11 +90,13 @@ import useInventory from "../composables/useInventory";
|
|
|
90
90
|
import useUtils from "../composables/useUtils";
|
|
91
91
|
import { REQUEST_STATUS, requestLineText, requestOutcomeText } from "../utils/inventory-request-text";
|
|
92
92
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
93
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
93
94
|
|
|
94
95
|
const props = defineProps({
|
|
95
96
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
96
97
|
siteId: { type: String, required: true },
|
|
97
98
|
});
|
|
99
|
+
const ctl = computed(() => inventoryControls(props.store.access));
|
|
98
100
|
const emit = defineEmits(["changed"]);
|
|
99
101
|
|
|
100
102
|
const api = useInventory();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</v-row>
|
|
11
11
|
|
|
12
12
|
<div class="d-flex flex-wrap ga-2 align-center mb-3">
|
|
13
|
-
<v-menu v-if="
|
|
13
|
+
<v-menu v-if="ctl.receive && !storeEmpty">
|
|
14
14
|
<template #activator="{ props: menu }">
|
|
15
15
|
<v-btn v-bind="menu" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-truck-delivery-outline" append-icon="mdi-chevron-down">Receive Stock</v-btn>
|
|
16
16
|
</template>
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
<v-list-item prepend-icon="mdi-file-excel-outline" title="Import deliveries from Excel" @click="showReceiveImport = true" />
|
|
20
20
|
</v-list>
|
|
21
21
|
</v-menu>
|
|
22
|
-
<v-btn v-if="
|
|
22
|
+
<v-btn v-if="ctl.issue && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
|
|
23
23
|
Issue Stock
|
|
24
24
|
</v-btn>
|
|
25
|
-
<v-btn v-if="
|
|
25
|
+
<v-btn v-if="ctl.request" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="open('request')">
|
|
26
26
|
Request Stock
|
|
27
27
|
</v-btn>
|
|
28
|
-
<v-menu v-if="
|
|
28
|
+
<v-menu v-if="ctl.addItems">
|
|
29
29
|
<template #activator="{ props: menu }">
|
|
30
30
|
<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>
|
|
31
31
|
</template>
|
|
@@ -103,12 +103,14 @@ import useInventory from "../composables/useInventory";
|
|
|
103
103
|
import useFile from "../composables/useFile";
|
|
104
104
|
import useUtils from "../composables/useUtils";
|
|
105
105
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
106
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
106
107
|
|
|
107
108
|
const props = defineProps({
|
|
108
109
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
109
110
|
siteId: { type: String, required: true },
|
|
110
111
|
sites: { type: Array as PropType<{ _id: string; name: string }[]>, default: () => [] },
|
|
111
112
|
});
|
|
113
|
+
const ctl = computed(() => inventoryControls(props.store.access));
|
|
112
114
|
const emit = defineEmits(["changed"]);
|
|
113
115
|
|
|
114
116
|
const { getItems } = useInventory();
|
|
@@ -132,7 +134,7 @@ const headers = computed(() => [
|
|
|
132
134
|
{ title: "Item", value: "name" },
|
|
133
135
|
{ title: "In store", value: "onHand" },
|
|
134
136
|
{ title: "Low stock alert at", value: "reorderLevel" },
|
|
135
|
-
...(
|
|
137
|
+
...(ctl.value.unitPrice ? [{ title: "Unit price", value: "unitCost" }] : []),
|
|
136
138
|
]);
|
|
137
139
|
|
|
138
140
|
const money = (v: number) => `$${Number(v).toFixed(2)}`;
|
|
@@ -15,16 +15,14 @@
|
|
|
15
15
|
aria-label="Supplier — pick from your list or type a new one"
|
|
16
16
|
@blur="snap"
|
|
17
17
|
>
|
|
18
|
+
<!-- The Add row lives in #append-item only: it also renders when nothing matches, so a second copy here showed twice. -->
|
|
18
19
|
<template #no-data>
|
|
19
|
-
<v-list-item v-if="typed"
|
|
20
|
-
<v-list-item-title>Add "{{ typed }}" as a new supplier</v-list-item-title>
|
|
21
|
-
</v-list-item>
|
|
22
|
-
<v-list-item v-else>
|
|
20
|
+
<v-list-item v-if="!typed">
|
|
23
21
|
<v-list-item-title class="text-medium-emphasis">No suppliers yet — type a name to add one</v-list-item-title>
|
|
24
22
|
</v-list-item>
|
|
25
23
|
</template>
|
|
26
24
|
<template v-if="typed && !listedSupplierName(typed, names)" #append-item>
|
|
27
|
-
<v-divider />
|
|
25
|
+
<v-divider v-if="names.length" />
|
|
28
26
|
<v-list-item prepend-icon="mdi-plus" @click="model = typed">
|
|
29
27
|
<v-list-item-title>Add "{{ typed }}" as a new supplier</v-list-item-title>
|
|
30
28
|
</v-list-item>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<div class="d-flex align-center mb-2">
|
|
14
14
|
<div class="font-weight-medium">{{ g.holderName || "Unnamed" }}</div>
|
|
15
15
|
<v-spacer />
|
|
16
|
-
<v-btn class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
|
|
16
|
+
<v-btn v-if="ctl.takeReturn" class="text-none screen-btn-ghost" variant="outlined" size="small" prepend-icon="mdi-keyboard-return" @click="openReturn(g)">Receive return</v-btn>
|
|
17
17
|
</div>
|
|
18
18
|
<div class="d-flex flex-wrap ga-2">
|
|
19
19
|
<v-chip v-for="h in g.items" :key="h._id" variant="tonal">{{ h.itemName }}: {{ h.qty }} {{ h.unit }} left · {{ h.usedThisMonth ?? 0 }} used this month</v-chip>
|
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
|
|
35
35
|
<script setup lang="ts">
|
|
36
36
|
import useInventory from "../composables/useInventory";
|
|
37
|
+
import { inventoryControls } from "../utils/inventory-presets";
|
|
37
38
|
|
|
38
39
|
const props = defineProps({ store: { type: Object as PropType<TInventoryStore>, required: true } });
|
|
40
|
+
const ctl = computed(() => inventoryControls(props.store.access));
|
|
39
41
|
const emit = defineEmits(["changed"]);
|
|
40
42
|
|
|
41
43
|
const { getHoldings } = useInventory();
|
|
@@ -171,6 +171,12 @@
|
|
|
171
171
|
|
|
172
172
|
<!-- Expanded checkboxes for the one open module -->
|
|
173
173
|
<template v-if="isCategoryOpen(String(resourceKey))">
|
|
174
|
+
<RolePermissionPresets
|
|
175
|
+
:resource="String(resourceKey)"
|
|
176
|
+
:selected="permState.selected"
|
|
177
|
+
:offered="Object.keys(safePermissions[String(resourceKey)] ?? {})"
|
|
178
|
+
@apply="applyPreset"
|
|
179
|
+
/>
|
|
174
180
|
<template
|
|
175
181
|
v-for="[actionKey, action] in Object.entries(actions || {})"
|
|
176
182
|
:key="actionKey"
|
|
@@ -198,7 +204,7 @@
|
|
|
198
204
|
<v-list-item-title
|
|
199
205
|
class="text-capitalize role-create__row-title"
|
|
200
206
|
>
|
|
201
|
-
{{ formatActionLabel(String(actionKey)) }}
|
|
207
|
+
{{ action.label || formatActionLabel(String(actionKey)) }}
|
|
202
208
|
</v-list-item-title>
|
|
203
209
|
<v-list-item-subtitle class="role-create__row-sub">
|
|
204
210
|
{{ action.description }}
|
|
@@ -210,17 +216,21 @@
|
|
|
210
216
|
</div>
|
|
211
217
|
</v-col>
|
|
212
218
|
|
|
213
|
-
<v-col cols="12" class="my-2">
|
|
214
|
-
<v-row no-gutters>
|
|
215
|
-
<v-col cols="12" class="text-center">
|
|
216
|
-
<span class="role-create__error">{{ message }}</span>
|
|
217
|
-
</v-col>
|
|
218
|
-
</v-row>
|
|
219
|
-
</v-col>
|
|
220
219
|
</v-row>
|
|
221
220
|
</v-form>
|
|
222
221
|
</div>
|
|
223
222
|
|
|
223
|
+
<!--
|
|
224
|
+
WHY A SAVE FAILED, where it can be seen (owner, 2026-09-29: Wing Tai pressed
|
|
225
|
+
Submit on a 35-module role and "nothing happened"). The server's answer
|
|
226
|
+
used to be printed at the END of the scrolling list, below every module,
|
|
227
|
+
so a refused save looked like an ignored click. It now sits above the
|
|
228
|
+
buttons, outside the scroll, and is read out by screen readers.
|
|
229
|
+
-->
|
|
230
|
+
<div v-if="message" class="role-create__save-error" role="alert">
|
|
231
|
+
Couldn't save: {{ message }}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
224
234
|
<div class="role-create__footer">
|
|
225
235
|
<AppButton
|
|
226
236
|
v-if="disabledModuleCount > 0 || !showDisabledModules"
|
|
@@ -251,6 +261,11 @@ import {
|
|
|
251
261
|
import useRole from "../composables/useRole";
|
|
252
262
|
import useUtils from "../composables/useUtils";
|
|
253
263
|
import useSite from "../composables/useSite";
|
|
264
|
+
import { readApiError } from "../utils/subscription-form";
|
|
265
|
+
|
|
266
|
+
/** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
|
|
267
|
+
const SAVE_FALLBACK =
|
|
268
|
+
"the server could not be reached. Please check your connection and try again.";
|
|
254
269
|
|
|
255
270
|
const props = defineProps({
|
|
256
271
|
permissions: {
|
|
@@ -391,6 +406,11 @@ function toggleCategory(resource: string, value: boolean | null) {
|
|
|
391
406
|
}
|
|
392
407
|
}
|
|
393
408
|
|
|
409
|
+
/** A module preset replaced that module's ticks (see RolePermissionPresets). */
|
|
410
|
+
function applyPreset(selected: string[]) {
|
|
411
|
+
permState.value = { selected, enabled: permState.value.enabled };
|
|
412
|
+
}
|
|
413
|
+
|
|
394
414
|
function toggleAction(resource: string, action: string, value: boolean | null) {
|
|
395
415
|
if (value !== true && value !== false) return;
|
|
396
416
|
const key = `${resource}:${action}`;
|
|
@@ -476,6 +496,7 @@ watch(
|
|
|
476
496
|
|
|
477
497
|
async function submit() {
|
|
478
498
|
disable.value = true;
|
|
499
|
+
message.value = "";
|
|
479
500
|
try {
|
|
480
501
|
await createRole({
|
|
481
502
|
name: name.value,
|
|
@@ -489,9 +510,7 @@ async function submit() {
|
|
|
489
510
|
// Same unguarded chain as the preview dialog had: on a network or timeout
|
|
490
511
|
// failure `error.response` is undefined and the catch threw inside itself,
|
|
491
512
|
// leaving the user with no message at all.
|
|
492
|
-
message.value =
|
|
493
|
-
error?.response?._data?.message ??
|
|
494
|
-
"Could not create this role. Please check your connection and try again.";
|
|
513
|
+
message.value = readApiError(error, SAVE_FALLBACK);
|
|
495
514
|
} finally {
|
|
496
515
|
disable.value = false;
|
|
497
516
|
}
|
|
@@ -692,10 +711,15 @@ const isAllSelected = computed(() => {
|
|
|
692
711
|
color: var(--text2);
|
|
693
712
|
}
|
|
694
713
|
|
|
695
|
-
.role-
|
|
714
|
+
.role-create__save-error {
|
|
715
|
+
margin: 0 16px;
|
|
716
|
+
padding: 8px 12px;
|
|
717
|
+
border-radius: 6px;
|
|
696
718
|
font-size: var(--fs-cell);
|
|
697
719
|
font-weight: 600;
|
|
698
720
|
color: var(--err);
|
|
721
|
+
background: var(--err-bg);
|
|
722
|
+
border: 1px solid var(--err-border, transparent);
|
|
699
723
|
}
|
|
700
724
|
|
|
701
725
|
/* The design's dialog footer. Deliberately not the shared
|
|
@@ -132,6 +132,13 @@
|
|
|
132
132
|
</template>
|
|
133
133
|
</v-list-item>
|
|
134
134
|
<template v-if="isCategoryOpen(String(resourceKey))">
|
|
135
|
+
<RolePermissionPresets
|
|
136
|
+
:resource="String(resourceKey)"
|
|
137
|
+
:selected="definedModel"
|
|
138
|
+
:offered="Object.keys(safePermissions[String(resourceKey)] ?? {})"
|
|
139
|
+
:disabled="!edit"
|
|
140
|
+
@apply="definedModel = $event"
|
|
141
|
+
/>
|
|
135
142
|
<template
|
|
136
143
|
v-for="[actionKey, action] in Object.entries(actions || {})"
|
|
137
144
|
:key="actionKey"
|
|
@@ -159,7 +166,7 @@
|
|
|
159
166
|
/>
|
|
160
167
|
</template>
|
|
161
168
|
<v-list-item-title class="text-capitalize role-perm__row-title">
|
|
162
|
-
{{ formatActionLabel(String(actionKey)) }}
|
|
169
|
+
{{ action.label || formatActionLabel(String(actionKey)) }}
|
|
163
170
|
</v-list-item-title>
|
|
164
171
|
<v-list-item-subtitle class="role-perm__row-sub">
|
|
165
172
|
{{ action.description }}
|
|
@@ -209,22 +216,23 @@
|
|
|
209
216
|
</div>
|
|
210
217
|
</v-col>
|
|
211
218
|
|
|
212
|
-
<!--
|
|
213
|
-
One message, once. This block used to appear twice -- inside
|
|
214
|
-
`v-if="edit"` and again unconditionally -- so a refused save printed
|
|
215
|
-
the server's sentence back to back.
|
|
216
|
-
-->
|
|
217
|
-
<v-col v-if="message" cols="12" class="my-2">
|
|
218
|
-
<v-row no-gutters>
|
|
219
|
-
<v-col cols="12" class="text-center">
|
|
220
|
-
<span class="role-perm__error">{{ message }}</span>
|
|
221
|
-
</v-col>
|
|
222
|
-
</v-row>
|
|
223
|
-
</v-col>
|
|
224
219
|
</v-row>
|
|
225
220
|
</v-form>
|
|
226
221
|
</div>
|
|
227
222
|
|
|
223
|
+
<!--
|
|
224
|
+
WHY A SAVE FAILED - or why Submit is switched off - where it can be seen:
|
|
225
|
+
above the buttons, outside the scrolling list (owner, 2026-09-29: a refused
|
|
226
|
+
save printed below 35 modules looked like an ignored click). One message,
|
|
227
|
+
once; read out by screen readers.
|
|
228
|
+
-->
|
|
229
|
+
<div v-if="message" class="role-perm__save-error" role="alert">
|
|
230
|
+
Couldn't save: {{ message }}
|
|
231
|
+
</div>
|
|
232
|
+
<div v-else-if="edit && errorMessages" class="role-perm__save-error" role="alert">
|
|
233
|
+
{{ errorMessages }}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
228
236
|
<div class="role-perm__footer">
|
|
229
237
|
<AppButton
|
|
230
238
|
v-if="disabledModuleCount > 0 || showDisabledModules"
|
|
@@ -259,6 +267,7 @@ import {
|
|
|
259
267
|
} from "../utils/permission-labels";
|
|
260
268
|
import useRole from "../composables/useRole";
|
|
261
269
|
import useUtils from "../composables/useUtils";
|
|
270
|
+
import { readApiError } from "../utils/subscription-form";
|
|
262
271
|
import {
|
|
263
272
|
holdsPermission,
|
|
264
273
|
withoutPermission,
|
|
@@ -685,8 +694,13 @@ const errorMessages = computed(() => {
|
|
|
685
694
|
|
|
686
695
|
const { updatePermissionById } = useRole();
|
|
687
696
|
|
|
697
|
+
/** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
|
|
698
|
+
const SAVE_FALLBACK =
|
|
699
|
+
"the server could not be reached. Please check your connection and try again.";
|
|
700
|
+
|
|
688
701
|
async function submit() {
|
|
689
702
|
disable.value = true;
|
|
703
|
+
message.value = "";
|
|
690
704
|
try {
|
|
691
705
|
await updatePermissionById(props.id, definedModel.value);
|
|
692
706
|
emit("success");
|
|
@@ -694,9 +708,7 @@ async function submit() {
|
|
|
694
708
|
// `error.response` is undefined on a network or timeout failure, so the old
|
|
695
709
|
// unguarded chain threw inside its own catch: nothing was shown, the button
|
|
696
710
|
// came back enabled, and the save looked like it had simply been ignored.
|
|
697
|
-
message.value =
|
|
698
|
-
error?.response?._data?.message ??
|
|
699
|
-
"Could not save this role. Please check your connection and try again.";
|
|
711
|
+
message.value = readApiError(error, SAVE_FALLBACK);
|
|
700
712
|
} finally {
|
|
701
713
|
disable.value = false;
|
|
702
714
|
}
|
|
@@ -772,6 +784,17 @@ async function submit() {
|
|
|
772
784
|
color: var(--err);
|
|
773
785
|
}
|
|
774
786
|
|
|
787
|
+
.role-perm__save-error {
|
|
788
|
+
margin: 0 16px;
|
|
789
|
+
padding: 8px 12px;
|
|
790
|
+
border-radius: 6px;
|
|
791
|
+
font-size: var(--fs-cell);
|
|
792
|
+
font-weight: 600;
|
|
793
|
+
color: var(--err);
|
|
794
|
+
background: var(--err-bg);
|
|
795
|
+
border: 1px solid var(--err-border, transparent);
|
|
796
|
+
}
|
|
797
|
+
|
|
775
798
|
/* The design's dialog footer. Deliberately not the shared
|
|
776
799
|
`.screen-modal__footer` - that class is on the batch-D branch (#1150), not
|
|
777
800
|
on this base, and a second copy in `screens.css` would collide with it. */
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
One-click presets for a module (inventory today): "Staff: request & use",
|
|
4
|
+
"Supervisor", "Storekeeper", "Inventory admin". A preset REPLACES this
|
|
5
|
+
module's ticks and leaves every other module alone; the ticks below stay
|
|
6
|
+
editable afterwards.
|
|
7
|
+
-->
|
|
8
|
+
<div v-if="presets.length" class="role-presets px-4 pt-2 pb-1">
|
|
9
|
+
<div class="role-presets__label mb-1">Quick set</div>
|
|
10
|
+
<div class="d-flex flex-wrap ga-2">
|
|
11
|
+
<v-tooltip v-for="p in presets" :key="p.key" :text="p.description" location="top">
|
|
12
|
+
<template #activator="{ props: tip }">
|
|
13
|
+
<v-chip
|
|
14
|
+
v-bind="tip"
|
|
15
|
+
:color="current?.key === p.key ? 'primary' : undefined"
|
|
16
|
+
:variant="current?.key === p.key ? 'flat' : 'outlined'"
|
|
17
|
+
:disabled="disabled"
|
|
18
|
+
size="small"
|
|
19
|
+
class="text-none"
|
|
20
|
+
@click="emit('apply', applyPermissionPreset(selected, resource, p, offered))"
|
|
21
|
+
>
|
|
22
|
+
<v-icon v-if="current?.key === p.key" start icon="mdi-check" />
|
|
23
|
+
{{ p.label }}
|
|
24
|
+
</v-chip>
|
|
25
|
+
</template>
|
|
26
|
+
</v-tooltip>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="role-presets__hint mt-1">
|
|
29
|
+
{{ current ? current.description : "Pick a set to start from, then adjust the ticks below if needed." }}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script setup lang="ts">
|
|
35
|
+
import { applyPermissionPreset, matchingPermissionPreset, PERMISSION_PRESETS } from "../utils/inventory-presets";
|
|
36
|
+
|
|
37
|
+
const props = defineProps<{
|
|
38
|
+
resource: string;
|
|
39
|
+
/** Every permission the role holds or is being given (all modules). */
|
|
40
|
+
selected: string[];
|
|
41
|
+
/** The actions this editor offers for `resource`. */
|
|
42
|
+
offered: string[];
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
}>();
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits<{ apply: [permissions: string[]] }>();
|
|
47
|
+
|
|
48
|
+
const presets = computed(() => PERMISSION_PRESETS[props.resource] ?? []);
|
|
49
|
+
const current = computed(() => matchingPermissionPreset(props.selected, props.resource, props.offered));
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.role-presets__label {
|
|
54
|
+
font-size: 12px;
|
|
55
|
+
font-weight: 600;
|
|
56
|
+
color: rgb(var(--v-theme-on-surface));
|
|
57
|
+
}
|
|
58
|
+
.role-presets__hint {
|
|
59
|
+
font-size: 12px;
|
|
60
|
+
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -315,19 +315,38 @@ export function useCommonPermissions() {
|
|
|
315
315
|
const inventoryPermissions: Record<string, TPermission> = {
|
|
316
316
|
"view-inventory": {
|
|
317
317
|
check: true,
|
|
318
|
-
|
|
318
|
+
label: "view stock",
|
|
319
|
+
description: "See the store's items and how many are in stock.",
|
|
319
320
|
},
|
|
320
321
|
"request-stock": {
|
|
321
322
|
check: true,
|
|
322
|
-
|
|
323
|
+
label: "request items",
|
|
324
|
+
description: "Ask the store for items and cancel their own requests. Shows My Requests and My Stock.",
|
|
325
|
+
},
|
|
326
|
+
"record-usage": {
|
|
327
|
+
check: true,
|
|
328
|
+
label: "record usage",
|
|
329
|
+
description: "Record where they used the items they hold (before and after photos required). Unused items go back to the storekeeper.",
|
|
323
330
|
},
|
|
324
331
|
"approve-requests": {
|
|
325
332
|
check: true,
|
|
326
|
-
|
|
333
|
+
label: "approve requests",
|
|
334
|
+
description: "Approve or reject stock requests (approving hands the items over) and mark usage records as checked.",
|
|
335
|
+
},
|
|
336
|
+
"issue-receive-stock": {
|
|
337
|
+
check: true,
|
|
338
|
+
label: "issue and receive stock (storekeeper)",
|
|
339
|
+
description: "Receive deliveries, hand items over at the counter, take back returns and count stock.",
|
|
340
|
+
},
|
|
341
|
+
"view-reports": {
|
|
342
|
+
check: true,
|
|
343
|
+
label: "view reports and export",
|
|
344
|
+
description: "See the stock movement, usage, purchases and per-person reports, and export them to Excel.",
|
|
327
345
|
},
|
|
328
346
|
"manage-inventory": {
|
|
329
347
|
check: true,
|
|
330
|
-
|
|
348
|
+
label: "inventory admin (full access)",
|
|
349
|
+
description: "Everything above, plus set up the store, add and edit items and suppliers, imports and settings.",
|
|
331
350
|
},
|
|
332
351
|
};
|
|
333
352
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TImportResult } from "../utils/import-result";
|
|
2
|
+
import { inventoryCaps } from "../utils/inventory-presets";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Inventory API (`/api/inventory`). What a person may do in a store comes back
|
|
@@ -21,7 +22,8 @@ export default function useInventory() {
|
|
|
21
22
|
return {
|
|
22
23
|
typeLabels,
|
|
23
24
|
|
|
24
|
-
getStores: (site: string) =>
|
|
25
|
+
getStores: async (site: string) =>
|
|
26
|
+
((await api<TInventoryStore[]>("/stores", { query: { site } })) ?? []).map((s) => ({ ...s, access: inventoryCaps(s.access) })),
|
|
25
27
|
createStore: (body: { org: string; sites: string[]; name: string }) =>
|
|
26
28
|
api<TInventoryStore>("/stores", { method: "POST", body }),
|
|
27
29
|
updateStore: (id: string, body: Partial<Pick<TInventoryStore, "name" | "sites" | "showInResidentApp" | "showCostInResidentApp">>) =>
|
|
@@ -4,7 +4,7 @@ import usePermission from "./usePermission";
|
|
|
4
4
|
import { permissionsDenied } from "../utils/module-routes";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Whether to show the Inventory menu. Any one of the
|
|
7
|
+
* Whether to show the Inventory menu. Any one of the inventory
|
|
8
8
|
* permissions is enough (an empty list or `*` grants all, the estate rule);
|
|
9
9
|
* which buttons appear inside the screen is decided by the API per store.
|
|
10
10
|
*/
|
|
@@ -19,10 +19,14 @@ export default function useInventoryPermission() {
|
|
|
19
19
|
if (!role) return false;
|
|
20
20
|
if (permissionsDenied(["inventory"], userAppModulesDenied?.value)) return false;
|
|
21
21
|
if (role.permissions.includes("*")) return true;
|
|
22
|
+
// Literal strings, one per call: the gate tests read them from this source.
|
|
22
23
|
return (
|
|
23
24
|
hasPermission(role, permissions, "inventory", "view-inventory") ||
|
|
24
25
|
hasPermission(role, permissions, "inventory", "request-stock") ||
|
|
26
|
+
hasPermission(role, permissions, "inventory", "record-usage") ||
|
|
25
27
|
hasPermission(role, permissions, "inventory", "approve-requests") ||
|
|
28
|
+
hasPermission(role, permissions, "inventory", "issue-receive-stock") ||
|
|
29
|
+
hasPermission(role, permissions, "inventory", "view-reports") ||
|
|
26
30
|
hasPermission(role, permissions, "inventory", "manage-inventory")
|
|
27
31
|
);
|
|
28
32
|
});
|
package/constants/permissions.ts
CHANGED
|
@@ -169,7 +169,15 @@ export const PERMISSIONS = {
|
|
|
169
169
|
"import-units",
|
|
170
170
|
"delete-unit",
|
|
171
171
|
],
|
|
172
|
-
inventory: [
|
|
172
|
+
inventory: [
|
|
173
|
+
"view-inventory",
|
|
174
|
+
"request-stock",
|
|
175
|
+
"record-usage",
|
|
176
|
+
"approve-requests",
|
|
177
|
+
"issue-receive-stock",
|
|
178
|
+
"view-reports",
|
|
179
|
+
"manage-inventory",
|
|
180
|
+
],
|
|
173
181
|
"supply-mgmt": [
|
|
174
182
|
"see-all-supplies",
|
|
175
183
|
"add-supply",
|
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.
|
|
5
|
+
"version": "4.70.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.",
|
package/types/inventory.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ declare type TInventoryAccess = {
|
|
|
3
3
|
canManage: boolean;
|
|
4
4
|
canApprove: boolean;
|
|
5
5
|
canRequest: boolean;
|
|
6
|
+
/** Record usage of stock the person holds. */
|
|
7
|
+
canUse: boolean;
|
|
8
|
+
/** Storekeeper: receive deliveries, hand stock over, take returns, count. */
|
|
9
|
+
canHandle: boolean;
|
|
10
|
+
/** Reports and Excel export. */
|
|
11
|
+
canReports: boolean;
|
|
6
12
|
};
|
|
7
13
|
|
|
8
14
|
declare type TInventoryStore = {
|
package/types/permission.d.ts
CHANGED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inventory roles in plain words: one permission per job, and the four ready-made
|
|
3
|
+
* sets the role editor offers as one-click presets. Browser-safe, no Node globals.
|
|
4
|
+
*
|
|
5
|
+
* The server (API-core `utils/inventory-access.ts`) decides what each string
|
|
6
|
+
* allows; `manage-inventory` is the full set there, so "Inventory admin" ticks
|
|
7
|
+
* everything and a role holding the old four strings keeps full access.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The inventory actions, in the order a person reads them. */
|
|
11
|
+
export const INVENTORY_ACTIONS = [
|
|
12
|
+
"view-inventory",
|
|
13
|
+
"request-stock",
|
|
14
|
+
"record-usage",
|
|
15
|
+
"approve-requests",
|
|
16
|
+
"issue-receive-stock",
|
|
17
|
+
"view-reports",
|
|
18
|
+
"manage-inventory",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type TPermissionPreset = {
|
|
22
|
+
key: string;
|
|
23
|
+
label: string;
|
|
24
|
+
description: string;
|
|
25
|
+
actions: string[];
|
|
26
|
+
/** Holding this action alone already means the whole preset (the server's rule). */
|
|
27
|
+
impliedBy?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const INVENTORY_PRESETS: TPermissionPreset[] = [
|
|
31
|
+
{
|
|
32
|
+
key: "staff",
|
|
33
|
+
label: "Staff: request & use",
|
|
34
|
+
description: "See stock, request items, see My Requests and My Stock, and record where items were used.",
|
|
35
|
+
actions: ["view-inventory", "request-stock", "record-usage"],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "supervisor",
|
|
39
|
+
label: "Supervisor",
|
|
40
|
+
description: "See stock, approve or reject requests, check usage records, and see reports.",
|
|
41
|
+
actions: ["view-inventory", "approve-requests", "view-reports"],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: "storekeeper",
|
|
45
|
+
label: "Storekeeper",
|
|
46
|
+
description: "See stock, approve requests, receive deliveries, hand items over, take back returns and count stock.",
|
|
47
|
+
actions: ["view-inventory", "approve-requests", "issue-receive-stock"],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: "admin",
|
|
51
|
+
label: "Inventory admin",
|
|
52
|
+
description: "Everything, including store set-up, items, suppliers, imports, settings and reports.",
|
|
53
|
+
actions: [...INVENTORY_ACTIONS],
|
|
54
|
+
// A role with inventory admin has full access whatever else is ticked (e.g. the four older boxes).
|
|
55
|
+
impliedBy: "manage-inventory",
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Presets per module key; only inventory has them today. */
|
|
60
|
+
export const PERMISSION_PRESETS: Record<string, TPermissionPreset[]> = { inventory: INVENTORY_PRESETS };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The permission list with `resource`'s grants replaced by `preset`'s actions --
|
|
64
|
+
* only the actions this editor offers (`offered`). Every other module is kept.
|
|
65
|
+
*/
|
|
66
|
+
export function applyPermissionPreset(
|
|
67
|
+
held: readonly string[],
|
|
68
|
+
resource: string,
|
|
69
|
+
preset: TPermissionPreset,
|
|
70
|
+
offered: readonly string[],
|
|
71
|
+
): string[] {
|
|
72
|
+
const kept = held.filter((p) => !p.startsWith(`${resource}:`));
|
|
73
|
+
return [...kept, ...preset.actions.filter((a) => offered.includes(a)).map((a) => `${resource}:${a}`)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The preset whose actions are exactly what the list holds for `resource`, if any. */
|
|
77
|
+
export function matchingPermissionPreset(
|
|
78
|
+
held: readonly string[],
|
|
79
|
+
resource: string,
|
|
80
|
+
offered: readonly string[],
|
|
81
|
+
): TPermissionPreset | undefined {
|
|
82
|
+
const mine = new Set(held.filter((p) => p.startsWith(`${resource}:`)).map((p) => p.slice(resource.length + 1)));
|
|
83
|
+
const presets = PERMISSION_PRESETS[resource] ?? [];
|
|
84
|
+
const implied = presets.find((preset) => preset.impliedBy && mine.has(preset.impliedBy));
|
|
85
|
+
if (implied) return implied;
|
|
86
|
+
return presets.find((preset) => {
|
|
87
|
+
const want = preset.actions.filter((a) => offered.includes(a));
|
|
88
|
+
return want.length === mine.size && want.every((a) => mine.has(a));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The store's `access` from the API, with every capability present. An API
|
|
94
|
+
* from before the split (no `canUse`) is read the way it behaved then, so a
|
|
95
|
+
* screen deployed first never hides what the server still allows.
|
|
96
|
+
*/
|
|
97
|
+
export function inventoryCaps(a?: Partial<TInventoryAccess> | null): TInventoryAccess {
|
|
98
|
+
const v = (x?: boolean) => Boolean(x);
|
|
99
|
+
if (!a) {
|
|
100
|
+
return { canView: false, canManage: false, canApprove: false, canRequest: false, canUse: false, canHandle: false, canReports: false };
|
|
101
|
+
}
|
|
102
|
+
const before = a.canUse === undefined;
|
|
103
|
+
return {
|
|
104
|
+
canView: v(a.canView),
|
|
105
|
+
canManage: v(a.canManage),
|
|
106
|
+
canApprove: v(a.canApprove),
|
|
107
|
+
canRequest: v(a.canRequest),
|
|
108
|
+
canUse: before ? v(a.canRequest) : v(a.canUse),
|
|
109
|
+
canHandle: before ? v(a.canManage || a.canApprove) : v(a.canHandle),
|
|
110
|
+
canReports: before ? v(a.canManage || a.canApprove || (a.canView && !a.canRequest)) : v(a.canReports),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What the Inventory screen shows for a store, from its `access`. Hide, never
|
|
116
|
+
* grey: a control a person cannot use is not drawn. Web (layer-common) and the
|
|
117
|
+
* mobile apps apply the same rules; the API refuses anything else with a 403.
|
|
118
|
+
*/
|
|
119
|
+
export function inventoryControls(a?: Partial<TInventoryAccess> | null) {
|
|
120
|
+
const c = inventoryCaps(a);
|
|
121
|
+
return {
|
|
122
|
+
tabs: {
|
|
123
|
+
stock: c.canView,
|
|
124
|
+
mine: (c.canRequest || c.canUse) && !c.canManage,
|
|
125
|
+
requests: c.canRequest || c.canApprove,
|
|
126
|
+
people: c.canApprove || c.canHandle,
|
|
127
|
+
reports: c.canReports || c.canApprove,
|
|
128
|
+
suppliers: c.canManage || c.canHandle,
|
|
129
|
+
settings: c.canManage,
|
|
130
|
+
},
|
|
131
|
+
request: c.canRequest && !c.canManage,
|
|
132
|
+
recordUsage: c.canUse,
|
|
133
|
+
review: c.canApprove,
|
|
134
|
+
checkUsage: c.canApprove,
|
|
135
|
+
receive: c.canHandle,
|
|
136
|
+
issue: c.canHandle,
|
|
137
|
+
takeReturn: c.canHandle,
|
|
138
|
+
count: c.canHandle,
|
|
139
|
+
addItems: c.canManage,
|
|
140
|
+
editItem: c.canManage,
|
|
141
|
+
fullReports: c.canReports,
|
|
142
|
+
exportExcel: c.canReports,
|
|
143
|
+
unitPrice: c.canManage || c.canApprove || c.canHandle || c.canReports,
|
|
144
|
+
};
|
|
145
|
+
}
|