@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,206 @@
1
+ /**
2
+ * Turning a stored history row into something a person reads.
3
+ *
4
+ * `asset_history` holds `{ action, changes: [{field, from, to}], data: {…ids} }`, which is a shape
5
+ * for a database and not a sentence. A panel that renders it raw shows somebody a JSON diff of
6
+ * their own laptop; the point of a timeline is that it reads as "Ada handed it to Bruno".
7
+ *
8
+ * **"it", not the asset tag** — and this note exists because the README claimed otherwise for a
9
+ * release. The timeline only ever appears inside that asset's own panel, under a header already
10
+ * showing `INV-0042`, so naming the tag in all fifty entries would repeat on every line the one
11
+ * thing that cannot change while somebody is reading it.
12
+ *
13
+ * The decisions live here rather than inside the component for the reason `price.ts` does: a
14
+ * `.svelte` file drags a compiler behind it and cannot be unit-tested, and every one of these is a
15
+ * fact with a right answer. The component supplies the words — this file only says which words and
16
+ * about whom.
17
+ *
18
+ * Nothing here imports `@kernhq/ui`. That entry point reaches the Svelte components, and a helper
19
+ * that pulls a compiler into whatever imports it is a helper nothing can test.
20
+ */
21
+
22
+ /** Every action this client knows how to phrase. Anything else is honest about not knowing. */
23
+ const KNOWN = new Set([
24
+ 'created',
25
+ 'updated',
26
+ 'assigned',
27
+ 'transferred',
28
+ 'returned',
29
+ 'retired',
30
+ 'restored',
31
+ 'repair_logged',
32
+ 'repair_completed',
33
+ 'attachment_added',
34
+ 'attachment_removed',
35
+ ])
36
+
37
+ /**
38
+ * The message key for one entry's headline.
39
+ *
40
+ * An unknown action is **not** silently rendered as "changed it". The rows outlive the client that
41
+ * reads them: repairs and attachments write their own actions, and a browser tab left open across
42
+ * a deploy will meet one. `history_unknown` says somebody changed it and names the action, which is
43
+ * honest about the gap; a generic "changed it" would quietly describe a repair as an edit for as
44
+ * long as the tab stayed open.
45
+ */
46
+ export function actionKey(action: string): string {
47
+ return KNOWN.has(action) ? `history_${action}` : 'history_unknown'
48
+ }
49
+
50
+ export const isKnownAction = (action: string): boolean => KNOWN.has(action)
51
+
52
+ /**
53
+ * Which sentence a single field change wants.
54
+ *
55
+ * "from nothing to Desk 4" is not a change a person recognises — they filled a field in. The three
56
+ * shapes are the three sentences: filled in, cleared, and moved from one value to another.
57
+ *
58
+ * An empty string counts as absent, because that is what the asset form writes for a field somebody
59
+ * emptied: `description` is `not null` in the database, so clearing it stores `''` and never
60
+ * `null`, and `'' → 'x'` reported as "changed from to x" is a sentence with a hole in it.
61
+ */
62
+ export type ChangeShape = 'set' | 'cleared' | 'changed'
63
+
64
+ const absent = (value: unknown): boolean => value === null || value === undefined || value === ''
65
+
66
+ export function changeShape(from: unknown, to: unknown): ChangeShape {
67
+ if (absent(to)) return 'cleared'
68
+ if (absent(from)) return 'set'
69
+ return 'changed'
70
+ }
71
+
72
+ /**
73
+ * Who did it is `resolveName` in `members.ts`, and deliberately not here.
74
+ *
75
+ * This file used to carry an `actorKind(actorId, known)` that answered `person`, `former` or
76
+ * `system` from a bare set of ids. It was a second implementation of a question `members.ts`
77
+ * already answers, and it made the same mistake `nameOf` did: a set that has not been fetched yet
78
+ * is indistinguishable from a set somebody has left, so an id absent from it came back as `former`
79
+ * while the request was still in flight. A set cannot say which — only the request's own state can
80
+ * — so the question belongs where the directory is, with the five answers `NameKind` names.
81
+ */
82
+
83
+ /**
84
+ * The message key naming a field, for the one sentence per change.
85
+ *
86
+ * A stored diff names its column (`serialNumber`, `warrantyUntil`), which is a word for a database
87
+ * and not for a reader — «warrantyUntil از ۲۰۲۷ به ۲۰۲۸ تغییر کرد» is English leaking into a Persian
88
+ * sentence in exactly the place a translation was supposed to be. The map is the whole of
89
+ * `AssetInput`, and `timeline.test.ts` holds it to that: a field added to the contract with no key
90
+ * here shows up as a failing test rather than as a column name on somebody's screen.
91
+ *
92
+ * `null` for a field this client has never heard of. The rows outlive the image that wrote them, so
93
+ * a browser tab open across a deploy will meet one, and the component prints the raw name — which is
94
+ * ugly and true, where a guessed label would be neither.
95
+ */
96
+ const FIELD_KEYS: Record<string, string> = {
97
+ name: 'name',
98
+ description: 'description',
99
+ categoryId: 'category',
100
+ serialNumber: 'serial_number',
101
+ location: 'location',
102
+ purchasedFrom: 'purchased_from',
103
+ purchasedOn: 'purchased_on',
104
+ warrantyUntil: 'warranty_until',
105
+ priceMinor: 'price',
106
+ currency: 'currency',
107
+ photoFileId: 'photo',
108
+ }
109
+
110
+ export function fieldKey(field: string): string | null {
111
+ return FIELD_KEYS[field] ?? null
112
+ }
113
+
114
+ /**
115
+ * Which fields hold prose rather than a value, and therefore cannot be read out in a sentence.
116
+ *
117
+ * `description` is `z.string().max(8000)` in the contract, and the timeline rendered a change to it
118
+ * as "Description changed from … to …" with both versions inline — up to sixteen kilobytes of
119
+ * somebody's prose in a single row of a 440px panel, and every row below it pushed off the screen.
120
+ * A diff line is a sentence about a value; eight thousand characters is not a value.
121
+ *
122
+ * So a long-text change says **that** it changed and offers the text behind a disclosure, which is
123
+ * `TimelineText.svelte` — the two versions are only put in the document when somebody asks for
124
+ * them. The list stays a list, and nothing is hidden: the text is one keystroke away, labelled
125
+ * before and after.
126
+ *
127
+ * A set rather than a length check on the value, because the decision is about the *field*: a short
128
+ * description is still prose, and rendering it inline on the days it happens to be short would make
129
+ * the timeline's shape depend on what somebody typed.
130
+ */
131
+ const LONG_TEXT = new Set(['description'])
132
+
133
+ export const isLongText = (field: string): boolean => LONG_TEXT.has(field)
134
+
135
+ /**
136
+ * The sentence a change to one field wants, as a key name.
137
+ *
138
+ * One function rather than the component branching on `isLongText` and `changeShape` separately,
139
+ * because the combination is the decision: clearing a description is still just "Description
140
+ * cleared" — short, complete, and nothing to disclose — while setting or changing one is the case
141
+ * that must not read its own value out.
142
+ *
143
+ * `text_changed` covers both `set` and `changed` for prose. "Description set to …" and "Description
144
+ * changed from … to …" are the same sentence once the values are gone, and two keys saying the same
145
+ * thing are two translations to keep in step for no gain.
146
+ */
147
+ export type ChangeLine = 'cleared' | 'replaced' | 'set' | 'changed' | 'text_changed'
148
+
149
+ /**
150
+ * A field whose value is an opaque id is reported as a replacement rather than with a uuid in it.
151
+ *
152
+ * `photoFileId` is the only one: the value is a file id, and "Photo changed from 0192… to 0193…"
153
+ * says less than "Photo replaced" while being longer and uglier.
154
+ */
155
+ const OPAQUE = new Set(['photoFileId'])
156
+
157
+ export const isOpaque = (field: string): boolean => OPAQUE.has(field)
158
+
159
+ export function changeLineKind(field: string, from: unknown, to: unknown): ChangeLine {
160
+ const shape = changeShape(from, to)
161
+ if (shape === 'cleared') return 'cleared'
162
+ if (isOpaque(field)) return 'replaced'
163
+ return isLongText(field) ? 'text_changed' : shape
164
+ }
165
+
166
+ /**
167
+ * The other person in a custody sentence, out of the entry's `data`.
168
+ *
169
+ * `assigned` and `transferred` name who received it; `returned` names who gave it back, and both are
170
+ * stored under different keys because they are different facts. Reading `userId` for all three would
171
+ * make a return read "took it back from nobody" — the key is simply absent there.
172
+ */
173
+ export function personIdOf(action: string, data: Record<string, unknown>): string | null {
174
+ const value = action === 'returned' ? data.previousUserId : data.userId
175
+ return typeof value === 'string' && value ? value : null
176
+ }
177
+
178
+ /** The handover note, if whoever handed the item over left one. */
179
+ export function noteOf(data: Record<string, unknown>): string | null {
180
+ const note = data.note
181
+ return typeof note === 'string' && note.trim() ? note.trim() : null
182
+ }
183
+
184
+ /**
185
+ * The thing an entry is *about*, when the sentence alone does not say it.
186
+ *
187
+ * "Ada sent it for repair" is a sentence; which repair is the summary somebody typed, and "Ada
188
+ * attached a file" is missing the only interesting word in it. Both are rendered as a second line
189
+ * under the headline rather than interpolated into it, and neither goes through the catalogue —
190
+ * this is text a person typed or a file they named, so translating around it would be translating
191
+ * a placeholder.
192
+ *
193
+ * Two keys rather than one because they are two facts: a repair's `summary` and a file's `name`.
194
+ * Reading one key for both would print a file id where a summary belongs the day a third action
195
+ * arrives. Null for anything else, and for a row written before this client knew the key — the rows
196
+ * outlive the image that wrote them.
197
+ */
198
+ export function subjectOf(action: string, data: Record<string, unknown>): string | null {
199
+ const value =
200
+ action === 'repair_logged' || action === 'repair_completed'
201
+ ? data.summary
202
+ : action === 'attachment_added' || action === 'attachment_removed'
203
+ ? data.name
204
+ : null
205
+ return typeof value === 'string' && value.trim() ? value.trim() : null
206
+ }
@@ -1,44 +1,89 @@
1
1
  <script lang="ts">
