@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1841
- GET /api/roles/id/:role that membership's role document
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
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1844
- staff = member.type === "admin" && role.type === "admin"
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="store.access.canManage">
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="store.access.canRequest && !store.access.canManage" value="mine">
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="store.access.canApprove" value="technicians">
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="isStaff" value="reports">
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="isStaff" value="suppliers">
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="store.access.canManage" value="settings">
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
- ...(a.canRequest && !a.canManage ? [{ value: "mine", label: "My Stock" }] : []),
192
- { value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 },
193
- ...(a.canApprove ? [{ value: "technicians", label: worker.many }] : []),
194
- ...(isStaff.value ? [{ value: "reports", label: "Reports" }] : []),
195
- ...(isStaff.value ? [{ value: "suppliers", label: "Suppliers" }] : []),
196
- ...(a.canManage ? [{ value: "settings", label: "Settings" }] : []),
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.</div>
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
- const report = ref<"movement" | "usage" | "technicians" | "purchases">("movement");
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(() => props.store.access.canApprove || props.store.access.canManage);
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="store.access.canRequest && !store.access.canManage" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="showRequest = true">
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="store.access.canApprove" class="text-none screen-btn-primary" variant="flat" @click="openDecision(r)">Review</v-btn>
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="store.access.canManage && !storeEmpty">
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="store.access.canApprove && !storeEmpty" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-hand-extended-outline" @click="open('issue')">
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="store.access.canRequest && !store.access.canManage" class="text-none screen-btn-primary" variant="flat" prepend-icon="mdi-cart-arrow-down" @click="open('request')">
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="store.access.canManage">
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
- ...(props.store.access.canManage || props.store.access.canApprove ? [{ title: "Unit price", value: "unitCost" }] : []),
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" prepend-icon="mdi-plus" @click="model = 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-create__error {
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
- description: "Allows the user to see the store's stock and reports.",
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
- description: "Allows the user to request stock from the store and record where they used it.",
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
- description: "Allows the user to approve or reject stock requests, issue stock and take returns.",
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
- description: "Allows the user to set up the store, add items, receive deliveries, count stock and change store settings.",
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) => api<TInventoryStore[]>("/stores", { query: { site } }),
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 four inventory
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
  });
@@ -169,7 +169,15 @@ export const PERMISSIONS = {
169
169
  "import-units",
170
170
  "delete-unit",
171
171
  ],
172
- inventory: ["view-inventory", "request-stock", "approve-requests", "manage-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.69.0",
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.",
@@ -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 = {
@@ -5,6 +5,8 @@ declare type TPermissionCheck =
5
5
  declare type TPermission = {
6
6
  check: TPermissionCheck;
7
7
  description: string;
8
+ /** The tick-box name in the role editor, when the key alone reads badly. */
9
+ label?: string;
8
10
  };
9
11
 
10
12
  declare type TPermissions = {
@@ -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
+ }