@kernhq/module-inventory 0.2.0 → 0.3.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.
Files changed (193) hide show
  1. package/README.md +104 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +322 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +213 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1052 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +225 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1337 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +363 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +158 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/meta/0002_snapshot.json +1054 -0
  117. package/migrations/meta/0003_snapshot.json +1070 -0
  118. package/migrations/meta/0004_snapshot.json +1130 -0
  119. package/migrations/meta/0005_snapshot.json +1135 -0
  120. package/migrations/meta/_journal.json +42 -0
  121. package/package.json +1 -1
  122. package/src/client/api-instance.ts +27 -2
  123. package/src/client/bidi.test.ts +148 -0
  124. package/src/client/bidi.ts +85 -0
  125. package/src/client/components/AssetDetailPanel.svelte +614 -0
  126. package/src/client/components/AssetFormDialog.svelte +71 -22
  127. package/src/client/components/AssetPhoto.svelte +178 -0
  128. package/src/client/components/AttachmentsSection.svelte +327 -0
  129. package/src/client/components/CustodyDialog.svelte +201 -0
  130. package/src/client/components/RepairDialog.svelte +271 -0
  131. package/src/client/components/RepairsSection.svelte +318 -0
  132. package/src/client/components/Timeline.svelte +347 -0
  133. package/src/client/components/TimelineText.svelte +124 -0
  134. package/src/client/core-api.ts +71 -0
  135. package/src/client/custody.test.ts +31 -0
  136. package/src/client/custody.ts +34 -0
  137. package/src/client/errors.test.ts +365 -0
  138. package/src/client/errors.ts +201 -0
  139. package/src/client/links.test.ts +74 -0
  140. package/src/client/links.ts +44 -0
  141. package/src/client/members.test.ts +132 -0
  142. package/src/client/members.ts +116 -0
  143. package/src/client/messages.test.ts +127 -0
  144. package/src/client/messages.ts +1043 -18
  145. package/src/client/mock.test.ts +395 -1
  146. package/src/client/mock.ts +1066 -79
  147. package/src/client/module.ts +54 -0
  148. package/src/client/pages/AssetsPage.svelte +317 -45
  149. package/src/client/permissions.ts +7 -0
  150. package/src/client/price.test.ts +151 -3
  151. package/src/client/price.ts +155 -11
  152. package/src/client/query.ts +36 -0
  153. package/src/client/repairs.test.ts +38 -0
  154. package/src/client/repairs.ts +38 -0
  155. package/src/client/settings/CategoriesSettings.svelte +421 -0
  156. package/src/client/settings/GeneralSettings.svelte +0 -0
  157. package/src/client/status.ts +29 -0
  158. package/src/client/timeline.test.ts +175 -0
  159. package/src/client/timeline.ts +206 -0
  160. package/src/client/widgets/OverviewWidget.svelte +127 -26
  161. package/src/client/widgets/RepairsWidget.svelte +124 -0
  162. package/src/contract/capabilities.ts +53 -9
  163. package/src/contract/events.ts +49 -0
  164. package/src/contract/index.ts +1 -0
  165. package/src/contract/models.ts +237 -0
  166. package/src/contract/notifications.ts +73 -0
  167. package/src/contract/permissions.ts +55 -2
  168. package/src/contract/router.ts +255 -1
  169. package/src/contract/settings.ts +24 -4
  170. package/src/module.test.ts +194 -4
  171. package/src/server/index.ts +311 -4
  172. package/src/server/inventory.int.test.ts +3573 -18
  173. package/src/server/jobs.ts +444 -0
  174. package/src/server/migrations.test.ts +113 -0
  175. package/src/server/router.ts +462 -6
  176. package/src/server/schema.ts +160 -3
  177. package/src/server/services/assets.ts +214 -54
  178. package/src/server/services/attachments.ts +215 -0
  179. package/src/server/services/audience.ts +77 -0
  180. package/src/server/services/categories.ts +136 -0
  181. package/src/server/services/cursor.ts +104 -0
  182. package/src/server/services/custody.ts +471 -0
  183. package/src/server/services/db-errors.ts +42 -0
  184. package/src/server/services/index.ts +34 -1
  185. package/src/server/services/members.ts +54 -0
  186. package/src/server/services/notify.ts +52 -7
  187. package/src/server/services/offboarding.ts +150 -0
  188. package/src/server/services/repairs.ts +567 -0
  189. package/src/server/services/search.ts +166 -0
  190. package/src/server/services/stats.ts +88 -0
  191. package/src/server/services/status.test.ts +34 -0
  192. package/src/server/services/status.ts +143 -0
  193. package/src/client/settings/core-api.ts +0 -32