2
- import { Badge, type BadgeTone, WidgetState } from '@kernhq/ui'
2
+ import { Badge, messageLocale, type WidgetProps, WidgetState } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getInventoryApi } from '../api-instance.js'
5
+ import { isolated } from '../bidi.js'
5
6
  import { t } from '../i18n.js'
7
+ import { assetHref } from '../links.js'
8
+ import { inventoryKeys } from '../query.js'
9
+ import { statusTone } from '../status.js'
6
10
 
7
11
  /**
8
12
  * A dashboard card.
9
13
  *
10
14
  * `WidgetState` draws loading, failed and empty so every card on the board reports those three the
11
15
  * same way — and so a module does not have to translate "Retry" to show a widget that failed.
16
+ *
17
+ * `WidgetProps` rather than a hand-written pair, and that is what makes the rows work: the shell
18
+ * passes `workspaceSlug`, which is the half a link to another screen cannot be built without.
19
+ * Declaring two of the eight props by hand is how a card ends up unable to act on anything.
20
+ *
21
+ * `kern-widget` §3 also says to hide row actions while `editing`, and there is nothing to hide
22
+ * here: the action *is* the row, and `WidgetFrame` drags from its grip button rather than from the
23
+ * card's body — so an anchor in the body is never what somebody is holding.
12
24
  */
