@kernhq/module-inventory 0.1.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/LICENSE +662 -0
- package/README.md +32 -0
- package/dist/contract.d.ts +387 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +119 -0
- package/dist/contract.js.map +1 -0
- package/dist/server/_impl.d.ts +427 -0
- package/dist/server/_impl.d.ts.map +1 -0
- package/dist/server/_impl.js +204 -0
- package/dist/server/_impl.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +32 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/schema.d.ts +1552 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +175 -0
- package/dist/server/schema.js.map +1 -0
- package/drizzle.config.ts +8 -0
- package/migrations/0000_init.sql +129 -0
- package/migrations/0001_rls.sql +62 -0
- package/migrations/meta/0000_snapshot.json +994 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +106 -0
- package/src/client/api-instance.ts +28 -0
- package/src/client/api.ts +10 -0
- package/src/client/components/AssetFormDialog.svelte +185 -0
- package/src/client/i18n.ts +169 -0
- package/src/client/index.ts +26 -0
- package/src/client/mock.ts +95 -0
- package/src/client/module.ts +96 -0
- package/src/client/pages/AssetsPage.svelte +266 -0
- package/src/client/permissions.ts +30 -0
- package/src/client/query.ts +12 -0
- package/src/client/widgets/OverviewWidget.svelte +92 -0
- package/src/contract.ts +143 -0
- package/src/module.test.ts +79 -0
- package/src/server/_impl.ts +275 -0
- package/src/server/index.ts +33 -0
- package/src/server/schema.ts +228 -0
- package/tsconfig.client.json +10 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { defineClientModule } from '@kernhq/ui'
|
|
2
|
+
import { inventoryMessageBundles, t } from './i18n.js'
|
|
3
|
+
import { INVENTORY_PERMISSIONS } from './permissions.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This module as the shell sees it.
|
|
7
|
+
*
|
|
8
|
+
* Everything the interface offers is declared here — navigation, routes, commands, widgets,
|
|
9
|
+
* settings pages, sidebars, presenters — and the shell renders whatever it finds. There are no
|
|
10
|
+
* route files in the app to keep in step: deleting this package removes the feature completely,
|
|
11
|
+
* which is the test of whether something is a module at all.
|
|
12
|
+
*
|
|
13
|
+
* Labels are **getters** because a module is defined once at import time while the interface
|
|
14
|
+
* language can change afterwards. Reading them on render keeps the rail in the language chosen.
|
|
15
|
+
*/
|
|
16
|
+
export const inventoryClientModule = defineClientModule({
|
|
17
|
+
id: 'inventory',
|
|
18
|
+
name: 'Inventory',
|
|
19
|
+
icon: 'briefcase',
|
|
20
|
+
messages: inventoryMessageBundles,
|
|
21
|
+
|
|
22
|
+
nav: [
|
|
23
|
+
{
|
|
24
|
+
id: 'inventory',
|
|
25
|
+
get label() {
|
|
26
|
+
return t('nav')
|
|
27
|
+
},
|
|
28
|
+
icon: 'briefcase',
|
|
29
|
+
href: '/inventory',
|
|
30
|
+
order: 55,
|
|
31
|
+
permission: INVENTORY_PERMISSIONS.view,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Routes are declarations, not files. `:name` matches one segment and reaches the component as
|
|
37
|
+
* `params.name`; specificity decides ties, literal segments first.
|
|
38
|
+
*/
|
|
39
|
+
routes: [
|
|
40
|
+
{
|
|
41
|
+
path: '/inventory',
|
|
42
|
+
component: () => import('./pages/AssetsPage.svelte'),
|
|
43
|
+
get title() {
|
|
44
|
+
return t('title')
|
|
45
|
+
},
|
|
46
|
+
permission: INVENTORY_PERMISSIONS.view,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
|
|
50
|
+
commands: [
|
|
51
|
+
{
|
|
52
|
+
id: 'inventory.open',
|
|
53
|
+
get label() {
|
|
54
|
+
return t('nav')
|
|
55
|
+
},
|
|
56
|
+
icon: 'briefcase',
|
|
57
|
+
permission: INVENTORY_PERMISSIONS.view,
|
|
58
|
+
run: (ctx) => ctx.navigate('/inventory'),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
|
|
62
|
+
widgets: [
|
|
63
|
+
{
|
|
64
|
+
id: 'inventory.recent',
|
|
65
|
+
get title() {
|
|
66
|
+
return t('widget_title')
|
|
67
|
+
},
|
|
68
|
+
get description() {
|
|
69
|
+
return t('widget_desc')
|
|
70
|
+
},
|
|
71
|
+
icon: 'briefcase',
|
|
72
|
+
permission: INVENTORY_PERMISSIONS.view,
|
|
73
|
+
sizes: ['m', 'l'],
|
|
74
|
+
defaultSize: 'm',
|
|
75
|
+
order: 55,
|
|
76
|
+
settings: [
|
|
77
|
+
{
|
|
78
|
+
kind: 'number',
|
|
79
|
+
key: 'limit',
|
|
80
|
+
get label() {
|
|
81
|
+
return t('common.setting_rows')
|
|
82
|
+
},
|
|
83
|
+
default: 5,
|
|
84
|
+
min: 3,
|
|
85
|
+
max: 20,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
component: () => import('./widgets/OverviewWidget.svelte'),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
|
|
92
|
+
/** A settings page's `id` is its URL: the shell mounts it at `/<ws>/settings/inventory/<id>`. */
|
|
93
|
+
settingsPages: [],
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export default inventoryClientModule
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Input,
|
|
8
|
+
Select,
|
|
9
|
+
type SelectOption,
|
|
10
|
+
Skeleton,
|
|
11
|
+
Spinner,
|
|
12
|
+
StatTile,
|
|
13
|
+
session,
|
|
14
|
+
} from '@kernhq/ui'
|
|
15
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
16
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
17
|
+
import AssetFormDialog from '../components/AssetFormDialog.svelte'
|
|
18
|
+
import { t } from '../i18n.js'
|
|
19
|
+
import { canInventory, INVENTORY_PERMISSIONS } from '../permissions.js'
|
|
20
|
+
import { inventoryKeys } from '../query.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This module's screen.
|
|
24
|
+
*
|
|
25
|
+
* The shell passes `workspaceId` and `workspaceSlug`, and `params` for any `:name` segment the
|
|
26
|
+
* route declared. **Do not read `$app/state` or `$app/navigation`** — they are SvelteKit aliases,
|
|
27
|
+
* this package is type-checked on its own, and they resolve only because the app happens to be
|
|
28
|
+
* compiling you. Ask `navigation` from `@kernhq/ui` for the current location instead.
|
|
29
|
+
*/
|
|
30
|
+
interface Props {
|
|
31
|
+
workspaceId: string
|
|
32
|
+
workspaceSlug: string
|
|
33
|
+
params?: Record<string, string>
|
|
34
|
+
}
|
|
35
|
+
const { workspaceId }: Props = $props()
|
|
36
|
+
|
|
37
|
+
const api = getInventoryApi()
|
|
38
|
+
|
|
39
|
+
let search = $state('')
|
|
40
|
+
let statusFilter = $state<string>('')
|
|
41
|
+
let showArchived = $state(false)
|
|
42
|
+
let dialogOpen = $state(false)
|
|
43
|
+
|
|
44
|
+
// Debounce the search into the cache key, so typing does not refetch per keystroke.
|
|
45
|
+
let q = $state('')
|
|
46
|
+
$effect(() => {
|
|
47
|
+
const value = search
|
|
48
|
+
const timer = setTimeout(() => (q = value), 250)
|
|
49
|
+
return () => clearTimeout(timer)
|
|
50
|
+
})
|
|
51
|
+
|
|
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
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const assetsQuery = createQuery(() => ({
|
|
61
|
+
queryKey: inventoryKeys.assets(workspaceId, filters),
|
|
62
|
+
queryFn: () =>
|
|
63
|
+
api.assets.list({
|
|
64
|
+
workspaceId,
|
|
65
|
+
...(q ? { q } : {}),
|
|
66
|
+
...(statusFilter
|
|
67
|
+
? { status: statusFilter as 'in_stock' | 'assigned' | 'under_repair' | 'retired' }
|
|
68
|
+
: {}),
|
|
69
|
+
}),
|
|
70
|
+
enabled: Boolean(workspaceId),
|
|
71
|
+
}))
|
|
72
|
+
|
|
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
|
+
})
|
|
81
|
+
|
|
82
|
+
const statusOptions: SelectOption[] = [
|
|
83
|
+
{ value: '', label: t('filter_all_statuses') },
|
|
84
|
+
{ value: 'in_stock', label: t('status_in_stock') },
|
|
85
|
+
{ value: 'assigned', label: t('status_assigned') },
|
|
86
|
+
{ value: 'under_repair', label: t('status_under_repair') },
|
|
87
|
+
{ value: 'retired', label: t('status_retired') },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
function statusTone(status: string): BadgeTone {
|
|
91
|
+
switch (status) {
|
|
92
|
+
case 'assigned':
|
|
93
|
+
return 'info'
|
|
94
|
+
case 'under_repair':
|
|
95
|
+
return 'warning'
|
|
96
|
+
case 'retired':
|
|
97
|
+
return 'grey'
|
|
98
|
+
default:
|
|
99
|
+
return 'success'
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const statusLabel = (status: string) => t(`status_${status}`)
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<svelte:head><title>{t('title')} · {session.workspaces.find((w) => w.id === workspaceId)?.name ?? ''}</title></svelte:head>
|
|
106
|
+
|
|
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>
|
|
118
|
+
|
|
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)} />
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{#if assetsQuery.isPending}
|
|
127
|
+
<Spinner />
|
|
128
|
+
{:else if assetsQuery.isError}
|
|
129
|
+
<p class="error">{t('common.error')}</p>
|
|
130
|
+
{:else if shownAssets.length === 0}
|
|
131
|
+
<EmptyState icon="briefcase" title={t('empty')} description={t('empty_desc')}>
|
|
132
|
+
{#snippet actions()}
|
|
133
|
+
{#if canInventory('manage')}
|
|
134
|
+
<Button onclick={() => (dialogOpen = true)}>{t('new')}</Button>
|
|
135
|
+
{/if}
|
|
136
|
+
{/snippet}
|
|
137
|
+
</EmptyState>
|
|
138
|
+
{: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">
|
|
152
|
+
<span class="stack">
|
|
153
|
+
<span class="name">{asset.name}</span>
|
|
154
|
+
{#if asset.serialNumber}<span class="sub">S/N {asset.serialNumber}</span>{/if}
|
|
155
|
+
</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">
|
|
160
|
+
{#if asset.archivedAt}
|
|
161
|
+
<Badge tone="grey">{t('archived')}</Badge>
|
|
162
|
+
{:else}
|
|
163
|
+
<Badge tone={statusTone(asset.status)}>{statusLabel(asset.status)}</Badge>
|
|
164
|
+
{/if}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
{/each}
|
|
168
|
+
</div>
|
|
169
|
+
{/if}
|
|
170
|
+
</section>
|
|
171
|
+
|
|
172
|
+
<AssetFormDialog bind:open={dialogOpen} {workspaceId} />
|
|
173
|
+
|
|
174
|
+
<style>
|
|
175
|
+
header {
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
justify-content: space-between;
|
|
179
|
+
gap: 12px;
|
|
180
|
+
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;
|
|
197
|
+
}
|
|
198
|
+
.count {
|
|
199
|
+
margin: 14px 0 6px;
|
|
200
|
+
font-size: 12px;
|
|
201
|
+
color: var(--kern-ink-500);
|
|
202
|
+
}
|
|
203
|
+
.error {
|
|
204
|
+
color: var(--kern-danger);
|
|
205
|
+
font-size: 13px;
|
|
206
|
+
}
|
|
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;
|
|
239
|
+
}
|
|
240
|
+
code {
|
|
241
|
+
font-size: 12px;
|
|
242
|
+
color: var(--kern-ink-700);
|
|
243
|
+
}
|
|
244
|
+
.who .stack {
|
|
245
|
+
display: flex;
|
|
246
|
+
flex-direction: column;
|
|
247
|
+
min-width: 0;
|
|
248
|
+
}
|
|
249
|
+
.name {
|
|
250
|
+
font-weight: 500;
|
|
251
|
+
color: var(--kern-ink-900);
|
|
252
|
+
overflow: hidden;
|
|
253
|
+
text-overflow: ellipsis;
|
|
254
|
+
white-space: nowrap;
|
|
255
|
+
}
|
|
256
|
+
.sub {
|
|
257
|
+
font-size: 11px;
|
|
258
|
+
color: var(--kern-ink-500);
|
|
259
|
+
overflow: hidden;
|
|
260
|
+
text-overflow: ellipsis;
|
|
261
|
+
white-space: nowrap;
|
|
262
|
+
}
|
|
263
|
+
.muted {
|
|
264
|
+
color: var(--kern-ink-500);
|
|
265
|
+
}
|
|
266
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { session } from '@kernhq/ui'
|
|
2
|
+
import { inventoryPermissions, MODULE_ID } from '../contract.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What this module lets somebody do.
|
|
6
|
+
*
|
|
7
|
+
* **Derived from the contract, never re-typed.** `key()` throws at import if a name is not declared,
|
|
8
|
+
* which is the whole point: a hand-copied permission string type-checks perfectly while being wrong,
|
|
9
|
+
* and a wrong one silently hides a control or offers one the server refuses.
|
|
10
|
+
*
|
|
11
|
+
* Hide what a person may never do; disable — with a reason — what they cannot do right now. The
|
|
12
|
+
* server checks again on every call regardless; this only stops the interface offering a door that
|
|
13
|
+
* will not open.
|
|
14
|
+
*/
|
|
15
|
+
const key = (suffix: string) => {
|
|
16
|
+
const found = inventoryPermissions.find((p) => p.key === `${MODULE_ID}.${suffix}`)
|
|
17
|
+
if (!found) throw new Error(`${MODULE_ID}: no permission declared for ${MODULE_ID}.${suffix}`)
|
|
18
|
+
return found.key
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const INVENTORY_PERMISSIONS = {
|
|
22
|
+
view: key('asset.view'),
|
|
23
|
+
manage: key('asset.manage'),
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export type InventoryPermission = keyof typeof INVENTORY_PERMISSIONS
|
|
27
|
+
|
|
28
|
+
export function canInventory(permission: InventoryPermission): boolean {
|
|
29
|
+
return session.can(INVENTORY_PERMISSIONS[permission])
|
|
30
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys for Inventory.
|
|
3
|
+
*
|
|
4
|
+
* `[module, entity, …scope]`, so a realtime `change` event invalidates precisely what it touched.
|
|
5
|
+
* Filters are part of the key wherever a screen can ask the same question two ways — a cached list
|
|
6
|
+
* for "all" must not be served when somebody asked for "assigned".
|
|
7
|
+
*/
|
|
8
|
+
export const inventoryKeys = {
|
|
9
|
+
assets: (ws: string, filters?: Record<string, unknown>) =>
|
|
10
|
+
filters ? (['inventory', 'assets', ws, filters] as const) : (['inventory', 'assets', ws] as const),
|
|
11
|
+
asset: (ws: string, id: string) => ['inventory', 'asset', ws, id] as const,
|
|
12
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, type BadgeTone, WidgetState } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A dashboard card.
|
|
9
|
+
*
|
|
10
|
+
* `WidgetState` draws loading, failed and empty so every card on the board reports those three the
|
|
11
|
+
* same way — and so a module does not have to translate "Retry" to show a widget that failed.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
workspaceId: string
|
|
15
|
+
settings?: Record<string, string | number | boolean | null>
|
|
16
|
+
}
|
|
17
|
+
const { workspaceId, settings }: Props = $props()
|
|
18
|
+
|
|
19
|
+
const limit = $derived(Number(settings?.limit ?? 5))
|
|
20
|
+
const api = getInventoryApi()
|
|
21
|
+
|
|
22
|
+
const assets = createQuery(() => ({
|
|
23
|
+
queryKey: ['inventory', 'assets', workspaceId],
|
|
24
|
+
queryFn: () => api.assets.list({ workspaceId }),
|
|
25
|
+
enabled: Boolean(workspaceId),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const items = $derived((assets.data?.items ?? []).filter((a) => !a.archivedAt).slice(0, limit))
|
|
29
|
+
|
|
30
|
+
function statusTone(status: string): BadgeTone {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case 'assigned':
|
|
33
|
+
return 'info'
|
|
34
|
+
case 'under_repair':
|
|
35
|
+
return 'warning'
|
|
36
|
+
case 'retired':
|
|
37
|
+
return 'grey'
|
|
38
|
+
default:
|
|
39
|
+
return 'success'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<WidgetState
|
|
45
|
+
pending={assets.isPending}
|
|
46
|
+
error={assets.error}
|
|
47
|
+
empty={!items.length}
|
|
48
|
+
emptyTitle={t('empty')}
|
|
49
|
+
emptyIcon="briefcase"
|
|
50
|
+
onRetry={() => assets.refetch()}
|
|
51
|
+
>
|
|
52
|
+
<ul>
|
|
53
|
+
{#each items as asset (asset.id)}
|
|
54
|
+
<li>
|
|
55
|
+
<span class="code">{asset.code}</span>
|
|
56
|
+
<span class="name">{asset.name}</span>
|
|
57
|
+
<Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
|
|
58
|
+
</li>
|
|
59
|
+
{/each}
|
|
60
|
+
</ul>
|
|
61
|
+
</WidgetState>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
ul {
|
|
65
|
+
list-style: none;
|
|
66
|
+
margin: 0;
|
|
67
|
+
padding: 0;
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
gap: 8px;
|
|
71
|
+
}
|
|
72
|
+
li {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 10px;
|
|
76
|
+
font-size: 13px;
|
|
77
|
+
}
|
|
78
|
+
.code {
|
|
79
|
+
font-size: 11px;
|
|
80
|
+
color: var(--kern-ink-500);
|
|
81
|
+
font-variant-numeric: tabular-nums;
|
|
82
|
+
min-width: 64px;
|
|
83
|
+
}
|
|
84
|
+
.name {
|
|
85
|
+
flex: 1;
|
|
86
|
+
min-width: 0;
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
text-overflow: ellipsis;
|
|
89
|
+
white-space: nowrap;
|
|
90
|
+
color: var(--kern-ink-900);
|
|
91
|
+
}
|
|
92
|
+
</style>
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { baseContract, defineEvent, definePermissions, PageInput, page, WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What this module offers, as data.
|
|
6
|
+
*
|
|
7
|
+
* Imported by **both** halves — the server implements it, the client calls it — so nothing here may
|
|
8
|
+
* touch Node. The contract is the only thing that crosses that line, which is why a procedure that
|
|
9
|
+
* exists here and not in the router is a lie that compiles. `module.test.ts` checks exactly that.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Lowercase, 2-32 characters. Names the API prefix, the Postgres schema `mod_<id>` and every event. */
|
|
13
|
+
export const MODULE_ID = 'inventory'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An asset's lifecycle. `in_stock` and `assigned` follow custody (an open row in
|
|
17
|
+
* `custody_periods` means assigned); `under_repair` follows an open repair; `retired` is set by
|
|
18
|
+
* hand when an item leaves the company. Stored, because every list filter needs it, and kept in
|
|
19
|
+
* step inside the same transaction that writes the row it derives from.
|
|
20
|
+
*/
|
|
21
|
+
export const AssetStatus = z.enum(['in_stock', 'assigned', 'under_repair', 'retired'])
|
|
22
|
+
export type AssetStatus = z.infer<typeof AssetStatus>
|
|
23
|
+
|
|
24
|
+
export const Asset = z.object({
|
|
25
|
+
id: z.uuid(),
|
|
26
|
+
workspaceId: WorkspaceId,
|
|
27
|
+
/** Human-facing asset tag (`INV-0001`), assigned by the server, unique per workspace. */
|
|
28
|
+
code: z.string().min(1).max(40),
|
|
29
|
+
name: z.string().min(1).max(200),
|
|
30
|
+
description: z.string().max(4000),
|
|
31
|
+
categoryId: z.uuid().nullable(),
|
|
32
|
+
status: AssetStatus,
|
|
33
|
+
/**
|
|
34
|
+
* The member currently holding the item. A plain uuid — cross-schema foreign keys are what the
|
|
35
|
+
* module boundary exists to prevent — resolved against core membership at read time.
|
|
36
|
+
*/
|
|
37
|
+
custodianUserId: z.uuid().nullable(),
|
|
38
|
+
custodySince: z.string().nullable(),
|
|
39
|
+
serialNumber: z.string().max(200).nullable(),
|
|
40
|
+
location: z.string().max(200).nullable(),
|
|
41
|
+
purchasedOn: z.string().nullable(),
|
|
42
|
+
purchasedFrom: z.string().max(200).nullable(),
|
|
43
|
+
/** Minor units (cents), the convention billing established; formatted on the client. */
|
|
44
|
+
priceMinor: z.number().int().nullable(),
|
|
45
|
+
currency: z.string().length(3).nullable(),
|
|
46
|
+
warrantyUntil: z.string().nullable(),
|
|
47
|
+
photoFileId: z.uuid().nullable(),
|
|
48
|
+
createdAt: z.string(),
|
|
49
|
+
updatedAt: z.string(),
|
|
50
|
+
archivedAt: z.string().nullable(),
|
|
51
|
+
})
|
|
52
|
+
export type Asset = z.infer<typeof Asset>
|
|
53
|
+
|
|
54
|
+
const ws = z.object({ workspaceId: WorkspaceId })
|
|
55
|
+
|
|
56
|
+
const AssetCreateInput = z.object({
|
|
57
|
+
name: z.string().min(1).max(200),
|
|
58
|
+
description: z.string().max(4000).default(''),
|
|
59
|
+
categoryId: z.uuid().nullish(),
|
|
60
|
+
serialNumber: z.string().max(200).nullish(),
|
|
61
|
+
location: z.string().max(200).nullish(),
|
|
62
|
+
purchasedFrom: z.string().max(200).nullish(),
|
|
63
|
+
purchasedOn: z.iso.date().nullish(),
|
|
64
|
+
warrantyUntil: z.iso.date().nullish(),
|
|
65
|
+
priceMinor: z.number().int().min(0).nullish(),
|
|
66
|
+
currency: z.string().length(3).nullish(),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export const inventoryContract = {
|
|
70
|
+
assets: {
|
|
71
|
+
list: baseContract
|
|
72
|
+
.route({ method: 'GET', path: '/assets', tags: ['inventory'] })
|
|
73
|
+
.input(
|
|
74
|
+
ws.extend({
|
|
75
|
+
...PageInput.shape,
|
|
76
|
+
q: z.string().max(200).optional(),
|
|
77
|
+
categoryId: z.uuid().optional(),
|
|
78
|
+
status: AssetStatus.optional(),
|
|
79
|
+
sort: z.enum(['recent', 'name', 'code']).default('recent'),
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
.output(page(Asset)),
|
|
83
|
+
get: baseContract
|
|
84
|
+
.route({ method: 'GET', path: '/assets/{assetId}', tags: ['inventory'] })
|
|
85
|
+
.input(ws.extend({ assetId: z.uuid() }))
|
|
86
|
+
.output(Asset),
|
|
87
|
+
create: baseContract
|
|
88
|
+
.route({ method: 'POST', path: '/assets', tags: ['inventory'] })
|
|
89
|
+
.input(ws.extend(AssetCreateInput.shape))
|
|
90
|
+
.output(Asset),
|
|
91
|
+
update: baseContract
|
|
92
|
+
.route({ method: 'PATCH', path: '/assets/{assetId}', tags: ['inventory'] })
|
|
93
|
+
.input(
|
|
94
|
+
ws.extend({
|
|
95
|
+
assetId: z.uuid(),
|
|
96
|
+
...AssetCreateInput.partial().shape,
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
.output(Asset),
|
|
100
|
+
archive: baseContract
|
|
101
|
+
.route({ method: 'POST', path: '/assets/{assetId}/archive', tags: ['inventory'] })
|
|
102
|
+
.input(ws.extend({ assetId: z.uuid(), archived: z.boolean().default(true) }))
|
|
103
|
+
.output(Asset),
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
export type InventoryContract = typeof inventoryContract
|
|
107
|
+
|
|
108
|
+
/** `<module>.<entity>.<action>`. Anything that emits one declares it here. Payloads carry ids, never rows. */
|
|
109
|
+
export const inventoryEvents = {
|
|
110
|
+
assetCreated: defineEvent(
|
|
111
|
+
'inventory.asset.created',
|
|
112
|
+
z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
|
|
113
|
+
),
|
|
114
|
+
assetUpdated: defineEvent(
|
|
115
|
+
'inventory.asset.updated',
|
|
116
|
+
z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
|
|
117
|
+
),
|
|
118
|
+
assetArchived: defineEvent(
|
|
119
|
+
'inventory.asset.archived',
|
|
120
|
+
z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `<module>.<resource>.<action>`, each with the narrowest scope that works and the roles that hold it
|
|
126
|
+
* by default. A workspace can add or remove any of them afterwards with a custom role.
|
|
127
|
+
*/
|
|
128
|
+
export const inventoryPermissions = definePermissions([
|
|
129
|
+
{
|
|
130
|
+
key: 'inventory.asset.view',
|
|
131
|
+
label: 'View assets',
|
|
132
|
+
scope: 'workspace',
|
|
133
|
+
defaultRoles: ['owner', 'admin', 'member', 'guest'],
|
|
134
|
+
dangerous: false,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
key: 'inventory.asset.manage',
|
|
138
|
+
label: 'Create and edit assets',
|
|
139
|
+
scope: 'workspace',
|
|
140
|
+
defaultRoles: ['owner', 'admin', 'member'],
|
|
141
|
+
dangerous: false,
|
|
142
|
+
},
|
|
143
|
+
])
|