@@ -87,6 +87,42 @@ export const inventoryClientModule = defineClientModule({
87
87
  ],
88
88
  component: () => import('./widgets/OverviewWidget.svelte'),
89
89
  },
90
+ {
91
+ /**
92
+ * A second card rather than a `view` option on the first, because this one is gated.
93
+ *
94
+ * `capability` is what the shell filters the dashboard by, so a workspace that does not
95
+ * record repairs is never offered this card at all. An option inside the other card's
96
+ * settings would offer the question and then answer it with an empty card — which is exactly
97
+ * the "a switch that changes nothing" failure the capability mechanism exists to avoid.
98
+ */
99
+ id: 'inventory.repairs',
100
+ get title() {
101
+ return t('widget_repairs_title')
102
+ },
103
+ get description() {
104
+ return t('widget_repairs_desc')
105
+ },
106
+ icon: 'wrench',
107
+ permission: INVENTORY_PERMISSIONS.view,
108
+ capability: 'repairs',
109
+ sizes: ['m', 'l'],
110
+ defaultSize: 'm',
111
+ order: 56,
112
+ settings: [
113
+ {
114
+ kind: 'number',
115
+ key: 'limit',
116
+ get label() {
117
+ return t('common.setting_rows')
118
+ },
119
+ default: 5,
120
+ min: 3,
121
+ max: 20,
122
+ },
123
+ ],
124
+ component: () => import('./widgets/RepairsWidget.svelte'),
125
+ },
90
126
  ],
91
127
 
92
128
  /**
@@ -110,6 +146,24 @@ export const inventoryClientModule = defineClientModule({
110
146
  order: 1,
111
147
  component: () => import('./settings/GeneralSettings.svelte'),
112
148
  },
149
+ {
150
+ /**
151
+ * Categories are this module's own data rather than core's, so this one is gated on this
152
+ * module's own key — `inventory.category.manage`, which is exactly what `categories.create`,
153
+ * `.update` and `.archive` require on the server. General above is gated on
154
+ * `core.modules.manage` because *that* page writes through core's settings procedure; the two
155
+ * pages sit side by side and are deliberately not gated the same way.
156
+ */
157
+ id: 'categories',
158
+ get label() {
159
+ return t('settings_categories')
160
+ },
161
+ icon: 'tag',
162
+ scope: 'workspace',
163
+ permission: INVENTORY_PERMISSIONS.categories,
164
+ order: 2,
165
+ component: () => import('./settings/CategoriesSettings.svelte'),
166
+ },
113
167
  ],
114
168
  })
115
169
 
@@ -1,14 +1,17 @@
1
1
  <script lang="ts">
