@kernhq/module-inventory 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +106 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +345 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +245 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1108 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +253 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1392 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +385 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +169 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +157 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +309 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/0008_category_order_unique.sql +71 -0
  117. package/migrations/meta/0002_snapshot.json +1054 -0
  118. package/migrations/meta/0003_snapshot.json +1070 -0
  119. package/migrations/meta/0004_snapshot.json +1130 -0
  120. package/migrations/meta/0005_snapshot.json +1135 -0
  121. package/migrations/meta/_journal.json +49 -0
  122. package/package.json +2 -1
  123. package/src/client/api-instance.ts +27 -2
  124. package/src/client/bidi.test.ts +148 -0
  125. package/src/client/bidi.ts +85 -0
  126. package/src/client/components/AssetDetailPanel.svelte +614 -0
  127. package/src/client/components/AssetFormDialog.svelte +71 -22
  128. package/src/client/components/AssetPhoto.svelte +178 -0
  129. package/src/client/components/AttachmentsSection.svelte +327 -0
  130. package/src/client/components/CustodyDialog.svelte +201 -0
  131. package/src/client/components/RepairDialog.svelte +271 -0
  132. package/src/client/components/RepairsSection.svelte +318 -0
  133. package/src/client/components/Timeline.svelte +347 -0
  134. package/src/client/components/TimelineText.svelte +124 -0
  135. package/src/client/core-api.ts +71 -0
  136. package/src/client/custody.test.ts +31 -0
  137. package/src/client/custody.ts +34 -0
  138. package/src/client/errors.test.ts +395 -0
  139. package/src/client/errors.ts +229 -0
  140. package/src/client/links.test.ts +74 -0
  141. package/src/client/links.ts +44 -0
  142. package/src/client/members.test.ts +132 -0
  143. package/src/client/members.ts +116 -0
  144. package/src/client/messages.test.ts +127 -0
  145. package/src/client/messages.ts +1106 -18
  146. package/src/client/mock.test.ts +465 -1
  147. package/src/client/mock.ts +1105 -79
  148. package/src/client/module.ts +73 -1
  149. package/src/client/pages/AssetsPage.svelte +317 -45
  150. package/src/client/permissions.ts +7 -0
  151. package/src/client/price.test.ts +151 -3
  152. package/src/client/price.ts +155 -11
  153. package/src/client/query.ts +36 -0
  154. package/src/client/reorder.test.ts +100 -0
  155. package/src/client/reorder.ts +79 -0
  156. package/src/client/repairs.test.ts +38 -0
  157. package/src/client/repairs.ts +38 -0
  158. package/src/client/sequence.test.ts +248 -0
  159. package/src/client/sequence.ts +185 -0
  160. package/src/client/settings/CategoriesSettings.svelte +746 -0
  161. package/src/client/settings/GeneralSettings.svelte +0 -0
  162. package/src/client/status.ts +29 -0
  163. package/src/client/timeline.test.ts +175 -0
  164. package/src/client/timeline.ts +206 -0
  165. package/src/client/widgets/OverviewWidget.svelte +127 -26
  166. package/src/client/widgets/RepairsWidget.svelte +124 -0
  167. package/src/contract/capabilities.ts +53 -9
  168. package/src/contract/events.ts +49 -0
  169. package/src/contract/index.ts +1 -0
  170. package/src/contract/models.ts +270 -0
  171. package/src/contract/notifications.ts +73 -0
  172. package/src/contract/permissions.ts +55 -2
  173. package/src/contract/router.ts +284 -1
  174. package/src/contract/settings.ts +24 -4
  175. package/src/module.test.ts +217 -4
  176. package/src/server/index.ts +311 -4
  177. package/src/server/inventory.int.test.ts +4109 -19
  178. package/src/server/jobs.ts +444 -0
  179. package/src/server/migrations.test.ts +253 -2
  180. package/src/server/router.ts +486 -6
  181. package/src/server/schema.ts +171 -3
  182. package/src/server/services/assets.ts +214 -54
  183. package/src/server/services/attachments.ts +215 -0
  184. package/src/server/services/audience.ts +77 -0
  185. package/src/server/services/categories.ts +337 -0
  186. package/src/server/services/cursor.ts +104 -0
  187. package/src/server/services/custody.ts +471 -0
  188. package/src/server/services/db-errors.ts +42 -0
  189. package/src/server/services/index.ts +34 -1
  190. package/src/server/services/members.ts +54 -0
  191. package/src/server/services/notify.ts +52 -7
  192. package/src/server/services/offboarding.ts +150 -0
  193. package/src/server/services/repairs.ts +567 -0
  194. package/src/server/services/search.ts +166 -0
  195. package/src/server/services/stats.ts +88 -0
  196. package/src/server/services/status.test.ts +34 -0
  197. package/src/server/services/status.ts +143 -0
  198. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,614 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Badge,