13
- interface Props {
14
- workspaceId: string
15
- settings?: Record<string, string | number | boolean | null>
16
- }
17
- const { workspaceId, settings }: Props = $props()
25
+ const { workspaceId, workspaceSlug, settings }: WidgetProps = $props()
18
26
 
19
27
  const limit = $derived(Number(settings?.limit ?? 5))
20
28
  const api = getInventoryApi()
21
29
 
30
+ /**
31
+ * The card asks for exactly what it shows.
32
+ *
33
+ * It used to fetch the default page and then drop archived rows and slice — so a workspace whose
34
+ * last twenty assets were all archived rendered an empty card that was not empty. The server knows
35
+ * how to exclude them and how many to send.
36
+ */
37
+ const filters = $derived({ archived: false, limit })
38
+
22
39
  const assets = createQuery(() => ({
23
- queryKey: ['inventory', 'assets', workspaceId],
24
- queryFn: () => api.assets.list({ workspaceId }),
40
+ queryKey: inventoryKeys.assets(workspaceId, filters),
41
+ queryFn: () => api.assets.list({ workspaceId, ...filters }),
25
42
  enabled: Boolean(workspaceId),
26
43
  }))
27
44
 
28
- const items = $derived((assets.data?.items ?? []).filter((a) => !a.archivedAt).slice(0, limit))
45
+ const items = $derived(assets.data?.items ?? [])
29
46
 
