@kernhq/module-inventory 0.2.0 → 0.4.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 (198) hide show
  1. package/README.md +106 -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 +345 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +245 -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 +1108 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +253 -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 +1392 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +385 -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 +169 -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 +157 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +309 -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/0008_category_order_unique.sql +71 -0
  117. package/migrations/meta/0002_snapshot.json +1054 -0
  118. package/migrations/meta/0003_snapshot.json +1070 -0
  119. package/migrations/meta/0004_snapshot.json +1130 -0
  120. package/migrations/meta/0005_snapshot.json +1135 -0
  121. package/migrations/meta/_journal.json +49 -0
  122. package/package.json +2 -1
  123. package/src/client/api-instance.ts +27 -2
  124. package/src/client/bidi.test.ts +148 -0
  125. package/src/client/bidi.ts +85 -0
  126. package/src/client/components/AssetDetailPanel.svelte +614 -0
  127. package/src/client/components/AssetFormDialog.svelte +71 -22
  128. package/src/client/components/AssetPhoto.svelte +178 -0
  129. package/src/client/components/AttachmentsSection.svelte +327 -0
  130. package/src/client/components/CustodyDialog.svelte +201 -0
  131. package/src/client/components/RepairDialog.svelte +271 -0
  132. package/src/client/components/RepairsSection.svelte +318 -0
  133. package/src/client/components/Timeline.svelte +347 -0
  134. package/src/client/components/TimelineText.svelte +124 -0
  135. package/src/client/core-api.ts +71 -0
  136. package/src/client/custody.test.ts +31 -0
  137. package/src/client/custody.ts +34 -0
  138. package/src/client/errors.test.ts +395 -0
  139. package/src/client/errors.ts +229 -0
  140. package/src/client/links.test.ts +74 -0
  141. package/src/client/links.ts +44 -0
  142. package/src/client/members.test.ts +132 -0
  143. package/src/client/members.ts +116 -0
  144. package/src/client/messages.test.ts +127 -0
  145. package/src/client/messages.ts +1106 -18
  146. package/src/client/mock.test.ts +465 -1
  147. package/src/client/mock.ts +1105 -79
  148. package/src/client/module.ts +73 -1
  149. package/src/client/pages/AssetsPage.svelte +317 -45
  150. package/src/client/permissions.ts +7 -0
  151. package/src/client/price.test.ts +151 -3
  152. package/src/client/price.ts +155 -11
  153. package/src/client/query.ts +36 -0
  154. package/src/client/reorder.test.ts +100 -0
  155. package/src/client/reorder.ts +79 -0
  156. package/src/client/repairs.test.ts +38 -0
  157. package/src/client/repairs.ts +38 -0
  158. package/src/client/sequence.test.ts +248 -0
  159. package/src/client/sequence.ts +185 -0
  160. package/src/client/settings/CategoriesSettings.svelte +746 -0
  161. package/src/client/settings/GeneralSettings.svelte +0 -0
  162. package/src/client/status.ts +29 -0
  163. package/src/client/timeline.test.ts +175 -0
  164. package/src/client/timeline.ts +206 -0
  165. package/src/client/widgets/OverviewWidget.svelte +127 -26
  166. package/src/client/widgets/RepairsWidget.svelte +124 -0
  167. package/src/contract/capabilities.ts +53 -9
  168. package/src/contract/events.ts +49 -0
  169. package/src/contract/index.ts +1 -0
  170. package/src/contract/models.ts +270 -0
  171. package/src/contract/notifications.ts +73 -0
  172. package/src/contract/permissions.ts +55 -2
  173. package/src/contract/router.ts +284 -1
  174. package/src/contract/settings.ts +24 -4
  175. package/src/module.test.ts +217 -4
  176. package/src/server/index.ts +311 -4
  177. package/src/server/inventory.int.test.ts +4109 -19
  178. package/src/server/jobs.ts +444 -0
  179. package/src/server/migrations.test.ts +253 -2
  180. package/src/server/router.ts +486 -6
  181. package/src/server/schema.ts +171 -3
  182. package/src/server/services/assets.ts +214 -54
  183. package/src/server/services/attachments.ts +215 -0
  184. package/src/server/services/audience.ts +77 -0
  185. package/src/server/services/categories.ts +337 -0
  186. package/src/server/services/cursor.ts +104 -0
  187. package/src/server/services/custody.ts +471 -0
  188. package/src/server/services/db-errors.ts +42 -0
  189. package/src/server/services/index.ts +34 -1
  190. package/src/server/services/members.ts +54 -0
  191. package/src/server/services/notify.ts +52 -7
  192. package/src/server/services/offboarding.ts +150 -0
  193. package/src/server/services/repairs.ts +567 -0
  194. package/src/server/services/search.ts +166 -0
  195. package/src/server/services/stats.ts +88 -0
  196. package/src/server/services/status.test.ts +34 -0
  197. package/src/server/services/status.ts +143 -0
  198. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,746 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Field,
