@kernhq/module-inventory 0.1.1 → 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.
Files changed (103) hide show
  1. package/README.md +55 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +52 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +33 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +22 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +15 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +15 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +146 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +107 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/permissions.d.ts +22 -0
  19. package/dist/contract/permissions.d.ts.map +1 -0
  20. package/dist/contract/permissions.js +26 -0
  21. package/dist/contract/permissions.js.map +1 -0
  22. package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
  23. package/dist/contract/router.d.ts.map +1 -0
  24. package/dist/contract/router.js +42 -0
  25. package/dist/contract/router.js.map +1 -0
  26. package/dist/contract/settings.d.ts +18 -0
  27. package/dist/contract/settings.d.ts.map +1 -0
  28. package/dist/contract/settings.js +29 -0
  29. package/dist/contract/settings.js.map +1 -0
  30. package/dist/server/index.d.ts +4 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +9 -13
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
  35. package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
  36. package/dist/server/router.js +83 -0
  37. package/dist/server/router.js.map +1 -0
  38. package/dist/server/schema.d.ts +25 -11
  39. package/dist/server/schema.d.ts.map +1 -1
  40. package/dist/server/schema.js +28 -10
  41. package/dist/server/schema.js.map +1 -1
  42. package/dist/server/services/assets.d.ts +73 -0
  43. package/dist/server/services/assets.d.ts.map +1 -0
  44. package/dist/server/services/assets.js +261 -0
  45. package/dist/server/services/assets.js.map +1 -0
  46. package/dist/server/services/index.d.ts +10 -0
  47. package/dist/server/services/index.d.ts.map +1 -0
  48. package/dist/server/services/index.js +15 -0
  49. package/dist/server/services/index.js.map +1 -0
  50. package/dist/server/services/notify.d.ts +63 -0
  51. package/dist/server/services/notify.d.ts.map +1 -0
  52. package/dist/server/services/notify.js +105 -0
  53. package/dist/server/services/notify.js.map +1 -0
  54. package/migrations/0000_init.sql +12 -3
  55. package/migrations/0001_rls.sql +24 -0
  56. package/migrations/meta/0000_snapshot.json +40 -13
  57. package/migrations/meta/_journal.json +2 -2
  58. package/package.json +12 -9
  59. package/src/client/api.ts +1 -1
  60. package/src/client/components/AssetFormDialog.svelte +130 -47
  61. package/src/client/i18n.ts +11 -166
  62. package/src/client/index.ts +8 -1
  63. package/src/client/messages.test.ts +169 -0
  64. package/src/client/messages.ts +399 -0
  65. package/src/client/mock.test.ts +161 -0
  66. package/src/client/mock.ts +267 -45
  67. package/src/client/module.ts +22 -2
  68. package/src/client/pages/AssetsPage.svelte +358 -137
  69. package/src/client/permissions.ts +1 -1
  70. package/src/client/price.test.ts +106 -0
  71. package/src/client/price.ts +135 -0
  72. package/src/client/query.test.ts +58 -0
  73. package/src/client/query.ts +15 -2
  74. package/src/client/settings/GeneralSettings.svelte +0 -0
  75. package/src/client/settings/core-api.ts +32 -0
  76. package/src/client/widgets/OverviewWidget.svelte +16 -3
  77. package/src/contract/capabilities.ts +55 -0
  78. package/src/contract/events.ts +34 -0
  79. package/src/contract/index.ts +15 -0
  80. package/src/contract/models.ts +123 -0
  81. package/src/contract/permissions.ts +26 -0
  82. package/src/contract/router.ts +46 -0
  83. package/src/contract/settings.ts +30 -0
  84. package/src/module.test.ts +140 -7
  85. package/src/server/index.ts +16 -13
  86. package/src/server/inventory.int.test.ts +819 -0
  87. package/src/server/migrations.test.ts +138 -0
  88. package/src/server/router.ts +118 -0
  89. package/src/server/schema.ts +27 -10
  90. package/src/server/services/assets.ts +368 -0
  91. package/src/server/services/index.ts +23 -0
  92. package/src/server/services/notify.ts +151 -0
  93. package/tsconfig.base.json +22 -0
  94. package/tsconfig.client.json +1 -1
  95. package/tsconfig.json +1 -1
  96. package/vitest.config.ts +18 -3
  97. package/dist/contract.d.ts.map +0 -1
  98. package/dist/contract.js +0 -119
  99. package/dist/contract.js.map +0 -1
  100. package/dist/server/_impl.js +0 -204
  101. package/dist/server/_impl.js.map +0 -1
  102. package/src/contract.ts +0 -143
  103. 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
- Spinner,
12
- StatTile,
17
+ Switch,
13
18
  session,