5
+ Button,
6
+ coreApi,
7
+ EmptyState,
8
+ formatDate,
9
+ formatDateRange,
10
+ formatDateTime,
11
+ IconButton,
12
+ keys,
13
+ messageLocale,
14
+ Sheet,
15
+ Skeleton,
16
+ session,
17
+ type TabItem,
18
+ Tabs,
19
+ } from '@kernhq/ui'
20
+ import { createQuery } from '@tanstack/svelte-query'
21
+ import type { Asset, Category, CustodyPeriod, RepairListItem } from '../../contract/index.js'
22
+ import { MODULE_ID } from '../../contract/index.js'
23
+ import { getInventoryApi } from '../api-instance.js'
24
+ import { isolated } from '../bidi.js'
25
+ import type { CoreApi, CoreMember } from '../core-api.js'
26
+ import { type CustodyAction, custodyActions } from '../custody.js'
27
+ import { t } from '../i18n.js'
28
+ import { directory, directoryStatus, nameOf, resolveName } from '../members.js'
29
+ import { canInventory } from '../permissions.js'
30
+ import { formatPrice } from '../price.js'
31
+ import { inventoryKeys } from '../query.js'
32
+ import type { RepairAction } from '../repairs.js'
33
+ import { statusTone } from '../status.js'
34
+ import AssetPhoto from './AssetPhoto.svelte'
35
+ import AttachmentsSection from './AttachmentsSection.svelte'
36
+ import CustodyDialog from './CustodyDialog.svelte'
37
+ import RepairDialog from './RepairDialog.svelte'
38
+ import RepairsSection from './RepairsSection.svelte'
39
+ import Timeline from './Timeline.svelte'
40
+
41
+ /**
42
+ * Everything one asset is, in a panel over the list.
43
+ *
44
+ * A 440px sheet rather than a page of its own (DESIGN.md §3.13): the list keeps its scroll, its
45
+ * filters and its cursor, and closing the panel puts you back exactly where you were. Which asset
46
+ * is open lives in the URL as `?asset=<id>`, so it can be linked to, reloaded and shared — the page
47
+ * owns that parameter, not this component.
48
+ *
49
+ * Five sections at most, because an asset answers five different questions: **what is it**
50
+ * (Details), **who has it** (Custody), **what has been fixed** (Repairs), **what paperwork it has**
51
+ * (Files) and **what has happened to it** (History).
52
+ *
53
+ * Two of them are capabilities, so a workspace that does not record repairs or keep receipts sees
54
+ * three tabs and not five. The tab is not merely hidden: every procedure behind it answers 404 in
55
+ * that workspace, which is what makes the interface and the API agree instead of the interface
56
+ * hiding a door that is still open.
57
+ */
58
+ interface Props {
59
+ workspaceId: string
60
+ /** The asset id from the URL. Null closes the panel. */
61
+ assetId: string | null
62
+ onclose: () => void
63
+ /** Editing is the list's dialog, so the panel asks rather than growing a second copy of it. */
64
+ onedit: (asset: Asset) => void
65
+ }
66
+ const { workspaceId, assetId, onclose, onedit }: Props = $props()
67
+
68
+ const api = getInventoryApi()
69
+ const core = coreApi<CoreApi>()
70
+
71
+ const enabled = $derived(Boolean(workspaceId && assetId))
72
+
73
+ const assetQuery = createQuery(() => ({
74
+ queryKey: inventoryKeys.asset(workspaceId, assetId ?? ''),
75
+ queryFn: () => api.assets.get({ workspaceId, assetId: assetId as string }),
76
+ enabled,
77
+ }))
78
+ const asset = $derived(assetQuery.data ?? null)
79
+
80
+ /**
81
+ * Categories **including archived ones**, and that is the whole reason this query is separate from
82
+ * the page's.
83
+ *
84
+ * An asset filed under a category somebody later archived still carries its id, and the point of
85
+ * archiving rather than deleting is that the row goes on being able to say what it is. Asking for
86
+ * live categories only would blank exactly the rows archiving was supposed to protect.
87
+ */
88
+ const categoriesQuery = createQuery(() => ({
89
+ queryKey: inventoryKeys.categories(workspaceId, true),
90
+ queryFn: () => api.categories.list({ workspaceId, archived: true }),
91
+ enabled,
92
+ }))
93
+ const categoryNames = $derived(
94
+ new Map((categoriesQuery.data ?? []).map((row: Category) => [row.id, row.name])),
95
+ )
96
+ const categoryName = (id: string): string | null => categoryNames.get(id) ?? null
97
+
98
+ const membersQuery = createQuery(() => ({
99
+ queryKey: keys.members(workspaceId),
100
+ queryFn: () => core.workspaces.members.list({ workspaceId }),
101
+ enabled,
102
+ }))
103
+ const members = $derived<readonly CoreMember[]>(membersQuery.data?.items ?? [])
104
+ /**
105
+ * The directory carries the request's state, not just its rows.
106
+ *
107
+ * An empty map is what "still loading", "failed" and "an empty workspace" all look like, and this
108
+ * panel used to hand all three to `nameOf` as the same thing — so every custody row and every
109
+ * timeline entry read "A former member" until the request came back, and permanently if it did not.
110
+ */
111
+ const dir = $derived(directory(members, directoryStatus(membersQuery)))
112
+ const words = $derived({
113
+ loading: t('member_loading'),
114
+ unknown: t('member_unknown'),
115
+ former: t('member_former'),
116
+ system: t('member_system'),
117
+ })
118
+
119
+ const custodyQuery = createQuery(() => ({
120
+ queryKey: inventoryKeys.assetCustody(workspaceId, assetId ?? ''),
121
+ queryFn: () => api.custody.history({ workspaceId, assetId: assetId as string }),
122
+ enabled,
123
+ }))
124
+ const periods = $derived<readonly CustodyPeriod[]>(custodyQuery.data ?? [])
125
+ /** Everything that is not the open one: "who had this before me". */
126
+ const previous = $derived(periods.filter((period) => period.effectiveTo !== null))
127
+
128
+ const holderName = $derived(asset?.custodianUserId ? nameOf(asset.custodianUserId, dir, words) : null)
129
+ /** The same person, unresolved, so the avatar shows initials only for somebody actually found. */
130
+ const holder = $derived(asset?.custodianUserId ? resolveName(asset.custodianUserId, dir) : null)
131
+
132
+ // ---------------------------------------------------------------------- what may be done here
133
+
134
+ const canManage = $derived(canInventory('manage'))
135
+ const canCustody = $derived(canInventory('custody'))
136
+ /**
137
+ * Which handovers this item can actually take, decided in `custody.ts`.
138
+ *
139
+ * Empty for somebody without the permission — hidden, because they may never do it — and empty for
140
+ * an archived item, where the sentence below says why rather than three buttons that cannot be
141
+ * pressed. The server refuses every one of these again; this only stops the panel offering a door
142
+ * that will not open.
143
+ */
144
+ const available = $derived(
145
+ custodyActions({
146
+ held: Boolean(asset?.custodianUserId),
147
+ archived: Boolean(asset?.archivedAt),
148
+ may: canCustody,
149
+ }),
150
+ )
151
+
152
+ let action = $state<CustodyAction | null>(null)
153
+
154
+ /** `inventory.repair.manage`, and `inventory.asset.manage` is what attaching a file takes. */
155
+ const canRepair = $derived(canInventory('repairs'))
156
+
157
+ /**
158
+ * What this workspace has switched on.
159
+ *
160
+ * Read from the session rather than derived here: it is the set the **server** resolved, with
161
+ * defaults applied, `required` forced on and anything whose dependency is off pruned. Working it
162
+ * out again from raw settings would be a second implementation of that closure, and two
163
+ * implementations eventually disagree — the way that shows up is a tab whose API answers 404.
164
+ */
165
+ const hasRepairs = $derived(session.hasCapability(MODULE_ID, 'repairs'))
166
+ const hasFiles = $derived(session.hasCapability(MODULE_ID, 'attachments'))
167
+
168
+ let repairAction = $state<RepairAction | null>(null)
169
+ let repairRow = $state<RepairListItem | null>(null)
170
+
171
+ function openRepair(next: RepairAction, row: RepairListItem | null) {
172
+ repairRow = row
173
+ repairAction = next
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------------- the tabs
177
+
178
+ let tab = $state('details')
179
+ const TABS = $derived<TabItem[]>([
180
+ { value: 'details', label: t('details'), icon: 'info' },
181
+ { value: 'custody', label: t('custody'), icon: 'user' },
182
+ ...(hasRepairs ? [{ value: 'repairs', label: t('repairs'), icon: 'wrench' }] : []),
183
+ ...(hasFiles ? [{ value: 'files', label: t('files'), icon: 'paperclip' }] : []),
184
+ { value: 'history', label: t('history'), icon: 'clock' },
185
+ ])
186
+
187
+ /**
188
+ * A tab a workspace has just switched off must not leave the panel on it.
189
+ *
190
+ * `Tabs` keeps whatever value it was given, so a panel sitting on Repairs when an administrator
191
+ * turns the capability off in another tab would render nothing at all — the content snippet has no
192
+ * branch for a tab that is not in the list.
193
+ */
194
+ $effect(() => {
195
+ if (!TABS.some((item) => item.value === tab)) tab = 'details'
196
+ })
197
+
198
+ /** The rows of the Details section: a label, and a value already turned into words. */
199
+ const facts = $derived.by<Array<{ label: string; value: string; ltr?: boolean }>>(() => {
200
+ if (!asset) return []
201
+ const rows: Array<{ label: string; value: string; ltr?: boolean }> = [
202
+ {
203
+ label: t('category'),
204
+ value: (asset.categoryId && categoryName(asset.categoryId)) || t('category_none'),
205
+ },
206
+ { label: t('serial_number'), value: asset.serialNumber ?? '—', ltr: true },
207
+ { label: t('location'), value: asset.location ?? '—' },
208
+ { label: t('purchased_on'), value: asset.purchasedOn ? formatDate(asset.purchasedOn) : '—' },
209
+ { label: t('purchased_from'), value: asset.purchasedFrom ?? '—' },
210
+ {
211
+ label: t('price'),
212
+ value:
213
+ asset.priceMinor === null
214
+ ? '—'
215
+ : // The currency decides the decimal places, not a constant: ¥1000 has none and a
216
+ // dinar has three, so reading the column as hundredths showed both wrong.
217
+ `${formatPrice(asset.priceMinor, messageLocale(), asset.currency)} ${asset.currency ?? ''}`.trim(),
218
+ },
219
+ { label: t('warranty_until'), value: asset.warrantyUntil ? formatDate(asset.warrantyUntil) : '—' },
220
+ { label: t('added_on'), value: formatDateTime(asset.createdAt) },
221
+ { label: t('updated_on'), value: formatDateTime(asset.updatedAt) },
222
+ ]
223
+ return rows
224
+ })
225
+ </script>
226
+
227
+ <Sheet
228
+ open={assetId !== null}
229
+ onOpenChange={(next) => {
230
+ if (!next) onclose()
231
+ }}
232
+ >
233
+ {#snippet header()}
234
+ <div class="head">
235
+ <code class="tag">{asset?.code ?? ''}</code>
236
+ <h2 class="name">{asset?.name ?? ''}</h2>
237
+ </div>
238
+ {/snippet}
239
+
240
+ {#snippet actions()}
241
+ {#if asset && canManage}
242
+ <IconButton
243
+ icon="square-pen"
244
+ size={28}
245
+ variant="sidebar"
246
+ label={t('edit')}
247
+ onclick={() => onedit(asset)}
248
+ />
249
+ {/if}
250
+ {/snippet}
251
+
252
+ {#if assetQuery.isPending}
253
+ <div class="loading">
254
+ <Skeleton height="14px" width="55%" />
255
+ <Skeleton height="14px" width="72%" />
256
+ <Skeleton height="14px" width="40%" />
257
+ <Skeleton height="14px" width="64%" />
258
+ </div>
259
+ {:else if assetQuery.isError || !asset}
260
+ <EmptyState bare icon="triangle-alert" title={t('asset_error')}>
261
+ {#snippet actions()}
262
+ <Button size="sm" variant="secondary" onclick={() => void assetQuery.refetch()}>
263
+ {t('common.retry')}
264
+ </Button>
265
+ {/snippet}
266
+ </EmptyState>
267
+ {:else}
268
+ <div class="status-line">
269
+ {#if asset.archivedAt}
270
+ <Badge tone="grey">{t('archived')}</Badge>
271
+ {:else}
272
+ <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
273
+ {/if}
274
+ </div>
275
+
276
+ <!-- The tab strip scrolls rather than pushing the panel sideways: five pills in German do not
277
+ fit 440px, and a page that scrolls horizontally is a defect the e2e sweep fails on. -->
278
+ <Tabs class="panel-tabs" items={TABS} bind:value={tab} label={t('title')}>
279
+ {#snippet children(pane)}
280
+ {#if pane === 'details'}
281
+ <!-- The photo is a field of the asset, so it sits with the asset's other fields rather
282
+ than in Files — which a workspace can switch off, and the photo is not part of it. -->
283
+ <div class="photo-slot">
284
+ <AssetPhoto {workspaceId} {asset} canManage={canManage} />
285
+ </div>
286
+ <dl class="facts">
287
+ {#each facts as fact (fact.label)}
288
+ <dt>{fact.label}</dt>
289
+ <dd class:ltr={fact.ltr}>{fact.value}</dd>
290
+ {/each}
291
+ </dl>
292
+ {#if asset.description}
293
+ <p class="description">{asset.description}</p>
294
+ {/if}
295
+ {:else if pane === 'custody'}
296
+ <div class="custody">
297
+ <div class="holder">
298
+ {#if asset.custodianUserId}
299
+ <Avatar name={holder?.name ?? null} id={asset.custodianUserId} size={32} />
300
+ <div class="holder-text">
301
+ <span class="holder-label">{t('custody_holder')}</span>
302
+ <span class="holder-name">{holderName}</span>
303
+ {#if asset.custodySince}
304
+ <span class="holder-since">
305
+ {t('custody_since', isolated({ date: formatDate(asset.custodySince) }))}
306
+ </span>
307
+ {/if}
308
+ </div>
309
+ {:else}
310
+ <div class="holder-text">
311
+ <span class="holder-label">{t('custody_holder')}</span>
312
+ <span class="holder-name">{t('custody_nobody')}</span>
313
+ </div>
314
+ {/if}
315
+ </div>
316
+
317
+ {#if canCustody && asset.archivedAt}
318
+ <!-- A disabled control with no explanation is a bug; one sentence says why instead. -->
319
+ <p class="hint">{t('custody_archived_hint')}</p>
320
+ {:else if available.length}
321
+ <div class="acts">
322
+ {#each available as verb (verb)}
323
+ <Button
324
+ size="sm"
325
+ variant={verb === 'return' ? 'secondary' : 'primary'}
326
+ icon={verb === 'assign' ? 'user-plus' : verb === 'transfer' ? 'arrow-right' : 'undo-2'}
327
+ onclick={() => (action = verb)}
328
+ >
329
+ {t(`custody_${verb}`)}
330
+ </Button>
331
+ {/each}
332
+ </div>
333
+ {/if}
334
+
335
+ <h3 class="section">{t('custody_previous')}</h3>
336
+ {#if custodyQuery.isPending}
337
+ <Skeleton height="40px" radius="8px" />
338
+ {:else if custodyQuery.isError}
339
+ <EmptyState bare compact icon="triangle-alert" title={t('custody_error')}>
340
+ {#snippet actions()}
341
+ <Button size="sm" variant="secondary" onclick={() => void custodyQuery.refetch()}>
342
+ {t('common.retry')}
343
+ </Button>
344
+ {/snippet}
345
+ </EmptyState>
346
+ {:else if previous.length === 0}
347
+ <EmptyState
348
+ bare
349
+ compact
350
+ icon="user"
351
+ title={t('custody_empty')}
352
+ description={t('custody_empty_desc')}
353
+ />
354
+ {:else}
355
+ <ul class="periods">
356
+ {#each previous as period (period.id)}
357
+ {@const holder = resolveName(period.userId, dir)}
358
+ {@const who = nameOf(period.userId, dir, words)}
359
+ <li class="period">
360
+ <!-- Initials only for somebody resolved; the colour is seeded from the id
361
+ either way, so the square does not change when the list arrives. -->
362
+ <Avatar name={holder.name} id={period.userId} size={22} />
363
+ <div class="period-text">
364
+ <span class="period-who">{who}</span>
365
+ <!-- A range through `Intl`, never two dates and a dash: hand-built it reads
366
+ backwards in RTL, earliest date to the right of the latest. -->
367
+ <span class="period-when">
368
+ {formatDateRange(period.effectiveFrom, period.effectiveTo as string)}
369
+ </span>
370
+ {#if period.note}<span class="period-note">{period.note}</span>{/if}
371
+ </div>
372
+ </li>
373
+ {/each}
374
+ </ul>
375
+ {/if}
376
+ </div>
377
+ {:else if pane === 'repairs'}
378
+ <RepairsSection
379
+ {workspaceId}
380
+ {asset}
381
+ canManage={canRepair}
382
+ canAttach={canManage}
383
+ {hasFiles}
384
+ onact={openRepair}
385
+ />
386
+ {:else if pane === 'files'}
387
+ <div class="files-pane">
388
+ <AttachmentsSection
389
+ {workspaceId}
390
+ assetId={asset.id}
391
+ repairId={null}
392
+ canManage={canManage}
393
+ />
394
+ </div>
395
+ {:else}
396
+ <Timeline
397
+ {workspaceId}
398
+ assetId={asset.id}
399
+ {dir}
400
+ {categoryName}
401
+ currency={asset.currency}
402
+ />
403
+ {/if}
404
+ {/snippet}
405
+ </Tabs>
406
+ {/if}
407
+ </Sheet>
408
+
409
+ <CustodyDialog
410
+ {workspaceId}
411
+ asset={action ? asset : null}
412
+ {action}
413
+ {members}
414
+ {holderName}
415
+ onclose={() => (action = null)}
416
+ />
417
+
418
+ <RepairDialog
419
+ {workspaceId}
420
+ asset={repairAction ? asset : null}
421
+ repair={repairRow}
422
+ action={repairAction}
423
+ onclose={() => {
424
+ repairAction = null
425
+ repairRow = null
426
+ }}
427
+ />
428
+
429
+ <style>
430
+ .head {
431
+ display: flex;
432
+ flex-direction: column;
433
+ gap: 2px;
434
+ min-width: 0;
435
+ }
436
+ .tag {
437
+ font-family: var(--kern-font-mono);
438
+ font-size: 11.5px;
439
+ color: var(--kern-ink-500);
440
+ /* A tag is read character by character off a sticker, so it stays left-to-right inside a
441
+ Persian or Arabic panel and the bidi algorithm does not reorder its digits. */
442
+ direction: ltr;
443
+ unicode-bidi: isolate;
444
+ }
445
+ .name {
446
+ margin: 0;
447
+ font-size: 15px;
448
+ font-weight: 600;
449
+ line-height: 1.35;
450
+ color: var(--kern-ink-900);
451
+ overflow: hidden;
452
+ text-overflow: ellipsis;
453
+ white-space: nowrap;
454
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
455
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
456
+ its own trailing punctuation instead of donating it to the paragraph. */
457
+ unicode-bidi: plaintext;
458
+ }
459
+ .loading {
460
+ display: flex;
461
+ flex-direction: column;
462
+ gap: 12px;
463
+ }
464
+ .status-line {
465
+ margin-bottom: 12px;
466
+ }
467
+ /**
468
+ * Five pills do not fit 440px in German, and a document that scrolls sideways is a defect the
469
+ * end-to-end sweep fails on. Wide content scrolls inside its own box instead; the scrollbar is
470
+ * hidden because the pills already say there are more of them.
471
+ */
472
+ :global(.panel-tabs .ktabs-list) {
473
+ overflow-x: auto;
474
+ scrollbar-width: none;
475
+ }
476
+ :global(.panel-tabs .ktabs-list::-webkit-scrollbar) {
477
+ display: none;
478
+ }
479
+ .photo-slot {
480
+ margin-top: 14px;
481
+ }
482
+ .files-pane {
483
+ margin-top: 14px;
484
+ }
485
+ .facts {
486
+ display: grid;
487
+ grid-template-columns: minmax(96px, auto) minmax(0, 1fr);
488
+ gap: 8px 14px;
489
+ margin: 14px 0 0;
490
+ font-size: 13px;
491
+ }
492
+ dt {
493
+ color: var(--kern-ink-500);
494
+ }
495
+ dd {
496
+ margin: 0;
497
+ color: var(--kern-ink-900);
498
+ overflow-wrap: anywhere;
499
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
500
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
501
+ its own trailing punctuation instead of donating it to the paragraph. */
502
+ unicode-bidi: plaintext;
503
+ }
504
+ .ltr {
505
+ direction: ltr;
506
+ unicode-bidi: isolate;
507
+ }
508
+ .description {
509
+ margin: 16px 0 0;
510
+ font-size: 13px;
511
+ line-height: 1.6;
512
+ color: var(--kern-ink-700);
513
+ white-space: pre-wrap;
514
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
515
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
516
+ its own trailing punctuation instead of donating it to the paragraph. */
517
+ unicode-bidi: plaintext;
518
+ }
519
+ .custody {
520
+ display: flex;
521
+ flex-direction: column;
522
+ gap: 14px;
523
+ margin-top: 14px;
524
+ }
525
+ .holder {
526
+ display: flex;
527
+ align-items: center;
528
+ gap: 10px;
529
+ }
530
+ .holder-text {
531
+ display: flex;
532
+ flex-direction: column;
533
+ gap: 1px;
534
+ min-width: 0;
535
+ }
536
+ .holder-label {
537
+ font-size: 11.5px;
538
+ color: var(--kern-ink-500);
539
+ }
540
+ .holder-name {
541
+ font-size: 13.5px;
542
+ font-weight: 500;
543
+ color: var(--kern-ink-900);
544
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
545
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
546
+ its own trailing punctuation instead of donating it to the paragraph. */
547
+ unicode-bidi: plaintext;
548
+ }
549
+ .holder-since {
550
+ font-size: 11.5px;
551
+ color: var(--kern-ink-500);
552
+ }
553
+ .hint {
554
+ margin: 0;
555
+ font-size: 12.5px;
556
+ line-height: 1.5;
557
+ /* Muted with a colour; opacity would fade the sentence against the panel. */
558
+ color: var(--kern-ink-500);
559
+ }
560
+ .acts {
561
+ display: flex;
562
+ gap: 8px;
563
+ flex-wrap: wrap;
564
+ }
565
+ .section {
566
+ margin: 4px 0 0;
567
+ font-size: 11.5px;
568
+ font-weight: 600;
569
+ letter-spacing: 0.04em;
570
+ text-transform: uppercase;
571
+ color: var(--kern-ink-500);
572
+ }
573
+ .periods {
574
+ display: flex;
575
+ flex-direction: column;
576
+ gap: 12px;
577
+ margin: 0;
578
+ padding: 0;
579
+ list-style: none;
580
+ }
581
+ .period {
582
+ display: grid;
583
+ grid-template-columns: 22px minmax(0, 1fr);
584
+ gap: 10px;
585
+ align-items: start;
586
+ }
587
+ .period-text {
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: 1px;
591
+ min-width: 0;
592
+ }
593
+ .period-who {
594
+ font-size: 13px;
595
+ color: var(--kern-ink-900);
596
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
597
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
598
+ its own trailing punctuation instead of donating it to the paragraph. */
599
+ unicode-bidi: plaintext;
600
+ }
601
+ .period-when {
602
+ font-size: 11.5px;
603
+ color: var(--kern-ink-500);
604
+ }
605
+ .period-note {
606
+ font-size: 12.5px;
607
+ line-height: 1.5;
608
+ color: var(--kern-ink-600);
609
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
610
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
611
+ its own trailing punctuation instead of donating it to the paragraph. */
612
+ unicode-bidi: plaintext;
613
+ }
614
+ </style>