@7365admin1/layer-common 4.68.0 → 4.70.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,23 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.70.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ## 4.69.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 3eaaf7e: Inventory supplier picker: one "Add ... as a new supplier" row (it showed twice when nothing matched).
14
+
15
+ ## 4.69.0
16
+
17
+ ### Minor Changes
18
+
19
+ - cb9d2a1: Inventory: show "Equipment is now in Inventory." when a retired Equipment page forwards here (?moved=equipment).
20
+
3
21
  ## 4.68.0
4
22
 
5
23
  ### Minor Changes
@@ -1831,11 +1849,11 @@
1831
1849
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1832
1850
  own rule from two endpoints the console already calls, unprojected:
1833
1851
 
1834
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1835
- GET /api/roles/id/:role that membership's role document
1852
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1853
+ GET /api/roles/id/:role that membership's role document
1836
1854
 
1837
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1838
- staff = member.type === "admin" && role.type === "admin"
1855
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1856
+ staff = member.type === "admin" && role.type === "admin"
1839
1857
 
1840
1858
  `role.default` is the marker because it is the only property of a platform
1841
1859
  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();
@@ -29,6 +29,11 @@
29
29
  </template>
30
30
  </PageHeader>
31
31
 
32
+ <!-- The old Equipment pages forward here with ?moved=equipment (retired 2026-09-28). -->
33
+ <v-alert v-if="movedFromEquipment" type="info" variant="tonal" density="compact" closable class="mb-4">
34
+ Equipment is now in Inventory.
35
+ </v-alert>
36
+
32
37
  <v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
33
38
 
34
39
  <AppCard v-else-if="loadError" class="pa-6">
@@ -88,22 +93,22 @@
88
93
  <v-window-item value="stock">
89
94
  <InventoryStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
90
95
  </v-window-item>
91
- <v-window-item v-if="store.access.canRequest && !store.access.canManage" value="mine">
96
+ <v-window-item v-if="ctl.tabs.mine" value="mine">
92
97
  <InventoryMyStockTab :store="store" :site-id="siteId" :sites="storeSites" @changed="refreshCounts" />
93
98
  </v-window-item>
94
- <v-window-item value="requests">
99
+ <v-window-item v-if="ctl.tabs.requests" value="requests">
95
100
  <InventoryRequestsTab :store="store" :site-id="siteId" @changed="refreshCounts" />
96
101
  </v-window-item>
97
- <v-window-item v-if="store.access.canApprove" value="technicians">
102
+ <v-window-item v-if="ctl.tabs.people" value="technicians">
98
103
  <InventoryTechniciansTab :store="store" @changed="refreshCounts" />
99
104
  </v-window-item>
100
- <v-window-item v-if="isStaff" value="reports">
105
+ <v-window-item v-if="ctl.tabs.reports" value="reports">
101
106
  <InventoryReportsTab :store="store" :sites="storeSites" />
102
107
  </v-window-item>
103
- <v-window-item v-if="isStaff" value="suppliers">
108
+ <v-window-item v-if="ctl.tabs.suppliers" value="suppliers">
104
109
  <InventorySuppliersTab :store="store" />
105
110
  </v-window-item>
106
- <v-window-item v-if="store.access.canManage" value="settings">
111
+ <v-window-item v-if="ctl.tabs.settings" value="settings">
107
112
  <InventorySettingsTab :store="store" :org-sites="orgSites" @saved="loadStores" />
108
113
  </v-window-item>
109
114
  </v-window>
@@ -119,12 +124,15 @@ import useWorkerTerm from "../composables/useWorkerTerm";
119
124
  import useSite from "../composables/useSite";
120
125
  import useUtils from "../composables/useUtils";
121
126
  import { inventoryStoreLabel, inventorySuppliesText } from "../utils/inventory-store-label";
127
+ import { inventoryControls } from "../utils/inventory-presets";
122
128
 
123
129
  const props = defineProps({
124
130
  orgId: { type: String, required: true },
125
131
  siteId: { type: String, required: true },
126
132
  });
127
133
 
134
+ const movedFromEquipment = useRoute().query.moved === "equipment";
135
+
128
136
  const { getStores, createStore: apiCreateStore, getRequests } = useInventory();
129
137
  const { getSites } = useSite();
130
138
  const { requiredRule } = useUtils();
@@ -149,11 +157,6 @@ function notify(text: string, color = "success") {
149
157
  }
150
158
  provide("inventoryNotify", notify);
151
159
 
152
- const isStaff = computed(() => {
153
- const a = store.value?.access;
154
- return !!a && (a.canManage || a.canApprove || (a.canView && !a.canRequest));
155
- });
156
-
157
160
  const storeSites = computed(() =>
158
161
  (store.value?.sites ?? []).map((id) => ({
159
162
  _id: id,
@@ -176,17 +179,20 @@ const subtitle = computed(() => {
176
179
  // Who holds this store's stock: the property manager's store lends to service providers' workers.
177
180
  const worker = useWorkerTerm({ providers: true });
178
181
 
182
+ const ctl = computed(() => inventoryControls(store.value?.access));
183
+
179
184
  const tabs = computed(() => {
180
185
  const a = store.value?.access;
181
186
  if (!a) return [];
182
187
  return [
183
188
  { value: "stock", label: "Stock" },
184
- ...(a.canRequest && !a.canManage ? [{ value: "mine", label: "My Stock" }] : []),
185
- { value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 },
186
- ...(a.canApprove ? [{ value: "technicians", label: worker.many }] : []),
187
- ...(isStaff.value ? [{ value: "reports", label: "Reports" }] : []),
188
- ...(isStaff.value ? [{ value: "suppliers", label: "Suppliers" }] : []),
189
- ...(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" }] : []),
190
196
  ];
191
197
  });
192
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 }}
@@ -391,6 +397,11 @@ function toggleCategory(resource: string, value: boolean | null) {
391
397
  }
392
398
  }
393
399
 
400
+ /** A module preset replaced that module's ticks (see RolePermissionPresets). */
401
+ function applyPreset(selected: string[]) {
402
+ permState.value = { selected, enabled: permState.value.enabled };
403
+ }
404
+
394
405
  function toggleAction(resource: string, action: string, value: boolean | null) {
395
406
  if (value !== true && value !== false) return;
396
407
  const key = `${resource}:${action}`;
@@ -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 }}
@@ -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.68.0",
5
+ "version": "4.70.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.",
@@ -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
+ }