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