@kernhq/module-inventory 0.2.0 → 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 +104 -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 +322 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +213 -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 +1052 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +225 -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 +1337 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +363 -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 +158 -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 +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 +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/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 +42 -0
- package/package.json +1 -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 +365 -0
- package/src/client/errors.ts +201 -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 +1043 -18
- package/src/client/mock.test.ts +395 -1
- package/src/client/mock.ts +1066 -79
- package/src/client/module.ts +54 -0
- 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/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 +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 +237 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +255 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +194 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +3573 -18
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +113 -0
- package/src/server/router.ts +462 -6
- package/src/server/schema.ts +160 -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 +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 +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, EmptyState, formatDate, IconButton, messageLocale, Skeleton } from '@kernhq/ui'
|
|
3
|
+
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import type { Asset, RepairListItem } from '../../contract/index.js'
|
|
5
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
6
|
+
import { isolated } from '../bidi.js'
|
|
7
|
+
import { t } from '../i18n.js'
|
|
8
|
+
import { formatPrice } from '../price.js'
|
|
9
|
+
import { inventoryKeys } from '../query.js'
|
|
10
|
+
import { type RepairAction, repairActions } from '../repairs.js'
|
|
11
|
+
import AttachmentsSection from './AttachmentsSection.svelte'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What went away to be fixed, for one asset.
|
|
15
|
+
*
|
|
16
|
+
* Two halves, because they answer two questions: **the open one**, which is where the item is right
|
|
17
|
+
* now, and **the past ones**, which are what it has cost to keep. At most one can be open — the
|
|
18
|
+
* database says so with a unique index — so the top of this section is a single card rather than a
|
|
19
|
+
* list, and `Send for repair` is offered only when there is no card there.
|
|
20
|
+
*
|
|
21
|
+
* A repair's own paperwork sits inside its card: the invoice belongs with the repair it is for, not
|
|
22
|
+
* in the asset's general Files tab beside the purchase receipt.
|
|
23
|
+
*/
|
|
24
|
+
interface Props {
|
|
25
|
+
workspaceId: string
|
|
26
|
+
asset: Asset
|
|
27
|
+
/** `inventory.repair.manage` — whether any of this can be written. */
|
|
28
|
+
canManage: boolean
|
|
29
|
+
/** `inventory.asset.manage`, which is what attaching a file to a repair takes. */
|
|
30
|
+
canAttach: boolean
|
|
31
|
+
/** Whether this workspace has the `attachments` capability; false hides the paperwork entirely. */
|
|
32
|
+
hasFiles: boolean
|
|
33
|
+
/** Opens the shared dialog. The panel owns it, so there is one dialog rather than one per row. */
|
|
34
|
+
onact: (action: RepairAction, repair: RepairListItem | null) => void
|
|
35
|
+
}
|
|
36
|
+
const { workspaceId, asset, canManage, canAttach, hasFiles, onact }: Props = $props()
|
|
37
|
+
|
|
38
|
+
const api = getInventoryApi()
|
|
39
|
+
|
|
40
|
+
interface RepairPage {
|
|
41
|
+
items: RepairListItem[]
|
|
42
|
+
nextCursor: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const repairsQuery = createInfiniteQuery(() => ({
|
|
46
|
+
queryKey: inventoryKeys.assetRepairs(workspaceId, asset.id),
|
|
47
|
+
queryFn: ({ pageParam }): Promise<RepairPage> =>
|
|
48
|
+
api.repairs.list({
|
|
49
|
+
workspaceId,
|
|
50
|
+
assetId: asset.id,
|
|
51
|
+
...(pageParam ? { cursor: pageParam as string } : {}),
|
|
52
|
+
}) as Promise<RepairPage>,
|
|
53
|
+
initialPageParam: undefined as string | undefined,
|
|
54
|
+
getNextPageParam: (last: RepairPage) => last.nextCursor ?? undefined,
|
|
55
|
+
enabled: Boolean(workspaceId && asset.id),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const repairs = $derived(repairsQuery.data?.pages.flatMap((page) => page.items) ?? [])
|
|
59
|
+
/** At most one, by construction: `inventory_repairs_one_open_uq` makes a second one impossible. */
|
|
60
|
+
const open = $derived(repairs.find((row) => row.returnedOn === null) ?? null)
|
|
61
|
+
const past = $derived(repairs.filter((row) => row.returnedOn !== null))
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Which of the three this section may offer, decided in `repairs.ts`.
|
|
65
|
+
*
|
|
66
|
+
* Hidden rather than disabled for somebody without the permission — they may never do it — and
|
|
67
|
+
* empty for an archived item, where the sentence below says why. The server refuses each of these
|
|
68
|
+
* again; this only stops the panel offering a door that will not open.
|
|
69
|
+
*/
|
|
70
|
+
const available = $derived(
|
|
71
|
+
repairActions({
|
|
72
|
+
open: open !== null,
|
|
73
|
+
archived: Boolean(asset.archivedAt),
|
|
74
|
+
may: canManage,
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/** A cost in the reader's own numbers, with the unit it was recorded in. */
|
|
79
|
+
function cost(row: RepairListItem): string | null {
|
|
80
|
+
if (row.costMinor === null) return null
|
|
81
|
+
// Its own currency decides how many decimal places the stored minor units carry — yen has none.
|
|
82
|
+
return `${formatPrice(row.costMinor, messageLocale(), row.currency)} ${row.currency ?? ''}`.trim()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SKELETON_ROWS = [0, 1]
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
{#if repairsQuery.isPending}
|
|
89
|
+
<div class="skeleton">
|
|
90
|
+
{#each SKELETON_ROWS as row (row)}
|
|
91
|
+
<Skeleton height="52px" radius="10px" />
|
|
92
|
+
{/each}
|
|
93
|
+
</div>
|
|
94
|
+
{:else if repairsQuery.isError}
|
|
95
|
+
<EmptyState bare compact icon="triangle-alert" title={t('repairs_error')}>
|
|
96
|
+
{#snippet actions()}
|
|
97
|
+
<Button size="sm" variant="secondary" onclick={() => void repairsQuery.refetch()}>
|
|
98
|
+
{t('common.retry')}
|
|
99
|
+
</Button>
|
|
100
|
+
{/snippet}
|
|
101
|
+
</EmptyState>
|
|
102
|
+
{:else}
|
|
103
|
+
<div class="repairs">
|
|
104
|
+
{#if open}
|
|
105
|
+
<section class="current" aria-label={t('repair_current')}>
|
|
106
|
+
<div class="chead">
|
|
107
|
+
<h3 class="section">{t('repair_current')}</h3>
|
|
108
|
+
{#if available.includes('edit')}
|
|
109
|
+
<IconButton
|
|
110
|
+
icon="square-pen"
|
|
111
|
+
size={26}
|
|
112
|
+
label={t('repair_edit')}
|
|
113
|
+
onclick={() => onact('edit', open)}
|
|
114
|
+
/>
|
|
115
|
+
{/if}
|
|
116
|
+
</div>
|
|
117
|
+
<p class="summary">{open.summary}</p>
|
|
118
|
+
<p class="meta">
|
|
119
|
+
<span>{t('repair_away_since', isolated({ date: formatDate(open.sentOn) }))}</span>
|
|
120
|
+
{#if open.vendor}<span>· {open.vendor}</span>{/if}
|
|
121
|
+
{#if cost(open)}<span class="ltr">· {cost(open)}</span>{/if}
|
|
122
|
+
</p>
|
|
123
|
+
{#if open.detail}<p class="detail">{open.detail}</p>{/if}
|
|
124
|
+
{#if hasFiles}
|
|
125
|
+
<AttachmentsSection
|
|
126
|
+
{workspaceId}
|
|
127
|
+
assetId={asset.id}
|
|
128
|
+
repairId={open.id}
|
|
129
|
+
canManage={canAttach}
|
|
130
|
+
compact
|
|
131
|
+
/>
|
|
132
|
+
{/if}
|
|
133
|
+
{#if available.includes('complete')}
|
|
134
|
+
<div class="acts">
|
|
135
|
+
<Button size="sm" icon="circle-check" onclick={() => onact('complete', open)}>
|
|
136
|
+
{t('repair_complete')}
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
</section>
|
|
141
|
+
{:else if canManage && asset.archivedAt}
|
|
142
|
+
<!-- A disabled control with no explanation is a bug; one sentence says why instead. -->
|
|
143
|
+
<p class="hint">{t('repair_archived_hint')}</p>
|
|
144
|
+
{:else if available.includes('create')}
|
|
145
|
+
<div class="acts">
|
|
146
|
+
<Button size="sm" icon="wrench" onclick={() => onact('create', null)}>{t('repair_new')}</Button>
|
|
147
|
+
</div>
|
|
148
|
+
{/if}
|
|
149
|
+
|
|
150
|
+
{#if past.length}
|
|
151
|
+
<h3 class="section">{t('repair_past')}</h3>
|
|
152
|
+
<ul class="past">
|
|
153
|
+
{#each past as row (row.id)}
|
|
154
|
+
<li class="entry">
|
|
155
|
+
<div class="ehead">
|
|
156
|
+
<span class="esummary">{row.summary}</span>
|
|
157
|
+
{#if canManage}
|
|
158
|
+
<IconButton
|
|
159
|
+
icon="square-pen"
|
|
160
|
+
size={26}
|
|
161
|
+
label={t('repair_edit')}
|
|
162
|
+
onclick={() => onact('edit', row)}
|
|
163
|
+
/>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
<p class="meta">
|
|
167
|
+
<!-- Through `Intl`, never two dates and a dash: hand-built it reads backwards in
|
|
168
|
+
RTL, with the earlier date to the right of the later one. -->
|
|
169
|
+
<span>
|
|
170
|
+
{t('repair_sent_on')}
|
|
171
|
+
{formatDate(row.sentOn)}
|
|
172
|
+
</span>
|
|
173
|
+
<span>
|
|
174
|
+
· {t('repair_returned_on')}
|
|
175
|
+
{formatDate(row.returnedOn as string)}
|
|
176
|
+
</span>
|
|
177
|
+
{#if row.vendor}<span>· {row.vendor}</span>{/if}
|
|
178
|
+
{#if cost(row)}<span class="ltr">· {cost(row)}</span>{/if}
|
|
179
|
+
</p>
|
|
180
|
+
{#if hasFiles}
|
|
181
|
+
<AttachmentsSection
|
|
182
|
+
{workspaceId}
|
|
183
|
+
assetId={asset.id}
|
|
184
|
+
repairId={row.id}
|
|
185
|
+
canManage={canAttach}
|
|
186
|
+
compact
|
|
187
|
+
/>
|
|
188
|
+
{/if}
|
|
189
|
+
</li>
|
|
190
|
+
{/each}
|
|
191
|
+
</ul>
|
|
192
|
+
{:else if !open}
|
|
193
|
+
<EmptyState
|
|
194
|
+
bare
|
|
195
|
+
compact
|
|
196
|
+
icon="wrench"
|
|
197
|
+
title={t('repairs_empty')}
|
|
198
|
+
description={t('repairs_empty_desc')}
|
|
199
|
+
/>
|
|
200
|
+
{/if}
|
|
201
|
+
|
|
202
|
+
{#if repairsQuery.hasNextPage}
|
|
203
|
+
<div class="more">
|
|
204
|
+
<Button
|
|
205
|
+
size="sm"
|
|
206
|
+
variant="secondary"
|
|
207
|
+
loading={repairsQuery.isFetchingNextPage}
|
|
208
|
+
onclick={() => repairsQuery.fetchNextPage()}
|
|
209
|
+
>
|
|
210
|
+
{t('load_more')}
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
213
|
+
{/if}
|
|
214
|
+
</div>
|
|
215
|
+
{/if}
|
|
216
|
+
|
|
217
|
+
<style>
|
|
218
|
+
.skeleton,
|
|
219
|
+
.repairs {
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
gap: 14px;
|
|
223
|
+
margin-top: 14px;
|
|
224
|
+
}
|
|
225
|
+
.current {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-direction: column;
|
|
228
|
+
gap: 8px;
|
|
229
|
+
padding: 12px;
|
|
230
|
+
border: 1px solid var(--kern-border);
|
|
231
|
+
border-radius: var(--kern-r-lg);
|
|
232
|
+
/* Not `--kern-surface-raised`: a `Sheet` is already that colour, so the card would have been a
|
|
233
|
+
bordered box with no ground of its own. `--kern-surface-chip` is a shade off it in both
|
|
234
|
+
themes, and the muted text on it measures 5.66:1 in light and 5.37:1 in dark. */
|
|
235
|
+
background: var(--kern-surface-chip);
|
|
236
|
+
}
|
|
237
|
+
.chead,
|
|
238
|
+
.ehead {
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
justify-content: space-between;
|
|
242
|
+
gap: 8px;
|
|
243
|
+
min-width: 0;
|
|
244
|
+
}
|
|
245
|
+
.section {
|
|
246
|
+
margin: 0;
|
|
247
|
+
font-size: 11.5px;
|
|
248
|
+
font-weight: 600;
|
|
249
|
+
letter-spacing: 0.04em;
|
|
250
|
+
text-transform: uppercase;
|
|
251
|
+
color: var(--kern-ink-500);
|
|
252
|
+
}
|
|
253
|
+
.summary,
|
|
254
|
+
.esummary {
|
|
255
|
+
margin: 0;
|
|
256
|
+
font-size: 13.5px;
|
|
257
|
+
font-weight: 500;
|
|
258
|
+
color: var(--kern-ink-900);
|
|
259
|
+
min-width: 0;
|
|
260
|
+
overflow-wrap: anywhere;
|
|
261
|
+
}
|
|
262
|
+
.meta {
|
|
263
|
+
display: flex;
|
|
264
|
+
flex-wrap: wrap;
|
|
265
|
+
gap: 4px;
|
|
266
|
+
margin: 0;
|
|
267
|
+
font-size: 11.5px;
|
|
268
|
+
/* Muted with a colour rather than opacity, which fades the text against the panel. */
|
|
269
|
+
color: var(--kern-ink-500);
|
|
270
|
+
}
|
|
271
|
+
.detail {
|
|
272
|
+
margin: 0;
|
|
273
|
+
font-size: 12.5px;
|
|
274
|
+
line-height: 1.55;
|
|
275
|
+
color: var(--kern-ink-700);
|
|
276
|
+
white-space: pre-wrap;
|
|
277
|
+
}
|
|
278
|
+
.hint {
|
|
279
|
+
margin: 0;
|
|
280
|
+
font-size: 12.5px;
|
|
281
|
+
line-height: 1.5;
|
|
282
|
+
color: var(--kern-ink-500);
|
|
283
|
+
}
|
|
284
|
+
.acts {
|
|
285
|
+
display: flex;
|
|
286
|
+
gap: 8px;
|
|
287
|
+
flex-wrap: wrap;
|
|
288
|
+
}
|
|
289
|
+
.past {
|
|
290
|
+
display: flex;
|
|
291
|
+
flex-direction: column;
|
|
292
|
+
gap: 12px;
|
|
293
|
+
margin: 0;
|
|
294
|
+
padding: 0;
|
|
295
|
+
list-style: none;
|
|
296
|
+
}
|
|
297
|
+
.entry {
|
|
298
|
+
display: flex;
|
|
299
|
+
flex-direction: column;
|
|
300
|
+
gap: 5px;
|
|
301
|
+
padding-bottom: 12px;
|
|
302
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
303
|
+
}
|
|
304
|
+
.entry:last-child {
|
|
305
|
+
border-bottom: 0;
|
|
306
|
+
padding-bottom: 0;
|
|
307
|
+
}
|
|
308
|
+
/* An amount is a Latin number read left to right, whatever direction the panel runs. */
|
|
309
|
+
.ltr {
|
|
310
|
+
direction: ltr;
|
|
311
|
+
unicode-bidi: isolate;
|
|
312
|
+
}
|
|
313
|
+
.more {
|
|
314
|
+
display: flex;
|
|
315
|
+
justify-content: center;
|
|
316
|
+
padding-top: 4px;
|
|
317
|
+
}
|
|
318
|
+
</style>
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Button,
|
|
5
|
+
EmptyState,
|
|
6
|
+
formatDate,
|
|
7
|
+
formatDateTime,
|
|
8
|
+
messageLocale,
|
|
9
|
+
relativeTime,
|
|
10
|
+
Skeleton,
|
|
11
|
+
} from '@kernhq/ui'
|
|
12
|
+
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
|
13
|
+
import type { AssetHistoryEntry } from '../../contract/index.js'
|
|
14
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
15
|
+
import { isolate, isolated } from '../bidi.js'
|
|
16
|
+
import { t } from '../i18n.js'
|
|
17
|
+
import type { Directory } from '../members.js'
|
|
18
|
+
import { nameOf, resolveName } from '../members.js'
|
|
19
|
+
import { formatPrice } from '../price.js'
|
|
20
|
+
import { inventoryKeys } from '../query.js'
|
|
21
|
+
import { actionKey, changeLineKind, fieldKey, noteOf, personIdOf, subjectOf } from '../timeline.js'
|
|
22
|
+
import TimelineText from './TimelineText.svelte'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An asset's own timeline, as sentences.
|
|
26
|
+
*
|
|
27
|
+
* `asset_history` stores `{ action, changes: [{field, from, to}], data: {…ids} }`, which is a shape
|
|
28
|
+
* for a database. Rendered raw it is a JSON diff of somebody's own laptop; rendered here it is
|
|
29
|
+
* "Ada handed it to Bruno" and "Warranty until changed from Mar 14, 2027 to Mar 14, 2028" — "it"
|
|
30
|
+
* rather than the tag, because the panel's own header is already showing `INV-0042`.
|
|
31
|
+
*
|
|
32
|
+
* Which sentence, and about whom, is decided in `timeline.ts` — pure, and therefore tested. This
|
|
33
|
+
* file supplies the words and the layout and nothing else.
|
|
34
|
+
*/
|
|
35
|
+
interface Props {
|
|
36
|
+
workspaceId: string
|
|
37
|
+
assetId: string
|
|
38
|
+
/** Who the workspace currently has, for turning a stored uuid into a name. */
|
|
39
|
+
dir: Directory
|
|
40
|
+
/** A category id as its name, or null when the id names nothing this workspace still lists. */
|
|
41
|
+
categoryName: (id: string) => string | null
|
|
42
|
+
/**
|
|
43
|
+
* The asset's currency now, for reading a stored `priceMinor` back into an amount.
|
|
44
|
+
*
|
|
45
|
+
* How many decimal places minor units carry is a fact about the currency — none for yen, three
|
|
46
|
+
* for the dinar — so a timeline that assumed hundredths printed every yen price 100× too small.
|
|
47
|
+
* An entry that *changed* the currency carries both sides itself and is read from those instead;
|
|
48
|
+
* this is the answer for every other entry, which is almost all of them.
|
|
49
|
+
*/
|
|
50
|
+
currency: string | null
|
|
51
|
+
}
|
|
52
|
+
const { workspaceId, assetId, dir, categoryName, currency }: Props = $props()
|
|
53
|
+
|
|
54
|
+
const api = getInventoryApi()
|
|
55
|
+
|
|
56
|
+
interface HistoryPage {
|
|
57
|
+
items: AssetHistoryEntry[]
|
|
58
|
+
nextCursor: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const historyQuery = createInfiniteQuery(() => ({
|
|
62
|
+
queryKey: inventoryKeys.assetHistory(workspaceId, assetId),
|
|
63
|
+
queryFn: ({ pageParam }): Promise<HistoryPage> =>
|
|
64
|
+
api.assets.history({
|
|
65
|
+
workspaceId,
|
|
66
|
+
assetId,
|
|
67
|
+
...(pageParam ? { cursor: pageParam as string } : {}),
|
|
68
|
+
}) as Promise<HistoryPage>,
|
|
69
|
+
initialPageParam: undefined as string | undefined,
|
|
70
|
+
getNextPageParam: (last: HistoryPage) => last.nextCursor ?? undefined,
|
|
71
|
+
enabled: Boolean(workspaceId && assetId),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const entries = $derived(historyQuery.data?.pages.flatMap((page) => page.items) ?? [])
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The four words `nameOf` needs, read on render so they follow the interface language.
|
|
78
|
+
*
|
|
79
|
+
* Four rather than two, and that is the fix: an id the directory does not have used to read as
|
|
80
|
+
* "a former member" whether the member list was still in flight, had failed, or really did not
|
|
81
|
+
* contain them — so every entry said "A former member handed it to A former member" for the first
|
|
82
|
+
* moments of every panel, and for ever if the request failed. `members.ts` decides which of the
|
|
83
|
+
* four applies; this only supplies the wording.
|
|
84
|
+
*/
|
|
85
|
+
const words = $derived({
|
|
86
|
+
loading: t('member_loading'),
|
|
87
|
+
unknown: t('member_unknown'),
|
|
88
|
+
former: t('member_former'),
|
|
89
|
+
system: t('member_system'),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One value, in the units a person reads it in.
|
|
94
|
+
*
|
|
95
|
+
* A stored diff holds what the column holds: minor units for a price, an ISO date for a date, a
|
|
96
|
+
* uuid for a category. "priceMinor changed from 129900 to 149900" is arithmetic homework, and
|
|
97
|
+
* "categoryId changed from 0192… to 0192…" is not a sentence at all.
|
|
98
|
+
*/
|
|
99
|
+
function readable(field: string, value: unknown, money: string | null): string {
|
|
100
|
+
if (value === null || value === undefined || value === '') return '—'
|
|
101
|
+
if (field === 'categoryId') return categoryName(String(value)) ?? t('category_none')
|
|
102
|
+
if (field === 'priceMinor') return formatPrice(Number(value), messageLocale(), money)
|
|
103
|
+
if (field === 'purchasedOn' || field === 'warrantyUntil') return formatDate(String(value))
|
|
104
|
+
return String(value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Which currency one side of one entry is in — the entry's own answer where it has one.
|
|
109
|
+
*
|
|
110
|
+
* Somebody who re-priced a laptop from ¥120000 to €890 changed two columns in one edit, so the
|
|
111
|
+
* entry carries a `currency` change beside the `priceMinor` change and each side of the sentence
|
|
112
|
+
* is in a different currency. Reading both through today's currency would print one of them
|
|
113
|
+
* wrong, and it is the *old* one — the number nobody can check any more.
|
|
114
|
+
*/
|
|
115
|
+
function currencyFor(entry: AssetHistoryEntry, side: 'from' | 'to'): string | null {
|
|
116
|
+
const changed = entry.changes.find((change) => change.field === 'currency')
|
|
117
|
+
if (!changed) return currency
|
|
118
|
+
const value = changed[side]
|
|
119
|
+
return typeof value === 'string' && value ? value : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The label for one field, and the only value here that must *not* be isolated afterwards.
|
|
124
|
+
*
|
|
125
|
+
* A key that resolves is a translated word and belongs to the sentence around it. A field this
|
|
126
|
+
* client has never heard of prints its own name — ugly and true, because the rows outlive the image
|
|
127
|
+
* that wrote them — and that one is a Latin identifier dropped into a Persian sentence, so it is
|
|
128
|
+
* isolated here rather than left to reorder the words on either side of it.
|
|
129
|
+
*/
|
|
130
|
+
function fieldLabel(field: string): string {
|
|
131
|
+
const key = fieldKey(field)
|
|
132
|
+
return key ? t(key) : isolate(field)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One line under an entry, for one field that changed.
|
|
137
|
+
*
|
|
138
|
+
* `changeLineKind` in `timeline.ts` decides which of the five sentences applies — including the one
|
|
139
|
+
* this used to get wrong, where a `description` change read its whole eight-kilobyte value out into
|
|
140
|
+
* a row of a 440px panel. `text_changed` says that it changed and `TimelineText` carries the text.
|
|
141
|
+
*
|
|
142
|
+
* Every value goes through `isolated()`: a serial number, a location, a price and a category name
|
|
143
|
+
* are all values somebody typed, and a Latin one inside «{field} از {from} به {to} تغییر کرد»
|
|
144
|
+
* loses its own punctuation to the sentence around it.
|
|
145
|
+
*/
|
|
146
|
+
function changeLine(entry: AssetHistoryEntry, change: { field: string; from: unknown; to: unknown }): string {
|
|
147
|
+
const field = fieldLabel(change.field)
|
|
148
|
+
const kind = changeLineKind(change.field, change.from, change.to)
|
|
149
|
+
if (kind === 'cleared') return t('history_cleared', isolated({ field }))
|
|
150
|
+
if (kind === 'replaced') return t('history_replaced', isolated({ field }))
|
|
151
|
+
if (kind === 'text_changed') return t('history_text_changed', isolated({ field }))
|
|
152
|
+
if (kind === 'set')
|
|
153
|
+
return t(
|
|
154
|
+
'history_set',
|
|
155
|
+
isolated({ field, to: readable(change.field, change.to, currencyFor(entry, 'to')) }),
|
|
156
|
+
)
|
|
157
|
+
return t(
|
|
158
|
+
'history_changed',
|
|
159
|
+
isolated({
|
|
160
|
+
field,
|
|
161
|
+
from: readable(change.field, change.from, currencyFor(entry, 'from')),
|
|
162
|
+
to: readable(change.field, change.to, currencyFor(entry, 'to')),
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The text a `text_changed` line discloses. Null for `from` when there was nothing there before. */
|
|
168
|
+
const textOf = (value: unknown): string | null =>
|
|
169
|
+
value === null || value === undefined || value === '' ? null : String(value)
|
|
170
|
+
|
|
171
|
+
function headline(entry: AssetHistoryEntry): string {
|
|
172
|
+
const personId = personIdOf(entry.action, entry.data)
|
|
173
|
+
// `action` is a machine token — `repair_logged` — and lands inside a translated sentence for an
|
|
174
|
+
// action this client has no wording for, so it is isolated with the two names.
|
|
175
|
+
return t(
|
|
176
|
+
actionKey(entry.action),
|
|
177
|
+
isolated({
|
|
178
|
+
actor: nameOf(entry.actorId, dir, words),
|
|
179
|
+
person: nameOf(personId, dir, words),
|
|
180
|
+
action: entry.action,
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const SKELETON_ROWS = [0, 1, 2]
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
{#if historyQuery.isPending}
|
|
189
|
+
<ul class="skeleton">
|
|
190
|
+
{#each SKELETON_ROWS as row (row)}
|
|
191
|
+
<li class="srow">
|
|
192
|
+
<Skeleton height="22px" width="22px" radius="999px" />
|
|
193
|
+
<div class="slines">
|
|
194
|
+
<Skeleton height="12px" width="62%" />
|
|
195
|
+
<Skeleton height="10px" width="28%" />
|
|
196
|
+
</div>
|
|
197
|
+
</li>
|
|
198
|
+
{/each}
|
|
199
|
+
</ul>
|
|
200
|
+
{:else if historyQuery.isError}
|
|
201
|
+
<EmptyState bare compact icon="triangle-alert" title={t('history_error')}>
|
|
202
|
+
{#snippet actions()}
|
|
203
|
+
<Button size="sm" variant="secondary" onclick={() => void historyQuery.refetch()}>
|
|
204
|
+
{t('common.retry')}
|
|
205
|
+
</Button>
|
|
206
|
+
{/snippet}
|
|
207
|
+
</EmptyState>
|
|
208
|
+
{:else if entries.length === 0}
|
|
209
|
+
<!-- Unreachable in practice — `create` writes the first entry in the same transaction as the
|
|
210
|
+
asset — and rendered honestly all the same, because "unreachable" is a claim about today. -->
|
|
211
|
+
<EmptyState bare compact icon="clock" title={t('history_empty')} />
|
|
212
|
+
{:else}
|
|
213
|
+
<ol class="entries">
|
|
214
|
+
{#each entries as entry (entry.id)}
|
|
215
|
+
<!-- The avatar shows initials only for somebody actually resolved. Its colour is seeded from
|
|
216
|
+
the id either way, so it does not change when the member list arrives — and an unresolved
|
|
217
|
+
actor gets a plain square rather than the initials of "…". -->
|
|
218
|
+
{@const actor = resolveName(entry.actorId, dir)}
|
|
219
|
+
{@const note = noteOf(entry.data)}
|
|
220
|
+
{@const subject = subjectOf(entry.action, entry.data)}
|
|
221
|
+
<li class="entry">
|
|
222
|
+
<Avatar name={actor.name} id={entry.actorId} size={22} />
|
|
223
|
+
<div class="body">
|
|
224
|
+
<p class="line">{headline(entry)}</p>
|
|
225
|
+
<!-- What the entry is about — a repair's summary, a file's name. Rendered bare rather
|
|
226
|
+
than through the catalogue: it is text somebody typed, not an interface string. -->
|
|
227
|
+
{#if subject}<p class="subject">{subject}</p>{/if}
|
|
228
|
+
{#if entry.changes.length}
|
|
229
|
+
<ul class="changes">
|
|
230
|
+
{#each entry.changes as change (change.field)}
|
|
231
|
+
<li>
|
|
232
|
+
{changeLine(entry, change)}
|
|
233
|
+
{#if changeLineKind(change.field, change.from, change.to) === 'text_changed'}
|
|
234
|
+
<TimelineText
|
|
235
|
+
before={textOf(change.from)}
|
|
236
|
+
after={textOf(change.to) ?? ''}
|
|
237
|
+
/>
|
|
238
|
+
{/if}
|
|
239
|
+
</li>
|
|
240
|
+
{/each}
|
|
241
|
+
</ul>
|
|
242
|
+
{/if}
|
|
243
|
+
{#if note}
|
|
244
|
+
<p class="note">{t('history_note', isolated({ note }))}</p>
|
|
245
|
+
{/if}
|
|
246
|
+
<!-- The exact moment is on the element, so a hover and a screen reader both get it;
|
|
247
|
+
the visible text is relative, which is what somebody reading a timeline wants. -->
|
|
248
|
+
<time class="when" datetime={entry.occurredAt} title={formatDateTime(entry.occurredAt)}>
|
|
249
|
+
{relativeTime(entry.occurredAt)}
|
|
250
|
+
</time>
|
|
251
|
+
</div>
|
|
252
|
+
</li>
|
|
253
|
+
{/each}
|
|
254
|
+
</ol>
|
|
255
|
+
|
|
256
|
+
{#if historyQuery.hasNextPage}
|
|
257
|
+
<div class="more">
|
|
258
|
+
<Button
|
|
259
|
+
size="sm"
|
|
260
|
+
variant="secondary"
|
|
261
|
+
loading={historyQuery.isFetchingNextPage}
|
|
262
|
+
onclick={() => historyQuery.fetchNextPage()}
|
|
263
|
+
>
|
|
264
|
+
{t('load_more')}
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
{/if}
|
|
268
|
+
{/if}
|
|
269
|
+
|
|
270
|
+
<style>
|
|
271
|
+
.entries,
|
|
272
|
+
.skeleton {
|
|
273
|
+
display: flex;
|
|
274
|
+
flex-direction: column;
|
|
275
|
+
gap: 14px;
|
|
276
|
+
margin: 0;
|
|
277
|
+
padding: 0;
|
|
278
|
+
list-style: none;
|
|
279
|
+
}
|
|
280
|
+
.entry,
|
|
281
|
+
.srow {
|
|
282
|
+
display: grid;
|
|
283
|
+
/* Logical, so the avatar sits on the reading-start edge in Persian and Arabic too. */
|
|
284
|
+
grid-template-columns: 22px minmax(0, 1fr);
|
|
285
|
+
gap: 10px;
|
|
286
|
+
align-items: start;
|
|
287
|
+
}
|
|
288
|
+
.slines {
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-direction: column;
|
|
291
|
+
gap: 6px;
|
|
292
|
+
}
|
|
293
|
+
.body {
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
gap: 3px;
|
|
297
|
+
min-width: 0;
|
|
298
|
+
}
|
|
299
|
+
.line {
|
|
300
|
+
margin: 0;
|
|
301
|
+
font-size: 13px;
|
|
302
|
+
line-height: 1.5;
|
|
303
|
+
color: var(--kern-ink-900);
|
|
304
|
+
}
|
|
305
|
+
.changes {
|
|
306
|
+
margin: 2px 0 0;
|
|
307
|
+
/* Logical: a bulleted list indents from the reading-start edge in both directions. */
|
|
308
|
+
padding-inline-start: 16px;
|
|
309
|
+
display: flex;
|
|
310
|
+
flex-direction: column;
|
|
311
|
+
gap: 2px;
|
|
312
|
+
font-size: 12.5px;
|
|
313
|
+
line-height: 1.5;
|
|
314
|
+
/* Muted with a colour rather than with opacity, which fades text against the page. */
|
|
315
|
+
color: var(--kern-ink-600);
|
|
316
|
+
}
|
|
317
|
+
.note {
|
|
318
|
+
margin: 2px 0 0;
|
|
319
|
+
font-size: 12.5px;
|
|
320
|
+
line-height: 1.5;
|
|
321
|
+
color: var(--kern-ink-600);
|
|
322
|
+
border-inline-start: 2px solid var(--kern-border-hairline);
|
|
323
|
+
padding-inline-start: 8px;
|
|
324
|
+
}
|
|
325
|
+
.subject {
|
|
326
|
+
margin: 1px 0 0;
|
|
327
|
+
font-size: 12.5px;
|
|
328
|
+
line-height: 1.5;
|
|
329
|
+
/* Muted with a colour, never with opacity: a faded line at 0.5 is unreadable whatever its
|
|
330
|
+
token says, and this one carries the only specific word in the entry. */
|
|
331
|
+
color: var(--kern-ink-600);
|
|
332
|
+
overflow-wrap: anywhere;
|
|
333
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
334
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
335
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
336
|
+
unicode-bidi: plaintext;
|
|
337
|
+
}
|
|
338
|
+
.when {
|
|
339
|
+
font-size: 11.5px;
|
|
340
|
+
color: var(--kern-ink-500);
|
|
341
|
+
}
|
|
342
|
+
.more {
|
|
343
|
+
display: flex;
|
|
344
|
+
justify-content: center;
|
|
345
|
+
padding-top: 12px;
|
|
346
|
+
}
|
|
347
|
+
</style>
|