@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
|
@@ -1,23 +1,49 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
Field,
|
|
6
|
+
Input,
|
|
7
|
+
messageLocale,
|
|
8
|
+
Select,
|
|
9
|
+
type SelectOption,
|
|
10
|
+
Textarea,
|
|
11
|
+
toast,
|
|
12
|
+
} from '@kernhq/ui'
|
|
13
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
14
|
+
import type { Asset, Category } from '../../contract/index.js'
|
|
4
15
|
import { getInventoryApi } from '../api-instance.js'
|
|
16
|
+
import { isolated } from '../bidi.js'
|
|
17
|
+
import { errorMessage } from '../errors.js'
|
|
5
18
|
import { t } from '../i18n.js'
|
|
6
19
|
import { canInventory } from '../permissions.js'
|
|
20
|
+
import { currencyOptions, formatPrice, parsePrice, priceExample } from '../price.js'
|
|
21
|
+
import { inventoryKeys } from '../query.js'
|
|
7
22
|
|
|
8
23
|
/**
|
|
9
|
-
* Add an asset.
|
|
10
|
-
*
|
|
24
|
+
* Add or change an asset.
|
|
25
|
+
*
|
|
26
|
+
* One dialog for both, because they ask for exactly the same nine things and a second component
|
|
27
|
+
* would be nine fields to keep in step. `asset` is what decides which: null creates, a row edits.
|
|
28
|
+
* `assets.update` had no entry point anywhere in the module before this — the procedure existed,
|
|
29
|
+
* was tested, and no screen could reach it.
|
|
30
|
+
*
|
|
31
|
+
* The tag (`INV-…`) is the server's job — people never invent codes, they read them off stickers —
|
|
32
|
+
* so this form asks only for what a human actually knows.
|
|
11
33
|
*/
|
|
12
34
|
interface Props {
|
|
13
35
|
open: boolean
|
|
14
36
|
workspaceId: string
|
|
37
|
+
/** The row being changed, or null to add one. */
|
|
38
|
+
asset?: Asset | null
|
|
15
39
|
}
|
|
16
|
-
let { open = $bindable(false), workspaceId }: Props = $props()
|
|
40
|
+
let { open = $bindable(false), workspaceId, asset = null }: Props = $props()
|
|
17
41
|
|
|
18
42
|
const api = getInventoryApi()
|
|
19
43
|
const queryClient = useQueryClient()
|
|
20
44
|
|
|
45
|
+
const editing = $derived(asset !== null)
|
|
46
|
+
|
|
21
47
|
let shown = $state(false)
|
|
22
48
|
$effect(() => {
|
|
23
49
|
if (open) shown = true
|
|
@@ -25,67 +51,166 @@ $effect(() => {
|
|
|
25
51
|
|
|
26
52
|
let name = $state('')
|
|
27
53
|
let description = $state('')
|
|
54
|
+
let categoryId = $state('')
|
|
28
55
|
let serialNumber = $state('')
|
|
29
56
|
let location = $state('')
|
|
30
57
|
let purchasedOn = $state('')
|
|
31
58
|
let purchasedFrom = $state('')
|
|
32
|
-
let
|
|
59
|
+
let priceText = $state('')
|
|
33
60
|
let currency = $state('')
|
|
34
61
|
let warrantyUntil = $state('')
|
|
35
62
|
|
|
63
|
+
/**
|
|
64
|
+
* What the form was last filled from, so opening it on a different row re-seeds it.
|
|
65
|
+
*
|
|
66
|
+
* A plain variable rather than `$state`: the effect below writes it on every run, and a reactive
|
|
67
|
+
* flag it also reads is a dependency on its own write.
|
|
68
|
+
*/
|
|
69
|
+
let seededFrom: string | null = null
|
|
70
|
+
|
|
71
|
+
$effect(() => {
|
|
72
|
+
const key = open ? (asset?.id ?? 'new') : null
|
|
73
|
+
if (key === null) {
|
|
74
|
+
seededFrom = null
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (seededFrom === key) return
|
|
78
|
+
seededFrom = key
|
|
79
|
+
fill(asset)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What this asset can be filed under.
|
|
84
|
+
*
|
|
85
|
+
* Archived categories are asked for as well, so a row that already carries one keeps naming it —
|
|
86
|
+
* the picker offers only live categories plus whichever this asset is already in. Without the
|
|
87
|
+
* second half, opening the form on an asset in an archived category would silently show "no
|
|
88
|
+
* category" and saving would move it out of one nobody meant to leave.
|
|
89
|
+
*
|
|
90
|
+
* `enabled` on `open` rather than always: this dialog is mounted by the list page for its whole
|
|
91
|
+
* life, and a picker nobody has opened does not need a request. The key is the page's own, so when
|
|
92
|
+
* it does fire the answer is usually already in the cache.
|
|
93
|
+
*/
|
|
94
|
+
const categoriesQuery = createQuery(() => ({
|
|
95
|
+
queryKey: inventoryKeys.categories(workspaceId, true),
|
|
96
|
+
queryFn: () => api.categories.list({ workspaceId, archived: true }),
|
|
97
|
+
enabled: Boolean(workspaceId) && shown,
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
const categoryOptions = $derived<SelectOption[]>([
|
|
101
|
+
{ value: '', label: t('category_none') },
|
|
102
|
+
...(categoriesQuery.data ?? [])
|
|
103
|
+
.filter((row: Category) => !row.archivedAt || row.id === categoryId)
|
|
104
|
+
.map((row: Category) => ({ value: row.id, label: row.name })),
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
function fill(row: Asset | null) {
|
|
108
|
+
name = row?.name ?? ''
|
|
109
|
+
description = row?.description ?? ''
|
|
110
|
+
categoryId = row?.categoryId ?? ''
|
|
111
|
+
serialNumber = row?.serialNumber ?? ''
|
|
112
|
+
location = row?.location ?? ''
|
|
113
|
+
purchasedOn = row?.purchasedOn ?? ''
|
|
114
|
+
purchasedFrom = row?.purchasedFrom ?? ''
|
|
115
|
+
currency = row?.currency ?? ''
|
|
116
|
+
// Formatted for the reader's own locale *and this row's currency* — `parsePrice` reads back
|
|
117
|
+
// exactly what this writes only if both halves agree on how many decimal places the money has.
|
|
118
|
+
priceText = formatPrice(row?.priceMinor, messageLocale(), currency || null)
|
|
119
|
+
warrantyUntil = row?.warrantyUntil ?? ''
|
|
120
|
+
}
|
|
121
|
+
|
|
36
122
|
const close = () => {
|
|
37
123
|
shown = false
|
|
38
124
|
open = false
|
|
39
125
|
}
|
|
40
126
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The price, parsed the way the reader's own language writes one.
|
|
129
|
+
*
|
|
130
|
+
* `Number.parseFloat(raw.replace(',', '.'))` replaced the first comma only, so `1.234,56` — German,
|
|
131
|
+
* Turkish and Persian for twelve hundred — was stored as €1.23, and `abc` and `-5` were stored as
|
|
132
|
+
* no price at all. Neither said anything. Now an amount this locale cannot read is an error on the
|
|
133
|
+
* field, and there is no path where a wrong number is saved quietly.
|
|
134
|
+
*
|
|
135
|
+
* The **currency** is a dependency of the parse, not decoration beside it: yen has no minor unit
|
|
136
|
+
* and the dinar has three, so the same keystrokes mean different money in different currencies.
|
|
137
|
+
* Switching the picker re-parses what is already typed, which is what makes `1000` in a yen field
|
|
138
|
+
* ¥1000 rather than ¥10 — and `19.99` there an error rather than a silent ¥20.
|
|
139
|
+
*/
|
|
140
|
+
const currencyCode = $derived(currency.trim().toUpperCase() || null)
|
|
141
|
+
const priceResult = $derived(parsePrice(priceText, messageLocale(), currencyCode))
|
|
142
|
+
const priceError = $derived(
|
|
143
|
+
priceResult.ok
|
|
144
|
+
? null
|
|
145
|
+
: t('price_invalid', isolated({ example: priceExample(messageLocale(), currencyCode) })),
|
|
146
|
+
)
|
|
52
147
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
148
|
+
/**
|
|
149
|
+
* `disabled={save.isPending}` does not stop the second click.
|
|
150
|
+
*
|
|
151
|
+
* The attribute reaches the button on the next render and two quick clicks are one render apart, so
|
|
152
|
+
* a double-click files two assets — two rows, two tags, one of them a duplicate somebody has to go
|
|
153
|
+
* and find. A plain flag set in the same tick as the click is what actually guards it.
|
|
154
|
+
*/
|
|
155
|
+
let submitting = $state(false)
|
|
59
156
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
157
|
+
/** What both procedures take; `update` adds the id and takes it as a patch. */
|
|
158
|
+
const fields = () => ({
|
|
159
|
+
name: name.trim(),
|
|
160
|
+
description: description.trim(),
|
|
161
|
+
// `null`, never `''`: the contract takes a uuid or nothing, and an empty string is neither.
|
|
162
|
+
categoryId: categoryId || null,
|
|
163
|
+
serialNumber: serialNumber.trim() || null,
|
|
164
|
+
location: location.trim() || null,
|
|
165
|
+
purchasedFrom: purchasedFrom.trim() || null,
|
|
166
|
+
purchasedOn: purchasedOn || null,
|
|
167
|
+
warrantyUntil: warrantyUntil || null,
|
|
168
|
+
priceMinor: priceResult.ok ? priceResult.minor : null,
|
|
169
|
+
currency: currencyCode,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const save = createMutation(() => ({
|
|
173
|
+
mutationFn: () => {
|
|
174
|
+
const row = asset
|
|
175
|
+
return row
|
|
176
|
+
? api.assets.update({ workspaceId, assetId: row.id, ...fields() })
|
|
177
|
+
: api.assets.create({ workspaceId, ...fields() })
|
|
178
|
+
},
|
|
179
|
+
onSuccess: (saved: Asset) => {
|
|
180
|
+
// Not `t('new')`: that is the label on the button that opened this, so the toast used to
|
|
181
|
+
// congratulate somebody on the words "New asset" rather than telling them what happened.
|
|
182
|
+
toast.success(t(editing ? 'updated_toast' : 'created_toast', isolated({ name: saved.name })))
|
|
183
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
78
184
|
close()
|
|
79
185
|
},
|
|
80
|
-
|
|
186
|
+
// Translated rather than the server's English prose: see `errors.ts`.
|
|
187
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
188
|
+
onSettled: () => {
|
|
189
|
+
submitting = false
|
|
190
|
+
},
|
|
81
191
|
}))
|
|
82
192
|
|
|
83
|
-
|
|
193
|
+
/**
|
|
194
|
+
* `submitting` is deliberately **not** in here.
|
|
195
|
+
*
|
|
196
|
+
* It is the guard `submit()` checks, and putting it in the disabled expression as well disables the
|
|
197
|
+
* button under the finger of whoever just pressed it — the browser blurs a focused element the
|
|
198
|
+
* moment it becomes disabled and hands focus nowhere, so a keyboard user loses their place on the
|
|
199
|
+
* page mid-save. `loading={submitting}` says the same thing to a screen reader through `aria-busy`
|
|
200
|
+
* and moves nothing.
|
|
201
|
+
*/
|
|
202
|
+
const canSubmit = $derived(Boolean(name.trim()) && priceResult.ok && canInventory('manage'))
|
|
203
|
+
|
|
204
|
+
function submit() {
|
|
205
|
+
if (submitting || !canSubmit) return
|
|
206
|
+
submitting = true
|
|
207
|
+
save.mutate()
|
|
208
|
+
}
|
|
84
209
|
</script>
|
|
85
210
|
|
|
86
211
|
<Dialog
|
|
87
212
|
bind:open={shown}
|
|
88
|
-
title={t('new')}
|
|
213
|
+
title={editing ? t('edit') : t('new')}
|
|
89
214
|
onOpenChange={(next) => {
|
|
90
215
|
if (!next) close()
|
|
91
216
|
}}
|
|
@@ -102,6 +227,12 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
102
227
|
{/snippet}
|
|
103
228
|
</Field>
|
|
104
229
|
|
|
230
|
+
<Field label={t('category')} id="inv-category">
|
|
231
|
+
{#snippet children(id)}
|
|
232
|
+
<Select {id} bind:value={categoryId} options={categoryOptions} ariaLabel={t('category')} />
|
|
233
|
+
{/snippet}
|
|
234
|
+
</Field>
|
|
235
|
+
|
|
105
236
|
<div class="pair">
|
|
106
237
|
<Field label={t('serial_number')} id="inv-serial">
|
|
107
238
|
{#snippet children(id)}
|
|
@@ -135,24 +266,21 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
135
266
|
{/snippet}
|
|
136
267
|
</Field>
|
|
137
268
|
<div class="price">
|
|
269
|
+
<!-- The error goes on the `Input`, not the `Field`: it is the half that also sets
|
|
270
|
+
`aria-invalid` and points `aria-describedby` at the message, and putting it on both
|
|
271
|
+
would print the sentence twice. -->
|
|
138
272
|
<Field label={t('price')} id="inv-price">
|
|
139
273
|
{#snippet children(id)}
|
|
140
|
-
<Input {id} type="text" inputmode="decimal" bind:value={
|
|
274
|
+
<Input {id} type="text" inputmode="decimal" bind:value={priceText} error={priceError} />
|
|
141
275
|
{/snippet}
|
|
142
276
|
</Field>
|
|
143
|
-
|
|
277
|
+
<!-- Was `label="ISO" hint="USD"`: an untranslated abbreviation labelling a control on a
|
|
278
|
+
Persian, Arabic, German or Turkish form, explained by an example that is not a hint. -->
|
|
279
|
+
<Field label={t('currency')} id="inv-currency">
|
|
144
280
|
{#snippet children(id)}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
options={[
|
|
149
|
-
{ value: '', label: '—' },
|
|
150
|
-
{ value: 'USD', label: 'USD' },
|
|
151
|
-
{ value: 'EUR', label: 'EUR' },
|
|
152
|
-
{ value: 'IRR', label: 'IRR' },
|
|
153
|
-
{ value: 'AED', label: 'AED' },
|
|
154
|
-
]}
|
|
155
|
-
/>
|
|
281
|
+
<!-- The list lives in `price.ts`: the repair form asks the same question, and two
|
|
282
|
+
copies are two lists that gain a currency in one form and not the other. -->
|
|
283
|
+
<Select {id} bind:value={currency} options={currencyOptions('—')} />
|
|
156
284
|
{/snippet}
|
|
157
285
|
</Field>
|
|
158
286
|
</div>
|
|
@@ -161,7 +289,7 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
161
289
|
|
|
162
290
|
{#snippet footer()}
|
|
163
291
|
<Button variant="ghost" onclick={close}>{t('common.cancel')}</Button>
|
|
164
|
-
<Button onclick={
|
|
292
|
+
<Button onclick={submit} disabled={!canSubmit} loading={submitting}>
|
|
165
293
|
{t('common.save')}
|
|
166
294
|
</Button>
|
|
167
295
|
{/snippet}
|
|
@@ -179,7 +307,11 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
179
307
|
}
|
|
180
308
|
.price {
|
|
181
309
|
display: grid;
|
|
182
|
-
|
|
310
|
+
/* 90px fitted "ISO" and nothing else — "Para birimi" and «واحد پول» need room to sit on one
|
|
311
|
+
line, and a wrapped two-word label beside a one-line one knocks the two controls out of
|
|
312
|
+
alignment. */
|
|
313
|
+
grid-template-columns: minmax(0, 1fr) 124px;
|
|
183
314
|
gap: 8px;
|
|
315
|
+
align-items: start;
|
|
184
316
|
}
|
|
185
317
|
</style>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, coreApi, Skeleton, toast, uploadFile } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, 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 { CoreApi } from '../core-api.js'
|
|
8
|
+
import { errorMessage } from '../errors.js'
|
|
9
|
+
import { t } from '../i18n.js'
|
|
10
|
+
import { inventoryKeys } from '../query.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The asset's photo.
|
|
14
|
+
*
|
|
15
|
+
* `assets.photoFileId` has been a field of every asset since the module existed, settable through
|
|
16
|
+
* `create` and `update` and by **nothing on screen** — an API that could store a photo and an
|
|
17
|
+
* interface that could not show one. This is that half.
|
|
18
|
+
*
|
|
19
|
+
* It sits in **Details**, not in Files, and that is a decision: the photo is a field of the asset,
|
|
20
|
+
* so it belongs with the asset's other fields. Putting it in the Files tab would hide it from any
|
|
21
|
+
* workspace that switches the `attachments` capability off, and the photo has nothing to do with
|
|
22
|
+
* that feature — it is `core`.
|
|
23
|
+
*
|
|
24
|
+
* The bytes go to core through `uploadFile`, exactly as an attachment's do; this module stores the
|
|
25
|
+
* id it is handed and asks core to sign a URL to read it back. `thumbnail: true`, because a
|
|
26
|
+
* full-size photograph in a 440px panel is a megabyte nobody looks at.
|
|
27
|
+
*/
|
|
28
|
+
interface Props {
|
|
29
|
+
workspaceId: string
|
|
30
|
+
asset: Asset
|
|
31
|
+
/** `inventory.asset.manage`. Without it the photo is shown and cannot be changed. */
|
|
32
|
+
canManage: boolean
|
|
33
|
+
}
|
|
34
|
+
const { workspaceId, asset, canManage }: Props = $props()
|
|
35
|
+
|
|
36
|
+
const api = getInventoryApi()
|
|
37
|
+
const core = coreApi<CoreApi>()
|
|
38
|
+
const queryClient = useQueryClient()
|
|
39
|
+
|
|
40
|
+
const photoQuery = createQuery(() => ({
|
|
41
|
+
// Keyed by the file rather than by the asset: replacing the photo changes the id, so the old
|
|
42
|
+
// signed URL is never served for the new photo.
|
|
43
|
+
queryKey: ['inventory', 'photo', asset.photoFileId ?? ''],
|
|
44
|
+
queryFn: () =>
|
|
45
|
+
core.files.downloadUrl({ id: asset.photoFileId as string, disposition: 'inline', thumbnail: true }),
|
|
46
|
+
enabled: Boolean(asset.photoFileId),
|
|
47
|
+
// Core signs these with a lifetime measured in minutes, so a panel left open all afternoon asks
|
|
48
|
+
// again rather than rendering a broken image.
|
|
49
|
+
staleTime: 4 * 60_000,
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A plain flag set in the same tick as the click, not `mutation.isPending`.
|
|
54
|
+
*
|
|
55
|
+
* The attribute reaches the button one render later and two quick clicks are one render apart.
|
|
56
|
+
*/
|
|
57
|
+
let busy = $state(false)
|
|
58
|
+
let input = $state<HTMLInputElement | null>(null)
|
|
59
|
+
|
|
60
|
+
const save = createMutation(() => ({
|
|
61
|
+
mutationFn: (photoFileId: string | null) =>
|
|
62
|
+
api.assets.update({ workspaceId, assetId: asset.id, photoFileId }),
|
|
63
|
+
onSuccess: (_saved: Asset, photoFileId: string | null) => {
|
|
64
|
+
toast.success(photoFileId ? t('photo_saved_toast') : t('photo_removed_toast'))
|
|
65
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
66
|
+
},
|
|
67
|
+
// Translated rather than the server's English prose: see `errors.ts`.
|
|
68
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
69
|
+
onSettled: () => {
|
|
70
|
+
busy = false
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
async function choose(list: FileList | null) {
|
|
75
|
+
const file = list?.[0]
|
|
76
|
+
if (!file || busy) return
|
|
77
|
+
busy = true
|
|
78
|
+
try {
|
|
79
|
+
const uploaded = await uploadFile({
|
|
80
|
+
workspaceId,
|
|
81
|
+
file,
|
|
82
|
+
name: file.name,
|
|
83
|
+
mimeType: file.type || undefined,
|
|
84
|
+
attachedTo: { module: 'inventory', type: 'asset', id: asset.id },
|
|
85
|
+
})
|
|
86
|
+
save.mutate(uploaded.id)
|
|
87
|
+
} catch {
|
|
88
|
+
toast.error(t('file_upload_failed', isolated({ name: file.name })))
|
|
89
|
+
busy = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* No confirmation, deliberately.
|
|
95
|
+
*
|
|
96
|
+
* Nothing is destroyed: the file stays in core's storage exactly where it was, and this clears one
|
|
97
|
+
* uuid on one row. A dialog asking whether you are sure about a thumbnail is a dialog people learn
|
|
98
|
+
* to dismiss without reading, which is what makes the ones that matter stop working.
|
|
99
|
+
*/
|
|
100
|
+
function remove() {
|
|
101
|
+
if (busy) return
|
|
102
|
+
busy = true
|
|
103
|
+
save.mutate(null)
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<div class="photo">
|
|
108
|
+
{#if canManage}
|
|
109
|
+
<input
|
|
110
|
+
bind:this={input}
|
|
111
|
+
type="file"
|
|
112
|
+
accept="image/*"
|
|
113
|
+
hidden
|
|
114
|
+
onchange={(e) => {
|
|
115
|
+
void choose(e.currentTarget.files)
|
|
116
|
+
e.currentTarget.value = ''
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
{#if asset.photoFileId}
|
|
122
|
+
{#if photoQuery.isPending}
|
|
123
|
+
<Skeleton height="120px" radius="10px" />
|
|
124
|
+
{:else if photoQuery.isError}
|
|
125
|
+
<!-- The field is set and the URL could not be signed. Saying so beats a broken image icon. -->
|
|
126
|
+
<p class="none">{t('files_error')}</p>
|
|
127
|
+
{:else}
|
|
128
|
+
<img src={photoQuery.data?.url} alt={t('photo_alt', isolated({ name: asset.name }))} />
|
|
129
|
+
{/if}
|
|
130
|
+
{:else}
|
|
131
|
+
<p class="none">{t('photo_none')}</p>
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
{#if canManage}
|
|
135
|
+
<div class="acts">
|
|
136
|
+
<Button size="sm" variant="secondary" icon="image" loading={busy} onclick={() => input?.click()}>
|
|
137
|
+
{asset.photoFileId ? t('photo_replace') : t('photo_set')}
|
|
138
|
+
</Button>
|
|
139
|
+
{#if asset.photoFileId}
|
|
140
|
+
<Button size="sm" variant="ghost" onclick={remove}>{t('photo_remove')}</Button>
|
|
141
|
+
{/if}
|
|
142
|
+
</div>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<style>
|
|
147
|
+
.photo {
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 8px;
|
|
151
|
+
}
|
|
152
|
+
img {
|
|
153
|
+
display: block;
|
|
154
|
+
inline-size: 100%;
|
|
155
|
+
max-block-size: 200px;
|
|
156
|
+
object-fit: cover;
|
|
157
|
+
border-radius: var(--kern-r-lg);
|
|
158
|
+
border: 1px solid var(--kern-border-hairline);
|
|
159
|
+
/* Behind a photo with transparency, and while one loads. Not `--kern-surface-raised`: a `Sheet`
|
|
160
|
+
is already that colour, so it would be no ground at all. */
|
|
161
|
+
background: var(--kern-surface-chip);
|
|
162
|
+
}
|
|
163
|
+
.none {
|
|
164
|
+
margin: 0;
|
|
165
|
+
padding: 18px 0;
|
|
166
|
+
text-align: center;
|
|
167
|
+
font-size: 12.5px;
|
|
168
|
+
/* Muted with a colour, never opacity: a faded sentence is unreadable whatever its token. */
|
|
169
|
+
color: var(--kern-ink-500);
|
|
170
|
+
border: 1px dashed var(--kern-border);
|
|
171
|
+
border-radius: var(--kern-r-lg);
|
|
172
|
+
}
|
|
173
|
+
.acts {
|
|
174
|
+
display: flex;
|
|
175
|
+
gap: 8px;
|
|
176
|
+
flex-wrap: wrap;
|
|
177
|
+
}
|
|
178
|
+
</style>
|