@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,318 @@
1
+ <script lang="ts">
2
+ import { Button, EmptyState, formatDate, IconButton, messageLocale, Skeleton } from '@kernhq/ui'
3
+ import { createInfiniteQuery } from '@tanstack/svelte-query'
4
+ import type { Asset, 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 { formatPrice } from '../price.js'
9
+ import { inventoryKeys } from '../query.js'
10
+ import { type RepairAction, repairActions } from '../repairs.js'
11
+ import AttachmentsSection from './AttachmentsSection.svelte'
12
+
13
+ /**
14
+ * What went away to be fixed, for one asset.
15
+ *
16
+ * Two halves, because they answer two questions: **the open one**, which is where the item is right
17
+ * now, and **the past ones**, which are what it has cost to keep. At most one can be open — the
18
+ * database says so with a unique index — so the top of this section is a single card rather than a
19
+ * list, and `Send for repair` is offered only when there is no card there.
20
+ *
21
+ * A repair's own paperwork sits inside its card: the invoice belongs with the repair it is for, not
22
+ * in the asset's general Files tab beside the purchase receipt.
23
+ */
24
+ interface Props {
25
+ workspaceId: string
26
+ asset: Asset
27
+ /** `inventory.repair.manage` — whether any of this can be written. */
28
+ canManage: boolean
29
+ /** `inventory.asset.manage`, which is what attaching a file to a repair takes. */
30
+ canAttach: boolean
31
+ /** Whether this workspace has the `attachments` capability; false hides the paperwork entirely. */
32
+ hasFiles: boolean
33
+ /** Opens the shared dialog. The panel owns it, so there is one dialog rather than one per row. */
34
+ onact: (action: RepairAction, repair: RepairListItem | null) => void
35
+ }
36
+ const { workspaceId, asset, canManage, canAttach, hasFiles, onact }: Props = $props()
37
+
38
+ const api = getInventoryApi()
39
+
40
+ interface RepairPage {
41
+ items: RepairListItem[]
42
+ nextCursor: string | null
43
+ }
44
+
45
+ const repairsQuery = createInfiniteQuery(() => ({
46
+ queryKey: inventoryKeys.assetRepairs(workspaceId, asset.id),
47
+ queryFn: ({ pageParam }): Promise<RepairPage> =>
48
+ api.repairs.list({
49
+ workspaceId,
50
+ assetId: asset.id,
51
+ ...(pageParam ? { cursor: pageParam as string } : {}),
52
+ }) as Promise<RepairPage>,
53
+ initialPageParam: undefined as string | undefined,
54
+ getNextPageParam: (last: RepairPage) => last.nextCursor ?? undefined,
55
+ enabled: Boolean(workspaceId && asset.id),
56
+ }))
57
+
58
+ const repairs = $derived(repairsQuery.data?.pages.flatMap((page) => page.items) ?? [])
59
+ /** At most one, by construction: `inventory_repairs_one_open_uq` makes a second one impossible. */
60
+ const open = $derived(repairs.find((row) => row.returnedOn === null) ?? null)
61
+ const past = $derived(repairs.filter((row) => row.returnedOn !== null))
62
+
63
+ /**
64
+ * Which of the three this section may offer, decided in `repairs.ts`.
65
+ *
66
+ * Hidden rather than disabled for somebody without the permission — they may never do it — and
67
+ * empty for an archived item, where the sentence below says why. The server refuses each of these
68
+ * again; this only stops the panel offering a door that will not open.
69
+ */
70
+ const available = $derived(
71
+ repairActions({
72
+ open: open !== null,
73
+ archived: Boolean(asset.archivedAt),
74
+ may: canManage,
75
+ }),
76
+ )
77
+
78
+ /** A cost in the reader's own numbers, with the unit it was recorded in. */
79
+ function cost(row: RepairListItem): string | null {
80
+ if (row.costMinor === null) return null
81
+ // Its own currency decides how many decimal places the stored minor units carry — yen has none.
82
+ return `${formatPrice(row.costMinor, messageLocale(), row.currency)} ${row.currency ?? ''}`.trim()
83
+ }
84
+
85
+ const SKELETON_ROWS = [0, 1]
86
+ </script>
87
+
88
+ {#if repairsQuery.isPending}
89
+ <div class="skeleton">
90
+ {#each SKELETON_ROWS as row (row)}
91
+ <Skeleton height="52px" radius="10px" />
92
+ {/each}
93
+ </div>
94
+ {:else if repairsQuery.isError}
95
+ <EmptyState bare compact icon="triangle-alert" title={t('repairs_error')}>
96
+ {#snippet actions()}
97
+ <Button size="sm" variant="secondary" onclick={() => void repairsQuery.refetch()}>
98
+ {t('common.retry')}
99
+ </Button>
100
+ {/snippet}
101
+ </EmptyState>
102
+ {:else}
103
+ <div class="repairs">
104
+ {#if open}
105
+ <section class="current" aria-label={t('repair_current')}>
106
+ <div class="chead">
107
+ <h3 class="section">{t('repair_current')}</h3>
108
+ {#if available.includes('edit')}
109
+ <IconButton
110
+ icon="square-pen"
111
+ size={26}
112
+ label={t('repair_edit')}
113
+ onclick={() => onact('edit', open)}
114
+ />
115
+ {/if}
116
+ </div>
117
+ <p class="summary">{open.summary}</p>
118
+ <p class="meta">
119
+ <span>{t('repair_away_since', isolated({ date: formatDate(open.sentOn) }))}</span>
120
+ {#if open.vendor}<span>· {open.vendor}</span>{/if}
121
+ {#if cost(open)}<span class="ltr">· {cost(open)}</span>{/if}
122
+ </p>
123
+ {#if open.detail}<p class="detail">{open.detail}</p>{/if}
124
+ {#if hasFiles}
125
+ <AttachmentsSection
126
+ {workspaceId}
127
+ assetId={asset.id}
128
+ repairId={open.id}
129
+ canManage={canAttach}
130
+ compact
131
+ />
132
+ {/if}
133
+ {#if available.includes('complete')}
134
+ <div class="acts">
135
+ <Button size="sm" icon="circle-check" onclick={() => onact('complete', open)}>
136
+ {t('repair_complete')}
137
+ </Button>
138
+ </div>
139
+ {/if}
140
+ </section>
141
+ {:else if canManage && asset.archivedAt}
142
+ <!-- A disabled control with no explanation is a bug; one sentence says why instead. -->
143
+ <p class="hint">{t('repair_archived_hint')}</p>
144
+ {:else if available.includes('create')}
145
+ <div class="acts">
146
+ <Button size="sm" icon="wrench" onclick={() => onact('create', null)}>{t('repair_new')}</Button>
147
+ </div>
148
+ {/if}
149
+
150
+ {#if past.length}
151
+ <h3 class="section">{t('repair_past')}</h3>
152
+ <ul class="past">
153
+ {#each past as row (row.id)}
154
+ <li class="entry">
155
+ <div class="ehead">
156
+ <span class="esummary">{row.summary}</span>
157
+ {#if canManage}
158
+ <IconButton
159
+ icon="square-pen"
160
+ size={26}
161
+ label={t('repair_edit')}
162
+ onclick={() => onact('edit', row)}
163
+ />
164
+ {/if}
165
+ </div>
166
+ <p class="meta">
167
+ <!-- Through `Intl`, never two dates and a dash: hand-built it reads backwards in
168
+ RTL, with the earlier date to the right of the later one. -->
169
+ <span>
170
+ {t('repair_sent_on')}
171
+ {formatDate(row.sentOn)}
172
+ </span>
173
+ <span>
174
+ · {t('repair_returned_on')}
175
+ {formatDate(row.returnedOn as string)}
176
+ </span>
177
+ {#if row.vendor}<span>· {row.vendor}</span>{/if}
178
+ {#if cost(row)}<span class="ltr">· {cost(row)}</span>{/if}
179
+ </p>
180
+ {#if hasFiles}
181
+ <AttachmentsSection
182
+ {workspaceId}
183
+ assetId={asset.id}
184
+ repairId={row.id}
185
+ canManage={canAttach}
186
+ compact
187
+ />
188
+ {/if}
189
+ </li>
190
+ {/each}
191
+ </ul>
192
+ {:else if !open}
193
+ <EmptyState
194
+ bare
195
+ compact
196
+ icon="wrench"
197
+ title={t('repairs_empty')}
198
+ description={t('repairs_empty_desc')}
199
+ />
200
+ {/if}
201
+
202
+ {#if repairsQuery.hasNextPage}
203
+ <div class="more">
204
+ <Button
205
+ size="sm"
206
+ variant="secondary"
207
+ loading={repairsQuery.isFetchingNextPage}
208
+ onclick={() => repairsQuery.fetchNextPage()}
209
+ >
210
+ {t('load_more')}
211
+ </Button>
212
+ </div>
213
+ {/if}
214
+ </div>
215
+ {/if}
216
+
217
+ <style>
218
+ .skeleton,
219
+ .repairs {
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 14px;
223
+ margin-top: 14px;
224
+ }
225
+ .current {
226
+ display: flex;
227
+ flex-direction: column;
228
+ gap: 8px;
229
+ padding: 12px;
230
+ border: 1px solid var(--kern-border);
231
+ border-radius: var(--kern-r-lg);
232
+ /* Not `--kern-surface-raised`: a `Sheet` is already that colour, so the card would have been a
233
+ bordered box with no ground of its own. `--kern-surface-chip` is a shade off it in both
234
+ themes, and the muted text on it measures 5.66:1 in light and 5.37:1 in dark. */
235
+ background: var(--kern-surface-chip);
236
+ }
237
+ .chead,
238
+ .ehead {
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: space-between;
242
+ gap: 8px;
243
+ min-width: 0;
244
+ }
245
+ .section {
246
+ margin: 0;
247
+ font-size: 11.5px;
248
+ font-weight: 600;
249
+ letter-spacing: 0.04em;
250
+ text-transform: uppercase;
251
+ color: var(--kern-ink-500);
252
+ }
253
+ .summary,
254
+ .esummary {
255
+ margin: 0;
256
+ font-size: 13.5px;
257
+ font-weight: 500;
258
+ color: var(--kern-ink-900);
259
+ min-width: 0;
260
+ overflow-wrap: anywhere;
261
+ }
262
+ .meta {
263
+ display: flex;
264
+ flex-wrap: wrap;
265
+ gap: 4px;
266
+ margin: 0;
267
+ font-size: 11.5px;
268
+ /* Muted with a colour rather than opacity, which fades the text against the panel. */
269
+ color: var(--kern-ink-500);
270
+ }
271
+ .detail {
272
+ margin: 0;
273
+ font-size: 12.5px;
274
+ line-height: 1.55;
275
+ color: var(--kern-ink-700);
276
+ white-space: pre-wrap;
277
+ }
278
+ .hint {
279
+ margin: 0;
280
+ font-size: 12.5px;
281
+ line-height: 1.5;
282
+ color: var(--kern-ink-500);
283
+ }
284
+ .acts {
285
+ display: flex;
286
+ gap: 8px;
287
+ flex-wrap: wrap;
288
+ }
289
+ .past {
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 12px;
293
+ margin: 0;
294
+ padding: 0;
295
+ list-style: none;
296
+ }
297
+ .entry {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 5px;
301
+ padding-bottom: 12px;
302
+ border-bottom: 1px solid var(--kern-border-hairline);
303
+ }
304
+ .entry:last-child {
305
+ border-bottom: 0;
306
+ padding-bottom: 0;
307
+ }
308
+ /* An amount is a Latin number read left to right, whatever direction the panel runs. */
309
+ .ltr {
310
+ direction: ltr;
311
+ unicode-bidi: isolate;
312
+ }
313
+ .more {
314
+ display: flex;
315
+ justify-content: center;
316
+ padding-top: 4px;
317
+ }
318
+ </style>
@@ -0,0 +1,347 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Button,
5
+ EmptyState,
6
+ formatDate,
7
+ formatDateTime,
8
+ messageLocale,
9
+ relativeTime,
10
+ Skeleton,
11
+ } from '@kernhq/ui'
12
+ import { createInfiniteQuery } from '@tanstack/svelte-query'
13
+ import type { AssetHistoryEntry } from '../../contract/index.js'
14
+ import { getInventoryApi } from '../api-instance.js'
15
+ import { isolate, isolated } from '../bidi.js'
16
+ import { t } from '../i18n.js'
17
+ import type { Directory } from '../members.js'
18
+ import { nameOf, resolveName } from '../members.js'
19
+ import { formatPrice } from '../price.js'
20
+ import { inventoryKeys } from '../query.js'
21
+ import { actionKey, changeLineKind, fieldKey, noteOf, personIdOf, subjectOf } from '../timeline.js'
22
+ import TimelineText from './TimelineText.svelte'
23
+
24
+ /**
25
+ * An asset's own timeline, as sentences.
26
+ *
27
+ * `asset_history` stores `{ action, changes: [{field, from, to}], data: {…ids} }`, which is a shape
28
+ * for a database. Rendered raw it is a JSON diff of somebody's own laptop; rendered here it is
29
+ * "Ada handed it to Bruno" and "Warranty until changed from Mar 14, 2027 to Mar 14, 2028" — "it"
30
+ * rather than the tag, because the panel's own header is already showing `INV-0042`.
31
+ *
32
+ * Which sentence, and about whom, is decided in `timeline.ts` — pure, and therefore tested. This
33
+ * file supplies the words and the layout and nothing else.
34
+ */
35
+ interface Props {
36
+ workspaceId: string
37
+ assetId: string
38
+ /** Who the workspace currently has, for turning a stored uuid into a name. */
39
+ dir: Directory
40
+ /** A category id as its name, or null when the id names nothing this workspace still lists. */
41
+ categoryName: (id: string) => string | null
42
+ /**
43
+ * The asset's currency now, for reading a stored `priceMinor` back into an amount.
44
+ *
45
+ * How many decimal places minor units carry is a fact about the currency — none for yen, three
46
+ * for the dinar — so a timeline that assumed hundredths printed every yen price 100× too small.
47
+ * An entry that *changed* the currency carries both sides itself and is read from those instead;
48
+ * this is the answer for every other entry, which is almost all of them.
49
+ */
50
+ currency: string | null
51
+ }
52
+ const { workspaceId, assetId, dir, categoryName, currency }: Props = $props()
53
+
54
+ const api = getInventoryApi()
55
+
56
+ interface HistoryPage {
57
+ items: AssetHistoryEntry[]
58
+ nextCursor: string | null
59
+ }
60
+
61
+ const historyQuery = createInfiniteQuery(() => ({
62
+ queryKey: inventoryKeys.assetHistory(workspaceId, assetId),
63
+ queryFn: ({ pageParam }): Promise<HistoryPage> =>
64
+ api.assets.history({
65
+ workspaceId,
66
+ assetId,
67
+ ...(pageParam ? { cursor: pageParam as string } : {}),
68
+ }) as Promise<HistoryPage>,
69
+ initialPageParam: undefined as string | undefined,
70
+ getNextPageParam: (last: HistoryPage) => last.nextCursor ?? undefined,
71
+ enabled: Boolean(workspaceId && assetId),
72
+ }))
73
+
74
+ const entries = $derived(historyQuery.data?.pages.flatMap((page) => page.items) ?? [])
75
+
76
+ /**
77
+ * The four words `nameOf` needs, read on render so they follow the interface language.
78
+ *
79
+ * Four rather than two, and that is the fix: an id the directory does not have used to read as
80
+ * "a former member" whether the member list was still in flight, had failed, or really did not
81
+ * contain them — so every entry said "A former member handed it to A former member" for the first
82
+ * moments of every panel, and for ever if the request failed. `members.ts` decides which of the
83
+ * four applies; this only supplies the wording.
84
+ */
85
+ const words = $derived({
86
+ loading: t('member_loading'),
87
+ unknown: t('member_unknown'),
88
+ former: t('member_former'),
89
+ system: t('member_system'),
90
+ })
91
+
92
+ /**
93
+ * One value, in the units a person reads it in.
94
+ *
95
+ * A stored diff holds what the column holds: minor units for a price, an ISO date for a date, a
96
+ * uuid for a category. "priceMinor changed from 129900 to 149900" is arithmetic homework, and
97
+ * "categoryId changed from 0192… to 0192…" is not a sentence at all.
98
+ */
99
+ function readable(field: string, value: unknown, money: string | null): string {
100
+ if (value === null || value === undefined || value === '') return '—'
101
+ if (field === 'categoryId') return categoryName(String(value)) ?? t('category_none')
102
+ if (field === 'priceMinor') return formatPrice(Number(value), messageLocale(), money)
103
+ if (field === 'purchasedOn' || field === 'warrantyUntil') return formatDate(String(value))
104
+ return String(value)
105
+ }
106
+
107
+ /**
108
+ * Which currency one side of one entry is in — the entry's own answer where it has one.
109
+ *
110
+ * Somebody who re-priced a laptop from ¥120000 to €890 changed two columns in one edit, so the
111
+ * entry carries a `currency` change beside the `priceMinor` change and each side of the sentence
112
+ * is in a different currency. Reading both through today's currency would print one of them
113
+ * wrong, and it is the *old* one — the number nobody can check any more.
114
+ */
115
+ function currencyFor(entry: AssetHistoryEntry, side: 'from' | 'to'): string | null {
116
+ const changed = entry.changes.find((change) => change.field === 'currency')
117
+ if (!changed) return currency
118
+ const value = changed[side]
119
+ return typeof value === 'string' && value ? value : null
120
+ }
121
+
122
+ /**
123
+ * The label for one field, and the only value here that must *not* be isolated afterwards.
124
+ *
125
+ * A key that resolves is a translated word and belongs to the sentence around it. A field this
126
+ * client has never heard of prints its own name — ugly and true, because the rows outlive the image
127
+ * that wrote them — and that one is a Latin identifier dropped into a Persian sentence, so it is
128
+ * isolated here rather than left to reorder the words on either side of it.
129
+ */
130
+ function fieldLabel(field: string): string {
131
+ const key = fieldKey(field)
132
+ return key ? t(key) : isolate(field)
133
+ }
134
+
135
+ /**
136
+ * One line under an entry, for one field that changed.
137
+ *
138
+ * `changeLineKind` in `timeline.ts` decides which of the five sentences applies — including the one
139
+ * this used to get wrong, where a `description` change read its whole eight-kilobyte value out into
140
+ * a row of a 440px panel. `text_changed` says that it changed and `TimelineText` carries the text.
141
+ *
142
+ * Every value goes through `isolated()`: a serial number, a location, a price and a category name
143
+ * are all values somebody typed, and a Latin one inside «{field} از {from} به {to} تغییر کرد»
144
+ * loses its own punctuation to the sentence around it.
145
+ */
146
+ function changeLine(entry: AssetHistoryEntry, change: { field: string; from: unknown; to: unknown }): string {
147
+ const field = fieldLabel(change.field)
148
+ const kind = changeLineKind(change.field, change.from, change.to)
149
+ if (kind === 'cleared') return t('history_cleared', isolated({ field }))
150
+ if (kind === 'replaced') return t('history_replaced', isolated({ field }))
151
+ if (kind === 'text_changed') return t('history_text_changed', isolated({ field }))
152
+ if (kind === 'set')
153
+ return t(
154
+ 'history_set',
155
+ isolated({ field, to: readable(change.field, change.to, currencyFor(entry, 'to')) }),
156
+ )
157
+ return t(
158
+ 'history_changed',
159
+ isolated({
160
+ field,
161
+ from: readable(change.field, change.from, currencyFor(entry, 'from')),
162
+ to: readable(change.field, change.to, currencyFor(entry, 'to')),
163
+ }),
164
+ )
165
+ }
166
+
167
+ /** The text a `text_changed` line discloses. Null for `from` when there was nothing there before. */
168
+ const textOf = (value: unknown): string | null =>
169
+ value === null || value === undefined || value === '' ? null : String(value)
170
+
171
+ function headline(entry: AssetHistoryEntry): string {
172
+ const personId = personIdOf(entry.action, entry.data)
173
+ // `action` is a machine token — `repair_logged` — and lands inside a translated sentence for an
174
+ // action this client has no wording for, so it is isolated with the two names.
175
+ return t(
176
+ actionKey(entry.action),
177
+ isolated({
178
+ actor: nameOf(entry.actorId, dir, words),
179
+ person: nameOf(personId, dir, words),
180
+ action: entry.action,
181
+ }),
182
+ )
183
+ }
184
+
185
+ const SKELETON_ROWS = [0, 1, 2]
186
+ </script>
187
+
188
+ {#if historyQuery.isPending}
189
+ <ul class="skeleton">
190
+ {#each SKELETON_ROWS as row (row)}
191
+ <li class="srow">
192
+ <Skeleton height="22px" width="22px" radius="999px" />
193
+ <div class="slines">
194
+ <Skeleton height="12px" width="62%" />
195
+ <Skeleton height="10px" width="28%" />
196
+ </div>
197
+ </li>
198
+ {/each}
199
+ </ul>
200
+ {:else if historyQuery.isError}
201
+ <EmptyState bare compact icon="triangle-alert" title={t('history_error')}>
202
+ {#snippet actions()}
203
+ <Button size="sm" variant="secondary" onclick={() => void historyQuery.refetch()}>
204
+ {t('common.retry')}
205
+ </Button>
206
+ {/snippet}
207
+ </EmptyState>
208
+ {:else if entries.length === 0}
209
+ <!-- Unreachable in practice — `create` writes the first entry in the same transaction as the
210
+ asset — and rendered honestly all the same, because "unreachable" is a claim about today. -->
211
+ <EmptyState bare compact icon="clock" title={t('history_empty')} />
212
+ {:else}
213
+ <ol class="entries">
214
+ {#each entries as entry (entry.id)}
215
+ <!-- The avatar shows initials only for somebody actually resolved. Its colour is seeded from
216
+ the id either way, so it does not change when the member list arrives — and an unresolved
217
+ actor gets a plain square rather than the initials of "…". -->
218
+ {@const actor = resolveName(entry.actorId, dir)}
219
+ {@const note = noteOf(entry.data)}
220
+ {@const subject = subjectOf(entry.action, entry.data)}
221
+ <li class="entry">
222
+ <Avatar name={actor.name} id={entry.actorId} size={22} />
223
+ <div class="body">
224
+ <p class="line">{headline(entry)}</p>
225
+ <!-- What the entry is about — a repair's summary, a file's name. Rendered bare rather
226
+ than through the catalogue: it is text somebody typed, not an interface string. -->
227
+ {#if subject}<p class="subject">{subject}</p>{/if}
228
+ {#if entry.changes.length}
229
+ <ul class="changes">
230
+ {#each entry.changes as change (change.field)}
231
+ <li>
232
+ {changeLine(entry, change)}
233
+ {#if changeLineKind(change.field, change.from, change.to) === 'text_changed'}
234
+ <TimelineText
235
+ before={textOf(change.from)}
236
+ after={textOf(change.to) ?? ''}
237
+ />
238
+ {/if}
239
+ </li>
240
+ {/each}
241
+ </ul>
242
+ {/if}
243
+ {#if note}
244
+ <p class="note">{t('history_note', isolated({ note }))}</p>
245
+ {/if}
246
+ <!-- The exact moment is on the element, so a hover and a screen reader both get it;
247
+ the visible text is relative, which is what somebody reading a timeline wants. -->
248
+ <time class="when" datetime={entry.occurredAt} title={formatDateTime(entry.occurredAt)}>
249
+ {relativeTime(entry.occurredAt)}
250
+ </time>
251
+ </div>
252
+ </li>
253
+ {/each}
254
+ </ol>
255
+
256
+ {#if historyQuery.hasNextPage}
257
+ <div class="more">
258
+ <Button
259
+ size="sm"
260
+ variant="secondary"
261
+ loading={historyQuery.isFetchingNextPage}
262
+ onclick={() => historyQuery.fetchNextPage()}
263
+ >
264
+ {t('load_more')}
265
+ </Button>
266
+ </div>
267
+ {/if}
268
+ {/if}
269
+
270
+ <style>
271
+ .entries,
272
+ .skeleton {
273
+ display: flex;
274
+ flex-direction: column;
275
+ gap: 14px;
276
+ margin: 0;
277
+ padding: 0;
278
+ list-style: none;
279
+ }
280
+ .entry,
281
+ .srow {
282
+ display: grid;
283
+ /* Logical, so the avatar sits on the reading-start edge in Persian and Arabic too. */
284
+ grid-template-columns: 22px minmax(0, 1fr);
285
+ gap: 10px;
286
+ align-items: start;
287
+ }
288
+ .slines {
289
+ display: flex;
290
+ flex-direction: column;
291
+ gap: 6px;
292
+ }
293
+ .body {
294
+ display: flex;
295
+ flex-direction: column;
296
+ gap: 3px;
297
+ min-width: 0;
298
+ }
299
+ .line {
300
+ margin: 0;
301
+ font-size: 13px;
302
+ line-height: 1.5;
303
+ color: var(--kern-ink-900);
304
+ }
305
+ .changes {
306
+ margin: 2px 0 0;
307
+ /* Logical: a bulleted list indents from the reading-start edge in both directions. */
308
+ padding-inline-start: 16px;
309
+ display: flex;
310
+ flex-direction: column;
311
+ gap: 2px;
312
+ font-size: 12.5px;
313
+ line-height: 1.5;
314
+ /* Muted with a colour rather than with opacity, which fades text against the page. */
315
+ color: var(--kern-ink-600);
316
+ }
317
+ .note {
318
+ margin: 2px 0 0;
319
+ font-size: 12.5px;
320
+ line-height: 1.5;
321
+ color: var(--kern-ink-600);
322
+ border-inline-start: 2px solid var(--kern-border-hairline);
323
+ padding-inline-start: 8px;
324
+ }
325
+ .subject {
326
+ margin: 1px 0 0;
327
+ font-size: 12.5px;
328
+ line-height: 1.5;
329
+ /* Muted with a colour, never with opacity: a faded line at 0.5 is unreadable whatever its
330
+ token says, and this one carries the only specific word in the entry. */
331
+ color: var(--kern-ink-600);
332
+ overflow-wrap: anywhere;
333
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
334
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
335
+ its own trailing punctuation instead of donating it to the paragraph. */
336
+ unicode-bidi: plaintext;
337
+ }
338
+ .when {
339
+ font-size: 11.5px;
340
+ color: var(--kern-ink-500);
341
+ }
342
+ .more {
343
+ display: flex;
344
+ justify-content: center;
345
+ padding-top: 12px;
346
+ }
347
+ </style>