30
- function statusTone(status: string): BadgeTone {
31
- switch (status) {
32
- case 'assigned':
33
- return 'info'
34
- case 'under_repair':
35
- return 'warning'
36
- case 'retired':
37
- return 'grey'
38
- default:
39
- return 'success'
40
- }
41
- }
47
+ /**
48
+ * The three numbers that make this card worth a place on a dashboard.
49
+ *
50
+ * A list of five recent rows says what somebody added last week, which is rarely the question. How
51
+ * many things there are, how many are sitting unclaimed, and how many are away being fixed is the
52
+ * question — and one request answers all three, so the strip costs a query rather than a screen.
53
+ *
54
+ * It fails **soft**: the card is a list of recent assets first, and a numbers strip that could not
55
+ * be loaded simply is not drawn. A dashboard where one card reports an error because a secondary
56
+ * query failed is a worse dashboard than one where a strip is missing.
57
+ */
58
+ const stats = createQuery(() => ({
59
+ queryKey: inventoryKeys.stats(workspaceId),
60
+ queryFn: () => api.stats.summary({ workspaceId }),
61
+ enabled: Boolean(workspaceId),
62
+ }))
63
+
64
+ /**
65
+ * `outForRepair` is null for a workspace that does not track repairs — not zero, which would be a
66
+ * claim it has not made. A null tile is simply absent.
67
+ */
68
+ const tiles = $derived(
69
+ stats.data
70
+ ? [
71
+ { label: t('stats_total'), value: stats.data.total },
72
+ { label: t('stats_unassigned'), value: stats.data.unassigned },
73
+ ...(stats.data.outForRepair !== null
74
+ ? [{ label: t('stats_out_for_repair'), value: stats.data.outForRepair }]
75
+ : []),
76
+ ]
77
+ : [],
78
+ )
79
+
80
+ /**
81
+ * A count in the reader's own digits — «۱۲» in Persian, not "12".
82
+ *
83
+ * Not `formatCount`, which caps at "99+": that is a badge helper, and a workspace with 214 assets
84
+ * would be told it has 99+ of them on the line whose job is to be the number.
85
+ */
86
+ const number = (n: number) => new Intl.NumberFormat(messageLocale()).format(n)
42
87
  </script>
43
88
 
44
89
  <WidgetState
