@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.
- package/README.md +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- 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 +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- 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 +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- 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 +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -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 +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- 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 +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- 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/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/0008_category_order_unique.sql +71 -0
- 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 +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- 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 +71 -22
- 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 +395 -0
- package/src/client/errors.ts +229 -0
- 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 +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -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 +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -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 +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- 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/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>
|