19
+ Table,
20
+ TableCell,
21
+ TableHeader,
22
+ TableRow,
23
+ toast,
14
24
  } from '@kernhq/ui'
15
- import { createQuery } from '@tanstack/svelte-query'
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, INVENTORY_PERMISSIONS } from '../permissions.js'
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 search = $state('')
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 q = $state('')
61
+ let query = $state('')
46
62
  $effect(() => {
47
- const value = search
48
- const timer = setTimeout(() => (q = value), 250)
63
+ const value = searchText
64
+ const timer = setTimeout(() => (query = value), 250)
49
65
  return () => clearTimeout(timer)
50
66
  })
51
67
 
52
- const filters = $derived.by(() => {
53
- const f: Record<string, unknown> = {}
54
- if (q) f.q = q
55
- if (statusFilter) f.status = statusFilter
56
- if (!showArchived) f.archived = false
57
- return Object.keys(f).length ? f : undefined
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
- const assetsQuery = createQuery(() => ({
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
- ...(q ? { q } : {}),
66
- ...(statusFilter
67
- ? { status: statusFilter as 'in_stock' | 'assigned' | 'under_repair' | 'retired' }
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
- <svelte:head><title>{t('title')} · {session.workspaces.find((w) => w.id === workspaceId)?.name ?? ''}</title></svelte:head>
170
+ // ---------------------------------------------------------------- row actions
106
171
 
107
- <section>
108
- <header>
109
- <h1>{t('nav')}</h1>
110
- <div class="actions">
111
- <Input bind:value={search} type="search" size="sm" placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} />
112
- <Select bind:value={statusFilter} options={statusOptions} size="sm" />
113
- {#if canInventory('manage')}
114
- <Button size="sm" onclick={() => (dialogOpen = true)}>{t('new')}</Button>
115
- {/if}
116
- </div>
117
- </header>
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
- <div class="tiles">
120
- <StatTile size="md" label={t('count', { n: counts.total })} value={String(counts.total)} />
121
- <StatTile size="md" label={t('status_in_stock')} value={String(counts.inStock)} />
122
- <StatTile size="md" label={t('status_assigned')} value={String(counts.assigned)} />
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
- <Spinner />
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
- <p class="error">{t('common.error')}</p>
130
- {:else if shownAssets.length === 0}
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 canInventory('manage')}
134
- <Button onclick={() => (dialogOpen = true)}>{t('new')}</Button>
322
+ {#if canManage}
323
+ <Button onclick={openCreate}>{t('new')}</Button>
135
324
  {/if}
136
325
  {/snippet}
137
326
  </EmptyState>
138
327
  {:else}
139
- <p class="count">{t('count', { n: shownAssets.length })}</p>
140
- <div class="table" role="table" aria-label={t('title')}>
141
- <div class="thead" role="row">
142
- <span role="columnheader">{t('code')}</span>
143
- <span role="columnheader">{t('name')}</span>
144
- <span role="columnheader">{t('location')}</span>
145
- <span role="columnheader">{t('warranty_until')}</span>
146
- <span role="columnheader">{t('category')}</span>
147
- </div>
148
- {#each shownAssets as asset (asset.id)}
149
- <div class="trow" role="row" tabindex="-1">
150
- <span class="cell code" role="cell"><code>{asset.code}</code></span>
151
- <span class="cell who" role="cell">
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
- {#if asset.serialNumber}<span class="sub">S/N {asset.serialNumber}</span>{/if}
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
- </span>
157
- <span class="cell muted" role="cell">{asset.location ?? '—'}</span>
158
- <span class="cell muted" role="cell">{asset.warrantyUntil ?? '—'}</span>
159
- <span class="cell" role="cell">
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)}>{statusLabel(asset.status)}</Badge>
359
+ <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
164
360
  {/if}
165
- </span>
166
- </div>
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
- </div>
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
- </section>
393
+ </Page>
394
+
395
+ <AssetFormDialog bind:open={dialogOpen} {workspaceId} asset={editingAsset} />
171
396
 
172
- <AssetFormDialog bind:open={dialogOpen} {workspaceId} />
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
- header {
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-500);
202
- }
203
- .error {
204
- color: var(--kern-danger);
205
- font-size: 13px;
424
+ color: var(--kern-ink-280);
206
425
  }
207
- .table {
208
- margin-top: 4px;
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-700);
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
- .who .stack {
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
- color: var(--kern-ink-500);
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
- .muted {
264
- color: var(--kern-ink-500);
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>
@@ -1,5 +1,5 @@
1
1
  import { session } from '@kernhq/ui'
2
- import { inventoryPermissions, MODULE_ID } from '../contract.js'
2
+ import { inventoryPermissions, MODULE_ID } from '../contract/index.js'
3
3
 
4
4
  /**
5
5
  * What this module lets somebody do.