@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.
- package/README.md +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- 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>
|