@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,201 @@
1
+ <script lang="ts">
2
+ import { Button, Dialog, Field, Select, type SelectOption, Textarea, toast } from '@kernhq/ui'
3
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query'
4
+ import type { Asset } from '../../contract/index.js'
5
+ import { getInventoryApi } from '../api-instance.js'
6
+ import { isolated } from '../bidi.js'
7
+ import type { CoreMember } from '../core-api.js'
8
+ import type { CustodyAction } from '../custody.js'
9
+ import { errorMessage } from '../errors.js'
10
+ import { t } from '../i18n.js'
11
+ import { displayName } from '../members.js'
12
+ import { inventoryKeys } from '../query.js'
13
+
14
+ /**
15
+ * Handing an item over, handing it on, and taking it back.
16
+ *
17
+ * One dialog for three verbs because they ask for the same two things — a person and an optional
18
+ * note — and differ only in which of them is already known. They are three *procedures* all the
19
+ * same, and deliberately so: assigning something somebody already has and handing on something
20
+ * nobody has are different mistakes, and the server refuses each of them with its own sentence.
21
+ *
22
+ * The member list comes from core through the shell's own client. A module cannot import the app,
23
+ * and `core-api.ts` names only the three procedures this module calls — a wider type would be a
24
+ * promise about core's surface that inventory has no standing to make.
25
+ *
26
+ * Which of the three may be offered at all is `custodyActions` in `custody.ts` — a fact with a
27
+ * right answer, so it lives where it can be tested rather than inside a component.
28
+ */
29
+ interface Props {
30
+ workspaceId: string
31
+ /** The item being handed over. Null closes the dialog. */
32
+ asset: Asset | null
33
+ /** Which of the three. Null closes the dialog. */
34
+ action: CustodyAction | null
35
+ /** The workspace's members, already fetched by whatever opened this. */
36
+ members: readonly CoreMember[]
37
+ /** Who is holding it now, as words — for the sentence a return has to state. */
38
+ holderName: string | null
39
+ onclose: () => void
40
+ }
41
+ const { workspaceId, asset, action, members, holderName, onclose }: Props = $props()
42
+
43
+ const api = getInventoryApi()
44
+ const queryClient = useQueryClient()
45
+
46
+ const open = $derived(asset !== null && action !== null)
47
+ const picksPerson = $derived(action === 'assign' || action === 'transfer')
48
+
49
+ let userId = $state('')
50
+ let note = $state('')
51
+
52
+ /**
53
+ * Clear the form whenever a different question is being asked.
54
+ *
55
+ * Without this, taking an item back and then handing it on again arrives with the previous note
56
+ * still in the box — and a note is a sentence somebody wrote about a *different* handover.
57
+ */
58
+ let seededFor: string | null = null
59
+ $effect(() => {
60
+ const key = open && asset && action ? `${action}:${asset.id}` : null
61
+ if (key === null) {
62
+ seededFor = null
63
+ return
64
+ }
65
+ if (seededFor === key) return
66
+ seededFor = key
67
+ userId = ''
68
+ note = ''
69
+ })
70
+
71
+ /**
72
+ * Who it can go to.
73
+ *
74
+ * The current holder is left out of a *transfer*: the server refuses handing something to the
75
+ * person who already has it, so offering them is offering a door that will not open. Sorted by the
76
+ * name actually shown, which is the order somebody scanning the list expects — core returns
77
+ * membership order, which is arbitrary to a reader.
78
+ */
79
+ const options = $derived.by<SelectOption[]>(() => {
80
+ const holderId = action === 'transfer' ? asset?.custodianUserId : null
81
+ const people = members
82
+ .filter((member) => member.userId !== holderId)
83
+ .map((member) => ({ value: member.userId, label: displayName(member) ?? member.userId }))
84
+ .sort((a, b) => a.label.localeCompare(b.label))
85
+ return [{ value: '', label: t('custody_person_none') }, ...people]
86
+ })
87
+
88
+ const title = $derived(
89
+ action === 'return'
90
+ ? t('custody_return_title', isolated({ name: asset?.name ?? '' }))
91
+ : action === 'transfer'
92
+ ? t('custody_transfer_title', isolated({ name: asset?.name ?? '' }))
93
+ : t('custody_assign_title', isolated({ name: asset?.name ?? '' })),
94
+ )
95
+
96
+ const confirmLabel = $derived(
97
+ action === 'return'
98
+ ? t('custody_return')
99
+ : action === 'transfer'
100
+ ? t('custody_transfer')
101
+ : t('custody_assign'),
102
+ )
103
+
104
+ /**
105
+ * `disabled={mutation.isPending}` reaches the button one render later, and two quick clicks are one
106
+ * render apart — so a double-click files two handovers. A plain flag set in the same tick as the
107
+ * click is the guard; `loading` says "busy" without disabling the control under somebody's finger.
108
+ */
109
+ let busy = $state(false)
110
+
111
+ const hand = createMutation(() => ({
112
+ mutationFn: () => {
113
+ const row = asset
114
+ if (!row || !action) throw new Error('nothing to hand over')
115
+ const base = { workspaceId, assetId: row.id, note: note.trim() || null }
116
+ if (action === 'return') return api.custody.return(base)
117
+ return action === 'transfer'
118
+ ? api.custody.transfer({ ...base, userId })
119
+ : api.custody.assign({ ...base, userId })
120
+ },
121
+ onSuccess: () => {
122
+ const name = asset?.name ?? ''
123
+ const person = options.find((option) => option.value === userId)?.label ?? ''
124
+ toast.success(
125
+ action === 'return'
126
+ ? t('custody_returned_toast', isolated({ name }))
127
+ : action === 'transfer'
128
+ ? t('custody_transferred_toast', isolated({ name, person }))
129
+ : t('custody_assigned_toast', isolated({ name, person })),
130
+ )
131
+ // Blunt on purpose: a handover moves the row, the list it is in, the panel, the timeline and
132
+ // the dashboard card, and every one of those hangs off `['inventory', …]`.
133
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
134
+ onclose()
135
+ },
136
+ // The server's messages *are* the actionable ones — "Somebody changed who is holding this a
137
+ // moment before you did" — and they are English prose, shown as they arrived to a reader who
138
+ // chose Persian. `errors.ts` translates the reason token instead: every refusal a handover can
139
+ // produce carries one, and `inventory.custody.conflict` is the most important sentence this
140
+ // module has.
141
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
142
+ onSettled: () => {
143
+ busy = false
144
+ },
145
+ }))
146
+
147
+ const canSubmit = $derived(open && (!picksPerson || Boolean(userId)))
148
+
149
+ function submit() {
150
+ if (busy || !canSubmit) return
151
+ busy = true
152
+ hand.mutate()
153
+ }
154
+ </script>
155
+
156
+ <Dialog
157
+ {open}
158
+ size="sm"
159
+ {title}
160
+ onOpenChange={(next) => {
161
+ if (!next) onclose()
162
+ }}
163
+ >
164
+ <div class="form">
165
+ {#if action === 'return'}
166
+ <p class="body">
167
+ {t('custody_return_body', isolated({ person: holderName ?? t('member_former') }))}
168
+ </p>
169
+ {:else}
170
+ <Field label={t('custody_person')} id="inv-custody-person" required>
171
+ {#snippet children(id)}
172
+ <Select {id} bind:value={userId} options={options} ariaLabel={t('custody_person')} />
173
+ {/snippet}
174
+ </Field>
175
+ {/if}
176
+
177
+ <Field label={t('custody_note')} id="inv-custody-note" hint={t('custody_note_hint')}>
178
+ {#snippet children(id)}
179
+ <Textarea {id} bind:value={note} rows={2} maxlength={500} />
180
+ {/snippet}
181
+ </Field>
182
+ </div>
183
+
184
+ {#snippet footer()}
185
+ <Button variant="ghost" onclick={onclose}>{t('common.cancel')}</Button>
186
+ <Button onclick={submit} disabled={!canSubmit} loading={busy}>{confirmLabel}</Button>
187
+ {/snippet}
188
+ </Dialog>
189
+
190
+ <style>
191
+ .form {
192
+ display: grid;
193
+ gap: 14px;
194
+ }
195
+ .body {
196
+ margin: 0;
197
+ font-size: 13.5px;
198
+ line-height: 1.55;
199
+ color: var(--kern-ink-700);
200
+ }
201
+ </style>
@@ -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>