@@ -49,37 +94,102 @@ function statusTone(status: string): BadgeTone {
49
94
  emptyIcon="briefcase"
50
95
  onRetry={() => assets.refetch()}
51
96
  >
97
+ {#if tiles.length}
98
+ <dl class="stats">
99
+ {#each tiles as tile (tile.label)}
100
+ <div class="stat">
101
+ <dt>{tile.label}</dt>
102
+ <dd>{number(tile.value)}</dd>
103
+ </div>
104
+ {/each}
105
+ </dl>
106
+ {/if}
52
107
  <ul>
53
108
  {#each items as asset (asset.id)}
54
109
  <li>
55
- <span class="code">{asset.code}</span>
56
- <span class="name">{asset.name}</span>
57
- <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
110
+ <!--
111
+ The row *is* the action. `kern-widget` §3: a card that only shows rows is a table of
112
+ contents, and this one showed five assets with nothing to do about any of them. An anchor
113
+ rather than a click handler, so the keyboard route is the same route — Tab reaches it,
114
+ Enter follows it, and the browser offers "open in a new tab" for free. It lands on the
115
+ list with the asset's own panel open, which is exactly where clicking the row on that
116
+ screen puts somebody.
117
+ -->
118
+ <a class="row" href={assetHref(workspaceSlug, asset.id)}
119
+ aria-label={t('open_asset', isolated({ name: asset.name }))}>
120
+ <span class="code">{asset.code}</span>
121
+ <span class="name">{asset.name}</span>
122
+ <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
123
+ </a>
58
124
  </li>
59
125
  {/each}
60
126
  </ul>
61
127
  </WidgetState>
62
128
 
63
129
  <style>
130
+ .stats {
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ gap: 6px 18px;
134
+ margin: 0 0 12px;
135
+ padding: 0 0 12px;
136
+ border-bottom: 1px solid var(--kern-border-hairline);
137
+ }
138
+ .stat {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 1px;
142
+ min-width: 0;
143
+ }
144
+ dt {
145
+ font-size: 11px;
146
+ /* Muted with a colour rather than opacity, which fades the label against the card. */
147
+ color: var(--kern-ink-500);
148
+ }
149
+ dd {
150
+ margin: 0;
151
+ font-size: 18px;
152
+ font-weight: 600;
153
+ line-height: 1.15;
154
+ letter-spacing: -0.02em;
155
+ color: var(--kern-ink-900);
156
+ font-variant-numeric: tabular-nums;
157
+ }
64
158
  ul {
65
159
  list-style: none;
66
160
  margin: 0;
67
161
  padding: 0;
68
162
  display: flex;
69
163
  flex-direction: column;
70
- gap: 8px;
164
+ gap: 2px;
71
165
  }
72
- li {
166
+ .row {
73
167
  display: flex;
74
168
  align-items: center;
75
169
  gap: 10px;
76
170
  font-size: 13px;
171
+ color: inherit;
172
+ text-decoration: none;
173
+ /* Logical, so the row's breathing room is on the reading-start edge in Persian too. Six
174
+ vertical pixels take the row past the 24px WCAG 2.5.8 target height on its own. */
175
+ padding-block: 5px;
176
+ padding-inline: 6px;
177
+ margin-inline: -6px;
178
+ border-radius: var(--kern-r-sm);
179
+ }
180
+ .row:hover {
181
+ background: var(--kern-surface-hover);
77
182
  }
78
183
  .code {
79
184
  font-size: 11px;
80
185
  color: var(--kern-ink-500);
81
186
  font-variant-numeric: tabular-nums;
82
187
  min-width: 64px;
188
+ /* A tag is read character by character off a sticker, so it stays left-to-right inside a
189
+ Persian or Arabic card and the bidi algorithm does not reorder its digits — the same rule
190
+ the repairs card next to it already follows. */
191
+ direction: ltr;
192
+ unicode-bidi: isolate;
83
193
  }
84
194
  .name {
85
195
  flex: 1;
@@ -88,5 +198,9 @@ function statusTone(status: string): BadgeTone {
88
198
  text-overflow: ellipsis;
89
199
  white-space: nowrap;
90
200
  color: var(--kern-ink-900);
201
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
202
+ first strong character, so a Latin name inside a Persian screen reads left to right and
203
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
204
+ unicode-bidi: plaintext;
91
205
  }
92
206
  </style>
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import { formatDate, type WidgetProps, WidgetState } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import type { RepairListItem } from '../../contract/index.js'
5
+ import { getInventoryApi } from '../api-instance.js'
6
+ import { isolated } from '../bidi.js'
7
+ import { t } from '../i18n.js'
8
+ import { assetHref } from '../links.js'
9
+ import { inventoryKeys } from '../query.js'
10
+
11
+ /**
12
+ * What is away being fixed, right now.
13
+ *
14
+ * A second card rather than a `view` setting on the first one, because this one is **behind a
15
+ * capability**: `capability: 'repairs'` in `module.ts` means the shell does not offer it at all to a
16
+ * workspace that does not record repairs, where an option inside a settings dropdown would offer
17
+ * the question and then answer it with an empty card. That is the whole difference between a switch
18
+ * that means something and a switch that does not.
19
+ *
20
+ * It asks `repairs.list` at workspace scope — the one question a per-asset list cannot answer — and
21
+ * the asset's tag and name arrive joined, so nothing here has to look an id up.
22
+ *
23
+ * A row opens the **asset**, not the repair: a repair has no screen of its own, and the panel's
24
+ * Repairs tab is where somebody logs it back in — which is the thing anybody reading "away since
25
+ * 3 March" actually wants to do next. `WidgetProps` is what carries the `workspaceSlug` that link
26
+ * needs; this file declared two props by hand and could therefore not build one.
27
+ */
28
+ const { workspaceId, workspaceSlug, settings }: WidgetProps = $props()
29
+
30
+ const limit = $derived(Number(settings?.limit ?? 5))
31
+ const api = getInventoryApi()
32
+
33
+ const filters = $derived({ open: true, limit })
34
+
35
+ const repairs = createQuery(() => ({
36
+ queryKey: inventoryKeys.repairs(workspaceId, filters),
37
+ queryFn: () => api.repairs.list({ workspaceId, ...filters }),
38
+ enabled: Boolean(workspaceId),
39
+ }))
40
+
41
+ const items = $derived<readonly RepairListItem[]>(repairs.data?.items ?? [])
42
+ </script>
43
+
44
+ <WidgetState
45
+ pending={repairs.isPending}
46
+ error={repairs.error}
47
+ empty={!items.length}
48
+ emptyTitle={t('widget_repairs_empty')}
49
+ emptyIcon="wrench"
50
+ onRetry={() => repairs.refetch()}
51
+ >
52
+ <ul>
53
+ {#each items as repair (repair.id)}
54
+ <li>
55
+ <!-- An anchor, so the keyboard route and the pointer route are one route. -->
56
+ <a class="row" href={assetHref(workspaceSlug, repair.assetId)}
57
+ aria-label={t('open_asset', isolated({ name: repair.assetName }))}>
58
+ <span class="code">{repair.assetCode}</span>
59
+ <span class="name">{repair.assetName}</span>
60
+ <span class="since">
61
+ {t('repair_away_since', isolated({ date: formatDate(repair.sentOn) }))}
62
+ </span>
63
+ </a>
64
+ </li>
65
+ {/each}
66
+ </ul>
67
+ </WidgetState>
68
+
69
+ <style>
70
+ ul {
71
+ list-style: none;
72
+ margin: 0;
73
+ padding: 0;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 2px;
77
+ }
78
+ .row {
79
+ display: flex;
80
+ align-items: baseline;
81
+ gap: 10px;
82
+ font-size: 13px;
83
+ min-width: 0;
84
+ color: inherit;
85
+ text-decoration: none;
86
+ /* Ten vertical pixels take the row past the 24px WCAG 2.5.8 target height; the inline padding
87
+ is given back by an equal negative margin, so the card's own gutter is unchanged. */
88
+ padding-block: 5px;
89
+ padding-inline: 6px;
90
+ margin-inline: -6px;
91
+ border-radius: var(--kern-r-sm);
92
+ }
93
+ .row:hover {
94
+ background: var(--kern-surface-hover);
95
+ }
96
+ .code {
97
+ font-size: 11px;
98
+ color: var(--kern-ink-500);
99
+ font-variant-numeric: tabular-nums;
100
+ min-width: 64px;
101
+ /* A tag is read character by character off a sticker, so it stays left-to-right inside a
102
+ Persian or Arabic card and the bidi algorithm does not reorder its digits. */
103
+ direction: ltr;
104
+ unicode-bidi: isolate;
105
+ }
106
+ .name {
107
+ flex: 1;
108
+ min-width: 0;
109
+ overflow: hidden;
110
+ text-overflow: ellipsis;
111
+ white-space: nowrap;
112
+ color: var(--kern-ink-900);
113
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
114
+ first strong character, so a Latin name inside a Persian screen reads left to right and
115
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
116
+ unicode-bidi: plaintext;
117
+ }
118
+ .since {
119
+ flex: none;
120
+ font-size: 11px;
121
+ /* Muted with a colour, never opacity: a faded line is unreadable whatever its token says. */
122
+ color: var(--kern-ink-500);
123
+ }
124
+ </style>
@@ -0,0 +1,99 @@
1
+ import { defineCapabilities } from '@kernhq/contracts'
2
+
3
+ /**
4
+ * How much Inventory this workspace has.
5
+ *
6
+ * The module answers two questions that look like one: *what do we own and who has it* (an office
7
+ * with forty laptops) and *how much of this do we have and where* (a warehouse with bins and
8
+ * reorder points). Both are "things the company has", both want the same categories, locations,
9
+ * suppliers, attachments and search — and almost nobody wants both halves at once. Capabilities are
10
+ * what keep the small case small: an office switches on nothing beyond the register and never meets
11
+ * the word "bin".
12
+ *
13
+ * **Declared here only once something is behind it.** A switch that changes nothing teaches an
14
+ * administrator that the switchboard does not mean anything, so this list grows with the module.
15
+ *
16
+ * Two rules decide whether something belongs here at all:
17
+ *
18
+ * - **Not a permission.** "May Ada write off a laptop" is a permission — true for her, false for
19
+ * somebody else, in the same workspace. "Does this company track stock levels" is a capability:
20
+ * one answer for everyone, the owner included, and the answer is 404 rather than 403.
21
+ * - **Reversible without a migration.** Switching one off writes a boolean into module settings and
22
+ * the rows stay exactly where they are. Anything that would need data thrown away to reverse is
23
+ * not a capability, however much it looks like one.
24
+ *
25
+ * The rest arrive with the work that makes them mean something: `labels` with the finished register;
26
+ * `custom_fields` and `locations` beside them; `stock` and `procurement` with stock control;
27
+ * `depreciation` and `reservations` last. Adding one here before its procedures exist is the mistake
28
+ * this comment is here to prevent.
29
+ */
30
+ export const inventoryCapabilities = defineCapabilities([
31
+ {
32
+ id: 'core',
33
+ label: 'Assets',
34
+ // Says only what the module answers today, which is the rule this line has been held to in both
35
+ // directions: custody and the change history were taken *out* of it while neither had a
36
+ // procedure, and put back in the change that gave them one.
37
+ description:
38
+ 'The asset register: what the company owns item by item, who is holding each one, and how each one got there',
39
+ required: true,
40
+ level: 1,
41
+ },
42
+ {
43
+ /**
44
+ * The first capability anyone can actually switch, and the reason it is one is the shape of the
45
+ * customers: an office that hands out laptops wants a register and never records a repair,
46
+ * while a company with a workshop wants little else. One answer for the whole workspace, not
47
+ * per person — so a capability rather than a permission — and switching it off writes a boolean
48
+ * into module settings and leaves every repair row exactly where it was.
49
+ *
50
+ * `dependsOn: ['core']` says the obvious out loud: a repair is a trip *an asset* made, so there
51
+ * is nothing to record without the register. It also means the closure prunes this
52
+ * automatically if `core` could ever be off, rather than each screen remembering to check two
53
+ * switches.
54
+ */
55
+ id: 'repairs',
56
+ label: 'Repairs',
57
+ description:
58
+ 'What went away to be fixed, to whom, what it cost and when it came back — and the `under repair` status that follows an open one',
59
+ dependsOn: ['core'],
60
+ defaultEnabled: true,
61
+ level: 1,
62
+ },
63
+ {
64
+ /**
65
+ * Files against an asset or a repair — a purchase receipt, a warranty card, a repair invoice.
66
+ *
67
+ * Separate from `repairs` because plenty of workspaces want one without the other: an office
68
+ * that keeps receipts and never records a repair, and a workshop that records repairs and keeps
69
+ * no paperwork. Switching it off hides the Files tab and answers 404; the rows and the files in
70
+ * core's storage are untouched, which is what makes it a capability rather than a migration.
71
+ */
72
+ id: 'attachments',
73
+ label: 'Files',
74
+ description: 'Receipts, warranties and manuals kept against an asset or one of its repairs',
75
+ dependsOn: ['core'],
76
+ defaultEnabled: true,
77
+ level: 1,
78
+ },
79
+ ])
80
+
81
+ export type InventoryCapabilityId = (typeof inventoryCapabilities)[number]['id']
82
+
83
+ /**
84
+ * Which procedures sit behind which capability.
85
+ *
86
+ * Declared as data because a missing `requiresCapability` is invisible: the procedure compiles,
87
+ * every other test passes, and the only symptom is a workspace calling a feature it switched off.
88
+ * `module.test.ts` reads this and fails when a procedure named here is not carrying the middleware.
89
+ *
90
+ * A procedure absent from this map belongs to the module as a whole and is reachable whenever
91
+ * Inventory is on — which for `core` is always, because it is `required`.
92
+ */
93
+ export const inventoryCapabilityProcedures: Record<string, readonly string[]> = {
94
+ repairs: ['repairs.list', 'repairs.create', 'repairs.update', 'repairs.complete'],
95
+ attachments: ['attachments.list', 'attachments.add', 'attachments.remove'],
96
+ // `stats.summary` is deliberately absent. It counts assets, which is `core`, and it answers
97
+ // `outForRepair: null` rather than a number when the workspace has `repairs` off — a count line
98
+ // and a dashboard card must not disappear because a *different* feature is switched off.
99
+ }