2
2
  import {
3
3
  Badge,
4
- type BadgeTone,
5
4
  Button,
5
+ coreApi,
6
6
  Dialog,
7
7
  DropdownMenu,
8
8
  EmptyState,
9
+ formatDate,
9
10
  IconButton,
10
11
  Input,
12
+ keys,
11
13
  type MenuItem,
14
+ navigation,
12
15
  Page,
13
16
  PageHeader,
14
17
  Select,
@@ -22,13 +25,20 @@ import {
22
25
  TableRow,
23
26
  toast,
24
27
  } from '@kernhq/ui'
25
- import { createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
26
- import type { Asset, AssetStatus } from '../../contract/index.js'
28
+ import { createInfiniteQuery, createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
29
+ import type { Asset, AssetStatus, Category } from '../../contract/index.js'
27
30
  import { getInventoryApi } from '../api-instance.js'
31
+ import { isolated } from '../bidi.js'
32
+ import AssetDetailPanel from '../components/AssetDetailPanel.svelte'
28
33
  import AssetFormDialog from '../components/AssetFormDialog.svelte'
34
+ import type { CoreApi, CoreMember } from '../core-api.js'
35
+ import { errorMessage } from '../errors.js'
29
36
  import { t } from '../i18n.js'
37
+ import { ASSET_PARAM } from '../links.js'
38
+ import { directory, directoryStatus, nameOf } from '../members.js'
30
39
  import { canInventory } from '../permissions.js'
31
40
  import { inventoryKeys } from '../query.js'
41
+ import { statusTone } from '../status.js'
32
42
 
33
43
  /**
34
44
  * This module's screen.
@@ -46,10 +56,12 @@ interface Props {
46
56
  const { workspaceId }: Props = $props()
47
57
 
48
58
  const api = getInventoryApi()
59
+ const core = coreApi<CoreApi>()
49
60
  const queryClient = useQueryClient()
50
61
 
51
62
  let searchText = $state('')
52
63
  let statusFilter = $state<string>('')
64
+ let categoryFilter = $state<string>('')
53
65
  let showArchived = $state(false)
54
66
  let dialogOpen = $state(false)
55
67
  /** The row the dialog is editing, or null when it is adding one. */
@@ -65,6 +77,18 @@ $effect(() => {
65
77
  return () => clearTimeout(timer)
66
78
  })
67
79
 
80
+ /**
81
+ * The two filters that live in the URL rather than in this component.
82
+ *
83
+ * `?asset=<id>` is which panel is open, and `?custodian=<userId>` is "what is this person holding"
84
+ * — the return list an offboarding notification links to. Both are in the URL for the same reason:
85
+ * they are places somebody went, so they survive a reload, can be shared with the person who has to
86
+ * act on them, and close on Back. Built from `navigation` because a module cannot read `$app/state`
87
+ * — it is a SvelteKit alias, and this package is type-checked on its own.
88
+ */
89
+ const params = $derived(new URLSearchParams(navigation.search))
90
+ const custodianFilter = $derived(params.get('custodian') ?? '')
91
+
68
92
  /**
69
93
  * Every filter is part of the key and part of the request.
70
94
  *
@@ -75,6 +99,8 @@ $effect(() => {
75
99
  const filters = $derived({
76
100
  ...(query ? { q: query } : {}),
77
101
  ...(statusFilter ? { status: statusFilter as AssetStatus } : {}),
102
+ ...(categoryFilter ? { categoryId: categoryFilter } : {}),
103
+ ...(custodianFilter ? { custodianUserId: custodianFilter } : {}),
78
104
  archived: showArchived,
79
105
  })
80
106
 
@@ -83,16 +109,57 @@ const filters = $derived({
83
109
  *
84
110
  * The archived switch is not in here: it widens the list rather than narrowing it, so a workspace
85
111
  * with no assets and the switch on is still empty rather than filtered, and offering to "clear" it
86
- * would hide rows somebody just asked to see.
112
+ * would hide rows somebody just asked to see. The custodian **is** in here, so the count line stops
113
+ * claiming the workspace total the moment the list is one person's.
87
114
  */
88
- const narrowed = $derived(Boolean(query) || Boolean(statusFilter))
115
+ const narrowed = $derived(
116
+ Boolean(query) || Boolean(statusFilter) || Boolean(categoryFilter) || Boolean(custodianFilter),
117
+ )
89
118
 
90
119
  function clearFilters() {
91
120
  searchText = ''
92
121
  query = ''
93
122
  statusFilter = ''
123
+ categoryFilter = ''
124
+ setParam('custodian', null)
94
125
  }
95
126
 
127
+ /**
128
+ * The people this workspace has, asked for only when there is a uuid on screen to turn into a name.
129
+ *
130
+ * The same key `AssetDetailPanel` uses, so opening a panel afterwards costs no second request — and
131
+ * no request at all in the ordinary case, where nothing is filtered by custodian.
132
+ */
133
+ const membersQuery = createQuery(() => ({
134
+ queryKey: keys.members(workspaceId),
135
+ queryFn: () => core.workspaces.members.list({ workspaceId }),
136
+ enabled: Boolean(workspaceId) && Boolean(custodianFilter),
137
+ }))
138
+ const dir = $derived(
139
+ directory((membersQuery.data?.items ?? []) as CoreMember[], directoryStatus(membersQuery)),
140
+ )
141
+ /**
142
+ * Whose list this is.
143
+ *
144
+ * `nameOf` never answers with the uuid: somebody who has since been removed from the workspace
145
+ * reads as "a former member", which is exactly the case this filter exists for — the offboarding
146
+ * notification is *about* somebody who has left.
147
+ *
148
+ * It only says so once the member list has actually arrived. Before that this line claimed the
149
+ * person had left the workspace while their name was still being fetched, on the one screen whose
150
+ * whole purpose is to be *about* a named person.
151
+ */
152
+ const custodianName = $derived(
153
+ custodianFilter
154
+ ? nameOf(custodianFilter, dir, {
155
+ loading: t('member_loading'),
156
+ unknown: t('member_unknown'),
157
+ former: t('member_former'),
158
+ system: t('member_system'),
159
+ })
160
+ : null,
161
+ )
162
+
96
163
  interface AssetPage {
97
164
  items: Asset[]
98
165
  nextCursor: string | null
@@ -115,30 +182,119 @@ const assetsQuery = createInfiniteQuery(() => ({
115
182
 
116
183
  const assets = $derived(assetsQuery.data?.pages.flatMap((page) => page.items) ?? [])
117
184
 
185
+ /**
186
+ * The workspace's categories, **archived ones included**.
187
+ *
188
+ * One query rather than two, and it asks for everything because the two things it feeds want
189
+ * different halves: the chip on a row has to name the category an asset carries even after somebody
190
+ * archived it — that is the whole point of archiving rather than deleting — while the filter offers
191
+ * only the live ones, because filtering by something nobody can file anything under is a dead end.
192
+ *
193
+ * The key is the same one `AssetDetailPanel` uses, so opening a panel costs no second request.
194
+ */
195
+ const categoriesQuery = createQuery(() => ({
196
+ queryKey: inventoryKeys.categories(workspaceId, true),
197
+ queryFn: () => api.categories.list({ workspaceId, archived: true }),
198
+ enabled: Boolean(workspaceId),
199
+ }))
200
+ const categories = $derived<Category[]>(categoriesQuery.data ?? [])
201
+ const categoryNames = $derived(new Map(categories.map((row) => [row.id, row.name])))
202
+
203
+ /**
204
+ * What the filter offers: the live categories, plus whichever one is currently chosen.
205
+ *
206
+ * The second half matters exactly once, and it is not hypothetical — somebody filters by "Cameras",
207
+ * an administrator archives it in another tab, and without this the control would show an empty
208
+ * value while the list stayed filtered. A filter that does not say what it is filtering by is worse
209
+ * than one offering a category nobody can file anything new under.
210
+ */
211
+ const categoryOptions = $derived<SelectOption[]>([
212
+ { value: '', label: t('filter_all_categories') },
213
+ ...categories
214
+ .filter((row) => !row.archivedAt || row.id === categoryFilter)
215
+ .map((row) => ({ value: row.id, label: row.name })),
216
+ ])
217
+
218
+ // ------------------------------------------------------------------- the panel, and the URL
219
+
220
+ /**
221
+ * Which asset is open lives in the URL, not in this component's state. See `params` above.
222
+ *
223
+ * The parameter's name comes from `links.ts`, which is also where a dashboard card builds the link
224
+ * that sets it — one constant, so a card cannot send somebody to a URL this page ignores.
225
+ */
226
+ const openAssetId = $derived(params.get(ASSET_PARAM))
227
+
228
+ /**
229
+ * Set or clear one search parameter, leaving every other one alone.
230
+ *
231
+ * One function rather than one per parameter, because "keep the rest of the URL" is the part that
232
+ * is easy to get wrong: clearing the custodian filter by rebuilding the query string from scratch
233
+ * would close the panel somebody has open.
234
+ *
235
+ * A push, deliberately — not the `replaceState` a filter change would use. Opening a panel, and
236
+ * arriving on somebody's return list from a notification, are both places somebody went, so Back
237
+ * should undo them; a keystroke in the search box is not, and a list that pushed every one of those
238
+ * would fill the back button with states nobody meant to visit.
239
+ */
240
+ function setParam(name: string, value: string | null) {
241
+ const next = new URLSearchParams(navigation.search)
242
+ if (value) next.set(name, value)
243
+ else next.delete(name)
244
+ const search = next.toString()
245
+ void navigation.go(`${navigation.pathname}${search ? `?${search}` : ''}`, {
246
+ keepFocus: true,
247
+ noScroll: true,
248
+ })
249
+ }
250
+
251
+ const setAsset = (id: string | null) => setParam(ASSET_PARAM, id)
252
+
253
+ /**
254
+ * The register in numbers, for the one line that has to be a number.
255
+ *
256
+ * One request for the whole workspace rather than a `count(*)` bolted onto every page of a keyset
257
+ * list — which is the expensive way to answer a question that does not change between pages.
258
+ */
259
+ const statsQuery = createQuery(() => ({
260
+ queryKey: inventoryKeys.stats(workspaceId),
261
+ queryFn: () => api.stats.summary({ workspaceId }),
262
+ enabled: Boolean(workspaceId),
263
+ }))
264
+
118
265
  /**
119
266
  * A count that stays true while the list pages.
120
267
  *
121
268
  * `t('count', { n: assets.length })` counted the pages *loaded*, so 120 assets read "50 assets" and
122
269
  * then "100 assets" after Load more — a number that is simply wrong, on the line whose whole job is
123
- * to be the number. When the server fills `total` that is the honest figure; until it does, a list
124
- * with another page says how many it is *showing* rather than claiming that is all there is.
270
+ * to be the number.
271
+ *
272
+ * Three answers, in order of how true they are. **The workspace total** when nothing is narrowing
273
+ * the list and archived rows are out, because then the list *is* every live asset and
274
+ * `stats.summary` has counted them. **What the server said** if it ever fills `total` on a page.
275
+ * Otherwise how many are being *shown*, which is honest about being a partial answer rather than
276
+ * claiming a filtered, half-loaded list is all there is.
125
277
  */
126
278
  const reportedTotal = $derived(assetsQuery.data?.pages.at(-1)?.total)
279
+ const workspaceTotal = $derived(!narrowed && !showArchived ? statsQuery.data?.total : undefined)
127
280
  const countLine = $derived(
128
- reportedTotal !== undefined
129
- ? t('count', { n: reportedTotal })
130
- : assetsQuery.hasNextPage
131
- ? t('count_showing', { n: assets.length })
132
- : t('count', { n: assets.length }),
281
+ workspaceTotal !== undefined
282
+ ? t('count', { n: workspaceTotal })
283
+ : reportedTotal !== undefined
284
+ ? t('count', { n: reportedTotal })
285
+ : assetsQuery.hasNextPage
286
+ ? t('count_showing', { n: assets.length })
287
+ : t('count', { n: assets.length }),
133
288
  )
134
289
 
135
290
  /**
136
291
  * Every status the contract can hold, offered ahead of the features that set them.
137
292
  *
138
- * Until custody and repairs ship, nothing writes a status, so every asset is `in_stock` and the
139
- * other five options correctly return nothing. That is the data being uniform rather than the
140
- * filter being broken and the alternative, hiding options and adding them back one release
141
- * later, teaches somebody the list is unstable. The README says the same thing in as many words.
293
+ * Three of the six are written now `in_stock` and `assigned` by custody, `under_repair` by a
294
+ * repair and `reserved`, `lost` and `retired` wait on the features that set them, so those three
295
+ * correctly return nothing. That is the data being uniform rather than the filter being broken, and
296
+ * the alternative hiding options and adding them back one release later teaches somebody the
297
+ * list is unstable. The README says the same thing in as many words.
142
298
  */
143
299
  const statusOptions: SelectOption[] = [
144
300
  { value: '', label: t('filter_all_statuses') },
@@ -150,23 +306,6 @@ const statusOptions: SelectOption[] = [
150
306
  { value: 'retired', label: t('status_retired') },
151
307
  ]
152
308
 
153
- function statusTone(status: string): BadgeTone {
154
- switch (status) {
155
- case 'assigned':
156
- return 'info'
157
- case 'reserved':
158
- return 'info'
159
- case 'under_repair':
160
- return 'warning'
161
- case 'lost':
162
- return 'danger'
163
- case 'retired':
164
- return 'grey'
165
- default:
166
- return 'success'
167
- }
168
- }
169
-
170
309
  // ---------------------------------------------------------------- row actions
171
310
 
172
311
  /**
@@ -189,11 +328,15 @@ const setArchived = createMutation(() => ({
189
328
  mutationFn: (vars: ArchiveVars) =>
190
329
  api.assets.archive({ workspaceId, assetId: vars.assetId, archived: vars.archived }),
191
330
  onSuccess: (_saved: Asset, vars: ArchiveVars) => {
192
- toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', { name: vars.name }))
331
+ toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', isolated({ name: vars.name })))
193
332
  void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
194
333
  archiving = null
195
334
  },
196
- onError: (error: Error) => toast.error(error.message || t('common.error')),
335
+ // Not `error.message`: that is a sentence the *server* wrote, in English, and archiving something
336
+ // somebody is still holding is exactly the refusal a reader needs to understand. `errors.ts` maps
337
+ // the reason token this module's server sends — `inventory.asset.still_held` — to a translated
338
+ // sentence, and keeps the server's words only for a failure it does not recognise.
339
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
197
340
  onSettled: () => {
198
341
  acting = false
199
342
  },
@@ -234,7 +377,12 @@ function openEdit(asset: Asset) {
234
377
  * not there at all rather than being a door that will not open.
235
378
  */
236
379
  function actionsFor(asset: Asset): MenuItem[] {
237
- const items: MenuItem[] = [{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(asset) }]
380
+ const items: MenuItem[] = [
381
+ // The name in the row is already a button that opens this; the menu carries it too because a
382
+ // menu that lists everything a row can do is the one place somebody looks for what a row can do.
383
+ { label: t('open'), icon: 'arrow-right', onSelect: () => setAsset(asset.id) },
384
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(asset) },
385
+ ]
238
386
  if (asset.archivedAt) {
239
387
  items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(asset) })
240
388
  } else {
@@ -271,6 +419,12 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
271
419
  />
272
420
  {/snippet}
273
421
  {#snippet actions()}
422
+ <Select
423
+ bind:value={categoryFilter}
424
+ options={categoryOptions}
425
+ size="sm"
426
+ ariaLabel={t('category')}
427
+ />
274
428
  <Select bind:value={statusFilter} options={statusOptions} size="sm" ariaLabel={t('status')} />
275
429
  {#if canManage}
276
430
  <Button size="sm" icon="plus" onclick={openCreate}>{t('new')}</Button>
@@ -279,6 +433,21 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
279
433
  </PageHeader>
280
434
 
281
435
  <Page>
436
+ <!--
437
+ The return list, when somebody arrived here from an offboarding notification.
438
+ Named rather than silent: a list that has quietly dropped nine tenths of the register with no
439
+ sentence saying why is the single most confusing thing a filter can do, and this one is not set
440
+ by any control on the page — it came in on the URL.
441
+ -->
442
+ {#if custodianFilter}
443
+ <div class="held-by">
444
+ <span>{t('held_by', isolated({ name: custodianName ?? '' }))}</span>
445
+ <Button size="sm" variant="ghost" onclick={() => setParam('custodian', null)}>
446
+ {t('held_by_clear')}
447
+ </Button>
448
+ </div>
449
+ {/if}
450
+
282
451
  <div class="bar">
283
452
  <p class="count" aria-live="polite">{countLine}</p>
284
453
  <Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
@@ -339,19 +508,50 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
339
508
  <TableCell><code>{asset.code}</code></TableCell>
340
509
  <TableCell>
341
510
  <span class="stack">
342
- <span class="name">{asset.name}</span>
343
- <!-- `S/N` was a literal English abbreviation sitting in the middle of a Persian,
344
- Arabic, German or Turkish table; `serial_number` is translated in all five. -->
345
- {#if asset.serialNumber}
511
+ <!--
512
+ A button rather than the whole row: a row carrying a menu cannot itself be a button
513
+ without nesting one inside the other, which is invalid and breaks the menu. The name
514
+ is what somebody aims at anyway, and it is reachable from the keyboard.
515
+ -->
516
+ <button
517
+ type="button"
518
+ class="name"
519
+ onclick={() => setAsset(asset.id)}
520
+ aria-label={t('open_asset', isolated({ name: asset.name }))}
521
+ >
522
+ {asset.name}
523
+ </button>
524
+ {#if asset.categoryId || asset.serialNumber}
346
525
  <span class="sub">
347
- {t('serial_number')}:
348
- <span class="ltr">{asset.serialNumber}</span>
526
+ {#if asset.categoryId}
527
+ <!-- Named even when the category has since been archived: `categories` is
528
+ fetched with archived rows for exactly this. -->
529
+ <span class="chip">
530
+ {categoryNames.get(asset.categoryId) ?? t('category_none')}
531
+ </span>
532
+ {/if}
533
+ <!-- `S/N` was a literal English abbreviation sitting in the middle of a Persian,
534
+ Arabic, German or Turkish table; `serial_number` is translated in all five. -->
535
+ {#if asset.serialNumber}
536
+ <span class="serial">
537
+ {t('serial_number')}:
538
+ <span class="ltr">{asset.serialNumber}</span>
539
+ </span>
540
+ {/if}
349
541
  </span>
350
542
  {/if}
351
543
  </span>
352
544
  </TableCell>
353
545
  <TableCell><span class="muted">{asset.location ?? '—'}</span></TableCell>
354
- <TableCell><span class="muted">{asset.warrantyUntil ?? ''}</span></TableCell>
546
+ <!-- `formatDate`, not the stored value. This column printed the ISO string 2027-03-14
547
+ — beside a panel that renders the same field as "14 Mar 2027", so the same fact read
548
+ two ways on one screen, and a Persian reader got Gregorian Latin digits in a table
549
+ where every other date follows their calendar. -->
550
+ <TableCell>
551
+ <span class="muted">
552
+ {asset.warrantyUntil ? formatDate(asset.warrantyUntil) : '—'}
553
+ </span>
554
+ </TableCell>
355
555
  <TableCell>
356
556
  {#if asset.archivedAt}
357
557
  <Badge tone="grey">{t('archived')}</Badge>
@@ -367,7 +567,7 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
367
567
  {...props}
368
568
  icon="ellipsis"
369
569
  size={28}
370
- label={t('row_actions', { name: asset.name })}
570
+ label={t('row_actions', isolated({ name: asset.name }))}
371
571
  />
372
572
  {/snippet}
373
573
  </DropdownMenu>
@@ -394,11 +594,23 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
394
594
 
395
595
  <AssetFormDialog bind:open={dialogOpen} {workspaceId} asset={editingAsset} />
396
596
 
597
+ <!--
598
+ The panel sits over the list rather than replacing it: the scroll position, the filters and the
599
+ page cursor are all still there when it closes. Editing is the list's own dialog, so the panel
600
+ asks for it rather than growing a second copy of the form.
601
+ -->
602
+ <AssetDetailPanel
603
+ {workspaceId}
604
+ assetId={openAssetId}
605
+ onclose={() => setAsset(null)}
606
+ onedit={(asset) => openEdit(asset)}
607
+ />
608
+
397
609
  <!-- Archiving states what happens and to what, rather than asking "Are you sure?". -->
398
610
  <Dialog
399
611
  open={archiving !== null}
400
612
  size="sm"
401
- title={t('archive_title', { name: archiving?.name ?? '' })}
613
+ title={t('archive_title', isolated({ name: archiving?.name ?? '' }))}
402
614
  onOpenChange={(next) => {
403
615
  if (!next) archiving = null
404
616
  }}
@@ -423,6 +635,24 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
423
635
  font-size: 12px;
424
636
  color: var(--kern-ink-280);
425
637
  }
638
+ .held-by {
639
+ display: flex;
640
+ align-items: center;
641
+ justify-content: space-between;
642
+ gap: 12px;
643
+ flex-wrap: wrap;
644
+ margin-bottom: 10px;
645
+ /* Logical, never left/right: the extra room is beside the text, which in Persian is the other
646
+ side of the box. */
647
+ padding-block: 8px;
648
+ padding-inline: 12px 8px;
649
+ border-radius: var(--kern-r-md);
650
+ background: var(--kern-surface-chip);
651
+ font-size: 13px;
652
+ /* A colour rather than opacity, and 600 rather than 500: this sentence is the reason the list
653
+ below it is short, so it is read rather than skimmed. */
654
+ color: var(--kern-ink-600);
655
+ }
426
656
  .muted {
427
657
  color: var(--kern-ink-280);
428
658
  }
@@ -446,16 +676,58 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
446
676
  min-width: 0;
447
677
  }
448
678
  .name {
679
+ /* A button that reads as the row's title: no chrome, the row's own type, and the pointer the
680
+ design system already puts on every `button`. */
681
+ appearance: none;
682
+ background: none;
683
+ border: 0;
684
+ font: inherit;
449
685
  font-weight: 500;
450
686
  color: var(--kern-ink-900);
687
+ text-align: start;
451
688
  overflow: hidden;
452
689
  text-overflow: ellipsis;
453
690
  white-space: nowrap;
691
+ /* WCAG 2.5.8 wants 24px; a line of 13px text is about 18. The padding grows the hit area and
692
+ the equal negative margin gives the space back, so the margin box — and therefore the row —
693
+ is exactly where it was. */
694
+ padding-block: 4px;
695
+ margin-block: -4px;
696
+ /* So the global `:focus-visible` ring is rounded like every other control. */
697
+ border-radius: var(--kern-r-sm);
698
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
699
+ first strong character, so a Latin name inside a Persian screen reads left to right and
700
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
701
+ unicode-bidi: plaintext;
702
+ }
703
+ .name:hover {
704
+ text-decoration: underline;
454
705
  }
455
706
  .sub {
707
+ display: flex;
708
+ align-items: center;
709
+ gap: 6px;
710
+ min-width: 0;
456
711
  font-size: 11px;
457
712
  /* Muted with a colour, never with opacity: a faded row at 0.5 is unreadable whatever its token. */
458
713
  color: var(--kern-ink-280);
714
+ }
715
+ .chip {
716
+ flex: none;
717
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
718
+ first strong character, so a Latin name inside a Persian screen reads left to right and
719
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
720
+ unicode-bidi: plaintext;
721
+ padding: 1px 6px;
722
+ border-radius: var(--kern-r-sm);
723
+ background: var(--kern-surface-chip);
724
+ color: var(--kern-ink-600);
725
+ max-inline-size: 140px;
726
+ overflow: hidden;
727
+ text-overflow: ellipsis;
728
+ white-space: nowrap;
729
+ }
730
+ .serial {
459
731
  overflow: hidden;
460
732
  text-overflow: ellipsis;
461
733
  white-space: nowrap;
@@ -21,6 +21,13 @@ const key = (suffix: string) => {
21
21
  export const INVENTORY_PERMISSIONS = {
22
22
  view: key('asset.view'),
23
23
  manage: key('asset.manage'),
24
+ /** Handing an item over and taking it back. Reading who holds what rides `view` — see the
25
+ * contract's `permissions.ts` for why that is a decision rather than an omission. */
26
+ custody: key('custody.manage'),
27
+ /** Sending an item away, correcting the record and logging it back. */
28
+ repairs: key('repair.manage'),
29
+ /** The categories settings page, and nothing else. */
30
+ categories: key('category.manage'),
24
31
  } as const
25
32
 
26
33
  export type InventoryPermission = keyof typeof INVENTORY_PERMISSIONS