9
+ Icon,
10
+ IconButton,
11
+ Input,
12
+ type MenuItem,
13
+ messageLocale,
14
+ navigation,
15
+ SettingsPage,
16
+ SettingsSection,
17
+ Skeleton,
18
+ Switch,
19
+ session,
20
+ toast,
21
+ } from '@kernhq/ui'
22
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
23
+ import { untrack } from 'svelte'
24
+ import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
25
+ import type { Category } from '../../contract/index.js'
26
+ import { getInventoryApi } from '../api-instance.js'
27
+ import { isolated } from '../bidi.js'
28
+ import { errorMessage, reasonOf } from '../errors.js'
29
+ import { t } from '../i18n.js'
30
+ import { INVENTORY_PERMISSIONS } from '../permissions.js'
31
+ import { inventoryKeys } from '../query.js'
32
+ import { placementOf } from '../reorder.js'
33
+ import {
34
+ consider as considered,
35
+ finalize as finalized,
36
+ move as moved,
37
+ refused,
38
+ reseed,
39
+ type Sequence,
40
+ type Step,
41
+ saved,
42
+ seed,
43
+ start,
44
+ } from '../sequence.js'
45
+
46
+ /**
47
+ * How a workspace groups what it owns, and the order it groups them in.
48
+ *
49
+ * `assets.list` has taken a `categoryId` filter since the module existed and nothing could create a
50
+ * category, so the filter had exactly one possible answer — this page is the other half of it.
51
+ *
52
+ * **The order is dragged, not typed.** This page used to carry a *Position* field: a number box on
53
+ * the add-and-rename dialog, with a hint explaining that lower comes first and that two categories
54
+ * sharing a number fall back to their names. That is a database column with a form around it.
55
+ * Nobody arranges their filing by integer, the field invited the one state it then had to explain,
56
+ * and moving a category two places meant working out a number that would land it there. It is a
57
+ * list you drag now, and `categories.reorder` writes the whole sequence in one transaction.
58
+ *
59
+ * **A drag is a gesture, never a requirement.** It is unreachable by keyboard and by anybody who
60
+ * cannot hold a pointer steady, so every row carries move-up and move-down buttons that do exactly
61
+ * the same thing, and the result is spoken into a live region rather than moving in silence. Those
62
+ * buttons are the *only* keyboard route: the drag library ships one of its own, and shipping both
63
+ * left both half-working, so it is switched off — see `keepKeyboardOnTheButtons`.
64
+ *
65
+ * **Nothing here deletes.** `assets.category_id` carries no foreign key, so removing a row would
66
+ * leave every asset filed under it pointing at nothing: a blank column, and a timeline entry saying
67
+ * the category changed *to* a name it can no longer print. Archiving takes it out of every picker
68
+ * and every filter and leaves each asset able to say what it is; the same procedure restores it.
69
+ */
70
+ const api = getInventoryApi()
71
+ const queryClient = useQueryClient()
72
+
73
+ const workspaceSlug = $derived(navigation.workspaceSlug)
74
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === workspaceSlug)?.id ?? '')
75
+
76
+ /**
77
+ * The permission the server enforces on every write here.
78
+ *
79
+ * The page is already gated on it in `module.ts`, so somebody without it never sees the entry —
80
+ * but a settings URL can be typed, and a read-only view of the list is a better answer than a form
81
+ * whose every button 403s.
82
+ */
83
+ const canManage = $derived(session.can(INVENTORY_PERMISSIONS.categories))
84
+
85
+ let showArchived = $state(false)
86
+
87
+ /**
88
+ * One query for every category, archived ones included, split here.
89
+ *
90
+ * Two things come out of that. It is the key `AssetsPage` and `AssetDetailPanel` already use, so
91
+ * arriving at this page after either of them costs no request — and, the reason it changed, it is
92
+ * the only way this page can tell **"no categories yet"** from **"every category is archived"**.
93
+ * With `archived: showArchived` in the request the two are the same empty array, and the page told
94
+ * a workspace that had archived all five of its categories that it had never made one — beside a
95
+ * *New category* button, with the row that would have fixed it one switch away.
96
+ */
97
+ const categoriesQuery = createQuery(() => ({
98
+ queryKey: inventoryKeys.categories(workspaceId, true),
99
+ queryFn: () => api.categories.list({ workspaceId, archived: true }),
100
+ enabled: Boolean(workspaceId),
101
+ }))
102
+ const everything = $derived<Category[]>(categoriesQuery.data ?? [])
103
+
104
+ /**
105
+ * The two lists, and why they are two.
106
+ *
107
+ * `live` is the sequence: it is what a person arranges, what every picker and filter shows, and the
108
+ * exact set `categories.reorder` insists on being handed. An archived category is in none of those
109
+ * places, so it has no position to arrange — putting it in the same draggable list would let
110
+ * somebody carefully place a row that nobody but this page will ever see, between two rows it does
111
+ * not sit between anywhere else. They get their own group, in their own order.
112
+ *
113
+ * Sorted with the reader's own collation rather than the runtime's: `localeCompare` with no locale
114
+ * sorts Persian and Turkish names by whatever the browser happens to default to.
115
+ */
116
+ const live = $derived(everything.filter((row) => !row.archivedAt))
117
+ const collator = $derived(new Intl.Collator(messageLocale()))
118
+ const archived = $derived(
119
+ everything.filter((row) => row.archivedAt).sort((a, b) => collator.compare(a.name, b.name)),
120
+ )
121
+ /** Rows exist, and the switch is hiding all of them. A different sentence from having none. */
122
+ const allArchived = $derived(live.length === 0 && archived.length > 0 && !showArchived)
123
+
124
+ // ------------------------------------------------------------------------------- the sequence
125
+
126
+ const FLIP = 140
127
+
128
+ /**
129
+ * The order on screen, the two snapshots behind it, and the two flags — one value, in `sequence.ts`.
130
+ *
131
+ * It lives next door rather than here because a `.svelte` file cannot be unit-tested in this package,
132
+ * and every defect this screen has had was an *ordering* rather than a calculation: a keyboard drag
133
+ * ending on a different event from a pointer drag, a refusal rolling back to a list the server had
134
+ * already rejected, a keypress arriving while the last one was still being written. Each of those is
135
+ * three assertions in `sequence.test.ts` and three careful readings here.
136
+ *
137
+ * `$state.raw` and not a plain `$state`: a deep-reactive proxy hands the drag library a different
138
+ * object on every read, and it reads that as an endless stream of changes.
139
+ */
140
+ let sequence = $state.raw<Sequence<Category>>(start<Category>([]))
141
+ let announcements = $state<[string, string]>(['', ''])
142
+ let pulse = false
143
+
144
+ /**
145
+ * The library's own types want a mutable array, and its keyboard action really does splice the one
146
+ * it is given — which is the route this screen switches off below. Nothing here ever mutates it.
147
+ */
148
+ const rows = $derived(sequence.rows as Category[])
149
+
150
+ /**
151
+ * Seed the sequence from the query, and *only* from it.
152
+ *
153
+ * `seed` declines while a drag or a save is in progress, so a refetch landing mid-gesture cannot pull
154
+ * the list out from under the pointer or replace the optimistic order with data the write has not
155
+ * reached yet. The flags are read inside `seed` rather than here, which is also what keeps them out
156
+ * of this effect's dependencies: reading one directly would re-run the effect when it cleared, and
157
+ * re-seed from a query the write has not reached.
158
+ *
159
+ * A skipped seed is dropped rather than queued, and the two ways back are deliberate: a successful
160
+ * write answers with the sequence it wrote, and a refusal re-seeds from what the server actually has.
161
+ *
162
+ * **`untrack` around the read, or this effect feeds itself.** `seed` returns a new object, so an
163
+ * effect that both reads and writes `sequence` invalidates its own dependency and Svelte stops it
164
+ * with `effect_update_depth_exceeded` — at runtime, on a screen that type-checks perfectly. The one
165
+ * thing this is allowed to react to is the query.
166
+ */
167
+ $effect(() => {
168
+ const next = live
169
+ sequence = untrack(() => seed(sequence, next))
170
+ })
171
+
172
+ const isShadow = (category: Category) =>
173
+ (category as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
174
+
175
+ /** The reason token the server sends when the list no longer describes the workspace. */
176
+ const ORDER_STALE = 'inventory.category.order_stale'
177
+
178
+ const reorder = createMutation(() => ({
179
+ mutationFn: (categoryIds: readonly string[]) =>
180
+ api.categories.reorder({ workspaceId, categoryIds: [...categoryIds] }),
181
+ onSuccess: (written: Category[]) => {
182
+ // The server answers the sequence it wrote, so there is nothing to guess and no flash: the
183
+ // optimistic list is replaced by the same list. When somebody kept pressing an arrow key while
184
+ // this was in flight, `saved` hands back the list those presses add up to and it goes now —
185
+ // one more request for any number of presses, and never one per keypress arriving out of order.
186
+ take(saved(sequence, written))
187
+ // Refreshes the picker on the asset form and the filter on the list, which read the same query.
188
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
189
+ },
190
+ /**
191
+ * Say why, and — for the one refusal a person can act on — leave them able to act on it.
192
+ *
193
+ * A rollback alone is what made `order_stale` unrecoverable. It restores `settled`, which is the
194
+ * list the server has just refused, and the seeding effect cannot replace it: that effect is keyed
195
+ * on the query's data, and the refetch after the invalidation returns the value it already skipped
196
+ * while the save was in flight. Same value, no change, no re-run — so every retry sent the same
197
+ * stale list and earned the same refusal, under a message telling the reader to try again.
198
+ *
199
+ * So the invalidation is awaited and the list is re-seeded from what actually came back. Read out
200
+ * of the cache rather than out of `live`, because that is the value this screen is about to be
201
+ * given and reading it directly does not depend on anything having changed.
202
+ */
203
+ onError: async (error: unknown) => {
204
+ toast.error(errorMessage(error, t))
205
+ const stale = reasonOf(error) === ORDER_STALE
206
+ sequence = refused(sequence)
207
+ await queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
208
+ if (!stale) return
209
+ const fresh = queryClient.getQueryData<Category[]>(inventoryKeys.categories(workspaceId, true))
210
+ if (fresh) sequence = reseed(fresh.filter((row) => !row.archivedAt))
211
+ },
212
+ }))
213
+
214
+ /**
215
+ * Adopt a transition, and do the work it leaves behind: post a list, speak a sentence, or neither.
216
+ *
217
+ * The three decisions themselves are in `sequence.ts`, with a test each. This is the wiring.
218
+ */
219
+ function take(step: Step<Category>) {
220
+ sequence = step.next
221
+ if (step.announce) announce(step.announce.list, step.announce.id)
222
+ if (step.save) reorder.mutate(step.save)
223
+ }
224
+
225
+ function move(category: Category, delta: number) {
226
+ take(moved(sequence, category.id, delta))
227
+ }
228
+
229
+ function consider(event: CustomEvent<{ items: Category[]; info: { trigger: string } }>) {
230
+ take(considered(sequence, event.detail.items, event.detail.info.trigger))
231
+ }
232
+
233
+ function finalize(event: CustomEvent<{ items: Category[]; info: { id: string } }>) {
234
+ take(
235
+ finalized(
236
+ sequence,
237
+ event.detail.items.filter((category) => !isShadow(category)),
238
+ event.detail.info.id,
239
+ ),
240
+ )
241
+ }
242
+
243
+ /**
244
+ * The keys `svelte-dnd-action` claims on a row, held back before they ever reach it.
245
+ *
246
+ * **This screen has one keyboard route to reordering, and it is the two buttons on each row.**
247
+ *
248
+ * The library ships a second one, and shipping both left both half-working. Its keyboard drag put a
249
+ * tab stop on every row — a stop that announces nothing and does nothing until you know to press
250
+ * Enter on it — and then fired a `finalize` on *every arrow key*, so moving a category three places
251
+ * was three writes, of which the guard discarded two. It also ends on a `consider` rather than a
252
+ * `finalize`, which is the event asymmetry `sequence.ts` exists to absorb.
253
+ *
254
+ * The house rule is that a drag must have a **non-drag equivalent**, not that the library's drag must
255
+ * also be driveable from the keyboard. *Move up* and *move down* are that equivalent: they are real
256
+ * buttons with real names, they are reachable in the same tab order as everything else on the page,
257
+ * and one press is one move whatever the network is doing. So the library's keyboard route is turned
258
+ * off rather than left as a worse duplicate of them — `zoneItemTabIndex: -1` takes the rows out of the
259
+ * tab order, and this takes the trigger keys away from a row that has been focused by a click, which
260
+ * is the one way left to reach it.
261
+ *
262
+ * In the capture phase on the list, because the library listens on each row: a capture handler on the
263
+ * ancestor runs first, and `stopPropagation` there means the row's own listener never sees the key. It
264
+ * only ever fires for a key pressed on a **row**; a key on a button inside one is somebody using the
265
+ * controls, and passes straight through.
266
+ */
267
+ const LIBRARY_DRAG_KEYS = new Set(['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
268
+
269
+ function keepKeyboardOnTheButtons(event: KeyboardEvent) {
270
+ const target = event.target as HTMLElement | null
271
+ if (!target || target.parentElement !== event.currentTarget) return
272
+ if (LIBRARY_DRAG_KEYS.has(event.key)) event.stopPropagation()
273
+ }
274
+
275
+ /**
276
+ * What a screen reader is told, and why it never contains a number.
277
+ *
278
+ * "position 4 of 9" asks somebody to hold two numbers in their head to work out what a neighbour's
279
+ * name says outright — and a number is the thing this page stopped showing. The sentence describes
280
+ * where the row *is* rather than what just happened, so it is still true when the answer is that
281
+ * the row could not move: pressing *move up* on the first row says it is first.
282
+ */
283
+ function announce(list: readonly Category[], id: string) {
284
+ const name = list.find((category) => category.id === id)?.name
285
+ const spot = placementOf(list, id)
286
+ if (name === undefined || spot.at === 'gone') return
287
+ const sentence =
288
+ spot.at === 'after'
289
+ ? t('category_position_after', isolated({ name, other: spot.previous.name }))
290
+ : t(spot.at === 'first' ? 'category_position_first' : 'category_position_last', isolated({ name }))
291
+ /**
292
+ * Two regions, written alternately, because one would fall silent.
293
+ *
294
+ * A live region announces a *change* to its text, and pressing *move down* twice on the row that
295
+ * is already last produces the same sentence twice — so the second press would say nothing, which
296
+ * reads as a broken button to the one person who cannot see that nothing moved. Filling one region
297
+ * while emptying the other makes every announcement a change, whatever the words are. The
298
+ * alternative trick is a trailing zero-width space, and it puts a character nobody can see into
299
+ * the source for somebody to delete by accident.
300
+ */
301
+ pulse = !pulse
302
+ announcements = pulse ? [sentence, ''] : ['', sentence]
303
+ }
304
+
305
+ // ------------------------------------------------------------------ the add / rename dialog
306
+
307
+ let editing = $state<Category | null>(null)
308
+ let dialogOpen = $state(false)
309
+ let name = $state('')
310
+
311
+ function openCreate() {
312
+ editing = null
313
+ name = ''
314
+ dialogOpen = true
315
+ }
316
+
317
+ function openEdit(category: Category) {
318
+ editing = category
319
+ name = category.name
320
+ dialogOpen = true
321
+ }
322
+
323
+ const canSubmit = $derived(canManage && Boolean(name.trim()))
324
+
325
+ /**
326
+ * Set in the same tick as the click, for the reason `sequence.saving` above is: the attribute from
327
+ * `disabled={mutation.isPending}` reaches the button on the next render, and two quick clicks are one
328
+ * render apart — so a double-click would file the same category twice. Guarded rather than disabled,
329
+ * because disabling the control somebody is standing on throws their focus out to the page.
330
+ */
331
+ let saving = $state(false)
332
+
333
+ const save = createMutation(() => ({
334
+ mutationFn: () => {
335
+ const row = editing
336
+ const values = { name: name.trim() }
337
+ return row
338
+ ? api.categories.update({ workspaceId, categoryId: row.id, ...values })
339
+ : api.categories.create({ workspaceId, ...values })
340
+ },
341
+ onSuccess: (saved: Category) => {
342
+ toast.success(
343
+ t(editing ? 'category_updated_toast' : 'category_created_toast', isolated({ name: saved.name })),
344
+ )
345
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
346
+ dialogOpen = false
347
+ },
348
+ // The server's sentence is the actionable one and it is in English — "This workspace already has
349
+ // a category called “Laptops”." was shown verbatim to a reader who chose Persian. The token it
350
+ // carries, `inventory.category.name_taken`, is the part a client can translate; the name is on
351
+ // screen in the box directly above the message, so the sentence does not need to repeat it.
352
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
353
+ onSettled: () => {
354
+ saving = false
355
+ },
356
+ }))
357
+
358
+ function submit() {
359
+ if (saving || !canSubmit) return
360
+ saving = true
361
+ save.mutate()
362
+ }
363
+
364
+ // ------------------------------------------------------------------------ archive and restore
365
+
366
+ let archiving = $state<Category | null>(null)
367
+ let acting = $state(false)
368
+
369
+ interface ArchiveVars {
370
+ categoryId: string
371
+ archived: boolean
372
+ name: string
373
+ }
374
+
375
+ const setArchived = createMutation(() => ({
376
+ mutationFn: (vars: ArchiveVars) =>
377
+ api.categories.archive({ workspaceId, categoryId: vars.categoryId, archived: vars.archived }),
378
+ onSuccess: (_saved: Category, vars: ArchiveVars) => {
379
+ toast.success(
380
+ t(vars.archived ? 'category_archived_toast' : 'category_restored_toast', isolated({ name: vars.name })),
381
+ )
382
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
383
+ archiving = null
384
+ },
385
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
386
+ onSettled: () => {
387
+ acting = false
388
+ },
389
+ }))
390
+
391
+ function confirmArchive() {
392
+ const target = archiving
393
+ if (!target || acting) return
394
+ acting = true
395
+ setArchived.mutate({ categoryId: target.id, archived: true, name: target.name })
396
+ }
397
+
398
+ function restore(category: Category) {
399
+ if (acting) return
400
+ acting = true
401
+ setArchived.mutate({ categoryId: category.id, archived: false, name: category.name })
402
+ }
403
+
404
+ /**
405
+ * Restoring takes nothing away and needs no confirmation; archiving states what happens to the
406
+ * assets already filed under it. Hidden rather than disabled without the permission.
407
+ */
408
+ function actionsFor(category: Category): MenuItem[] {
409
+ const items: MenuItem[] = [
410
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(category) },
411
+ ]
412
+ if (category.archivedAt) {
413
+ items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(category) })
414
+ } else {
415
+ items.push({ type: 'separator' })
416
+ items.push({
417
+ label: t('common.archive'),
418
+ icon: 'archive',
419
+ danger: true,
420
+ onSelect: () => (archiving = category),
421
+ })
422
+ }
423
+ return items
424
+ }
425
+
426
+ const SKELETON_ROWS = [0, 1, 2, 3]
427
+ </script>
428
+
429
+ <!--
430
+ One row, drawn the same whether it is part of the sequence or sitting in the archived group. The
431
+ grip, the two arrows and the drag itself belong only to the sequence: an archived category is in
432
+ no picker and no filter, so it has no position for anybody to arrange.
433
+ -->
434
+ {#snippet row(category: Category, sortable: boolean)}
435
+ <li
436
+ class="row"
437
+ class:sortable
438
+ class:shadow={isShadow(category)}
439
+ aria-label={sortable ? category.name : undefined}
440
+ >
441
+ {#if sortable}
442
+ <span class="grip" aria-hidden="true"><Icon name="grip-vertical" size={14} strokeWidth={1.8} /></span>
443
+ {/if}
444
+ <span class="cell">
445
+ <span class="name">{category.name}</span>
446
+ <!-- Only the archived state gets a badge. A "live" badge on every other row would be a column
447
+ of the same word, and the one it would have to borrow — `status_in_stock` — describes an
448
+ asset sitting in a cupboard, not a category. -->
449
+ {#if category.archivedAt}<Badge tone="grey">{t('archived')}</Badge>{/if}
450
+ </span>
451
+ {#if sortable}
452
+ <IconButton
453
+ icon="chevron-up"
454
+ size={28}
455
+ label={t('category_move_up', isolated({ name: category.name }))}
456
+ onclick={() => move(category, -1)}
457
+ />
458
+ <IconButton
459
+ icon="chevron-down"
460
+ size={28}
461
+ label={t('category_move_down', isolated({ name: category.name }))}
462
+ onclick={() => move(category, 1)}
463
+ />
464
+ {/if}
465
+ {#if canManage}
466
+ <DropdownMenu items={actionsFor(category)} align="end">
467
+ {#snippet trigger(props)}
468
+ <IconButton
469
+ {...props}
470
+ icon="ellipsis"
471
+ size={28}
472
+ label={t('row_actions', isolated({ name: category.name }))}
473
+ />
474
+ {/snippet}
475
+ </DropdownMenu>
476
+ {/if}
477
+ </li>
478
+ {/snippet}
479
+
480
+ <SettingsPage title={t('settings_categories')} description={t('settings_categories_desc')}>
481
+ {#snippet actions()}
482
+ {#if canManage}
483
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('category_new')}</Button>
484
+ {/if}
485
+ {/snippet}
486
+
487
+ <SettingsSection flush>
488
+ <div class="bar">
489
+ <!-- The hint earns its place only where the gesture is available and there is something to
490
+ reorder. On a one-category workspace it would explain a thing that cannot be done. -->
491
+ {#if canManage && live.length > 1}
492
+ <p class="hint">{t('category_reorder_hint')}</p>
493
+ {/if}
494
+ <Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
495
+ </div>
496
+
497
+ {#if categoriesQuery.isPending}
498
+ <div class="skeleton">
499
+ {#each SKELETON_ROWS as skeleton (skeleton)}
500
+ <div class="srow">
501
+ <Skeleton height="12px" width="52%" />
502
+ {#if canManage}<Skeleton height="12px" width="16px" />{/if}
503
+ </div>
504
+ {/each}
505
+ </div>
506
+ {:else if categoriesQuery.isError}
507
+ <EmptyState icon="triangle-alert" title={t('categories_error')} description={t('common.error')}>
508
+ {#snippet actions()}
509
+ <Button variant="secondary" onclick={() => void categoriesQuery.refetch()}>
510
+ {t('common.retry')}
511
+ </Button>
512
+ {/snippet}
513
+ </EmptyState>
514
+ {:else if everything.length === 0}
515
+ <!--
516
+ Reachable, and worth keeping: `onWorkspaceEnabled` seeds five categories, but it only runs
517
+ when a workspace switches the module on — an instance upgraded with Inventory already
518
+ enabled never ran it, and neither does a workspace whose seeding half failed.
519
+ -->
520
+ <EmptyState icon="tag" title={t('categories_empty')} description={t('categories_empty_desc')}>
521
+ {#snippet actions()}
522
+ {#if canManage}
523
+ <Button onclick={openCreate}>{t('category_new')}</Button>
524
+ {/if}
525
+ {/snippet}
526
+ </EmptyState>
527
+ {:else if allArchived}
528
+ <!--
529
+ Not "No categories yet": this workspace has categories and is looking at none of them. The
530
+ action is the one that fixes it — show the archived rows, which is where Restore lives —
531
+ rather than a New category button that would leave the archived ones exactly as they are.
532
+ -->
533
+ <EmptyState
534
+ icon="archive"
535
+ title={t('categories_all_archived')}
536
+ description={t('categories_all_archived_desc')}
537
+ >
538
+ {#snippet actions()}
539
+ <Button variant="secondary" onclick={() => (showArchived = true)}>
540
+ {t('show_archived')}
541
+ </Button>
542
+ {/snippet}
543
+ </EmptyState>
544
+ {:else}
545
+ {#if canManage}
546
+ <!--
547
+ The drag is a pointer gesture here, and the arrows on each row are the keyboard equivalent.
548
+
549
+ `autoAriaDisabled`, because the library speaks its own English to screen readers and this
550
+ product promises five languages; every sentence a reader hears here is one of ours, in the
551
+ live region below. `zoneTabIndex: -1` and `zoneItemTabIndex: -1` take the list and its rows
552
+ out of the tab order — stops that announce nothing and do nothing, now that the library is
553
+ neither describing them nor driving them — and `onkeydowncapture` takes the trigger keys
554
+ away from a row focused by a click, which is the one way left into the library's own
555
+ keyboard drag. See `keepKeyboardOnTheButtons` for why that route is off rather than fixed.
556
+ -->
557
+ <ul
558
+ class="rows"
559
+ role="list"
560
+ aria-label={t('settings_categories')}
561
+ aria-busy={sequence.saving}
562
+ use:dndzone={{
563
+ items: rows,
564
+ type: 'inventory-categories',
565
+ flipDurationMs: FLIP,
566
+ dropTargetStyle: {},
567
+ autoAriaDisabled: true,
568
+ zoneTabIndex: -1,
569
+ zoneItemTabIndex: -1,
570
+ }}
571
+ onconsider={consider}
572
+ onfinalize={finalize}
573
+ onkeydowncapture={keepKeyboardOnTheButtons}
574
+ >
575
+ {#each rows as category (category.id)}
576
+ {@render row(category, true)}
577
+ {/each}
578
+ </ul>
579
+ {:else}
580
+ <ul class="rows" role="list" aria-label={t('settings_categories')}>
581
+ {#each rows as category (category.id)}
582
+ {@render row(category, false)}
583
+ {/each}
584
+ </ul>
585
+ {/if}
586
+
587
+ <!--
588
+ Archived categories, in their own group and in their own order.
589
+
590
+ Not part of the sequence above, because they are in no picker and no filter — there is
591
+ nothing for a position to be a position *in*. Sorted by name rather than by the number they
592
+ happened to leave with, which is the one ordering somebody can predict. `h2`, not `h3`: this
593
+ section is passed no title, so the page's `h1` is the level directly above it.
594
+ -->
595
+ {#if showArchived && archived.length > 0}
596
+ <h2 class="kern-sublabel group">{t('archived')}</h2>
597
+ <ul class="rows" role="list" aria-label={t('archived')}>
598
+ {#each archived as category (category.id)}
599
+ {@render row(category, false)}
600
+ {/each}
601
+ </ul>
602
+ {/if}
603
+ {/if}
604
+ </SettingsSection>
605
+ </SettingsPage>
606
+
607
+ <!-- Rendered always, and empty until there is something to say: a live region that appears at the
608
+ same moment as its text is a region most screen readers never announce. Two of them, written
609
+ alternately, so the same sentence twice in a row is still a change — see `announce`. -->
610
+ <p class="kern-sr-only" aria-live="polite">{announcements[0]}</p>
611
+ <p class="kern-sr-only" aria-live="polite">{announcements[1]}</p>
612
+
613
+ <Dialog
614
+ bind:open={dialogOpen}
615
+ size="sm"
616
+ title={editing ? t('category_edit') : t('category_new')}
617
+ >
618
+ <div class="form">
619
+ <Field label={t('category')} id="inv-cat-name" required hint={t('category_name_placeholder')}>
620
+ {#snippet children(id)}
621
+ <Input {id} bind:value={name} maxlength={120} />
622
+ {/snippet}
623
+ </Field>
624
+ </div>
625
+
626
+ {#snippet footer()}
627
+ <Button variant="ghost" onclick={() => (dialogOpen = false)}>{t('common.cancel')}</Button>
628
+ <Button onclick={submit} disabled={!canSubmit} loading={saving}>{t('common.save')}</Button>
629
+ {/snippet}
630
+ </Dialog>
631
+
632
+ <!-- States what happens to the assets already filed under it, rather than asking "Are you sure?". -->
633
+ <Dialog
634
+ open={archiving !== null}
635
+ size="sm"
636
+ title={t('category_archive_title', isolated({ name: archiving?.name ?? '' }))}
637
+ onOpenChange={(next) => {
638
+ if (!next) archiving = null
639
+ }}
640
+ >
641
+ <p class="dialog-body">{t('category_archive_body')}</p>
642
+ {#snippet footer()}
643
+ <Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
644
+ <Button variant="danger" onclick={confirmArchive} loading={acting}>{t('common.archive')}</Button>
645
+ {/snippet}
646
+ </Dialog>
647
+
648
+ <style>
649
+ .bar {
650
+ display: flex;
651
+ align-items: center;
652
+ justify-content: flex-end;
653
+ gap: 16px;
654
+ padding: 10px 12px;
655
+ }
656
+ .hint {
657
+ margin: 0;
658
+ min-width: 0;
659
+ /* Logical, so the switch stays at the trailing edge in Persian and Arabic too. */
660
+ margin-inline-end: auto;
661
+ font-size: 12px;
662
+ line-height: 1.5;
663
+ /* A colour rather than opacity, which fades text against the page whatever token it names. */
664
+ color: var(--kern-ink-500);
665
+ }
666
+ .rows {
667
+ list-style: none;
668
+ margin: 0;
669
+ padding: 0;
670
+ display: flex;
671
+ flex-direction: column;
672
+ }
673
+ .row {
674
+ display: flex;
675
+ align-items: center;
676
+ gap: 8px;
677
+ min-height: 48px;
678
+ padding: 4px 12px;
679
+ border-top: 1px solid var(--kern-border-hairline);
680
+ font-size: 13px;
681
+ color: var(--kern-ink-600);
682
+ background: var(--kern-surface-raised);
683
+ }
684
+ .row.sortable {
685
+ cursor: grab;
686
+ }
687
+ .row.sortable:active {
688
+ cursor: grabbing;
689
+ }
690
+ /* The placeholder the drag library keeps under the cursor, marking where the row will land.
691
+ Hidden rather than faded: it still holds its space, so the gap opens exactly where the row is
692
+ going, and a half-transparent copy of a row that is already on screen under the pointer reads
693
+ as a rendering fault rather than as a target. */
694
+ .row.shadow {
695
+ visibility: hidden;
696
+ }
697
+ .grip {
698
+ display: inline-flex;
699
+ color: var(--kern-ink-400);
700
+ }
701
+ .cell {
702
+ display: flex;
703
+ align-items: center;
704
+ gap: 8px;
705
+ flex: 1;
706
+ min-width: 0;
707
+ }
708
+ .name {
709
+ font-weight: 500;
710
+ color: var(--kern-ink-900);
711
+ overflow: hidden;
712
+ text-overflow: ellipsis;
713
+ white-space: nowrap;
714
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
715
+ first strong character, so a Latin name inside a Persian screen reads left to right and
716
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
717
+ unicode-bidi: plaintext;
718
+ }
719
+ .group {
720
+ margin: 0;
721
+ padding: 16px 12px 6px;
722
+ border-top: 1px solid var(--kern-border-hairline);
723
+ }
724
+ .skeleton {
725
+ display: flex;
726
+ flex-direction: column;
727
+ }
728
+ .srow {
729
+ display: flex;
730
+ align-items: center;
731
+ justify-content: space-between;
732
+ gap: 12px;
733
+ padding: 18px 12px;
734
+ border-top: 1px solid var(--kern-border-hairline);
735
+ }
736
+ .form {
737
+ display: grid;
738
+ gap: 14px;
739
+ }
740
+ .dialog-body {
741
+ margin: 0;
742
+ font-size: 13.5px;
743
+ line-height: 1.55;
744
+ color: var(--kern-ink-700);
745
+ }
746
+ </style>