@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,271 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Field, Input, messageLocale, Select, Textarea, toast } from '@kernhq/ui'
3
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query'
4
+ import type { Asset, Repair } from '../../contract/index.js'
5
+ import { getInventoryApi } from '../api-instance.js'
6
+ import { isolated } from '../bidi.js'
7
+ import { errorMessage } from '../errors.js'
8
+ import { t } from '../i18n.js'
9
+ import { currencyOptions, formatPrice, parsePrice, priceExample } from '../price.js'
10
+ import { inventoryKeys } from '../query.js'
11
+ import type { RepairAction } from '../repairs.js'
12
+
13
+ /**
14
+ * Sending an item for repair, correcting what was recorded, and logging it back.
15
+ *
16
+ * One dialog for three verbs, the way `CustodyDialog` is one for three: they ask for overlapping
17
+ * things and differ in which of them is already known. They are three *procedures* all the same,
18
+ * because the server refuses each of them with its own sentence — sending something that is already
19
+ * away and completing something already completed are different mistakes.
20
+ *
21
+ * The money field is `price.ts`, not a second parser: it is the file that shipped a silent 100×
22
+ * data loss on the asset form, and a repair cost typed as `1.234,56` in German has to survive
23
+ * exactly as a purchase price does.
24
+ *
25
+ * `RepairAction` lives in `repairs.ts` beside the rule that decides which of the three may be
26
+ * offered — a type exported from a component's instance script is a prop, not a type.
27
+ */
28
+ interface Props {
29
+ workspaceId: string
30
+ /** The item being repaired — for the title, and for the currency a cost defaults to. */
31
+ asset: Asset | null
32
+ /** The repair being edited or completed. Null when creating one. */
33
+ repair: Repair | null
34
+ /** Which of the three. Null closes the dialog. */
35
+ action: RepairAction | null
36
+ onclose: () => void
37
+ }
38
+ const { workspaceId, asset, repair, action, onclose }: Props = $props()
39
+
40
+ const api = getInventoryApi()
41
+ const queryClient = useQueryClient()
42
+
43
+ const open = $derived(asset !== null && action !== null)
44
+ const completing = $derived(action === 'complete')
45
+
46
+ let summary = $state('')
47
+ let detail = $state('')
48
+ let vendor = $state('')
49
+ let sentOn = $state('')
50
+ let returnedOn = $state('')
51
+ let costText = $state('')
52
+ let currency = $state('')
53
+
54
+ /**
55
+ * Seed the form whenever a different question is being asked.
56
+ *
57
+ * A plain variable rather than `$state`, as `AssetFormDialog` explains: the effect writes it on
58
+ * every run, and a reactive flag it also reads is a dependency on its own write.
59
+ */
60
+ let seededFor: string | null = null
61
+ $effect(() => {
62
+ const key = open && action ? `${action}:${repair?.id ?? asset?.id ?? ''}` : null
63
+ if (key === null) {
64
+ seededFor = null
65
+ return
66
+ }
67
+ if (seededFor === key) return
68
+ seededFor = key
69
+ fill()
70
+ })
71
+
72
+ /** Today, in the browser's own calendar — a date input takes `YYYY-MM-DD` and nothing else. */
73
+ const today = () => new Date().toISOString().slice(0, 10)
74
+
75
+ function fill() {
76
+ summary = repair?.summary ?? ''
77
+ detail = repair?.detail ?? ''
78
+ vendor = repair?.vendor ?? ''
79
+ sentOn = repair?.sentOn ?? today()
80
+ returnedOn = repair?.returnedOn ?? today()
81
+ // A cost with no currency inherits the asset's, which is what the server does when none arrives —
82
+ // the form shows the same answer rather than leaving somebody to guess what will be stored.
83
+ currency = repair?.currency ?? asset?.currency ?? ''
84
+ // Seeded against that currency: how many decimal places the stored minor units carry is a fact
85
+ // about the money, not a constant, and yen carries none.
86
+ costText = formatPrice(repair?.costMinor, messageLocale(), currency || null)
87
+ }
88
+
89
+ const currencyCode = $derived(currency.trim().toUpperCase() || null)
90
+ const costResult = $derived(parsePrice(costText, messageLocale(), currencyCode))
91
+ const costError = $derived(
92
+ costResult.ok
93
+ ? null
94
+ : t('price_invalid', isolated({ example: priceExample(messageLocale(), currencyCode) })),
95
+ )
96
+
97
+ const title = $derived(
98
+ completing
99
+ ? t('repair_complete_title', isolated({ name: asset?.name ?? '' }))
100
+ : action === 'edit'
101
+ ? t('repair_edit')
102
+ : t('repair_new'),
103
+ )
104
+
105
+ const confirmLabel = $derived(
106
+ completing ? t('repair_complete') : action === 'edit' ? t('common.save') : t('repair_new'),
107
+ )
108
+
109
+ /**
110
+ * `disabled={mutation.isPending}` reaches the button one render later, and two quick clicks are one
111
+ * render apart — so a double-click files two repairs, and the second one is refused by the unique
112
+ * index with a conflict nobody caused. A plain flag set in the same tick as the click is the guard.
113
+ */
114
+ let busy = $state(false)
115
+
116
+ const money = () => ({
117
+ costMinor: costResult.ok ? costResult.minor : null,
118
+ currency: currencyCode,
119
+ })
120
+
121
+ const save = createMutation(() => ({
122
+ mutationFn: () => {
123
+ if (!action) throw new Error('nothing to do')
124
+ if (action === 'complete') {
125
+ if (!repair) throw new Error('no repair to complete')
126
+ return api.repairs.complete({ workspaceId, repairId: repair.id, returnedOn, ...money() })
127
+ }
128
+ const fields = {
129
+ summary: summary.trim(),
130
+ detail: detail.trim() || null,
131
+ vendor: vendor.trim() || null,
132
+ sentOn,
133
+ ...money(),
134
+ }
135
+ if (action === 'edit') {
136
+ if (!repair) throw new Error('no repair to edit')
137
+ return api.repairs.update({ workspaceId, repairId: repair.id, ...fields })
138
+ }
139
+ if (!asset) throw new Error('no asset to repair')
140
+ return api.repairs.create({ workspaceId, assetId: asset.id, ...fields })
141
+ },
142
+ onSuccess: () => {
143
+ const name = asset?.name ?? ''
144
+ toast.success(
145
+ action === 'complete'
146
+ ? t('repair_completed_toast', isolated({ name }))
147
+ : action === 'edit'
148
+ ? t('repair_saved_toast')
149
+ : t('repair_logged_toast', isolated({ name })),
150
+ )
151
+ // Blunt on purpose: a repair moves the asset's status, the row, the list, the panel, the
152
+ // timeline, the numbers and the dashboard card — and every one of those hangs off
153
+ // `['inventory', …]`.
154
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
155
+ onclose()
156
+ },
157
+ // The server's sentences are the actionable ones — "This item is already away for repair" — and
158
+ // they are English. `errors.ts` translates the reason token each of them carries, so a Persian
159
+ // reader is told which of the four refusals they hit rather than being shown English prose.
160
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
161
+ onSettled: () => {
162
+ busy = false
163
+ },
164
+ }))
165
+
166
+ const canSubmit = $derived(open && costResult.ok && (completing || Boolean(summary.trim())))
167
+
168
+ function submit() {
169
+ if (busy || !canSubmit) return
170
+ busy = true
171
+ save.mutate()
172
+ }
173
+
174
+ // The list and the empty option both live in `price.ts`: the asset form asks the same question,
175
+ // and two copies are two lists that gain a currency in one form and not the other.
176
+ const currencies = currencyOptions('—')
177
+ </script>
178
+
179
+ <Dialog
180
+ {open}
181
+ size="sm"
182
+ {title}
183
+ onOpenChange={(next) => {
184
+ if (!next) onclose()
185
+ }}
186
+ >
187
+ <div class="form">
188
+ {#if completing}
189
+ <p class="body">{t('repair_complete_body')}</p>
190
+ <Field label={t('repair_returned_on')} id="inv-repair-returned">
191
+ {#snippet children(id)}
192
+ <Input {id} type="date" bind:value={returnedOn} />
193
+ {/snippet}
194
+ </Field>
195
+ {:else}
196
+ <Field
197
+ label={t('repair_summary')}
198
+ id="inv-repair-summary"
199
+ required
200
+ hint={t('repair_summary_placeholder')}
201
+ >
202
+ {#snippet children(id)}
203
+ <Input {id} bind:value={summary} maxlength={200} />
204
+ {/snippet}
205
+ </Field>
206
+ <Field label={t('repair_detail')} id="inv-repair-detail">
207
+ {#snippet children(id)}
208
+ <Textarea {id} bind:value={detail} rows={2} />
209
+ {/snippet}
210
+ </Field>
211
+ <div class="pair">
212
+ <Field label={t('repair_vendor')} id="inv-repair-vendor" hint={t('repair_vendor_placeholder')}>
213
+ {#snippet children(id)}
214
+ <Input {id} bind:value={vendor} maxlength={200} />
215
+ {/snippet}
216
+ </Field>
217
+ <Field label={t('repair_sent_on')} id="inv-repair-sent">
218
+ {#snippet children(id)}
219
+ <Input {id} type="date" bind:value={sentOn} />
220
+ {/snippet}
221
+ </Field>
222
+ </div>
223
+ {/if}
224
+
225
+ <div class="cost">
226
+ <!-- The error goes on the `Input`, not the `Field`: that is the half that sets
227
+ `aria-invalid` and points `aria-describedby` at the message. -->
228
+ <Field label={t('repair_cost')} id="inv-repair-cost">
229
+ {#snippet children(id)}
230
+ <Input {id} type="text" inputmode="decimal" bind:value={costText} error={costError} />
231
+ {/snippet}
232
+ </Field>
233
+ <Field label={t('currency')} id="inv-repair-currency">
234
+ {#snippet children(id)}
235
+ <Select {id} bind:value={currency} options={currencies} />
236
+ {/snippet}
237
+ </Field>
238
+ </div>
239
+ </div>
240
+
241
+ {#snippet footer()}
242
+ <Button variant="ghost" onclick={onclose}>{t('common.cancel')}</Button>
243
+ <Button onclick={submit} disabled={!canSubmit} loading={busy}>{confirmLabel}</Button>
244
+ {/snippet}
245
+ </Dialog>
246
+
247
+ <style>
248
+ .form {
249
+ display: grid;
250
+ gap: 14px;
251
+ }
252
+ .body {
253
+ margin: 0;
254
+ font-size: 13.5px;
255
+ line-height: 1.55;
256
+ color: var(--kern-ink-700);
257
+ }
258
+ .pair {
259
+ display: grid;
260
+ grid-template-columns: 1fr 1fr;
261
+ gap: 12px;
262
+ }
263
+ .cost {
264
+ display: grid;
265
+ /* Room for «واحد پول» and "Para birimi" on one line: a wrapped two-word label beside a
266
+ one-line one knocks the two controls out of alignment. */
267
+ grid-template-columns: minmax(0, 1fr) 124px;
268
+ gap: 8px;
269
+ align-items: start;
270
+ }
271
+ </style>
@@ -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>