@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
@@ -0,0 +1,421 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Field,
9
+ IconButton,
10
+ Input,
11
+ type MenuItem,
12
+ navigation,
13
+ SettingsPage,
14
+ SettingsSection,
15
+ Skeleton,
16
+ Switch,
17
+ session,
18
+ Table,
19
+ TableCell,
20
+ TableHeader,
21
+ TableRow,
22
+ toast,
23
+ } from '@kernhq/ui'
24
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
25
+ import type { Category } from '../../contract/index.js'
26
+ import { getInventoryApi } from '../api-instance.js'
27
+ import { isolated } from '../bidi.js'
28
+ import { errorMessage } from '../errors.js'
29
+ import { t } from '../i18n.js'
30
+ import { INVENTORY_PERMISSIONS } from '../permissions.js'
31
+ import { inventoryKeys } from '../query.js'
32
+
33
+ /**
34
+ * How a workspace groups what it owns.
35
+ *
36
+ * `assets.list` has taken a `categoryId` filter since the module existed and nothing could create a
37
+ * category, so the filter had exactly one possible answer — this page is the other half of it.
38
+ *
39
+ * **Nothing here deletes.** `assets.category_id` carries no foreign key, so removing a row would
40
+ * leave every asset filed under it pointing at nothing: a blank column, and a timeline entry saying
41
+ * the category changed *to* a name it can no longer print. Archiving takes it out of every picker
42
+ * and every filter and leaves each asset able to say what it is; the same procedure restores it.
43
+ */
44
+ const api = getInventoryApi()
45
+ const queryClient = useQueryClient()
46
+
47
+ const workspaceSlug = $derived(navigation.workspaceSlug)
48
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === workspaceSlug)?.id ?? '')
49
+
50
+ /**
51
+ * The permission the server enforces on every write here.
52
+ *
53
+ * The page is already gated on it in `module.ts`, so somebody without it never sees the entry —
54
+ * but a settings URL can be typed, and a read-only view of the list is a better answer than a form
55
+ * whose every button 403s.
56
+ */
57
+ const canManage = $derived(session.can(INVENTORY_PERMISSIONS.categories))
58
+
59
+ let showArchived = $state(false)
60
+
61
+ /**
62
+ * One query for every category, archived ones included, filtered here.
63
+ *
64
+ * Two things come out of that. It is the key `AssetsPage` and `AssetDetailPanel` already use, so
65
+ * arriving at this page after either of them costs no request — and, the reason it changed, it is
66
+ * the only way this page can tell **"no categories yet"** from **"every category is archived"**.
67
+ * With `archived: showArchived` in the request the two are the same empty array, and the page told
68
+ * a workspace that had archived all five of its categories that it had never made one — beside a
69
+ * *New category* button, with the row that would have fixed it one switch away.
70
+ */
71
+ const categoriesQuery = createQuery(() => ({
72
+ queryKey: inventoryKeys.categories(workspaceId, true),
73
+ queryFn: () => api.categories.list({ workspaceId, archived: true }),
74
+ enabled: Boolean(workspaceId),
75
+ }))
76
+ const everything = $derived<Category[]>(categoriesQuery.data ?? [])
77
+ const categories = $derived(showArchived ? everything : everything.filter((row) => !row.archivedAt))
78
+ /** Rows exist, and the switch is hiding all of them. A different sentence from having none. */
79
+ const allArchived = $derived(everything.length > 0 && categories.length === 0)
80
+
81
+ // ------------------------------------------------------------------ the add / rename dialog
82
+
83
+ let editing = $state<Category | null>(null)
84
+ let dialogOpen = $state(false)
85
+ let name = $state('')
86
+ let orderText = $state('')
87
+
88
+ function openCreate() {
89
+ editing = null
90
+ name = ''
91
+ orderText = '0'
92
+ dialogOpen = true
93
+ }
94
+
95
+ function openEdit(category: Category) {
96
+ editing = category
97
+ name = category.name
98
+ orderText = String(category.order)
99
+ dialogOpen = true
100
+ }
101
+
102
+ /**
103
+ * A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither — so somebody
104
+ * typing the digits of their own language would be told their input is not a whole number.
105
+ */
106
+ const toLatinDigits = (value: string) =>
107
+ value
108
+ .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
109
+ .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
110
+
111
+ const order = $derived.by(() => {
112
+ const digits = toLatinDigits(orderText.trim())
113
+ return /^\d{1,4}$/.test(digits) ? Number(digits) : Number.NaN
114
+ })
115
+ /**
116
+ * A hint and an error are two different sentences, and this field passed the hint as both.
117
+ *
118
+ * `hint` explains how positions sort — "Lower comes first. Categories sharing a position fall back
119
+ * to their names." — and typing `x` restated exactly that, in red, under `aria-invalid`. It told
120
+ * somebody nothing about what was wrong with what they had typed, and it made the field's ordinary
121
+ * hint look like a failure the first time they read it. The error says what this box will accept.
122
+ */
123
+ const orderError = $derived(Number.isNaN(order) ? t('category_order_invalid') : null)
124
+ const canSubmit = $derived(canManage && Boolean(name.trim()) && !Number.isNaN(order))
125
+
126
+ /** Set in the same tick as the click: `disabled={mutation.isPending}` is one render too late. */
127
+ let saving = $state(false)
128
+
129
+ const save = createMutation(() => ({
130
+ mutationFn: () => {
131
+ const row = editing
132
+ const values = { name: name.trim(), order }
133
+ return row
134
+ ? api.categories.update({ workspaceId, categoryId: row.id, ...values })
135
+ : api.categories.create({ workspaceId, ...values })
136
+ },
137
+ onSuccess: (saved: Category) => {
138
+ toast.success(
139
+ t(editing ? 'category_updated_toast' : 'category_created_toast', isolated({ name: saved.name })),
140
+ )
141
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
142
+ dialogOpen = false
143
+ },
144
+ // The server's sentence is the actionable one and it is in English — "This workspace already has
145
+ // a category called “Laptops”." was shown verbatim to a reader who chose Persian. The token it
146
+ // carries, `inventory.category.name_taken`, is the part a client can translate; the name is on
147
+ // screen in the box directly above the message, so the sentence does not need to repeat it.
148
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
149
+ onSettled: () => {
150
+ saving = false
151
+ },
152
+ }))
153
+
154
+ function submit() {
155
+ if (saving || !canSubmit) return
156
+ saving = true
157
+ save.mutate()
158
+ }
159
+
160
+ // ------------------------------------------------------------------------ archive and restore
161
+
162
+ let archiving = $state<Category | null>(null)
163
+ let acting = $state(false)
164
+
165
+ interface ArchiveVars {
166
+ categoryId: string
167
+ archived: boolean
168
+ name: string
169
+ }
170
+
171
+ const setArchived = createMutation(() => ({
172
+ mutationFn: (vars: ArchiveVars) =>
173
+ api.categories.archive({ workspaceId, categoryId: vars.categoryId, archived: vars.archived }),
174
+ onSuccess: (_saved: Category, vars: ArchiveVars) => {
175
+ toast.success(
176
+ t(vars.archived ? 'category_archived_toast' : 'category_restored_toast', isolated({ name: vars.name })),
177
+ )
178
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
179
+ archiving = null
180
+ },
181
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
182
+ onSettled: () => {
183
+ acting = false
184
+ },
185
+ }))
186
+
187
+ function confirmArchive() {
188
+ const target = archiving
189
+ if (!target || acting) return
190
+ acting = true
191
+ setArchived.mutate({ categoryId: target.id, archived: true, name: target.name })
192
+ }
193
+
194
+ function restore(category: Category) {
195
+ if (acting) return
196
+ acting = true
197
+ setArchived.mutate({ categoryId: category.id, archived: false, name: category.name })
198
+ }
199
+
200
+ /**
201
+ * Restoring takes nothing away and needs no confirmation; archiving states what happens to the
202
+ * assets already filed under it. Hidden rather than disabled without the permission.
203
+ */
204
+ function actionsFor(category: Category): MenuItem[] {
205
+ const items: MenuItem[] = [
206
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(category) },
207
+ ]
208
+ if (category.archivedAt) {
209
+ items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(category) })
210
+ } else {
211
+ items.push({ type: 'separator' })
212
+ items.push({
213
+ label: t('common.archive'),
214
+ icon: 'archive',
215
+ danger: true,
216
+ onSelect: () => (archiving = category),
217
+ })
218
+ }
219
+ return items
220
+ }
221
+
222
+ const COLUMNS = $derived(canManage ? 'minmax(0, 1fr) 96px 44px' : 'minmax(0, 1fr) 96px')
223
+ const SKELETON_ROWS = [0, 1, 2, 3]
224
+ </script>
225
+
226
+ <SettingsPage title={t('settings_categories')} description={t('settings_categories_desc')}>
227
+ {#snippet actions()}
228
+ {#if canManage}
229
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('category_new')}</Button>
230
+ {/if}
231
+ {/snippet}
232
+
233
+ <SettingsSection flush>
234
+ <div class="bar">
235
+ <Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
236
+ </div>
237
+
238
+ {#if categoriesQuery.isPending}
239
+ <div class="skeleton">
240
+ {#each SKELETON_ROWS as row (row)}
241
+ <div class="srow" style:grid-template-columns={COLUMNS}>
242
+ <Skeleton height="12px" width="52%" />
243
+ <Skeleton height="12px" width="30%" />
244
+ {#if canManage}<Skeleton height="12px" width="16px" />{/if}
245
+ </div>
246
+ {/each}
247
+ </div>
248
+ {:else if categoriesQuery.isError}
249
+ <EmptyState icon="triangle-alert" title={t('categories_error')} description={t('common.error')}>
250
+ {#snippet actions()}
251
+ <Button variant="secondary" onclick={() => void categoriesQuery.refetch()}>
252
+ {t('common.retry')}
253
+ </Button>
254
+ {/snippet}
255
+ </EmptyState>
256
+ {:else if allArchived}
257
+ <!--
258
+ Not "No categories yet": this workspace has categories and is looking at none of them. The
259
+ action is the one that fixes it — show the archived rows, which is where Restore lives —
260
+ rather than a New category button that would leave the archived ones exactly as they are.
261
+ -->
262
+ <EmptyState
263
+ icon="archive"
264
+ title={t('categories_all_archived')}
265
+ description={t('categories_all_archived_desc')}
266
+ >
267
+ {#snippet actions()}
268
+ <Button variant="secondary" onclick={() => (showArchived = true)}>
269
+ {t('show_archived')}
270
+ </Button>
271
+ {/snippet}
272
+ </EmptyState>
273
+ {:else if categories.length === 0}
274
+ <!--
275
+ Reachable, and worth keeping: `onWorkspaceEnabled` seeds five categories, but it only runs
276
+ when a workspace switches the module on — an instance upgraded with Inventory already
277
+ enabled never ran it, and neither does a workspace whose seeding half failed.
278
+ -->
279
+ <EmptyState icon="tag" title={t('categories_empty')} description={t('categories_empty_desc')}>
280
+ {#snippet actions()}
281
+ {#if canManage}
282
+ <Button onclick={openCreate}>{t('category_new')}</Button>
283
+ {/if}
284
+ {/snippet}
285
+ </EmptyState>
286
+ {:else}
287
+ <Table columns={COLUMNS} ariaLabel={t('settings_categories')}>
288
+ <TableHeader>
289
+ <TableCell header>{t('category')}</TableCell>
290
+ <TableCell header>{t('category_order')}</TableCell>
291
+ {#if canManage}<TableCell header end></TableCell>{/if}
292
+ </TableHeader>
293
+ {#each categories as category (category.id)}
294
+ <TableRow>
295
+ <TableCell>
296
+ <span class="cell">
297
+ <span class="name">{category.name}</span>
298
+ <!-- Only the archived state gets a badge. A "live" badge on every other row would
299
+ be a column of the same word, and the one it would have to borrow —
300
+ `status_in_stock` — describes an asset sitting in a cupboard, not a category. -->
301
+ {#if category.archivedAt}<Badge tone="grey">{t('archived')}</Badge>{/if}
302
+ </span>
303
+ </TableCell>
304
+ <TableCell><span class="muted">{category.order}</span></TableCell>
305
+ {#if canManage}
306
+ <TableCell end>
307
+ <DropdownMenu items={actionsFor(category)} align="end">
308
+ {#snippet trigger(props)}
309
+ <IconButton
310
+ {...props}
311
+ icon="ellipsis"
312
+ size={28}
313
+ label={t('row_actions', isolated({ name: category.name }))}
314
+ />
315
+ {/snippet}
316
+ </DropdownMenu>
317
+ </TableCell>
318
+ {/if}
319
+ </TableRow>
320
+ {/each}
321
+ </Table>
322
+ {/if}
323
+ </SettingsSection>
324
+ </SettingsPage>
325
+
326
+ <Dialog
327
+ bind:open={dialogOpen}
328
+ size="sm"
329
+ title={editing ? t('category_edit') : t('category_new')}
330
+ >
331
+ <div class="form">
332
+ <Field label={t('category')} id="inv-cat-name" required hint={t('category_name_placeholder')}>
333
+ {#snippet children(id)}
334
+ <Input {id} bind:value={name} maxlength={120} />
335
+ {/snippet}
336
+ </Field>
337
+ <Field label={t('category_order')} id="inv-cat-order" hint={t('category_order_hint')}>
338
+ {#snippet children(id)}
339
+ <div class="narrow">
340
+ <Input {id} bind:value={orderText} inputmode="numeric" maxlength={4} mono error={orderError} />
341
+ </div>
342
+ {/snippet}
343
+ </Field>
344
+ </div>
345
+
346
+ {#snippet footer()}
347
+ <Button variant="ghost" onclick={() => (dialogOpen = false)}>{t('common.cancel')}</Button>
348
+ <Button onclick={submit} disabled={!canSubmit} loading={saving}>{t('common.save')}</Button>
349
+ {/snippet}
350
+ </Dialog>
351
+
352
+ <!-- States what happens to the assets already filed under it, rather than asking "Are you sure?". -->
353
+ <Dialog
354
+ open={archiving !== null}
355
+ size="sm"
356
+ title={t('category_archive_title', isolated({ name: archiving?.name ?? '' }))}
357
+ onOpenChange={(next) => {
358
+ if (!next) archiving = null
359
+ }}
360
+ >
361
+ <p class="dialog-body">{t('category_archive_body')}</p>
362
+ {#snippet footer()}
363
+ <Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
364
+ <Button variant="danger" onclick={confirmArchive} loading={acting}>{t('common.archive')}</Button>
365
+ {/snippet}
366
+ </Dialog>
367
+
368
+ <style>
369
+ .bar {
370
+ display: flex;
371
+ justify-content: flex-end;
372
+ padding: 10px 12px;
373
+ }
374
+ .cell {
375
+ display: flex;
376
+ align-items: center;
377
+ gap: 8px;
378
+ min-width: 0;
379
+ }
380
+ .name {
381
+ font-weight: 500;
382
+ color: var(--kern-ink-900);
383
+ overflow: hidden;
384
+ text-overflow: ellipsis;
385
+ white-space: nowrap;
386
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
387
+ first strong character, so a Latin name inside a Persian screen reads left to right and
388
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
389
+ unicode-bidi: plaintext;
390
+ }
391
+ .muted {
392
+ /* A colour rather than opacity, which fades text against the page whatever token it names. */
393
+ color: var(--kern-ink-500);
394
+ font-variant-numeric: tabular-nums;
395
+ }
396
+ .skeleton {
397
+ display: flex;
398
+ flex-direction: column;
399
+ gap: 1px;
400
+ }
401
+ .srow {
402
+ display: grid;
403
+ align-items: center;
404
+ gap: 12px;
405
+ padding: 14px 12px;
406
+ border-bottom: 1px solid var(--kern-border-hairline);
407
+ }
408
+ .form {
409
+ display: grid;
410
+ gap: 14px;
411
+ }
412
+ .narrow {
413
+ max-inline-size: 140px;
414
+ }
415
+ .dialog-body {
416
+ margin: 0;
417
+ font-size: 13.5px;
418
+ line-height: 1.55;
419
+ color: var(--kern-ink-700);
420
+ }
421
+ </style>
@@ -0,0 +1,29 @@
1
+ import type { BadgeTone } from '@kernhq/ui'
2
+
3
+ /**
4
+ * What colour a status wears, in the one place both screens read it from.
5
+ *
6
+ * The list and the detail panel show the same badge for the same row, and a `switch` copied into
7
+ * two components is two switches that drift — the day `reserved` gets its own colour, one of them
8
+ * changes. `import type` is erased at build time, so this file still pulls no runtime dependency on
9
+ * `@kernhq/ui` and stays unit-testable.
10
+ *
11
+ * The tones say what a reader needs to act on rather than what is "good": `in_stock` is the
12
+ * ordinary resting state, `lost` is the one somebody has to do something about, and an archived row
13
+ * is drawn grey by the screens without asking here — it is not a status, it is the absence of one.
14
+ */
15
+ export function statusTone(status: string): BadgeTone {
16
+ switch (status) {
17
+ case 'assigned':
18
+ case 'reserved':
19
+ return 'info'
20
+ case 'under_repair':
21
+ return 'warning'
22
+ case 'lost':
23
+ return 'danger'
24
+ case 'retired':
25
+ return 'grey'
26
+ default:
27
+ return 'success'
28
+ }
29
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { AssetInput } from '../contract/models.js'
3
+ import { en } from './messages.js'
4
+ import {
5
+ actionKey,
6
+ changeLineKind,
7
+ changeShape,
8
+ fieldKey,
9
+ isKnownAction,
10
+ isLongText,
11
+ isOpaque,
12
+ noteOf,
13
+ personIdOf,
14
+ } from './timeline.js'
15
+
16
+ /**
17
+ * The decisions behind a readable timeline, checked where they can be checked.
18
+ *
19
+ * `Timeline.svelte` supplies the words; every judgement it makes — which sentence, about whom, with
20
+ * what label — is in `timeline.ts`, and each one has a right answer. The three that matter are the
21
+ * ones nothing else can catch:
22
+ *
23
+ * 1. an action the catalogue has no sentence for must not be described as something else;
24
+ * 2. a field added to the contract must not reach a screen as its own column name;
25
+ * 3. a return names who gave it back, not who received it — they are different keys in `data`,
26
+ * and reading the wrong one renders "took it back from nobody".
27
+ */
28
+ describe('actionKey', () => {
29
+ it('names a key the catalogue actually has, for every action the server writes', () => {
30
+ // Exactly what `AssetService` and `CustodyService` write today.
31
+ for (const action of [
32
+ 'created',
33
+ 'updated',
34
+ 'assigned',
35
+ 'transferred',
36
+ 'returned',
37
+ 'retired',
38
+ 'restored',
39
+ ]) {
40
+ expect({ action, key: actionKey(action) }).toEqual({ action, key: `history_${action}` })
41
+ expect({ action, has: `inventory.${actionKey(action)}` in en }).toEqual({ action, has: true })
42
+ }
43
+ })
44
+
45
+ it('falls back honestly for an action written by a newer image', () => {
46
+ // Reservations and stock counts write their own actions, and a browser tab open across a
47
+ // deploy will meet one. "Somebody changed it — reserved" is true; "edited it" would not be.
48
+ expect(actionKey('reserved')).toBe('history_unknown')
49
+ expect(isKnownAction('reserved')).toBe(false)
50
+ expect('inventory.history_unknown' in en).toBe(true)
51
+ })
52
+
53
+ it('phrases what repairs and attachments write, rather than falling back', () => {
54
+ // These four were the fallback's example until the change that made them real. An action this
55
+ // client *does* know must never render as "changed it — repair_logged".
56
+ for (const action of ['repair_logged', 'repair_completed', 'attachment_added', 'attachment_removed']) {
57
+ expect({ action, key: actionKey(action) }).toEqual({ action, key: `history_${action}` })
58
+ expect({ action, has: `inventory.${actionKey(action)}` in en }).toEqual({ action, has: true })
59
+ }
60
+ })
61
+ })
62
+
63
+ describe('fieldKey', () => {
64
+ it('labels every field the contract lets somebody edit', () => {
65
+ // `AssetInput` is the whole of what `assets.update` accepts, and therefore the whole of what a
66
+ // stored diff can name. A field added there with no key here would print `warrantyUntil` in the
67
+ // middle of a Persian sentence.
68
+ for (const field of Object.keys(AssetInput.shape)) {
69
+ const key = fieldKey(field)
70
+ expect({ field, key }).not.toEqual({ field, key: null })
71
+ expect({ field, has: `inventory.${key}` in en }).toEqual({ field, has: true })
72
+ }
73
+ })
74
+
75
+ it('says it does not know a field rather than guessing at one', () => {
76
+ expect(fieldKey('somethingNewer')).toBe(null)
77
+ })
78
+ })
79
+
80
+ describe('changeShape', () => {
81
+ it('tells filling a field in from clearing it from moving it', () => {
82
+ expect(changeShape(null, 'Desk 4')).toBe('set')
83
+ expect(changeShape('Desk 4', null)).toBe('cleared')
84
+ expect(changeShape('Desk 4', 'Desk 5')).toBe('changed')
85
+ })
86
+
87
+ it('treats an empty string as absent, because that is what clearing a description stores', () => {
88
+ // `description` is `not null` in the database, so emptying it writes `''` — and "changed from
89
+ // to x" is a sentence with a hole in it.
90
+ expect(changeShape('', 'A note')).toBe('set')
91
+ expect(changeShape('A note', '')).toBe('cleared')
92
+ })
93
+ })
94
+
95
+ describe('personIdOf', () => {
96
+ it('reads the recipient for a handover and the giver for a return', () => {
97
+ expect(personIdOf('assigned', { userId: 'ada' })).toBe('ada')
98
+ expect(personIdOf('transferred', { userId: 'bruno', previousUserId: 'ada' })).toBe('bruno')
99
+ // The one that would silently read "nobody": `returned` stores no `userId` at all.
100
+ expect(personIdOf('returned', { previousUserId: 'ada' })).toBe('ada')
101
+ })
102
+
103
+ it('has no person for an entry that is not about one', () => {
104
+ expect(personIdOf('created', {})).toBe(null)
105
+ expect(personIdOf('assigned', { userId: 123 })).toBe(null)
106
+ })
107
+ })
108
+
109
+ describe('noteOf', () => {
110
+ it('takes a note only when somebody actually wrote one', () => {
111
+ expect(noteOf({ note: ' For the Berlin trip ' })).toBe('For the Berlin trip')
112
+ expect(noteOf({ note: ' ' })).toBe(null)
113
+ expect(noteOf({})).toBe(null)
114
+ })
115
+ })
116
+
117
+ /**
118
+ * The decision that keeps eight kilobytes of somebody's prose out of a row of the panel.
119
+ *
120
+ * `description` is `z.string().max(8000)` in the contract, and the timeline read a change to it out
121
+ * as "Description changed from … to …" with both versions inline — so one edit to one paragraph
122
+ * pushed every entry below it off a 440px sheet. Nothing failed: the string was correct, the
123
+ * translation was correct, and the row was unusable.
124
+ */
125
+ describe('changeLineKind', () => {
126
+ it('says only that a description changed, and never what it says', () => {
127
+ const long = 'x'.repeat(8000)
128
+ expect(changeLineKind('description', 'before', long)).toBe('text_changed')
129
+ // Filling one in for the first time is the same sentence once the value is gone: two keys
130
+ // saying "Description changed" would be two translations to keep in step for no gain.
131
+ expect(changeLineKind('description', null, long)).toBe('text_changed')
132
+ })
133
+
134
+ it('decides on the field, not on how long the value happens to be today', () => {
135
+ // A short description is still prose. Branching on length would make the timeline's shape
136
+ // depend on what somebody typed, so the same field would render two different ways.
137
+ expect(changeLineKind('description', 'a', 'b')).toBe('text_changed')
138
+ expect(isLongText('description')).toBe(true)
139
+ expect(isLongText('location')).toBe(false)
140
+ })
141
+
142
+ it('still says "cleared" when a description is emptied — there is nothing to disclose', () => {
143
+ // `description` is `not null` in the database, so clearing it stores `''`.
144
+ expect(changeLineKind('description', 'A note', '')).toBe('cleared')
145
+ expect(changeLineKind('description', 'A note', null)).toBe('cleared')
146
+ })
147
+
148
+ it('keeps reading an ordinary field out, which is the whole point of a timeline', () => {
149
+ expect(changeLineKind('location', null, 'Desk 4')).toBe('set')
150
+ expect(changeLineKind('location', 'Desk 4', 'Desk 5')).toBe('changed')
151
+ expect(changeLineKind('location', 'Desk 4', null)).toBe('cleared')
152
+ })
153
+
154
+ it('reports an opaque id as a replacement rather than printing a uuid', () => {
155
+ expect(isOpaque('photoFileId')).toBe(true)
156
+ expect(changeLineKind('photoFileId', 'a', 'b')).toBe('replaced')
157
+ // Removing the photo is still "cleared": there is no id to avoid printing.
158
+ expect(changeLineKind('photoFileId', 'a', null)).toBe('cleared')
159
+ })
160
+
161
+ it('names a key the catalogue has, for every kind it can return', () => {
162
+ const keys = {
163
+ cleared: 'history_cleared',
164
+ replaced: 'history_replaced',
165
+ set: 'history_set',
166
+ changed: 'history_changed',
167
+ text_changed: 'history_text_changed',
168
+ }
169
+ for (const key of Object.values(keys))
170
+ expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
171
+ // And the disclosure's own three, which `TimelineText.svelte` reads.
172
+ for (const key of ['history_show_text', 'history_text_before', 'history_text_after'])
173
+ expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
174
+ })
175
+ })