@kernhq/module-inventory 0.1.2 → 0.2.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 +55 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +52 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +33 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +22 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +146 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +107 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +22 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +26 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +42 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +18 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +29 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +4 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +9 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
- package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
- package/dist/server/router.js +83 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +25 -11
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +28 -10
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +73 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +261 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/index.d.ts +10 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +15 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/notify.d.ts +63 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +105 -0
- package/dist/server/services/notify.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/_journal.json +2 -2
- package/package.json +5 -4
- package/src/client/api.ts +1 -1
- package/src/client/components/AssetFormDialog.svelte +130 -47
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/messages.test.ts +169 -0
- package/src/client/messages.ts +399 -0
- package/src/client/mock.test.ts +161 -0
- package/src/client/mock.ts +267 -45
- package/src/client/module.ts +22 -2
- package/src/client/pages/AssetsPage.svelte +358 -137
- package/src/client/permissions.ts +1 -1
- package/src/client/price.test.ts +106 -0
- package/src/client/price.ts +135 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +15 -2
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/settings/core-api.ts +32 -0
- package/src/client/widgets/OverviewWidget.svelte +16 -3
- package/src/contract/capabilities.ts +55 -0
- package/src/contract/events.ts +34 -0
- package/src/contract/index.ts +15 -0
- package/src/contract/models.ts +123 -0
- package/src/contract/permissions.ts +26 -0
- package/src/contract/router.ts +46 -0
- package/src/contract/settings.ts +30 -0
- package/src/module.test.ts +140 -7
- package/src/server/index.ts +16 -13
- package/src/server/inventory.int.test.ts +819 -0
- package/src/server/migrations.test.ts +138 -0
- package/src/server/router.ts +118 -0
- package/src/server/schema.ts +27 -10
- package/src/server/services/assets.ts +368 -0
- package/src/server/services/index.ts +23 -0
- package/src/server/services/notify.ts +151 -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.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.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
|
@@ -3,20 +3,31 @@ import {
|
|
|
3
3
|
Badge,
|
|
4
4
|
type BadgeTone,
|
|
5
5
|
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
DropdownMenu,
|
|
6
8
|
EmptyState,
|
|
9
|
+
IconButton,
|
|
7
10
|
Input,
|
|
11
|
+
type MenuItem,
|
|
12
|
+
Page,
|
|
13
|
+
PageHeader,
|
|
8
14
|
Select,
|
|
9
15
|
type SelectOption,
|
|
10
16
|
Skeleton,
|
|
11
|
-
|
|
12
|
-
StatTile,
|
|
17
|
+
Switch,
|
|
13
18
|
session,
|
|
19
|
+
Table,
|
|
20
|
+
TableCell,
|
|
21
|
+
TableHeader,
|
|
22
|
+
TableRow,
|
|
23
|
+
toast,
|
|
14
24
|
} from '@kernhq/ui'
|
|
15
|
-
import {
|
|
25
|
+
import { createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
26
|
+
import type { Asset, AssetStatus } from '../../contract/index.js'
|
|
16
27
|
import { getInventoryApi } from '../api-instance.js'
|
|
17
28
|
import AssetFormDialog from '../components/AssetFormDialog.svelte'
|
|
18
29
|
import { t } from '../i18n.js'
|
|
19
|
-
import { canInventory
|
|
30
|
+
import { canInventory } from '../permissions.js'
|
|
20
31
|
import { inventoryKeys } from '../query.js'
|
|
21
32
|
|
|
22
33
|
/**
|
|
@@ -35,55 +46,107 @@ interface Props {
|
|
|
35
46
|
const { workspaceId }: Props = $props()
|
|
36
47
|
|
|
37
48
|
const api = getInventoryApi()
|
|
49
|
+
const queryClient = useQueryClient()
|
|
38
50
|
|
|
39
|
-
let
|
|
51
|
+
let searchText = $state('')
|
|
40
52
|
let statusFilter = $state<string>('')
|
|
41
53
|
let showArchived = $state(false)
|
|
42
54
|
let dialogOpen = $state(false)
|
|
55
|
+
/** The row the dialog is editing, or null when it is adding one. */
|
|
56
|
+
let editingAsset = $state<Asset | null>(null)
|
|
57
|
+
/** The row awaiting an archive confirmation. */
|
|
58
|
+
let archiving = $state<Asset | null>(null)
|
|
43
59
|
|
|
44
60
|
// Debounce the search into the cache key, so typing does not refetch per keystroke.
|
|
45
|
-
let
|
|
61
|
+
let query = $state('')
|
|
46
62
|
$effect(() => {
|
|
47
|
-
const value =
|
|
48
|
-
const timer = setTimeout(() => (
|
|
63
|
+
const value = searchText
|
|
64
|
+
const timer = setTimeout(() => (query = value), 250)
|
|
49
65
|
return () => clearTimeout(timer)
|
|
50
66
|
})
|
|
51
67
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Every filter is part of the key and part of the request.
|
|
70
|
+
*
|
|
71
|
+
* Filtering in the browser is only ever right for a list that is entirely loaded, and this one is
|
|
72
|
+
* paged: dropping archived rows client-side returned twenty rows, showed eleven, and made the list
|
|
73
|
+
* look short rather than paged.
|
|
74
|
+
*/
|
|
75
|
+
const filters = $derived({
|
|
76
|
+
...(query ? { q: query } : {}),
|
|
77
|
+
...(statusFilter ? { status: statusFilter as AssetStatus } : {}),
|
|
78
|
+
archived: showArchived,
|
|
58
79
|
})
|
|
59
80
|
|
|
60
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Whether anything is *narrowing* the list.
|
|
83
|
+
*
|
|
84
|
+
* The archived switch is not in here: it widens the list rather than narrowing it, so a workspace
|
|
85
|
+
* with no assets and the switch on is still empty rather than filtered, and offering to "clear" it
|
|
86
|
+
* would hide rows somebody just asked to see.
|
|
87
|
+
*/
|
|
88
|
+
const narrowed = $derived(Boolean(query) || Boolean(statusFilter))
|
|
89
|
+
|
|
90
|
+
function clearFilters() {
|
|
91
|
+
searchText = ''
|
|
92
|
+
query = ''
|
|
93
|
+
statusFilter = ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface AssetPage {
|
|
97
|
+
items: Asset[]
|
|
98
|
+
nextCursor: string | null
|
|
99
|
+
/** `page()` in `@kernhq/contracts` declares it; this module's server does not fill it yet. */
|
|
100
|
+
total?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const assetsQuery = createInfiniteQuery(() => ({
|
|
61
104
|
queryKey: inventoryKeys.assets(workspaceId, filters),
|
|
62
|
-
queryFn: () =>
|
|
105
|
+
queryFn: ({ pageParam }): Promise<AssetPage> =>
|
|
63
106
|
api.assets.list({
|
|
64
107
|
workspaceId,
|
|
65
|
-
...
|
|
66
|
-
...(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
...filters,
|
|
109
|
+
...(pageParam ? { cursor: pageParam as string } : {}),
|
|
110
|
+
}) as Promise<AssetPage>,
|
|
111
|
+
initialPageParam: undefined as string | undefined,
|
|
112
|
+
getNextPageParam: (last: AssetPage) => last.nextCursor ?? undefined,
|
|
70
113
|
enabled: Boolean(workspaceId),
|
|
71
114
|
}))
|
|
72
115
|
|
|
73
|
-
const assets = $derived(assetsQuery.data?.items ?? [])
|
|
74
|
-
const shownAssets = $derived(showArchived ? assets : assets.filter((a) => !a.archivedAt))
|
|
75
|
-
const counts = $derived({
|
|
76
|
-
total: assets.length,
|
|
77
|
-
assigned: assets.filter((a) => a.status === 'assigned').length,
|
|
78
|
-
inStock: assets.filter((a) => a.status === 'in_stock').length,
|
|
79
|
-
repair: assets.filter((a) => a.status === 'under_repair').length,
|
|
80
|
-
})
|
|
116
|
+
const assets = $derived(assetsQuery.data?.pages.flatMap((page) => page.items) ?? [])
|
|
81
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A count that stays true while the list pages.
|
|
120
|
+
*
|
|
121
|
+
* `t('count', { n: assets.length })` counted the pages *loaded*, so 120 assets read "50 assets" and
|
|
122
|
+
* then "100 assets" after Load more — a number that is simply wrong, on the line whose whole job is
|
|
123
|
+
* to be the number. When the server fills `total` that is the honest figure; until it does, a list
|
|
124
|
+
* with another page says how many it is *showing* rather than claiming that is all there is.
|
|
125
|
+
*/
|
|
126
|
+
const reportedTotal = $derived(assetsQuery.data?.pages.at(-1)?.total)
|
|
127
|
+
const countLine = $derived(
|
|
128
|
+
reportedTotal !== undefined
|
|
129
|
+
? t('count', { n: reportedTotal })
|
|
130
|
+
: assetsQuery.hasNextPage
|
|
131
|
+
? t('count_showing', { n: assets.length })
|
|
132
|
+
: t('count', { n: assets.length }),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every status the contract can hold, offered ahead of the features that set them.
|
|
137
|
+
*
|
|
138
|
+
* Until custody and repairs ship, nothing writes a status, so every asset is `in_stock` and the
|
|
139
|
+
* other five options correctly return nothing. That is the data being uniform rather than the
|
|
140
|
+
* filter being broken — and the alternative, hiding options and adding them back one release
|
|
141
|
+
* later, teaches somebody the list is unstable. The README says the same thing in as many words.
|
|
142
|
+
*/
|
|
82
143
|
const statusOptions: SelectOption[] = [
|
|
83
144
|
{ value: '', label: t('filter_all_statuses') },
|
|
84
145
|
{ value: 'in_stock', label: t('status_in_stock') },
|
|
85
146
|
{ value: 'assigned', label: t('status_assigned') },
|
|
147
|
+
{ value: 'reserved', label: t('status_reserved') },
|
|
86
148
|
{ value: 'under_repair', label: t('status_under_repair') },
|
|
149
|
+
{ value: 'lost', label: t('status_lost') },
|
|
87
150
|
{ value: 'retired', label: t('status_retired') },
|
|
88
151
|
]
|
|
89
152
|
|
|
@@ -91,157 +154,293 @@ function statusTone(status: string): BadgeTone {
|
|
|
91
154
|
switch (status) {
|
|
92
155
|
case 'assigned':
|
|
93
156
|
return 'info'
|
|
157
|
+
case 'reserved':
|
|
158
|
+
return 'info'
|
|
94
159
|
case 'under_repair':
|
|
95
160
|
return 'warning'
|
|
161
|
+
case 'lost':
|
|
162
|
+
return 'danger'
|
|
96
163
|
case 'retired':
|
|
97
164
|
return 'grey'
|
|
98
165
|
default:
|
|
99
166
|
return 'success'
|
|
100
167
|
}
|
|
101
168
|
}
|
|
102
|
-
const statusLabel = (status: string) => t(`status_${status}`)
|
|
103
|
-
</script>
|
|
104
169
|
|
|
105
|
-
|
|
170
|
+
// ---------------------------------------------------------------- row actions
|
|
106
171
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
172
|
+
/**
|
|
173
|
+
* `assets.update` and `assets.archive` had no entry point anywhere in this module.
|
|
174
|
+
*
|
|
175
|
+
* Three of five procedures were unreachable and the module had one screen, so an asset could be
|
|
176
|
+
* created and then never corrected — a typo in a name was permanent, and a laptop that left the
|
|
177
|
+
* company stayed in the register for ever.
|
|
178
|
+
*/
|
|
179
|
+
let acting = $state(false)
|
|
180
|
+
|
|
181
|
+
/** Archive and restore are one procedure, so they are one mutation and one busy flag. */
|
|
182
|
+
interface ArchiveVars {
|
|
183
|
+
assetId: string
|
|
184
|
+
archived: boolean
|
|
185
|
+
name: string
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const setArchived = createMutation(() => ({
|
|
189
|
+
mutationFn: (vars: ArchiveVars) =>
|
|
190
|
+
api.assets.archive({ workspaceId, assetId: vars.assetId, archived: vars.archived }),
|
|
191
|
+
onSuccess: (_saved: Asset, vars: ArchiveVars) => {
|
|
192
|
+
toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', { name: vars.name }))
|
|
193
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
194
|
+
archiving = null
|
|
195
|
+
},
|
|
196
|
+
onError: (error: Error) => toast.error(error.message || t('common.error')),
|
|
197
|
+
onSettled: () => {
|
|
198
|
+
acting = false
|
|
199
|
+
},
|
|
200
|
+
}))
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The guard is a plain flag set in the same tick as the click.
|
|
204
|
+
*
|
|
205
|
+
* `disabled={mutation.isPending}` reaches the button one render later, and two quick clicks are one
|
|
206
|
+
* render apart — so on a confirmation it files two decisions.
|
|
207
|
+
*/
|
|
208
|
+
function confirmArchive() {
|
|
209
|
+
const target = archiving
|
|
210
|
+
if (!target || acting) return
|
|
211
|
+
acting = true
|
|
212
|
+
setArchived.mutate({ assetId: target.id, archived: true, name: target.name })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function restore(asset: Asset) {
|
|
216
|
+
if (acting) return
|
|
217
|
+
acting = true
|
|
218
|
+
setArchived.mutate({ assetId: asset.id, archived: false, name: asset.name })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function openCreate() {
|
|
222
|
+
editingAsset = null
|
|
223
|
+
dialogOpen = true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function openEdit(asset: Asset) {
|
|
227
|
+
editingAsset = asset
|
|
228
|
+
dialogOpen = true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Restoring is not destructive and needs no confirmation; archiving is, and states what happens.
|
|
233
|
+
* Hidden rather than disabled for somebody without `manage`: they may never do it, so the menu is
|
|
234
|
+
* not there at all rather than being a door that will not open.
|
|
235
|
+
*/
|
|
236
|
+
function actionsFor(asset: Asset): MenuItem[] {
|
|
237
|
+
const items: MenuItem[] = [{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(asset) }]
|
|
238
|
+
if (asset.archivedAt) {
|
|
239
|
+
items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(asset) })
|
|
240
|
+
} else {
|
|
241
|
+
items.push({ type: 'separator' })
|
|
242
|
+
items.push({
|
|
243
|
+
label: t('common.archive'),
|
|
244
|
+
icon: 'archive',
|
|
245
|
+
danger: true,
|
|
246
|
+
onSelect: () => (archiving = asset),
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
return items
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const workspaceName = $derived(session.workspaces.find((w) => w.id === workspaceId)?.name ?? '')
|
|
253
|
+
const canManage = $derived(canInventory('manage'))
|
|
254
|
+
/** The last column carries the row menu, and only exists when there is a menu to carry. */
|
|
255
|
+
const COLUMNS = $derived(
|
|
256
|
+
canManage
|
|
257
|
+
? 'minmax(96px, 110px) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 132px 44px'
|
|
258
|
+
: 'minmax(96px, 110px) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 132px',
|
|
259
|
+
)
|
|
260
|
+
const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
261
|
+
</script>
|
|
262
|
+
|
|
263
|
+
<PageHeader crumbs={[{ label: workspaceName }, { label: t('title') }]} title={t('title')}>
|
|
264
|
+
{#snippet search()}
|
|
265
|
+
<Input
|
|
266
|
+
bind:value={searchText}
|
|
267
|
+
type="search"
|
|
268
|
+
size="sm"
|
|
269
|
+
placeholder={t('search_placeholder')}
|
|
270
|
+
aria-label={t('search_placeholder')}
|
|
271
|
+
/>
|
|
272
|
+
{/snippet}
|
|
273
|
+
{#snippet actions()}
|
|
274
|
+
<Select bind:value={statusFilter} options={statusOptions} size="sm" ariaLabel={t('status')} />
|
|
275
|
+
{#if canManage}
|
|
276
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('new')}</Button>
|
|
277
|
+
{/if}
|
|
278
|
+
{/snippet}
|
|
279
|
+
</PageHeader>
|
|
118
280
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
<StatTile size="md" label={t('status_under_repair')} value={String(counts.repair)} />
|
|
281
|
+
<Page>
|
|
282
|
+
<div class="bar">
|
|
283
|
+
<p class="count" aria-live="polite">{countLine}</p>
|
|
284
|
+
<Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
|
|
124
285
|
</div>
|
|
125
286
|
|
|
126
287
|
{#if assetsQuery.isPending}
|
|
127
|
-
|
|
288
|
+
<!-- Shaped like the table it is standing in for, not a spinner in the middle of a content
|
|
289
|
+
area: the page does not jump when the rows arrive, and the shape says what is coming. -->
|
|
290
|
+
<div class="skeleton">
|
|
291
|
+
{#each SKELETON_ROWS as row (row)}
|
|
292
|
+
<div class="srow" style:grid-template-columns={COLUMNS}>
|
|
293
|
+
<Skeleton height="12px" width="72%" />
|
|
294
|
+
<Skeleton height="12px" width="58%" />
|
|
295
|
+
<Skeleton height="12px" width="46%" />
|
|
296
|
+
<Skeleton height="12px" width="40%" />
|
|
297
|
+
<Skeleton height="18px" width="76px" radius="999px" />
|
|
298
|
+
{#if canManage}<Skeleton height="12px" width="16px" />{/if}
|
|
299
|
+
</div>
|
|
300
|
+
{/each}
|
|
301
|
+
</div>
|
|
128
302
|
{:else if assetsQuery.isError}
|
|
129
|
-
|
|
130
|
-
|
|
303
|
+
<!-- An error needs a message *and* a way out of it. This was a bare red sentence. -->
|
|
304
|
+
<EmptyState icon="triangle-alert" title={t('load_error')} description={t('common.error')}>
|
|
305
|
+
{#snippet actions()}
|
|
306
|
+
<Button variant="secondary" onclick={() => void assetsQuery.refetch()}>
|
|
307
|
+
{t('common.retry')}
|
|
308
|
+
</Button>
|
|
309
|
+
{/snippet}
|
|
310
|
+
</EmptyState>
|
|
311
|
+
{:else if assets.length === 0 && narrowed}
|
|
312
|
+
<!-- A search that matched nothing used to say "No assets yet · Add the first laptop…" beside a
|
|
313
|
+
New asset button — the wrong sentence and the wrong action for a workspace full of them. -->
|
|
314
|
+
<EmptyState icon="search" title={t('no_matches')} description={t('no_matches_desc')}>
|
|
315
|
+
{#snippet actions()}
|
|
316
|
+
<Button variant="secondary" onclick={clearFilters}>{t('clear_filters')}</Button>
|
|
317
|
+
{/snippet}
|
|
318
|
+
</EmptyState>
|
|
319
|
+
{:else if assets.length === 0}
|
|
131
320
|
<EmptyState icon="briefcase" title={t('empty')} description={t('empty_desc')}>
|
|
132
321
|
{#snippet actions()}
|
|
133
|
-
{#if
|
|
134
|
-
<Button onclick={
|
|
322
|
+
{#if canManage}
|
|
323
|
+
<Button onclick={openCreate}>{t('new')}</Button>
|
|
135
324
|
{/if}
|
|
136
325
|
{/snippet}
|
|
137
326
|
</EmptyState>
|
|
138
327
|
{:else}
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
<
|
|
146
|
-
<
|
|
147
|
-
</
|
|
148
|
-
{#each
|
|
149
|
-
<
|
|
150
|
-
<
|
|
151
|
-
<
|
|
328
|
+
<Table columns={COLUMNS} ariaLabel={t('title')}>
|
|
329
|
+
<TableHeader>
|
|
330
|
+
<TableCell header>{t('code')}</TableCell>
|
|
331
|
+
<TableCell header>{t('name')}</TableCell>
|
|
332
|
+
<TableCell header>{t('location')}</TableCell>
|
|
333
|
+
<TableCell header>{t('warranty_until')}</TableCell>
|
|
334
|
+
<TableCell header>{t('status')}</TableCell>
|
|
335
|
+
{#if canManage}<TableCell header end></TableCell>{/if}
|
|
336
|
+
</TableHeader>
|
|
337
|
+
{#each assets as asset (asset.id)}
|
|
338
|
+
<TableRow>
|
|
339
|
+
<TableCell><code>{asset.code}</code></TableCell>
|
|
340
|
+
<TableCell>
|
|
152
341
|
<span class="stack">
|
|
153
342
|
<span class="name">{asset.name}</span>
|
|
154
|
-
|
|
343
|
+
<!-- `S/N` was a literal English abbreviation sitting in the middle of a Persian,
|
|
344
|
+
Arabic, German or Turkish table; `serial_number` is translated in all five. -->
|
|
345
|
+
{#if asset.serialNumber}
|
|
346
|
+
<span class="sub">
|
|
347
|
+
{t('serial_number')}:
|
|
348
|
+
<span class="ltr">{asset.serialNumber}</span>
|
|
349
|
+
</span>
|
|
350
|
+
{/if}
|
|
155
351
|
</span>
|
|
156
|
-
</
|
|
157
|
-
<span class="
|
|
158
|
-
<span class="
|
|
159
|
-
<
|
|
352
|
+
</TableCell>
|
|
353
|
+
<TableCell><span class="muted">{asset.location ?? '—'}</span></TableCell>
|
|
354
|
+
<TableCell><span class="muted">{asset.warrantyUntil ?? '—'}</span></TableCell>
|
|
355
|
+
<TableCell>
|
|
160
356
|
{#if asset.archivedAt}
|
|
161
357
|
<Badge tone="grey">{t('archived')}</Badge>
|
|
162
358
|
{:else}
|
|
163
|
-
<Badge tone={statusTone(asset.status)}>{
|
|
359
|
+
<Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
|
|
164
360
|
{/if}
|
|
165
|
-
</
|
|
166
|
-
|
|
361
|
+
</TableCell>
|
|
362
|
+
{#if canManage}
|
|
363
|
+
<TableCell end>
|
|
364
|
+
<DropdownMenu items={actionsFor(asset)} align="end">
|
|
365
|
+
{#snippet trigger(props)}
|
|
366
|
+
<IconButton
|
|
367
|
+
{...props}
|
|
368
|
+
icon="ellipsis"
|
|
369
|
+
size={28}
|
|
370
|
+
label={t('row_actions', { name: asset.name })}
|
|
371
|
+
/>
|
|
372
|
+
{/snippet}
|
|
373
|
+
</DropdownMenu>
|
|
374
|
+
</TableCell>
|
|
375
|
+
{/if}
|
|
376
|
+
</TableRow>
|
|
167
377
|
{/each}
|
|
168
|
-
</
|
|
378
|
+
</Table>
|
|
379
|
+
|
|
380
|
+
{#if assetsQuery.hasNextPage}
|
|
381
|
+
<div class="more">
|
|
382
|
+
<Button
|
|
383
|
+
size="sm"
|
|
384
|
+
variant="secondary"
|
|
385
|
+
disabled={assetsQuery.isFetchingNextPage}
|
|
386
|
+
onclick={() => assetsQuery.fetchNextPage()}
|
|
387
|
+
>
|
|
388
|
+
{assetsQuery.isFetchingNextPage ? t('common.loading') : t('load_more')}
|
|
389
|
+
</Button>
|
|
390
|
+
</div>
|
|
391
|
+
{/if}
|
|
169
392
|
{/if}
|
|
170
|
-
</
|
|
393
|
+
</Page>
|
|
394
|
+
|
|
395
|
+
<AssetFormDialog bind:open={dialogOpen} {workspaceId} asset={editingAsset} />
|
|
171
396
|
|
|
172
|
-
|
|
397
|
+
<!-- Archiving states what happens and to what, rather than asking "Are you sure?". -->
|
|
398
|
+
<Dialog
|
|
399
|
+
open={archiving !== null}
|
|
400
|
+
size="sm"
|
|
401
|
+
title={t('archive_title', { name: archiving?.name ?? '' })}
|
|
402
|
+
onOpenChange={(next) => {
|
|
403
|
+
if (!next) archiving = null
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
<p class="dialog-body">{t('archive_body')}</p>
|
|
407
|
+
{#snippet footer()}
|
|
408
|
+
<Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
|
|
409
|
+
<Button variant="danger" onclick={confirmArchive} loading={acting}>{t('common.archive')}</Button>
|
|
410
|
+
{/snippet}
|
|
411
|
+
</Dialog>
|
|
173
412
|
|
|
174
413
|
<style>
|
|
175
|
-
|
|
414
|
+
.bar {
|
|
176
415
|
display: flex;
|
|
177
416
|
align-items: center;
|
|
178
417
|
justify-content: space-between;
|
|
179
418
|
gap: 12px;
|
|
180
419
|
flex-wrap: wrap;
|
|
181
|
-
|
|
182
|
-
h1 {
|
|
183
|
-
font-size: 18px;
|
|
184
|
-
font-weight: 600;
|
|
185
|
-
color: var(--kern-ink-900);
|
|
186
|
-
}
|
|
187
|
-
.actions {
|
|
188
|
-
display: flex;
|
|
189
|
-
align-items: center;
|
|
190
|
-
gap: 8px;
|
|
191
|
-
}
|
|
192
|
-
.tiles {
|
|
193
|
-
display: grid;
|
|
194
|
-
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
195
|
-
gap: 10px;
|
|
196
|
-
margin-top: 14px;
|
|
420
|
+
margin-bottom: 8px;
|
|
197
421
|
}
|
|
198
422
|
.count {
|
|
199
|
-
margin: 14px 0 6px;
|
|
200
423
|
font-size: 12px;
|
|
201
|
-
color: var(--kern-ink-
|
|
202
|
-
}
|
|
203
|
-
.error {
|
|
204
|
-
color: var(--kern-danger);
|
|
205
|
-
font-size: 13px;
|
|
424
|
+
color: var(--kern-ink-280);
|
|
206
425
|
}
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
border: 1px solid var(--kern-line);
|
|
210
|
-
border-radius: 10px;
|
|
211
|
-
overflow: hidden;
|
|
212
|
-
background: var(--kern-paper);
|
|
213
|
-
}
|
|
214
|
-
.thead,
|
|
215
|
-
.trow {
|
|
216
|
-
display: grid;
|
|
217
|
-
grid-template-columns: 110px minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 120px;
|
|
218
|
-
gap: 10px;
|
|
219
|
-
align-items: center;
|
|
220
|
-
padding: 8px 12px;
|
|
221
|
-
}
|
|
222
|
-
.thead {
|
|
223
|
-
font-size: 11px;
|
|
224
|
-
text-transform: uppercase;
|
|
225
|
-
letter-spacing: 0.04em;
|
|
226
|
-
color: var(--kern-ink-500);
|
|
227
|
-
background: var(--kern-canvas-subtle, transparent);
|
|
228
|
-
border-bottom: 1px solid var(--kern-line);
|
|
229
|
-
}
|
|
230
|
-
.trow {
|
|
231
|
-
border-bottom: 1px solid var(--kern-line);
|
|
232
|
-
font-size: 13px;
|
|
233
|
-
}
|
|
234
|
-
.trow:last-child {
|
|
235
|
-
border-bottom: none;
|
|
236
|
-
}
|
|
237
|
-
.cell {
|
|
238
|
-
min-width: 0;
|
|
426
|
+
.muted {
|
|
427
|
+
color: var(--kern-ink-280);
|
|
239
428
|
}
|
|
240
429
|
code {
|
|
241
430
|
font-size: 12px;
|
|
242
|
-
color: var(--kern-ink-
|
|
431
|
+
color: var(--kern-ink-600);
|
|
432
|
+
}
|
|
433
|
+
/* A tag and a serial are Latin identifiers read character by character. Inside a Persian or
|
|
434
|
+
Arabic row the bidi algorithm will happily reorder a mixed letter-and-digit run, so they are
|
|
435
|
+
isolated and left-to-right — the characters have to match what is printed on the sticker. */
|
|
436
|
+
code,
|
|
437
|
+
.ltr {
|
|
438
|
+
direction: ltr;
|
|
439
|
+
/* `isolate` works on an inline box; `inline-block` would also work and would cost the
|
|
440
|
+
ellipsis on the line above it. */
|
|
441
|
+
unicode-bidi: isolate;
|
|
243
442
|
}
|
|
244
|
-
.
|
|
443
|
+
.stack {
|
|
245
444
|
display: flex;
|
|
246
445
|
flex-direction: column;
|
|
247
446
|
min-width: 0;
|
|
@@ -255,12 +454,34 @@ const statusLabel = (status: string) => t(`status_${status}`)
|
|
|
255
454
|
}
|
|
256
455
|
.sub {
|
|
257
456
|
font-size: 11px;
|
|
258
|
-
|
|
457
|
+
/* Muted with a colour, never with opacity: a faded row at 0.5 is unreadable whatever its token. */
|
|
458
|
+
color: var(--kern-ink-280);
|
|
259
459
|
overflow: hidden;
|
|
260
460
|
text-overflow: ellipsis;
|
|
261
461
|
white-space: nowrap;
|
|
262
462
|
}
|
|
263
|
-
.
|
|
264
|
-
|
|
463
|
+
.skeleton {
|
|
464
|
+
display: flex;
|
|
465
|
+
flex-direction: column;
|
|
466
|
+
gap: 1px;
|
|
467
|
+
}
|
|
468
|
+
.srow {
|
|
469
|
+
display: grid;
|
|
470
|
+
align-items: center;
|
|
471
|
+
gap: 12px;
|
|
472
|
+
/* The row heights of `Table`, so nothing shifts when the real rows replace these. */
|
|
473
|
+
padding: 14px 12px;
|
|
474
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
475
|
+
}
|
|
476
|
+
.more {
|
|
477
|
+
display: flex;
|
|
478
|
+
justify-content: center;
|
|
479
|
+
padding: 16px 0;
|
|
480
|
+
}
|
|
481
|
+
.dialog-body {
|
|
482
|
+
margin: 0;
|
|
483
|
+
font-size: 13.5px;
|
|
484
|
+
line-height: 1.55;
|
|
485
|
+
color: var(--kern-ink-700);
|
|
265
486
|
}
|
|
266
487
|
</style>
|