@kernhq/module-inventory 0.1.2 → 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 (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  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 +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  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 +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  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 +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  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 +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  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/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -0,0 +1,403 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ coreApi,
5
+ EmptyState,
6
+ Field,
7
+ Input,
8
+ keys,
9
+ navigation,
10
+ SettingsPage,
11
+ SettingsSection,
12
+ Skeleton,
13
+ session,
14
+ toast,
15
+ } from '@kernhq/ui'
16
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
17
+ import { MODULE_ID } from '../../contract/models.js'
18
+ import { InventorySettings } from '../../contract/settings.js'
19
+ import type { CoreApi } from '../core-api.js'
20
+ import { errorMessage } from '../errors.js'
21
+ import { t } from '../i18n.js'
22
+
23
+ /**
24
+ * Everything Inventory keeps at workspace level: what an asset tag looks like, and when the two
25
+ * nightly sweeps say something.
26
+ *
27
+ * `assetCodePrefix` and `assetCodePad` are enforced by the server on every `assets.create`, and
28
+ * `settingsPages` was `[]` — so the module declared two settings it honoured and gave nobody, at
29
+ * any permission level, a way to change either. A setting the server obeys and no screen exposes is
30
+ * a hard-coded value with extra steps. `warrantyNoticeDays` and `repairOverdueDays` arrive with the
31
+ * jobs that read them and are on this page in the same change, so that mistake is not repeated in
32
+ * the other direction.
33
+ *
34
+ * Settings live in core, not in this module's own schema, so they are read from
35
+ * `workspaces.modules.list` and written with `workspaces.modules.updateSettings` — the same
36
+ * mechanism HR's settings use, and the reason this module needs no settings table.
37
+ *
38
+ * **The write carries the whole `InventorySettings` object.** Core merges a partial write, so
39
+ * sending a subset would be safe — but this page owns the schema, and sending it whole is what
40
+ * makes the round trip self-evident. It is also why the repair window is sent from `stored` when
41
+ * the workspace has the capability off and its field is not on screen: a value nobody was shown is
42
+ * a value nobody meant to change.
43
+ */
44
+ const api = coreApi<CoreApi>()
45
+ const queryClient = useQueryClient()
46
+
47
+ const workspaceSlug = $derived(navigation.workspaceSlug)
48
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
49
+ const workspaceId = $derived(workspace?.id ?? '')
50
+
51
+ /** The permission core actually enforces on `workspaces.modules.updateSettings`. */
52
+ const canManage = $derived(session.can('core.modules.manage'))
53
+
54
+ /**
55
+ * Whether this workspace records repairs at all.
56
+ *
57
+ * Read from the session — the set the *server* resolved, with defaults applied, `required` forced
58
+ * on and anything whose dependency is off pruned — for the reason `AssetDetailPanel` gives: working
59
+ * the closure out again here would be a second implementation of it, and two implementations
60
+ * eventually disagree. A workspace with `repairs` off never sees the overdue window, because the
61
+ * sweep behind it asks the same question first and does nothing.
62
+ */
63
+ const hasRepairs = $derived(session.hasCapability(MODULE_ID, 'repairs'))
64
+
65
+ const modulesQuery = createQuery(() => ({
66
+ queryKey: keys.modules(workspaceId),
67
+ enabled: Boolean(workspaceId),
68
+ queryFn: () => api.workspaces.modules.list({ workspaceId }),
69
+ }))
70
+
71
+ const entry = $derived(modulesQuery.data?.find((m) => m.manifest.id === 'inventory'))
72
+
73
+ /**
74
+ * What is stored, with the schema's defaults applied.
75
+ *
76
+ * `safeParse` rather than `parse`: a settings blob written before a field existed must not take the
77
+ * page down, and every field has a default, so the fallback is the shape a new workspace has. That
78
+ * is not hypothetical any more — every workspace that saved this page before the two notice windows
79
+ * existed has a blob with neither in it.
80
+ */
81
+ const stored = $derived.by(() => {
82
+ const parsed = InventorySettings.safeParse(entry?.state.settings ?? {})
83
+ return parsed.success ? parsed.data : InventorySettings.parse({})
84
+ })
85
+
86
+ let prefix = $state('')
87
+ /**
88
+ * Held as text, not as a number: an input somebody is half-way through clearing is empty, and
89
+ * `bind:value` on a number input turns that into `undefined` — which reads as "no change" and would
90
+ * save the old value behind their back.
91
+ */
92
+ let padText = $state('')
93
+ /** The two notice windows, as text and for exactly the same reason. */
94
+ let warrantyDaysText = $state('')
95
+ let repairDaysText = $state('')
96
+
97
+ /**
98
+ * What the form was last filled from, so the effect below can tell "already seeded" from
99
+ * "the server now says something different".
100
+ *
101
+ * A plain `loaded` boolean cannot tell those apart, and that difference is a bug rather than a
102
+ * nicety: re-arming it after a save re-runs the effect on the **next Svelte flush**, long before
103
+ * the refetch resolves, so the form was re-seeded from the *pre-save* cached value and then left
104
+ * alone when the real one arrived. An admin saw a green "saved" toast and watched the field snap
105
+ * back to what it had been — and saving again wrote the old value over the new one.
106
+ */
107
+ let seededFrom = $state<string | null>(null)
108
+ const fingerprint = $derived(
109
+ [stored.assetCodePrefix, stored.assetCodePad, stored.warrantyNoticeDays, stored.repairOverdueDays].join(
110
+ ' ',
111
+ ),
112
+ )
113
+ const loaded = $derived(seededFrom !== null)
114
+
115
+ $effect(() => {
116
+ if (!entry || seededFrom === fingerprint) return
117
+ prefix = stored.assetCodePrefix
118
+ padText = String(stored.assetCodePad)
119
+ warrantyDaysText = String(stored.warrantyNoticeDays)
120
+ repairDaysText = String(stored.repairOverdueDays)
121
+ seededFrom = fingerprint
122
+ })
123
+
124
+ /** Discard and re-seed are the same act: drop the edits and let the effect above refill them. */
125
+ function discard() {
126
+ seededFrom = null
127
+ }
128
+
129
+ /**
130
+ * A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither — so somebody
131
+ * typing the digits of their own language would be told their input is not a whole number.
132
+ */
133
+ const toLatinDigits = (value: string) =>
134
+ value
135
+ .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
136
+ .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
137
+
138
+ /** A whole number of days inside the range the contract accepts, or NaN. */
139
+ const days = (text: string): number => {
140
+ const digits = toLatinDigits(text.trim())
141
+ if (!/^\d{1,3}$/.test(digits)) return Number.NaN
142
+ const value = Number(digits)
143
+ return value >= 1 && value <= 365 ? value : Number.NaN
144
+ }
145
+
146
+ const prefixError = $derived(prefix.length > 8 ? t('settings_prefix_too_long') : null)
147
+
148
+ const padDigits = $derived(toLatinDigits(padText.trim()))
149
+ const pad = $derived(/^\d{1,2}$/.test(padDigits) ? Number(padDigits) : Number.NaN)
150
+ const padError = $derived(pad >= 1 && pad <= 10 ? null : t('settings_pad_invalid'))
151
+
152
+ const warrantyDays = $derived(days(warrantyDaysText))
153
+ const warrantyError = $derived(Number.isNaN(warrantyDays) ? t('settings_days_invalid') : null)
154
+
155
+ /**
156
+ * The repair window is only validated when it is on screen.
157
+ *
158
+ * A workspace with `repairs` off never edits it, and holding the Save button hostage to a field
159
+ * nobody can see would be a form that cannot be submitted for a reason it does not show.
160
+ */
161
+ const repairDays = $derived(days(repairDaysText))
162
+ const repairError = $derived(hasRepairs && Number.isNaN(repairDays) ? t('settings_days_invalid') : null)
163
+
164
+ const valid = $derived(!prefixError && !padError && !warrantyError && !repairError)
165
+ const dirty = $derived(
166
+ loaded &&
167
+ (prefix !== stored.assetCodePrefix ||
168
+ pad !== stored.assetCodePad ||
169
+ warrantyDays !== stored.warrantyNoticeDays ||
170
+ (hasRepairs && repairDays !== stored.repairOverdueDays)),
171
+ )
172
+
173
+ /**
174
+ * A shape, not a promise about the next number.
175
+ *
176
+ * The counter is a row in `mod_inventory.counters` rather than a setting — a number an
177
+ * administrator can edit is a number that produces a duplicate tag — so this page does not know
178
+ * what comes next and does not claim to. 42 is a sample that makes the padding visible.
179
+ */
180
+ const EXAMPLE_NUMBER = 42
181
+ const example = $derived(Number.isNaN(pad) ? '—' : `${prefix}${String(EXAMPLE_NUMBER).padStart(pad, '0')}`)
182
+
183
+ let saving = $state(false)
184
+ let saveError = $state<string | null>(null)
185
+
186
+ const save = createMutation(() => ({
187
+ mutationFn: () =>
188
+ api.workspaces.modules.updateSettings({
189
+ workspaceId,
190
+ moduleId: 'inventory',
191
+ // Every field `InventorySettings` declares, which is what makes the round trip self-evident:
192
+ // nothing here can be a field somebody forgot to carry forward. Adding a field to the schema
193
+ // means adding it here, or core's merge silently decides its value instead of this page.
194
+ settings: {
195
+ assetCodePrefix: prefix,
196
+ assetCodePad: pad,
197
+ warrantyNoticeDays: warrantyDays,
198
+ // From `stored` when the field was never on screen: a workspace with `repairs` off must
199
+ // keep whatever it had rather than have this page decide for it.
200
+ repairOverdueDays: hasRepairs ? repairDays : stored.repairOverdueDays,
201
+ },
202
+ }),
203
+ onSuccess: () => {
204
+ saveError = null
205
+ // Only invalidate. The form already holds what was just sent, so re-seeding it here would seed
206
+ // from the cache as it is *now* — the pre-save value. When the refetch lands, `fingerprint`
207
+ // changes and the effect above re-seeds from what the server actually stored, which is the
208
+ // truth. Until then the form shows what the admin typed, which is the next best thing.
209
+ void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
210
+ toast.success(t('settings_saved'))
211
+ },
212
+ // `err.message` is a sentence core wrote, in English, and this page's own reader chose
213
+ // otherwise. `errors.ts` gives it the sentence for whatever class of refusal it was — a
214
+ // capability switched off, a permission missing, a validation the form did not catch.
215
+ onError: (err: unknown) => {
216
+ saveError = errorMessage(err, t)
217
+ },
218
+ onSettled: () => {
219
+ saving = false
220
+ },
221
+ }))
222
+
223
+ /**
224
+ * `disabled={save.isPending}` reaches the button a render too late, and two quick clicks are one
225
+ * render apart — so the guard is a plain flag set in the same tick as the click. It is deliberately
226
+ * not in the `disabled` expression: disabling the control somebody is standing on blurs it and
227
+ * hands focus nowhere, and `loading` already says "busy" through `aria-busy`.
228
+ */
229
+ function requestSave() {
230
+ if (saving || !dirty || !valid) return
231
+ saving = true
232
+ saveError = null
233
+ save.mutate()
234
+ }
235
+ </script>
236
+
237
+ {#snippet saveBar()}
238
+ <Button size="sm" variant="secondary" onclick={discard} disabled={!dirty}>
239
+ {t('common.discard')}
240
+ </Button>
241
+ <Button size="sm" onclick={requestSave} disabled={!dirty || !valid} loading={saving}>
242
+ {t('common.save')}
243
+ </Button>
244
+ {/snippet}
245
+
246
+ <SettingsPage title={t('settings_general')} description={t('settings_general_desc')}>
247
+ {#if modulesQuery.isPending}
248
+ <Skeleton height="220px" radius="10px" />
249
+ {:else if modulesQuery.isError}
250
+ <EmptyState icon="triangle-alert" title={t('settings_error')} description={t('common.error')}>
251
+ {#snippet actions()}
252
+ <Button variant="secondary" onclick={() => void modulesQuery.refetch()}>
253
+ {t('common.retry')}
254
+ </Button>
255
+ {/snippet}
256
+ </EmptyState>
257
+ {:else if !entry}
258
+ <EmptyState icon="briefcase" title={t('settings_not_enabled')} />
259
+ {:else}
260
+ {#if !canManage}
261
+ <p class="readonly">{t('settings_readonly')}</p>
262
+ {/if}
263
+
264
+ <SettingsSection title={t('settings_numbering')} description={t('settings_numbering_desc')}>
265
+ <div class="rows">
266
+ <Field inline label={t('settings_prefix')} hint={t('settings_prefix_hint')}>
267
+ {#snippet children(id)}
268
+ <div class="narrow">
269
+ <Input
270
+ {id}
271
+ bind:value={prefix}
272
+ maxlength={8}
273
+ mono
274
+ disabled={!canManage}
275
+ error={prefixError}
276
+ />
277
+ </div>
278
+ {/snippet}
279
+ </Field>
280
+
281
+ <Field inline label={t('settings_pad')} hint={t('settings_pad_hint')}>
282
+ {#snippet children(id)}
283
+ <div class="narrow">
284
+ <Input
285
+ {id}
286
+ bind:value={padText}
287
+ inputmode="numeric"
288
+ maxlength={2}
289
+ mono
290
+ disabled={!canManage}
291
+ error={padError}
292
+ />
293
+ </div>
294
+ {/snippet}
295
+ </Field>
296
+
297
+ <div class="example">
298
+ <span class="example-label">{t('settings_preview')}</span>
299
+ <!--
300
+ Interpolated as text on purpose. A tag is an identifier the server builds by
301
+ concatenation, so a Persian reader has to see the same characters that are printed on
302
+ the sticker — this is the one place on the page where localised digits would be wrong.
303
+ -->
304
+ <span class="example-value">{example}</span>
305
+ </div>
306
+ </div>
307
+ </SettingsSection>
308
+
309
+ <!--
310
+ One save bar, on the last section, because there is one settings object and one request. Two
311
+ bars would suggest two independent saves and then write both halves anyway.
312
+ -->
313
+ <SettingsSection
314
+ title={t('settings_notices')}
315
+ description={t('settings_notices_desc')}
316
+ footer={canManage ? saveBar : undefined}
317
+ >
318
+ <div class="rows">
319
+ <Field inline label={t('settings_warranty_days')} hint={t('settings_warranty_days_hint')}>
320
+ {#snippet children(id)}
321
+ <div class="narrow">
322
+ <Input
323
+ {id}
324
+ bind:value={warrantyDaysText}
325
+ inputmode="numeric"
326
+ maxlength={3}
327
+ mono
328
+ disabled={!canManage}
329
+ error={warrantyError}
330
+ />
331
+ </div>
332
+ {/snippet}
333
+ </Field>
334
+
335
+ {#if hasRepairs}
336
+ <Field inline label={t('settings_repair_days')} hint={t('settings_repair_days_hint')}>
337
+ {#snippet children(id)}
338
+ <div class="narrow">
339
+ <Input
340
+ {id}
341
+ bind:value={repairDaysText}
342
+ inputmode="numeric"
343
+ maxlength={3}
344
+ mono
345
+ disabled={!canManage}
346
+ error={repairError}
347
+ />
348
+ </div>
349
+ {/snippet}
350
+ </Field>
351
+ {/if}
352
+ </div>
353
+ </SettingsSection>
354
+
355
+ {#if saveError}
356
+ <p class="save-error" role="alert">{saveError}</p>
357
+ {/if}
358
+ {/if}
359
+ </SettingsPage>
360
+
361
+ <style>
362
+ .readonly {
363
+ margin: 0;
364
+ font-size: 12.5px;
365
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
366
+ color: var(--kern-ink-500);
367
+ }
368
+ .rows {
369
+ display: grid;
370
+ gap: 14px;
371
+ }
372
+ .narrow {
373
+ max-inline-size: 180px;
374
+ }
375
+ .example {
376
+ display: flex;
377
+ align-items: baseline;
378
+ gap: 10px;
379
+ padding: 10px 12px;
380
+ border-radius: var(--kern-r-md);
381
+ background: var(--kern-surface-chip);
382
+ }
383
+ .example-label {
384
+ font-size: 12.5px;
385
+ color: var(--kern-ink-500);
386
+ }
387
+ .example-value {
388
+ font-family: var(--kern-font-mono);
389
+ font-size: 14px;
390
+ font-weight: 500;
391
+ color: var(--kern-ink-900);
392
+ /* A tag is read character by character, so the glyphs line up rather than reflow. */
393
+ font-variant-numeric: tabular-nums;
394
+ /* The identifier stays left-to-right inside a Persian or Arabic sentence. */
395
+ direction: ltr;
396
+ unicode-bidi: isolate;
397
+ }
398
+ .save-error {
399
+ margin: 0;
400
+ font-size: 12.5px;
401
+ color: var(--kern-danger);
402
+ }
403
+ </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
+ })