@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,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>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '../i18n.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two versions of a long-text field, behind a disclosure.
|
|
6
|
+
*
|
|
7
|
+
* A description is up to eight thousand characters, and the timeline rendered a change to one as
|
|
8
|
+
* "Description changed from … to …" with both versions inline: sixteen kilobytes of somebody's
|
|
9
|
+
* prose in one row of a 440px panel, with every entry below it pushed off the screen. The line
|
|
10
|
+
* above this now says only *that* it changed, and the text lives here.
|
|
11
|
+
*
|
|
12
|
+
* **A component rather than a bare `<details>` in the timeline, for one reason:** `{#if open}`.
|
|
13
|
+
* `<details>` keeps its content in the document whether or not it is open, so fifty entries would
|
|
14
|
+
* still be four hundred kilobytes of text nodes the browser has to build and lay out. Binding
|
|
15
|
+
* `open` and gating on it means the prose exists only while somebody is reading it, and the
|
|
16
|
+
* disclosure is still a real `<details>` — keyboard-reachable, announced as a disclosure, and
|
|
17
|
+
* open by default in a printed page. Nothing is hidden: it is one keystroke away and shown whole,
|
|
18
|
+
* because truncating what somebody deliberately opened would be the same defect one layer down.
|
|
19
|
+
*
|
|
20
|
+
* `before` is null when there was nothing there — a description filled in for the first time has
|
|
21
|
+
* no previous version, and an empty "Before" block would invent one.
|
|
22
|
+
*/
|
|
23
|
+
interface Props {
|
|
24
|
+
before: string | null
|
|
25
|
+
after: string
|
|
26
|
+
}
|
|
27
|
+
const { before, after }: Props = $props()
|
|
28
|
+
|
|
29
|
+
let open = $state(false)
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<details class="disclosure" bind:open>
|
|
33
|
+
<summary>{t('history_show_text')}</summary>
|
|
34
|
+
{#if open}
|
|
35
|
+
<div class="body">
|
|
36
|
+
{#if before !== null}
|
|
37
|
+
<p class="label">{t('history_text_before')}</p>
|
|
38
|
+
<!-- `pre-wrap`, so the paragraphs somebody typed are the paragraphs they read back. -->
|
|
39
|
+
<blockquote class="text was">{before}</blockquote>
|
|
40
|
+
{/if}
|
|
41
|
+
<p class="label">{t('history_text_after')}</p>
|
|
42
|
+
<blockquote class="text">{after}</blockquote>
|
|
43
|
+
</div>
|
|
44
|
+
{/if}
|
|
45
|
+
</details>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.disclosure {
|
|
49
|
+
margin-top: 3px;
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
}
|
|
52
|
+
summary {
|
|
53
|
+
/* `list-style` on the summary is what removes the native marker in Firefox; the ::-webkit rule
|
|
54
|
+
below is what removes it in Safari and Chrome. Neither alone does both. */
|
|
55
|
+
list-style: none;
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 4px;
|
|
59
|
+
width: fit-content;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
color: var(--kern-ink-600);
|
|
62
|
+
/* WCAG 2.5.8 wants 24px and a line of 12px text is about 16. The padding grows the hit area
|
|
63
|
+
and the equal negative margin gives the space back, so nothing moves. */
|
|
64
|
+
padding-block: 4px;
|
|
65
|
+
margin-block: -4px;
|
|
66
|
+
border-radius: var(--kern-r-sm);
|
|
67
|
+
}
|
|
68
|
+
summary::-webkit-details-marker {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
summary:hover {
|
|
72
|
+
color: var(--kern-ink-900);
|
|
73
|
+
}
|
|
74
|
+
/* The disclosure triangle, drawn rather than borrowed, so it turns with the reading direction:
|
|
75
|
+
`▸` in an Arabic panel points the wrong way, and a rotated glyph does not. */
|
|
76
|
+
summary::before {
|
|
77
|
+
content: '';
|
|
78
|
+
width: 0;
|
|
79
|
+
height: 0;
|
|
80
|
+
border-block: 3.5px solid transparent;
|
|
81
|
+
border-inline-start: 5px solid currentColor;
|
|
82
|
+
transition: transform 120ms ease;
|
|
83
|
+
}
|
|
84
|
+
.disclosure[open] summary::before {
|
|
85
|
+
/* Logical would be ideal and there is no logical rotation; the sign is flipped in RTL below. */
|
|
86
|
+
transform: rotate(90deg);
|
|
87
|
+
}
|
|
88
|
+
:global([dir='rtl']) .disclosure[open] summary::before {
|
|
89
|
+
transform: rotate(-90deg);
|
|
90
|
+
}
|
|
91
|
+
.body {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 2px;
|
|
95
|
+
margin-top: 6px;
|
|
96
|
+
}
|
|
97
|
+
.label {
|
|
98
|
+
margin: 4px 0 0;
|
|
99
|
+
font-size: 11px;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
letter-spacing: 0.03em;
|
|
102
|
+
text-transform: uppercase;
|
|
103
|
+
/* Muted with a colour, never opacity: a faded label is unreadable whatever its token says. */
|
|
104
|
+
color: var(--kern-ink-500);
|
|
105
|
+
}
|
|
106
|
+
.text {
|
|
107
|
+
margin: 0;
|
|
108
|
+
padding-inline-start: 8px;
|
|
109
|
+
border-inline-start: 2px solid var(--kern-border-hairline);
|
|
110
|
+
font-size: 12.5px;
|
|
111
|
+
line-height: 1.55;
|
|
112
|
+
color: var(--kern-ink-700);
|
|
113
|
+
white-space: pre-wrap;
|
|
114
|
+
overflow-wrap: anywhere;
|
|
115
|
+
/* A description somebody typed decides its own direction: `plaintext` takes it from the
|
|
116
|
+
text's own first strong character, so an English paragraph inside a Persian panel reads
|
|
117
|
+
left to right and keeps its own punctuation. `isolate` would not — on a block it leaves
|
|
118
|
+
the paragraph direction inherited. */
|
|
119
|
+
unicode-bidi: plaintext;
|
|
120
|
+
}
|
|
121
|
+
.was {
|
|
122
|
+
color: var(--kern-ink-500);
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of core's API this module reaches for, named by shape rather than imported.
|
|
3
|
+
*
|
|
4
|
+
* A module talks to another module through `kernel.call()` on the server; on the client the shell
|
|
5
|
+
* hands over its own configured core client, and typing the seam structurally keeps the dependency
|
|
6
|
+
* pointing one way — inventory does not import core's router type, and core does not know inventory
|
|
7
|
+
* exists.
|
|
8
|
+
*
|
|
9
|
+
* Keep it to what is actually called. A wide type here is a promise about core's surface that this
|
|
10
|
+
* module has no standing to make: `Member` in core carries a role, role ids, group ids, a status
|
|
11
|
+
* and an invitation trail, and naming any of that here would make this module's screens break when
|
|
12
|
+
* a field it never reads changes shape. Four procedures are all of it, and each is gated in
|
|
13
|
+
* `core/src/modules/core/router.ts` — the two settings calls on `core.modules.manage`, the member
|
|
14
|
+
* list on `core.members.view`, the download URL on the file's own workspace membership.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A workspace member, as thinly as a picker and an avatar need one.
|
|
19
|
+
*
|
|
20
|
+
* `name` is nullable in core — somebody invited and not yet signed in has an email and nothing
|
|
21
|
+
* else — so every screen here falls back to the email rather than rendering an empty label.
|
|
22
|
+
*/
|
|
23
|
+
export interface CoreMember {
|
|
24
|
+
userId: string
|
|
25
|
+
user: {
|
|
26
|
+
id: string
|
|
27
|
+
name: string | null
|
|
28
|
+
email: string
|
|
29
|
+
avatarUrl?: string | null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CoreApi {
|
|
34
|
+
workspaces: {
|
|
35
|
+
members: {
|
|
36
|
+
/** Paged in core; this module asks for one large page and keeps the people, not the envelope. */
|
|
37
|
+
list(input: { workspaceId: string; limit?: number }): Promise<{ items: CoreMember[] }>
|
|
38
|
+
}
|
|
39
|
+
modules: {
|
|
40
|
+
list(input: { workspaceId: string }): Promise<
|
|
41
|
+
Array<{
|
|
42
|
+
manifest: { id: string }
|
|
43
|
+
state: {
|
|
44
|
+
enabled: boolean
|
|
45
|
+
settings?: Record<string, unknown>
|
|
46
|
+
}
|
|
47
|
+
}>
|
|
48
|
+
>
|
|
49
|
+
updateSettings(input: {
|
|
50
|
+
workspaceId: string
|
|
51
|
+
moduleId: string
|
|
52
|
+
settings: Record<string, unknown>
|
|
53
|
+
}): Promise<unknown>
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reading a file back.
|
|
58
|
+
*
|
|
59
|
+
* This module records that an asset has a file and never touches a byte — the bytes are core's,
|
|
60
|
+
* the URL is core's to sign, and it is signed for the person asking rather than for this module.
|
|
61
|
+
* `thumbnail: true` is what the asset photo asks for; a full-size photo in a 440px panel is a
|
|
62
|
+
* megabyte nobody looks at.
|
|
63
|
+
*/
|
|
64
|
+
files: {
|
|
65
|
+
downloadUrl(input: {
|
|
66
|
+
id: string
|
|
67
|
+
disposition?: 'inline' | 'attachment'
|
|
68
|
+
thumbnail?: boolean
|
|
69
|
+
}): Promise<{ url: string }>
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { custodyActions } from './custody.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the panel is allowed to offer.
|
|
6
|
+
*
|
|
7
|
+
* A decision rather than rendering, and wrong in a way nothing else would catch: a button that
|
|
8
|
+
* always produces a conflict is a door that will not open. What a stored uuid reads as is the
|
|
9
|
+
* neighbouring question and lives in `members.test.ts`, beside the file that answers it.
|
|
10
|
+
*/
|
|
11
|
+
describe('custodyActions', () => {
|
|
12
|
+
it('offers handing over when nobody has it, and handing on or back when somebody does', () => {
|
|
13
|
+
expect(custodyActions({ held: false, archived: false, may: true })).toEqual(['assign'])
|
|
14
|
+
expect(custodyActions({ held: true, archived: false, may: true })).toEqual(['transfer', 'return'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('offers nothing without the permission — hidden, not disabled', () => {
|
|
18
|
+
expect(custodyActions({ held: false, archived: false, may: false })).toEqual([])
|
|
19
|
+
expect(custodyActions({ held: true, archived: false, may: false })).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('offers nothing on an archived item, which the server refuses anyway', () => {
|
|
23
|
+
expect(custodyActions({ held: false, archived: true, may: true })).toEqual([])
|
|
24
|
+
expect(custodyActions({ held: true, archived: true, may: true })).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('never offers assigning something that is already held', () => {
|
|
28
|
+
// The one combination that would 409 every single time it was pressed.
|
|
29
|
+
expect(custodyActions({ held: true, archived: false, may: true })).not.toContain('assign')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which handover an asset can actually take right now.
|
|
3
|
+
*
|
|
4
|
+
* Three verbs and not one, because the server has three procedures and refuses the wrong one with
|
|
5
|
+
* its own sentence: assigning something somebody already holds is a mistake worth naming, and
|
|
6
|
+
* handing on something nobody holds is a different mistake. The interface's job is not to offer
|
|
7
|
+
* either — a button that always produces a conflict is a door that will not open.
|
|
8
|
+
*
|
|
9
|
+
* A `.ts` file rather than a `const` inside the panel for the reason `timeline.ts` and `price.ts`
|
|
10
|
+
* are: this is a fact with a right answer, and a `.svelte` file cannot be unit-tested.
|
|
11
|
+
*/
|
|
12
|
+
export type CustodyAction = 'assign' | 'transfer' | 'return'
|
|
13
|
+
|
|
14
|
+
export interface CustodyState {
|
|
15
|
+
/** Somebody is holding it — `assets.custodian_user_id` is set. */
|
|
16
|
+
held: boolean
|
|
17
|
+
/** The workspace has taken it out of the register; the server refuses every handover. */
|
|
18
|
+
archived: boolean
|
|
19
|
+
/** This person holds `inventory.custody.manage`. */
|
|
20
|
+
may: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What to offer, in the order the buttons appear.
|
|
25
|
+
*
|
|
26
|
+
* Empty for somebody without the permission — hide what a person may never do — and empty for an
|
|
27
|
+
* archived item, where the panel says *why* instead. An archived item deliberately offers nothing
|
|
28
|
+
* rather than a disabled row of buttons: the reason is one sentence, and one sentence beats three
|
|
29
|
+
* controls that cannot be pressed.
|
|
30
|
+
*/
|
|
31
|
+
export function custodyActions(state: CustodyState): CustodyAction[] {
|
|
32
|
+
if (!state.may || state.archived) return []
|
|
33
|
+
return state.held ? ['transfer', 'return'] : ['assign']
|
|
34
|
+
}
|