@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,614 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
coreApi,
|
|
7
|
+
EmptyState,
|
|
8
|
+
formatDate,
|
|
9
|
+
formatDateRange,
|
|
10
|
+
formatDateTime,
|
|
11
|
+
IconButton,
|
|
12
|
+
keys,
|
|
13
|
+
messageLocale,
|
|
14
|
+
Sheet,
|
|
15
|
+
Skeleton,
|
|
16
|
+
session,
|
|
17
|
+
type TabItem,
|
|
18
|
+
Tabs,
|
|
19
|
+
} from '@kernhq/ui'
|
|
20
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
21
|
+
import type { Asset, Category, CustodyPeriod, RepairListItem } from '../../contract/index.js'
|
|
22
|
+
import { MODULE_ID } from '../../contract/index.js'
|
|
23
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
24
|
+
import { isolated } from '../bidi.js'
|
|
25
|
+
import type { CoreApi, CoreMember } from '../core-api.js'
|
|
26
|
+
import { type CustodyAction, custodyActions } from '../custody.js'
|
|
27
|
+
import { t } from '../i18n.js'
|
|
28
|
+
import { directory, directoryStatus, nameOf, resolveName } from '../members.js'
|
|
29
|
+
import { canInventory } from '../permissions.js'
|
|
30
|
+
import { formatPrice } from '../price.js'
|
|
31
|
+
import { inventoryKeys } from '../query.js'
|
|
32
|
+
import type { RepairAction } from '../repairs.js'
|
|
33
|
+
import { statusTone } from '../status.js'
|
|
34
|
+
import AssetPhoto from './AssetPhoto.svelte'
|
|
35
|
+
import AttachmentsSection from './AttachmentsSection.svelte'
|
|
36
|
+
import CustodyDialog from './CustodyDialog.svelte'
|
|
37
|
+
import RepairDialog from './RepairDialog.svelte'
|
|
38
|
+
import RepairsSection from './RepairsSection.svelte'
|
|
39
|
+
import Timeline from './Timeline.svelte'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything one asset is, in a panel over the list.
|
|
43
|
+
*
|
|
44
|
+
* A 440px sheet rather than a page of its own (DESIGN.md §3.13): the list keeps its scroll, its
|
|
45
|
+
* filters and its cursor, and closing the panel puts you back exactly where you were. Which asset
|
|
46
|
+
* is open lives in the URL as `?asset=<id>`, so it can be linked to, reloaded and shared — the page
|
|
47
|
+
* owns that parameter, not this component.
|
|
48
|
+
*
|
|
49
|
+
* Five sections at most, because an asset answers five different questions: **what is it**
|
|
50
|
+
* (Details), **who has it** (Custody), **what has been fixed** (Repairs), **what paperwork it has**
|
|
51
|
+
* (Files) and **what has happened to it** (History).
|
|
52
|
+
*
|
|
53
|
+
* Two of them are capabilities, so a workspace that does not record repairs or keep receipts sees
|
|
54
|
+
* three tabs and not five. The tab is not merely hidden: every procedure behind it answers 404 in
|
|
55
|
+
* that workspace, which is what makes the interface and the API agree instead of the interface
|
|
56
|
+
* hiding a door that is still open.
|
|
57
|
+
*/
|
|
58
|
+
interface Props {
|
|
59
|
+
workspaceId: string
|
|
60
|
+
/** The asset id from the URL. Null closes the panel. */
|
|
61
|
+
assetId: string | null
|
|
62
|
+
onclose: () => void
|
|
63
|
+
/** Editing is the list's dialog, so the panel asks rather than growing a second copy of it. */
|
|
64
|
+
onedit: (asset: Asset) => void
|
|
65
|
+
}
|
|
66
|
+
const { workspaceId, assetId, onclose, onedit }: Props = $props()
|
|
67
|
+
|
|
68
|
+
const api = getInventoryApi()
|
|
69
|
+
const core = coreApi<CoreApi>()
|
|
70
|
+
|
|
71
|
+
const enabled = $derived(Boolean(workspaceId && assetId))
|
|
72
|
+
|
|
73
|
+
const assetQuery = createQuery(() => ({
|
|
74
|
+
queryKey: inventoryKeys.asset(workspaceId, assetId ?? ''),
|
|
75
|
+
queryFn: () => api.assets.get({ workspaceId, assetId: assetId as string }),
|
|
76
|
+
enabled,
|
|
77
|
+
}))
|
|
78
|
+
const asset = $derived(assetQuery.data ?? null)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Categories **including archived ones**, and that is the whole reason this query is separate from
|
|
82
|
+
* the page's.
|
|
83
|
+
*
|
|
84
|
+
* An asset filed under a category somebody later archived still carries its id, and the point of
|
|
85
|
+
* archiving rather than deleting is that the row goes on being able to say what it is. Asking for
|
|
86
|
+
* live categories only would blank exactly the rows archiving was supposed to protect.
|
|
87
|
+
*/
|
|
88
|
+
const categoriesQuery = createQuery(() => ({
|
|
89
|
+
queryKey: inventoryKeys.categories(workspaceId, true),
|
|
90
|
+
queryFn: () => api.categories.list({ workspaceId, archived: true }),
|
|
91
|
+
enabled,
|
|
92
|
+
}))
|
|
93
|
+
const categoryNames = $derived(
|
|
94
|
+
new Map((categoriesQuery.data ?? []).map((row: Category) => [row.id, row.name])),
|
|
95
|
+
)
|
|
96
|
+
const categoryName = (id: string): string | null => categoryNames.get(id) ?? null
|
|
97
|
+
|
|
98
|
+
const membersQuery = createQuery(() => ({
|
|
99
|
+
queryKey: keys.members(workspaceId),
|
|
100
|
+
queryFn: () => core.workspaces.members.list({ workspaceId }),
|
|
101
|
+
enabled,
|
|
102
|
+
}))
|
|
103
|
+
const members = $derived<readonly CoreMember[]>(membersQuery.data?.items ?? [])
|
|
104
|
+
/**
|
|
105
|
+
* The directory carries the request's state, not just its rows.
|
|
106
|
+
*
|
|
107
|
+
* An empty map is what "still loading", "failed" and "an empty workspace" all look like, and this
|
|
108
|
+
* panel used to hand all three to `nameOf` as the same thing — so every custody row and every
|
|
109
|
+
* timeline entry read "A former member" until the request came back, and permanently if it did not.
|
|
110
|
+
*/
|
|
111
|
+
const dir = $derived(directory(members, directoryStatus(membersQuery)))
|
|
112
|
+
const words = $derived({
|
|
113
|
+
loading: t('member_loading'),
|
|
114
|
+
unknown: t('member_unknown'),
|
|
115
|
+
former: t('member_former'),
|
|
116
|
+
system: t('member_system'),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const custodyQuery = createQuery(() => ({
|
|
120
|
+
queryKey: inventoryKeys.assetCustody(workspaceId, assetId ?? ''),
|
|
121
|
+
queryFn: () => api.custody.history({ workspaceId, assetId: assetId as string }),
|
|
122
|
+
enabled,
|
|
123
|
+
}))
|
|
124
|
+
const periods = $derived<readonly CustodyPeriod[]>(custodyQuery.data ?? [])
|
|
125
|
+
/** Everything that is not the open one: "who had this before me". */
|
|
126
|
+
const previous = $derived(periods.filter((period) => period.effectiveTo !== null))
|
|
127
|
+
|
|
128
|
+
const holderName = $derived(asset?.custodianUserId ? nameOf(asset.custodianUserId, dir, words) : null)
|
|
129
|
+
/** The same person, unresolved, so the avatar shows initials only for somebody actually found. */
|
|
130
|
+
const holder = $derived(asset?.custodianUserId ? resolveName(asset.custodianUserId, dir) : null)
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------- what may be done here
|
|
133
|
+
|
|
134
|
+
const canManage = $derived(canInventory('manage'))
|
|
135
|
+
const canCustody = $derived(canInventory('custody'))
|
|
136
|
+
/**
|
|
137
|
+
* Which handovers this item can actually take, decided in `custody.ts`.
|
|
138
|
+
*
|
|
139
|
+
* Empty for somebody without the permission — hidden, because they may never do it — and empty for
|
|
140
|
+
* an archived item, where the sentence below says why rather than three buttons that cannot be
|
|
141
|
+
* pressed. The server refuses every one of these again; this only stops the panel offering a door
|
|
142
|
+
* that will not open.
|
|
143
|
+
*/
|
|
144
|
+
const available = $derived(
|
|
145
|
+
custodyActions({
|
|
146
|
+
held: Boolean(asset?.custodianUserId),
|
|
147
|
+
archived: Boolean(asset?.archivedAt),
|
|
148
|
+
may: canCustody,
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
let action = $state<CustodyAction | null>(null)
|
|
153
|
+
|
|
154
|
+
/** `inventory.repair.manage`, and `inventory.asset.manage` is what attaching a file takes. */
|
|
155
|
+
const canRepair = $derived(canInventory('repairs'))
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What this workspace has switched on.
|
|
159
|
+
*
|
|
160
|
+
* Read from the session rather than derived here: it is the set the **server** resolved, with
|
|
161
|
+
* defaults applied, `required` forced on and anything whose dependency is off pruned. Working it
|
|
162
|
+
* out again from raw settings would be a second implementation of that closure, and two
|
|
163
|
+
* implementations eventually disagree — the way that shows up is a tab whose API answers 404.
|
|
164
|
+
*/
|
|
165
|
+
const hasRepairs = $derived(session.hasCapability(MODULE_ID, 'repairs'))
|
|
166
|
+
const hasFiles = $derived(session.hasCapability(MODULE_ID, 'attachments'))
|
|
167
|
+
|
|
168
|
+
let repairAction = $state<RepairAction | null>(null)
|
|
169
|
+
let repairRow = $state<RepairListItem | null>(null)
|
|
170
|
+
|
|
171
|
+
function openRepair(next: RepairAction, row: RepairListItem | null) {
|
|
172
|
+
repairRow = row
|
|
173
|
+
repairAction = next
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------------- the tabs
|
|
177
|
+
|
|
178
|
+
let tab = $state('details')
|
|
179
|
+
const TABS = $derived<TabItem[]>([
|
|
180
|
+
{ value: 'details', label: t('details'), icon: 'info' },
|
|
181
|
+
{ value: 'custody', label: t('custody'), icon: 'user' },
|
|
182
|
+
...(hasRepairs ? [{ value: 'repairs', label: t('repairs'), icon: 'wrench' }] : []),
|
|
183
|
+
...(hasFiles ? [{ value: 'files', label: t('files'), icon: 'paperclip' }] : []),
|
|
184
|
+
{ value: 'history', label: t('history'), icon: 'clock' },
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A tab a workspace has just switched off must not leave the panel on it.
|
|
189
|
+
*
|
|
190
|
+
* `Tabs` keeps whatever value it was given, so a panel sitting on Repairs when an administrator
|
|
191
|
+
* turns the capability off in another tab would render nothing at all — the content snippet has no
|
|
192
|
+
* branch for a tab that is not in the list.
|
|
193
|
+
*/
|
|
194
|
+
$effect(() => {
|
|
195
|
+
if (!TABS.some((item) => item.value === tab)) tab = 'details'
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
/** The rows of the Details section: a label, and a value already turned into words. */
|
|
199
|
+
const facts = $derived.by<Array<{ label: string; value: string; ltr?: boolean }>>(() => {
|
|
200
|
+
if (!asset) return []
|
|
201
|
+
const rows: Array<{ label: string; value: string; ltr?: boolean }> = [
|
|
202
|
+
{
|
|
203
|
+
label: t('category'),
|
|
204
|
+
value: (asset.categoryId && categoryName(asset.categoryId)) || t('category_none'),
|
|
205
|
+
},
|
|
206
|
+
{ label: t('serial_number'), value: asset.serialNumber ?? '—', ltr: true },
|
|
207
|
+
{ label: t('location'), value: asset.location ?? '—' },
|
|
208
|
+
{ label: t('purchased_on'), value: asset.purchasedOn ? formatDate(asset.purchasedOn) : '—' },
|
|
209
|
+
{ label: t('purchased_from'), value: asset.purchasedFrom ?? '—' },
|
|
210
|
+
{
|
|
211
|
+
label: t('price'),
|
|
212
|
+
value:
|
|
213
|
+
asset.priceMinor === null
|
|
214
|
+
? '—'
|
|
215
|
+
: // The currency decides the decimal places, not a constant: ¥1000 has none and a
|
|
216
|
+
// dinar has three, so reading the column as hundredths showed both wrong.
|
|
217
|
+
`${formatPrice(asset.priceMinor, messageLocale(), asset.currency)} ${asset.currency ?? ''}`.trim(),
|
|
218
|
+
},
|
|
219
|
+
{ label: t('warranty_until'), value: asset.warrantyUntil ? formatDate(asset.warrantyUntil) : '—' },
|
|
220
|
+
{ label: t('added_on'), value: formatDateTime(asset.createdAt) },
|
|
221
|
+
{ label: t('updated_on'), value: formatDateTime(asset.updatedAt) },
|
|
222
|
+
]
|
|
223
|
+
return rows
|
|
224
|
+
})
|
|
225
|
+
</script>
|
|
226
|
+
|
|
227
|
+
<Sheet
|
|
228
|
+
open={assetId !== null}
|
|
229
|
+
onOpenChange={(next) => {
|
|
230
|
+
if (!next) onclose()
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{#snippet header()}
|
|
234
|
+
<div class="head">
|
|
235
|
+
<code class="tag">{asset?.code ?? ''}</code>
|
|
236
|
+
<h2 class="name">{asset?.name ?? ''}</h2>
|
|
237
|
+
</div>
|
|
238
|
+
{/snippet}
|
|
239
|
+
|
|
240
|
+
{#snippet actions()}
|
|
241
|
+
{#if asset && canManage}
|
|
242
|
+
<IconButton
|
|
243
|
+
icon="square-pen"
|
|
244
|
+
size={28}
|
|
245
|
+
variant="sidebar"
|
|
246
|
+
label={t('edit')}
|
|
247
|
+
onclick={() => onedit(asset)}
|
|
248
|
+
/>
|
|
249
|
+
{/if}
|
|
250
|
+
{/snippet}
|
|
251
|
+
|
|
252
|
+
{#if assetQuery.isPending}
|
|
253
|
+
<div class="loading">
|
|
254
|
+
<Skeleton height="14px" width="55%" />
|
|
255
|
+
<Skeleton height="14px" width="72%" />
|
|
256
|
+
<Skeleton height="14px" width="40%" />
|
|
257
|
+
<Skeleton height="14px" width="64%" />
|
|
258
|
+
</div>
|
|
259
|
+
{:else if assetQuery.isError || !asset}
|
|
260
|
+
<EmptyState bare icon="triangle-alert" title={t('asset_error')}>
|
|
261
|
+
{#snippet actions()}
|
|
262
|
+
<Button size="sm" variant="secondary" onclick={() => void assetQuery.refetch()}>
|
|
263
|
+
{t('common.retry')}
|
|
264
|
+
</Button>
|
|
265
|
+
{/snippet}
|
|
266
|
+
</EmptyState>
|
|
267
|
+
{:else}
|
|
268
|
+
<div class="status-line">
|
|
269
|
+
{#if asset.archivedAt}
|
|
270
|
+
<Badge tone="grey">{t('archived')}</Badge>
|
|
271
|
+
{:else}
|
|
272
|
+
<Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
|
|
273
|
+
{/if}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<!-- The tab strip scrolls rather than pushing the panel sideways: five pills in German do not
|
|
277
|
+
fit 440px, and a page that scrolls horizontally is a defect the e2e sweep fails on. -->
|
|
278
|
+
<Tabs class="panel-tabs" items={TABS} bind:value={tab} label={t('title')}>
|
|
279
|
+
{#snippet children(pane)}
|
|
280
|
+
{#if pane === 'details'}
|
|
281
|
+
<!-- The photo is a field of the asset, so it sits with the asset's other fields rather
|
|
282
|
+
than in Files — which a workspace can switch off, and the photo is not part of it. -->
|
|
283
|
+
<div class="photo-slot">
|
|
284
|
+
<AssetPhoto {workspaceId} {asset} canManage={canManage} />
|
|
285
|
+
</div>
|
|
286
|
+
<dl class="facts">
|
|
287
|
+
{#each facts as fact (fact.label)}
|
|
288
|
+
<dt>{fact.label}</dt>
|
|
289
|
+
<dd class:ltr={fact.ltr}>{fact.value}</dd>
|
|
290
|
+
{/each}
|
|
291
|
+
</dl>
|
|
292
|
+
{#if asset.description}
|
|
293
|
+
<p class="description">{asset.description}</p>
|
|
294
|
+
{/if}
|
|
295
|
+
{:else if pane === 'custody'}
|
|
296
|
+
<div class="custody">
|
|
297
|
+
<div class="holder">
|
|
298
|
+
{#if asset.custodianUserId}
|
|
299
|
+
<Avatar name={holder?.name ?? null} id={asset.custodianUserId} size={32} />
|
|
300
|
+
<div class="holder-text">
|
|
301
|
+
<span class="holder-label">{t('custody_holder')}</span>
|
|
302
|
+
<span class="holder-name">{holderName}</span>
|
|
303
|
+
{#if asset.custodySince}
|
|
304
|
+
<span class="holder-since">
|
|
305
|
+
{t('custody_since', isolated({ date: formatDate(asset.custodySince) }))}
|
|
306
|
+
</span>
|
|
307
|
+
{/if}
|
|
308
|
+
</div>
|
|
309
|
+
{:else}
|
|
310
|
+
<div class="holder-text">
|
|
311
|
+
<span class="holder-label">{t('custody_holder')}</span>
|
|
312
|
+
<span class="holder-name">{t('custody_nobody')}</span>
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{#if canCustody && asset.archivedAt}
|
|
318
|
+
<!-- A disabled control with no explanation is a bug; one sentence says why instead. -->
|
|
319
|
+
<p class="hint">{t('custody_archived_hint')}</p>
|
|
320
|
+
{:else if available.length}
|
|
321
|
+
<div class="acts">
|
|
322
|
+
{#each available as verb (verb)}
|
|
323
|
+
<Button
|
|
324
|
+
size="sm"
|
|
325
|
+
variant={verb === 'return' ? 'secondary' : 'primary'}
|
|
326
|
+
icon={verb === 'assign' ? 'user-plus' : verb === 'transfer' ? 'arrow-right' : 'undo-2'}
|
|
327
|
+
onclick={() => (action = verb)}
|
|
328
|
+
>
|
|
329
|
+
{t(`custody_${verb}`)}
|
|
330
|
+
</Button>
|
|
331
|
+
{/each}
|
|
332
|
+
</div>
|
|
333
|
+
{/if}
|
|
334
|
+
|
|
335
|
+
<h3 class="section">{t('custody_previous')}</h3>
|
|
336
|
+
{#if custodyQuery.isPending}
|
|
337
|
+
<Skeleton height="40px" radius="8px" />
|
|
338
|
+
{:else if custodyQuery.isError}
|
|
339
|
+
<EmptyState bare compact icon="triangle-alert" title={t('custody_error')}>
|
|
340
|
+
{#snippet actions()}
|
|
341
|
+
<Button size="sm" variant="secondary" onclick={() => void custodyQuery.refetch()}>
|
|
342
|
+
{t('common.retry')}
|
|
343
|
+
</Button>
|
|
344
|
+
{/snippet}
|
|
345
|
+
</EmptyState>
|
|
346
|
+
{:else if previous.length === 0}
|
|
347
|
+
<EmptyState
|
|
348
|
+
bare
|
|
349
|
+
compact
|
|
350
|
+
icon="user"
|
|
351
|
+
title={t('custody_empty')}
|
|
352
|
+
description={t('custody_empty_desc')}
|
|
353
|
+
/>
|
|
354
|
+
{:else}
|
|
355
|
+
<ul class="periods">
|
|
356
|
+
{#each previous as period (period.id)}
|
|
357
|
+
{@const holder = resolveName(period.userId, dir)}
|
|
358
|
+
{@const who = nameOf(period.userId, dir, words)}
|
|
359
|
+
<li class="period">
|
|
360
|
+
<!-- Initials only for somebody resolved; the colour is seeded from the id
|
|
361
|
+
either way, so the square does not change when the list arrives. -->
|
|
362
|
+
<Avatar name={holder.name} id={period.userId} size={22} />
|
|
363
|
+
<div class="period-text">
|
|
364
|
+
<span class="period-who">{who}</span>
|
|
365
|
+
<!-- A range through `Intl`, never two dates and a dash: hand-built it reads
|
|
366
|
+
backwards in RTL, earliest date to the right of the latest. -->
|
|
367
|
+
<span class="period-when">
|
|
368
|
+
{formatDateRange(period.effectiveFrom, period.effectiveTo as string)}
|
|
369
|
+
</span>
|
|
370
|
+
{#if period.note}<span class="period-note">{period.note}</span>{/if}
|
|
371
|
+
</div>
|
|
372
|
+
</li>
|
|
373
|
+
{/each}
|
|
374
|
+
</ul>
|
|
375
|
+
{/if}
|
|
376
|
+
</div>
|
|
377
|
+
{:else if pane === 'repairs'}
|
|
378
|
+
<RepairsSection
|
|
379
|
+
{workspaceId}
|
|
380
|
+
{asset}
|
|
381
|
+
canManage={canRepair}
|
|
382
|
+
canAttach={canManage}
|
|
383
|
+
{hasFiles}
|
|
384
|
+
onact={openRepair}
|
|
385
|
+
/>
|
|
386
|
+
{:else if pane === 'files'}
|
|
387
|
+
<div class="files-pane">
|
|
388
|
+
<AttachmentsSection
|
|
389
|
+
{workspaceId}
|
|
390
|
+
assetId={asset.id}
|
|
391
|
+
repairId={null}
|
|
392
|
+
canManage={canManage}
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
395
|
+
{:else}
|
|
396
|
+
<Timeline
|
|
397
|
+
{workspaceId}
|
|
398
|
+
assetId={asset.id}
|
|
399
|
+
{dir}
|
|
400
|
+
{categoryName}
|
|
401
|
+
currency={asset.currency}
|
|
402
|
+
/>
|
|
403
|
+
{/if}
|
|
404
|
+
{/snippet}
|
|
405
|
+
</Tabs>
|
|
406
|
+
{/if}
|
|
407
|
+
</Sheet>
|
|
408
|
+
|
|
409
|
+
<CustodyDialog
|
|
410
|
+
{workspaceId}
|
|
411
|
+
asset={action ? asset : null}
|
|
412
|
+
{action}
|
|
413
|
+
{members}
|
|
414
|
+
{holderName}
|
|
415
|
+
onclose={() => (action = null)}
|
|
416
|
+
/>
|
|
417
|
+
|
|
418
|
+
<RepairDialog
|
|
419
|
+
{workspaceId}
|
|
420
|
+
asset={repairAction ? asset : null}
|
|
421
|
+
repair={repairRow}
|
|
422
|
+
action={repairAction}
|
|
423
|
+
onclose={() => {
|
|
424
|
+
repairAction = null
|
|
425
|
+
repairRow = null
|
|
426
|
+
}}
|
|
427
|
+
/>
|
|
428
|
+
|
|
429
|
+
<style>
|
|
430
|
+
.head {
|
|
431
|
+
display: flex;
|
|
432
|
+
flex-direction: column;
|
|
433
|
+
gap: 2px;
|
|
434
|
+
min-width: 0;
|
|
435
|
+
}
|
|
436
|
+
.tag {
|
|
437
|
+
font-family: var(--kern-font-mono);
|
|
438
|
+
font-size: 11.5px;
|
|
439
|
+
color: var(--kern-ink-500);
|
|
440
|
+
/* A tag is read character by character off a sticker, so it stays left-to-right inside a
|
|
441
|
+
Persian or Arabic panel and the bidi algorithm does not reorder its digits. */
|
|
442
|
+
direction: ltr;
|
|
443
|
+
unicode-bidi: isolate;
|
|
444
|
+
}
|
|
445
|
+
.name {
|
|
446
|
+
margin: 0;
|
|
447
|
+
font-size: 15px;
|
|
448
|
+
font-weight: 600;
|
|
449
|
+
line-height: 1.35;
|
|
450
|
+
color: var(--kern-ink-900);
|
|
451
|
+
overflow: hidden;
|
|
452
|
+
text-overflow: ellipsis;
|
|
453
|
+
white-space: nowrap;
|
|
454
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
455
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
456
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
457
|
+
unicode-bidi: plaintext;
|
|
458
|
+
}
|
|
459
|
+
.loading {
|
|
460
|
+
display: flex;
|
|
461
|
+
flex-direction: column;
|
|
462
|
+
gap: 12px;
|
|
463
|
+
}
|
|
464
|
+
.status-line {
|
|
465
|
+
margin-bottom: 12px;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Five pills do not fit 440px in German, and a document that scrolls sideways is a defect the
|
|
469
|
+
* end-to-end sweep fails on. Wide content scrolls inside its own box instead; the scrollbar is
|
|
470
|
+
* hidden because the pills already say there are more of them.
|
|
471
|
+
*/
|
|
472
|
+
:global(.panel-tabs .ktabs-list) {
|
|
473
|
+
overflow-x: auto;
|
|
474
|
+
scrollbar-width: none;
|
|
475
|
+
}
|
|
476
|
+
:global(.panel-tabs .ktabs-list::-webkit-scrollbar) {
|
|
477
|
+
display: none;
|
|
478
|
+
}
|
|
479
|
+
.photo-slot {
|
|
480
|
+
margin-top: 14px;
|
|
481
|
+
}
|
|
482
|
+
.files-pane {
|
|
483
|
+
margin-top: 14px;
|
|
484
|
+
}
|
|
485
|
+
.facts {
|
|
486
|
+
display: grid;
|
|
487
|
+
grid-template-columns: minmax(96px, auto) minmax(0, 1fr);
|
|
488
|
+
gap: 8px 14px;
|
|
489
|
+
margin: 14px 0 0;
|
|
490
|
+
font-size: 13px;
|
|
491
|
+
}
|
|
492
|
+
dt {
|
|
493
|
+
color: var(--kern-ink-500);
|
|
494
|
+
}
|
|
495
|
+
dd {
|
|
496
|
+
margin: 0;
|
|
497
|
+
color: var(--kern-ink-900);
|
|
498
|
+
overflow-wrap: anywhere;
|
|
499
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
500
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
501
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
502
|
+
unicode-bidi: plaintext;
|
|
503
|
+
}
|
|
504
|
+
.ltr {
|
|
505
|
+
direction: ltr;
|
|
506
|
+
unicode-bidi: isolate;
|
|
507
|
+
}
|
|
508
|
+
.description {
|
|
509
|
+
margin: 16px 0 0;
|
|
510
|
+
font-size: 13px;
|
|
511
|
+
line-height: 1.6;
|
|
512
|
+
color: var(--kern-ink-700);
|
|
513
|
+
white-space: pre-wrap;
|
|
514
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
515
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
516
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
517
|
+
unicode-bidi: plaintext;
|
|
518
|
+
}
|
|
519
|
+
.custody {
|
|
520
|
+
display: flex;
|
|
521
|
+
flex-direction: column;
|
|
522
|
+
gap: 14px;
|
|
523
|
+
margin-top: 14px;
|
|
524
|
+
}
|
|
525
|
+
.holder {
|
|
526
|
+
display: flex;
|
|
527
|
+
align-items: center;
|
|
528
|
+
gap: 10px;
|
|
529
|
+
}
|
|
530
|
+
.holder-text {
|
|
531
|
+
display: flex;
|
|
532
|
+
flex-direction: column;
|
|
533
|
+
gap: 1px;
|
|
534
|
+
min-width: 0;
|
|
535
|
+
}
|
|
536
|
+
.holder-label {
|
|
537
|
+
font-size: 11.5px;
|
|
538
|
+
color: var(--kern-ink-500);
|
|
539
|
+
}
|
|
540
|
+
.holder-name {
|
|
541
|
+
font-size: 13.5px;
|
|
542
|
+
font-weight: 500;
|
|
543
|
+
color: var(--kern-ink-900);
|
|
544
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
545
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
546
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
547
|
+
unicode-bidi: plaintext;
|
|
548
|
+
}
|
|
549
|
+
.holder-since {
|
|
550
|
+
font-size: 11.5px;
|
|
551
|
+
color: var(--kern-ink-500);
|
|
552
|
+
}
|
|
553
|
+
.hint {
|
|
554
|
+
margin: 0;
|
|
555
|
+
font-size: 12.5px;
|
|
556
|
+
line-height: 1.5;
|
|
557
|
+
/* Muted with a colour; opacity would fade the sentence against the panel. */
|
|
558
|
+
color: var(--kern-ink-500);
|
|
559
|
+
}
|
|
560
|
+
.acts {
|
|
561
|
+
display: flex;
|
|
562
|
+
gap: 8px;
|
|
563
|
+
flex-wrap: wrap;
|
|
564
|
+
}
|
|
565
|
+
.section {
|
|
566
|
+
margin: 4px 0 0;
|
|
567
|
+
font-size: 11.5px;
|
|
568
|
+
font-weight: 600;
|
|
569
|
+
letter-spacing: 0.04em;
|
|
570
|
+
text-transform: uppercase;
|
|
571
|
+
color: var(--kern-ink-500);
|
|
572
|
+
}
|
|
573
|
+
.periods {
|
|
574
|
+
display: flex;
|
|
575
|
+
flex-direction: column;
|
|
576
|
+
gap: 12px;
|
|
577
|
+
margin: 0;
|
|
578
|
+
padding: 0;
|
|
579
|
+
list-style: none;
|
|
580
|
+
}
|
|
581
|
+
.period {
|
|
582
|
+
display: grid;
|
|
583
|
+
grid-template-columns: 22px minmax(0, 1fr);
|
|
584
|
+
gap: 10px;
|
|
585
|
+
align-items: start;
|
|
586
|
+
}
|
|
587
|
+
.period-text {
|
|
588
|
+
display: flex;
|
|
589
|
+
flex-direction: column;
|
|
590
|
+
gap: 1px;
|
|
591
|
+
min-width: 0;
|
|
592
|
+
}
|
|
593
|
+
.period-who {
|
|
594
|
+
font-size: 13px;
|
|
595
|
+
color: var(--kern-ink-900);
|
|
596
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
597
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
598
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
599
|
+
unicode-bidi: plaintext;
|
|
600
|
+
}
|
|
601
|
+
.period-when {
|
|
602
|
+
font-size: 11.5px;
|
|
603
|
+
color: var(--kern-ink-500);
|
|
604
|
+
}
|
|
605
|
+
.period-note {
|
|
606
|
+
font-size: 12.5px;
|
|
607
|
+
line-height: 1.5;
|
|
608
|
+
color: var(--kern-ink-600);
|
|
609
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
610
|
+
first strong character, so a Latin name inside a Persian panel reads left to right and keeps
|
|
611
|
+
its own trailing punctuation instead of donating it to the paragraph. */
|
|
612
|
+
unicode-bidi: plaintext;
|
|
613
|
+
}
|
|
614
|
+
</style>
|