@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
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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'
|
|
5
15
|
import { getInventoryApi } from '../api-instance.js'
|
|
16
|
+
import { isolated } from '../bidi.js'
|
|
17
|
+
import { errorMessage } from '../errors.js'
|
|
6
18
|
import { t } from '../i18n.js'
|
|
7
19
|
import { canInventory } from '../permissions.js'
|
|
8
|
-
import { formatPrice, parsePrice } from '../price.js'
|
|
20
|
+
import { currencyOptions, formatPrice, parsePrice, priceExample } from '../price.js'
|
|
9
21
|
import { inventoryKeys } from '../query.js'
|
|
10
22
|
|
|
11
23
|
/**
|
|
@@ -39,6 +51,7 @@ $effect(() => {
|
|
|
39
51
|
|
|
40
52
|
let name = $state('')
|
|
41
53
|
let description = $state('')
|
|
54
|
+
let categoryId = $state('')
|
|
42
55
|
let serialNumber = $state('')
|
|
43
56
|
let location = $state('')
|
|
44
57
|
let purchasedOn = $state('')
|
|
@@ -66,16 +79,43 @@ $effect(() => {
|
|
|
66
79
|
fill(asset)
|
|
67
80
|
})
|
|
68
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
|
+
|
|
69
107
|
function fill(row: Asset | null) {
|
|
70
108
|
name = row?.name ?? ''
|
|
71
109
|
description = row?.description ?? ''
|
|
110
|
+
categoryId = row?.categoryId ?? ''
|
|
72
111
|
serialNumber = row?.serialNumber ?? ''
|
|
73
112
|
location = row?.location ?? ''
|
|
74
113
|
purchasedOn = row?.purchasedOn ?? ''
|
|
75
114
|
purchasedFrom = row?.purchasedFrom ?? ''
|
|
76
|
-
// Formatted for the reader's own locale, and `parsePrice` reads back exactly what this writes.
|
|
77
|
-
priceText = formatPrice(row?.priceMinor, messageLocale())
|
|
78
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)
|
|
79
119
|
warrantyUntil = row?.warrantyUntil ?? ''
|
|
80
120
|
}
|
|
81
121
|
|
|
@@ -91,10 +131,18 @@ const close = () => {
|
|
|
91
131
|
* Turkish and Persian for twelve hundred — was stored as €1.23, and `abc` and `-5` were stored as
|
|
92
132
|
* no price at all. Neither said anything. Now an amount this locale cannot read is an error on the
|
|
93
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.
|
|
94
139
|
*/
|
|
95
|
-
const
|
|
140
|
+
const currencyCode = $derived(currency.trim().toUpperCase() || null)
|
|
141
|
+
const priceResult = $derived(parsePrice(priceText, messageLocale(), currencyCode))
|
|
96
142
|
const priceError = $derived(
|
|
97
|
-
priceResult.ok
|
|
143
|
+
priceResult.ok
|
|
144
|
+
? null
|
|
145
|
+
: t('price_invalid', isolated({ example: priceExample(messageLocale(), currencyCode) })),
|
|
98
146
|
)
|
|
99
147
|
|
|
100
148
|
/**
|
|
@@ -110,13 +158,15 @@ let submitting = $state(false)
|
|
|
110
158
|
const fields = () => ({
|
|
111
159
|
name: name.trim(),
|
|
112
160
|
description: description.trim(),
|
|
161
|
+
// `null`, never `''`: the contract takes a uuid or nothing, and an empty string is neither.
|
|
162
|
+
categoryId: categoryId || null,
|
|
113
163
|
serialNumber: serialNumber.trim() || null,
|
|
114
164
|
location: location.trim() || null,
|
|
115
165
|
purchasedFrom: purchasedFrom.trim() || null,
|
|
116
166
|
purchasedOn: purchasedOn || null,
|
|
117
167
|
warrantyUntil: warrantyUntil || null,
|
|
118
168
|
priceMinor: priceResult.ok ? priceResult.minor : null,
|
|
119
|
-
currency:
|
|
169
|
+
currency: currencyCode,
|
|
120
170
|
})
|
|
121
171
|
|
|
122
172
|
const save = createMutation(() => ({
|
|
@@ -129,11 +179,12 @@ const save = createMutation(() => ({
|
|
|
129
179
|
onSuccess: (saved: Asset) => {
|
|
130
180
|
// Not `t('new')`: that is the label on the button that opened this, so the toast used to
|
|
131
181
|
// congratulate somebody on the words "New asset" rather than telling them what happened.
|
|
132
|
-
toast.success(t(editing ? 'updated_toast' : 'created_toast', { name: saved.name }))
|
|
182
|
+
toast.success(t(editing ? 'updated_toast' : 'created_toast', isolated({ name: saved.name })))
|
|
133
183
|
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
134
184
|
close()
|
|
135
185
|
},
|
|
136
|
-
|
|
186
|
+
// Translated rather than the server's English prose: see `errors.ts`.
|
|
187
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
137
188
|
onSettled: () => {
|
|
138
189
|
submitting = false
|
|
139
190
|
},
|
|
@@ -176,6 +227,12 @@ function submit() {
|
|
|
176
227
|
{/snippet}
|
|
177
228
|
</Field>
|
|
178
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
|
+
|
|
179
236
|
<div class="pair">
|
|
180
237
|
<Field label={t('serial_number')} id="inv-serial">
|
|
181
238
|
{#snippet children(id)}
|
|
@@ -221,17 +278,9 @@ function submit() {
|
|
|
221
278
|
Persian, Arabic, German or Turkish form, explained by an example that is not a hint. -->
|
|
222
279
|
<Field label={t('currency')} id="inv-currency">
|
|
223
280
|
{#snippet children(id)}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
options={[
|
|
228
|
-
{ value: '', label: '—' },
|
|
229
|
-
{ value: 'USD', label: 'USD' },
|
|
230
|
-
{ value: 'EUR', label: 'EUR' },
|
|
231
|
-
{ value: 'IRR', label: 'IRR' },
|
|
232
|
-
{ value: 'AED', label: 'AED' },
|
|
233
|
-
]}
|
|
234
|
-
/>
|
|
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('—')} />
|
|
235
284
|
{/snippet}
|
|
236
285
|
</Field>
|
|
237
286
|
</div>
|
|
@@ -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>
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
coreApi,
|
|
5
|
+
Dialog,
|
|
6
|
+
EmptyState,
|
|
7
|
+
formatBytes,
|
|
8
|
+
Icon,
|
|
9
|
+
IconButton,
|
|
10
|
+
Skeleton,
|
|
11
|
+
toast,
|
|
12
|
+
uploadFile,
|
|
13
|
+
} from '@kernhq/ui'
|
|
14
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
15
|
+
import type { Attachment } from '../../contract/index.js'
|
|
16
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
17
|
+
import { isolated } from '../bidi.js'
|
|
18
|
+
import type { CoreApi } from '../core-api.js'
|
|
19
|
+
import { errorMessage } from '../errors.js'
|
|
20
|
+
import { t } from '../i18n.js'
|
|
21
|
+
import { inventoryKeys } from '../query.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The files kept against an asset, or against one of its repairs.
|
|
25
|
+
*
|
|
26
|
+
* **This module does not upload.** The browser sends the bytes to core through `uploadFile` and is
|
|
27
|
+
* handed an id; `attachments.add` then records that the asset has that file. Reading one back is a
|
|
28
|
+
* URL core signs for the person asking — which is why a file another workspace owns cannot be read
|
|
29
|
+
* here even though this module's server can see its name.
|
|
30
|
+
*
|
|
31
|
+
* One component, mounted twice: with `repairId={null}` for the asset's own paperwork, and inside a
|
|
32
|
+
* repair with that repair's id. Both read one query — `attachments.list` returns every file on the
|
|
33
|
+
* asset — and filter it here. That is the one case where filtering in the browser is right: the
|
|
34
|
+
* list is bounded by how much paperwork one item collects and is entirely loaded, unlike a paged
|
|
35
|
+
* asset list, where the same shortcut returns twenty rows and shows eleven.
|
|
36
|
+
*/
|
|
37
|
+
interface Props {
|
|
38
|
+
workspaceId: string
|
|
39
|
+
assetId: string
|
|
40
|
+
/** Null for the asset's own files; a repair id for that repair's paperwork. */
|
|
41
|
+
repairId?: string | null
|
|
42
|
+
/** `inventory.asset.manage` — whether uploading and removing are offered at all. */
|
|
43
|
+
canManage: boolean
|
|
44
|
+
/** Inside a repair card: no empty state and no error card, because it is a detail of a detail. */
|
|
45
|
+
compact?: boolean
|
|
46
|
+
}
|
|
47
|
+
const { workspaceId, assetId, repairId = null, canManage, compact = false }: Props = $props()
|
|
48
|
+
|
|
49
|
+
const api = getInventoryApi()
|
|
50
|
+
const core = coreApi<CoreApi>()
|
|
51
|
+
const queryClient = useQueryClient()
|
|
52
|
+
|
|
53
|
+
const filesQuery = createQuery(() => ({
|
|
54
|
+
queryKey: inventoryKeys.assetAttachments(workspaceId, assetId),
|
|
55
|
+
queryFn: () => api.attachments.list({ workspaceId, assetId }),
|
|
56
|
+
enabled: Boolean(workspaceId && assetId),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
const rows = $derived<readonly Attachment[]>(
|
|
60
|
+
(filesQuery.data ?? []).filter((row: Attachment) => (row.repairId ?? null) === repairId),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A signed URL per file, resolved once and kept for as long as the panel is open.
|
|
65
|
+
*
|
|
66
|
+
* Core signs these with a lifetime measured in minutes, so they are deliberately not stored in the
|
|
67
|
+
* query cache — a link that worked when the panel opened and 404s an hour later is worse than one
|
|
68
|
+
* fetched again. `{#await}` on a memoised promise is what `module-chat` does with the same files.
|
|
69
|
+
*/
|
|
70
|
+
const urls = new Map<string, Promise<string>>()
|
|
71
|
+
function urlFor(fileId: string): Promise<string> {
|
|
72
|
+
const cached = urls.get(fileId)
|
|
73
|
+
if (cached) return cached
|
|
74
|
+
const pending = core.files.downloadUrl({ id: fileId, disposition: 'attachment' }).then((res) => res.url)
|
|
75
|
+
urls.set(fileId, pending)
|
|
76
|
+
return pending
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ------------------------------------------------------------------------------- uploading
|
|
80
|
+
|
|
81
|
+
let input = $state<HTMLInputElement | null>(null)
|
|
82
|
+
/**
|
|
83
|
+
* A plain flag set in the same tick as the click, not `mutation.isPending`.
|
|
84
|
+
*
|
|
85
|
+
* The attribute reaches the button one render later and two quick clicks are one render apart, so a
|
|
86
|
+
* double-click uploads the same file twice — the second insert is dropped by the unique index, but
|
|
87
|
+
* the bytes have already gone to storage.
|
|
88
|
+
*/
|
|
89
|
+
let busy = $state(false)
|
|
90
|
+
|
|
91
|
+
const attach = createMutation(() => ({
|
|
92
|
+
mutationFn: (fileIds: string[]) => api.attachments.add({ workspaceId, assetId, fileIds, repairId }),
|
|
93
|
+
onSuccess: (added: Attachment[]) => {
|
|
94
|
+
if (added.length) toast.success(t('file_added_toast', { n: added.length }))
|
|
95
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
96
|
+
},
|
|
97
|
+
// Translated rather than the server's English prose: see `errors.ts`.
|
|
98
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
99
|
+
onSettled: () => {
|
|
100
|
+
busy = false
|
|
101
|
+
},
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
/** Uploads first, then attaches: an attachment points at a file that already exists. */
|
|
105
|
+
async function upload(list: FileList | null) {
|
|
106
|
+
if (!list?.length || busy) return
|
|
107
|
+
busy = true
|
|
108
|
+
const ids: string[] = []
|
|
109
|
+
for (const file of Array.from(list)) {
|
|
110
|
+
try {
|
|
111
|
+
const uploaded = await uploadFile({
|
|
112
|
+
workspaceId,
|
|
113
|
+
file,
|
|
114
|
+
name: file.name,
|
|
115
|
+
mimeType: file.type || undefined,
|
|
116
|
+
// Core keeps the owning object, so a file uploaded here is not an orphan if this module
|
|
117
|
+
// then fails to record it.
|
|
118
|
+
attachedTo: { module: 'inventory', type: 'asset', id: assetId },
|
|
119
|
+
})
|
|
120
|
+
ids.push(uploaded.id)
|
|
121
|
+
} catch {
|
|
122
|
+
// Named, not "an upload failed": with several files the reader has to know which one.
|
|
123
|
+
toast.error(t('file_upload_failed', isolated({ name: file.name })))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (ids.length) attach.mutate(ids)
|
|
127
|
+
else busy = false
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// -------------------------------------------------------------------------------- removing
|
|
131
|
+
|
|
132
|
+
let removing = $state<Attachment | null>(null)
|
|
133
|
+
let removingBusy = $state(false)
|
|
134
|
+
|
|
135
|
+
const detach = createMutation(() => ({
|
|
136
|
+
mutationFn: (attachmentId: string) => api.attachments.remove({ workspaceId, attachmentId }),
|
|
137
|
+
onSuccess: () => {
|
|
138
|
+
toast.success(t('file_removed_toast', isolated({ name: removing?.name ?? '' })))
|
|
139
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
140
|
+
removing = null
|
|
141
|
+
},
|
|
142
|
+
// Translated rather than the server's English prose: see `errors.ts`.
|
|
143
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
144
|
+
onSettled: () => {
|
|
145
|
+
removingBusy = false
|
|
146
|
+
},
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
function confirmRemove() {
|
|
150
|
+
const target = removing
|
|
151
|
+
if (!target || removingBusy) return
|
|
152
|
+
removingBusy = true
|
|
153
|
+
detach.mutate(target.id)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A picture reads as a picture, a receipt as a receipt; anything else is just a file. */
|
|
157
|
+
function iconFor(mimeType: string | null): string {
|
|
158
|
+
if (mimeType?.startsWith('image/')) return 'image'
|
|
159
|
+
if (mimeType === 'application/pdf') return 'receipt'
|
|
160
|
+
return 'file-text'
|
|
161
|
+
}
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
<div class="files" class:compact>
|
|
165
|
+
{#if canManage}
|
|
166
|
+
<input
|
|
167
|
+
bind:this={input}
|
|
168
|
+
type="file"
|
|
169
|
+
multiple
|
|
170
|
+
hidden
|
|
171
|
+
onchange={(e) => {
|
|
172
|
+
void upload(e.currentTarget.files)
|
|
173
|
+
// Cleared so choosing the same file twice in a row still fires `change`.
|
|
174
|
+
e.currentTarget.value = ''
|
|
175
|
+
}}
|
|
176
|
+
/>
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
{#if filesQuery.isPending}
|
|
180
|
+
<Skeleton height="34px" radius="8px" />
|
|
181
|
+
{:else if filesQuery.isError && !compact}
|
|
182
|
+
<EmptyState bare compact icon="triangle-alert" title={t('files_error')}>
|
|
183
|
+
{#snippet actions()}
|
|
184
|
+
<Button size="sm" variant="secondary" onclick={() => void filesQuery.refetch()}>
|
|
185
|
+
{t('common.retry')}
|
|
186
|
+
</Button>
|
|
187
|
+
{/snippet}
|
|
188
|
+
</EmptyState>
|
|
189
|
+
{:else if rows.length === 0 && !compact}
|
|
190
|
+
<EmptyState bare compact icon="paperclip" title={t('files_empty')} description={t('files_empty_desc')} />
|
|
191
|
+
{:else if rows.length}
|
|
192
|
+
<ul class="rows">
|
|
193
|
+
{#each rows as row (row.id)}
|
|
194
|
+
<li class="row">
|
|
195
|
+
<Icon name={iconFor(row.mimeType)} size={14} strokeWidth={1.7} />
|
|
196
|
+
{#await urlFor(row.fileId)}
|
|
197
|
+
<span class="name" title={row.name}>{row.name}</span>
|
|
198
|
+
{:then url}
|
|
199
|
+
<a
|
|
200
|
+
class="name"
|
|
201
|
+
href={url}
|
|
202
|
+
target="_blank"
|
|
203
|
+
rel="noopener noreferrer"
|
|
204
|
+
title={row.name}
|
|
205
|
+
aria-label={t('file_download', isolated({ name: row.name }))}
|
|
206
|
+
>
|
|
207
|
+
{row.name}
|
|
208
|
+
</a>
|
|
209
|
+
{:catch}
|
|
210
|
+
<!-- The row still says what it is: a file whose URL could not be signed is not a file
|
|
211
|
+
that has gone away. -->
|
|
212
|
+
<span class="name" title={row.name}>{row.name}</span>
|
|
213
|
+
{/await}
|
|
214
|
+
{#if row.size !== null}<span class="size">{formatBytes(row.size)}</span>{/if}
|
|
215
|
+
{#if canManage}
|
|
216
|
+
<IconButton
|
|
217
|
+
icon="x"
|
|
218
|
+
size={26}
|
|
219
|
+
label={t('file_remove', isolated({ name: row.name }))}
|
|
220
|
+
onclick={() => (removing = row)}
|
|
221
|
+
/>
|
|
222
|
+
{/if}
|
|
223
|
+
</li>
|
|
224
|
+
{/each}
|
|
225
|
+
</ul>
|
|
226
|
+
{/if}
|
|
227
|
+
|
|
228
|
+
{#if canManage}
|
|
229
|
+
<div class="add">
|
|
230
|
+
<Button size="sm" variant="secondary" icon="upload" loading={busy} onclick={() => input?.click()}>
|
|
231
|
+
{t('file_add')}
|
|
232
|
+
</Button>
|
|
233
|
+
</div>
|
|
234
|
+
{/if}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<!-- States what happens and to what, rather than asking "Are you sure?" — and the sentence's whole
|
|
238
|
+
job is to say that the file itself survives, because this module never owned it. -->
|
|
239
|
+
<Dialog
|
|
240
|
+
open={removing !== null}
|
|
241
|
+
size="sm"
|
|
242
|
+
title={t('file_remove_title', isolated({ name: removing?.name ?? '' }))}
|
|
243
|
+
onOpenChange={(next) => {
|
|
244
|
+
if (!next) removing = null
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<p class="dialog-body">{t('file_remove_body')}</p>
|
|
248
|
+
{#snippet footer()}
|
|
249
|
+
<Button variant="ghost" onclick={() => (removing = null)}>{t('common.cancel')}</Button>
|
|
250
|
+
<Button variant="danger" onclick={confirmRemove} loading={removingBusy}>
|
|
251
|
+
{t('common.remove')}
|
|
252
|
+
</Button>
|
|
253
|
+
{/snippet}
|
|
254
|
+
</Dialog>
|
|
255
|
+
|
|
256
|
+
<style>
|
|
257
|
+
.files {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
gap: 10px;
|
|
261
|
+
}
|
|
262
|
+
.compact {
|
|
263
|
+
gap: 6px;
|
|
264
|
+
}
|
|
265
|
+
.rows {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
gap: 2px;
|
|
269
|
+
margin: 0;
|
|
270
|
+
padding: 0;
|
|
271
|
+
list-style: none;
|
|
272
|
+
}
|
|
273
|
+
.row {
|
|
274
|
+
display: grid;
|
|
275
|
+
grid-template-columns: 14px minmax(0, 1fr) auto auto;
|
|
276
|
+
align-items: center;
|
|
277
|
+
gap: 8px;
|
|
278
|
+
font-size: 12.5px;
|
|
279
|
+
color: var(--kern-ink-700);
|
|
280
|
+
/* Rhythm between the rows. It is *not* what makes the download link a legal target — the
|
|
281
|
+
comment here used to claim a negative margin that was not in the file, and padding on the
|
|
282
|
+
row would not have grown the anchor's hit area anyway: `align-items: center` sizes a grid
|
|
283
|
+
item to its own content. The link carries that itself, below. */
|
|
284
|
+
padding-block: 3px;
|
|
285
|
+
}
|
|
286
|
+
.name {
|
|
287
|
+
min-width: 0;
|
|
288
|
+
overflow: hidden;
|
|
289
|
+
text-overflow: ellipsis;
|
|
290
|
+
white-space: nowrap;
|
|
291
|
+
color: var(--kern-ink-900);
|
|
292
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
293
|
+
first strong character, so a Latin name inside a Persian screen reads left to right and
|
|
294
|
+
keeps its own trailing punctuation instead of donating it to the paragraph. */
|
|
295
|
+
unicode-bidi: plaintext;
|
|
296
|
+
}
|
|
297
|
+
a.name {
|
|
298
|
+
text-decoration: none;
|
|
299
|
+
border-radius: var(--kern-r-sm);
|
|
300
|
+
/* WCAG 2.5.8 wants 24px, and a line of 12.5px text is about 18 — with the remove button 26px
|
|
301
|
+
away, the spacing exception does not save it. The padding grows the hit area and the equal
|
|
302
|
+
negative margin gives the space back, so the margin box, and therefore the row, is exactly
|
|
303
|
+
where it was. Same shape as `.name` on the assets table. */
|
|
304
|
+
padding-block: 4px;
|
|
305
|
+
margin-block: -4px;
|
|
306
|
+
}
|
|
307
|
+
a.name:hover {
|
|
308
|
+
text-decoration: underline;
|
|
309
|
+
}
|
|
310
|
+
.size {
|
|
311
|
+
/* Muted with a colour, never opacity: a faded row at 0.5 is unreadable whatever its token. */
|
|
312
|
+
color: var(--kern-ink-500);
|
|
313
|
+
font-variant-numeric: tabular-nums;
|
|
314
|
+
/* A size is a Latin number with a Latin unit; it stays left-to-right inside an Arabic panel. */
|
|
315
|
+
direction: ltr;
|
|
316
|
+
unicode-bidi: isolate;
|
|
317
|
+
}
|
|
318
|
+
.add {
|
|
319
|
+
display: flex;
|
|
320
|
+
}
|
|
321
|
+
.dialog-body {
|
|
322
|
+
margin: 0;
|
|
323
|
+
font-size: 13.5px;
|
|
324
|
+
line-height: 1.55;
|
|
325
|
+
color: var(--kern-ink-700);
|
|
326
|
+
}
|
|
327
|
+
</style>
|