@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,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "7",
|
|
3
|
+
"dialect": "postgresql",
|
|
4
|
+
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"idx": 0,
|
|
7
|
+
"version": "7",
|
|
8
|
+
"when": 1787738327443,
|
|
9
|
+
"tag": "0000_init",
|
|
10
|
+
"breakpoints": true
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"idx": 1,
|
|
14
|
+
"version": "7",
|
|
15
|
+
"when": 1787738328443,
|
|
16
|
+
"tag": "0001_rls",
|
|
17
|
+
"breakpoints": true
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kernhq/module-inventory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Kern inventory module: the company's asset register — what it owns, who holds each item, purchase and warranty details, repairs, and a full history of every change",
|
|
5
|
+
"homepage": "https://github.com/KernAIO/module-inventory#readme",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/KernAIO/module-inventory.git"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"kern",
|
|
17
|
+
"kern-module"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"migrations",
|
|
22
|
+
"src",
|
|
23
|
+
"drizzle.config.ts",
|
|
24
|
+
"tsconfig.json",
|
|
25
|
+
"tsconfig.client.json",
|
|
26
|
+
"vitest.config.ts",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
"./contract": {
|
|
32
|
+
"types": "./dist/contract.d.ts",
|
|
33
|
+
"import": "./dist/contract.js",
|
|
34
|
+
"default": "./dist/contract.js"
|
|
35
|
+
},
|
|
36
|
+
"./server": {
|
|
37
|
+
"types": "./dist/server/index.d.ts",
|
|
38
|
+
"import": "./dist/server/index.js",
|
|
39
|
+
"default": "./dist/server/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./client": {
|
|
42
|
+
"types": "./src/client/index.ts",
|
|
43
|
+
"svelte": "./src/client/index.ts",
|
|
44
|
+
"default": "./src/client/index.ts"
|
|
45
|
+
},
|
|
46
|
+
"./migrations": "./migrations",
|
|
47
|
+
"./client/*": {
|
|
48
|
+
"types": "./src/client/*",
|
|
49
|
+
"svelte": "./src/client/*",
|
|
50
|
+
"default": "./src/client/*"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@kernhq/sdk": "^0.1.0",
|
|
55
|
+
"@orpc/contract": "^1.15.0",
|
|
56
|
+
"@orpc/server": "^1.15.0",
|
|
57
|
+
"drizzle-orm": "^0.45.0",
|
|
58
|
+
"zod": "^4.1.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@biomejs/biome": "^2.0.0",
|
|
62
|
+
"@changesets/cli": "^2.27.0",
|
|
63
|
+
"@kernhq/contracts": "^0.6.0",
|
|
64
|
+
"@kernhq/kernel": "^0.7.0",
|
|
65
|
+
"@kernhq/tsconfig": "^0.1.0",
|
|
66
|
+
"@kernhq/ui": "^0.8.0",
|
|
67
|
+
"@tanstack/svelte-query": "^6.1.0",
|
|
68
|
+
"@types/node": "^24.0.0",
|
|
69
|
+
"drizzle-kit": "^0.31.0",
|
|
70
|
+
"svelte": "^5.46.0",
|
|
71
|
+
"svelte-check": "^4.0.0",
|
|
72
|
+
"typescript": "~5.9.3",
|
|
73
|
+
"vitest": "^4.0.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependencies": {
|
|
76
|
+
"@kernhq/contracts": "^0.6.0",
|
|
77
|
+
"@kernhq/kernel": "^0.7.0",
|
|
78
|
+
"@kernhq/ui": "^0.8.0",
|
|
79
|
+
"@tanstack/svelte-query": "^6.1.0",
|
|
80
|
+
"svelte": "^5.46.0"
|
|
81
|
+
},
|
|
82
|
+
"peerDependenciesMeta": {
|
|
83
|
+
"@kernhq/ui": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@tanstack/svelte-query": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"svelte": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"engines": {
|
|
94
|
+
"node": ">=24"
|
|
95
|
+
},
|
|
96
|
+
"scripts": {
|
|
97
|
+
"build": "tsc -p tsconfig.json",
|
|
98
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
99
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
|
|
100
|
+
"test": "vitest run",
|
|
101
|
+
"db:generate": "drizzle-kit generate",
|
|
102
|
+
"lint": "biome check . && node scripts/check-ranges.mjs package.json && node scripts/check-icons.mjs",
|
|
103
|
+
"format": "biome check --write .",
|
|
104
|
+
"changeset": "changeset"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getHost } from '@kernhq/ui'
|
|
2
|
+
import { createInventoryClient, type InventoryApi } from './api.js'
|
|
3
|
+
import { createMockInventoryApi } from './mock.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This module's API client, made once and shared.
|
|
7
|
+
*
|
|
8
|
+
* The origin comes from the shell, never from an env var read here: same-origin in every real
|
|
9
|
+
* deployment, so a module never has to know which service hosts it or on what port.
|
|
10
|
+
*
|
|
11
|
+
* The shell also decides whether it is running against the in-memory implementation, which satisfies
|
|
12
|
+
* the same contract types — so no screen has a second code path for demos and end-to-end tests.
|
|
13
|
+
*/
|
|
14
|
+
let cached: InventoryApi | null = null
|
|
15
|
+
|
|
16
|
+
export function getInventoryApi(): InventoryApi {
|
|
17
|
+
if (cached) return cached
|
|
18
|
+
const host = getHost()
|
|
19
|
+
cached = host.isMock
|
|
20
|
+
? (createMockInventoryApi() as unknown as InventoryApi)
|
|
21
|
+
: createInventoryClient({ baseUrl: host.apiBaseUrl })
|
|
22
|
+
return cached
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Test seam: install a fake without touching module state elsewhere. */
|
|
26
|
+
export function __setInventoryApi(api: InventoryApi | null) {
|
|
27
|
+
cached = api
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createModuleClient, type KernClientOptions } from '@kernhq/sdk'
|
|
2
|
+
import type { ContractRouterClient } from '@orpc/contract'
|
|
3
|
+
import type { InventoryContract } from '../contract.js'
|
|
4
|
+
|
|
5
|
+
/** The typed client, derived from the contract — no hand-written method list to drift. */
|
|
6
|
+
export type InventoryApi = ContractRouterClient<InventoryContract>
|
|
7
|
+
|
|
8
|
+
export function createInventoryClient(opts: KernClientOptions): InventoryApi {
|
|
9
|
+
return createModuleClient<InventoryApi>(opts, 'inventory')
|
|
10
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Field, Input, Select, Textarea, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { canInventory } from '../permissions.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Add an asset. The tag (`INV-…`) is the server's job — people never invent codes, they read them
|
|
10
|
+
* off stickers — so this form asks only for what a human actually knows on day one.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
open: boolean
|
|
14
|
+
workspaceId: string
|
|
15
|
+
}
|
|
16
|
+
let { open = $bindable(false), workspaceId }: Props = $props()
|
|
17
|
+
|
|
18
|
+
const api = getInventoryApi()
|
|
19
|
+
const queryClient = useQueryClient()
|
|
20
|
+
|
|
21
|
+
let shown = $state(false)
|
|
22
|
+
$effect(() => {
|
|
23
|
+
if (open) shown = true
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
let name = $state('')
|
|
27
|
+
let description = $state('')
|
|
28
|
+
let serialNumber = $state('')
|
|
29
|
+
let location = $state('')
|
|
30
|
+
let purchasedOn = $state('')
|
|
31
|
+
let purchasedFrom = $state('')
|
|
32
|
+
let priceMinor = $state('')
|
|
33
|
+
let currency = $state('')
|
|
34
|
+
let warrantyUntil = $state('')
|
|
35
|
+
|
|
36
|
+
const close = () => {
|
|
37
|
+
shown = false
|
|
38
|
+
open = false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reset = () => {
|
|
42
|
+
name = ''
|
|
43
|
+
description = ''
|
|
44
|
+
serialNumber = ''
|
|
45
|
+
location = ''
|
|
46
|
+
purchasedOn = ''
|
|
47
|
+
purchasedFrom = ''
|
|
48
|
+
priceMinor = ''
|
|
49
|
+
currency = ''
|
|
50
|
+
warrantyUntil = ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Minor units are the wire format; the form takes what somebody reads off the receipt. */
|
|
54
|
+
function toPrice(raw: string): number | null {
|
|
55
|
+
if (!raw.trim()) return null
|
|
56
|
+
const parsed = Number.parseFloat(raw.replace(',', '.'))
|
|
57
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 100) : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const create = createMutation(() => ({
|
|
61
|
+
mutationFn: () =>
|
|
62
|
+
api.assets.create({
|
|
63
|
+
workspaceId,
|
|
64
|
+
name: name.trim(),
|
|
65
|
+
description: description.trim(),
|
|
66
|
+
serialNumber: serialNumber.trim() || null,
|
|
67
|
+
location: location.trim() || null,
|
|
68
|
+
purchasedFrom: purchasedFrom.trim() || null,
|
|
69
|
+
purchasedOn: purchasedOn || null,
|
|
70
|
+
warrantyUntil: warrantyUntil || null,
|
|
71
|
+
priceMinor: toPrice(priceMinor),
|
|
72
|
+
currency: currency.trim().toUpperCase() || null,
|
|
73
|
+
}),
|
|
74
|
+
onSuccess: () => {
|
|
75
|
+
toast.success(t('new'))
|
|
76
|
+
void queryClient.invalidateQueries({ queryKey: ['inventory', 'assets'] })
|
|
77
|
+
reset()
|
|
78
|
+
close()
|
|
79
|
+
},
|
|
80
|
+
onError: (error: Error) => toast.error(error.message),
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !create.isPending)
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<Dialog
|
|
87
|
+
bind:open={shown}
|
|
88
|
+
title={t('new')}
|
|
89
|
+
onOpenChange={(next) => {
|
|
90
|
+
if (!next) close()
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<div class="form">
|
|
94
|
+
<Field label={t('name')} id="inv-name" required hint={t('name_placeholder')}>
|
|
95
|
+
{#snippet children(id)}
|
|
96
|
+
<Input {id} bind:value={name} />
|
|
97
|
+
{/snippet}
|
|
98
|
+
</Field>
|
|
99
|
+
<Field label={t('description')} id="inv-desc">
|
|
100
|
+
{#snippet children(id)}
|
|
101
|
+
<Textarea {id} bind:value={description} rows={2} />
|
|
102
|
+
{/snippet}
|
|
103
|
+
</Field>
|
|
104
|
+
|
|
105
|
+
<div class="pair">
|
|
106
|
+
<Field label={t('serial_number')} id="inv-serial">
|
|
107
|
+
{#snippet children(id)}
|
|
108
|
+
<Input {id} bind:value={serialNumber} />
|
|
109
|
+
{/snippet}
|
|
110
|
+
</Field>
|
|
111
|
+
<Field label={t('location')} id="inv-location">
|
|
112
|
+
{#snippet children(id)}
|
|
113
|
+
<Input {id} bind:value={location} />
|
|
114
|
+
{/snippet}
|
|
115
|
+
</Field>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="pair">
|
|
119
|
+
<Field label={t('purchased_on')} id="inv-purchased-on">
|
|
120
|
+
{#snippet children(id)}
|
|
121
|
+
<Input {id} type="date" bind:value={purchasedOn} />
|
|
122
|
+
{/snippet}
|
|
123
|
+
</Field>
|
|
124
|
+
<Field label={t('warranty_until')} id="inv-warranty">
|
|
125
|
+
{#snippet children(id)}
|
|
126
|
+
<Input {id} type="date" bind:value={warrantyUntil} />
|
|
127
|
+
{/snippet}
|
|
128
|
+
</Field>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div class="pair">
|
|
132
|
+
<Field label={t('purchased_from')} id="inv-vendor">
|
|
133
|
+
{#snippet children(id)}
|
|
134
|
+
<Input {id} bind:value={purchasedFrom} />
|
|
135
|
+
{/snippet}
|
|
136
|
+
</Field>
|
|
137
|
+
<div class="price">
|
|
138
|
+
<Field label={t('price')} id="inv-price">
|
|
139
|
+
{#snippet children(id)}
|
|
140
|
+
<Input {id} type="text" inputmode="decimal" bind:value={priceMinor} />
|
|
141
|
+
{/snippet}
|
|
142
|
+
</Field>
|
|
143
|
+
<Field label="ISO" id="inv-currency" hint="USD">
|
|
144
|
+
{#snippet children(id)}
|
|
145
|
+
<Select
|
|
146
|
+
{id}
|
|
147
|
+
bind:value={currency}
|
|
148
|
+
options={[
|
|
149
|
+
{ value: '', label: '—' },
|
|
150
|
+
{ value: 'USD', label: 'USD' },
|
|
151
|
+
{ value: 'EUR', label: 'EUR' },
|
|
152
|
+
{ value: 'IRR', label: 'IRR' },
|
|
153
|
+
{ value: 'AED', label: 'AED' },
|
|
154
|
+
]}
|
|
155
|
+
/>
|
|
156
|
+
{/snippet}
|
|
157
|
+
</Field>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{#snippet footer()}
|
|
163
|
+
<Button variant="ghost" onclick={close}>{t('common.cancel')}</Button>
|
|
164
|
+
<Button onclick={() => create.mutate()} disabled={!canSubmit} loading={create.isPending}>
|
|
165
|
+
{t('common.save')}
|
|
166
|
+
</Button>
|
|
167
|
+
{/snippet}
|
|
168
|
+
</Dialog>
|
|
169
|
+
|
|
170
|
+
<style>
|
|
171
|
+
.form {
|
|
172
|
+
display: grid;
|
|
173
|
+
gap: 14px;
|
|
174
|
+
}
|
|
175
|
+
.pair {
|
|
176
|
+
display: grid;
|
|
177
|
+
grid-template-columns: 1fr 1fr;
|
|
178
|
+
gap: 12px;
|
|
179
|
+
}
|
|
180
|
+
.price {
|
|
181
|
+
display: grid;
|
|
182
|
+
grid-template-columns: 1fr 90px;
|
|
183
|
+
gap: 8px;
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { type Message, scopedT } from '@kernhq/ui'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This module's own strings, in every locale the platform ships.
|
|
5
|
+
*
|
|
6
|
+
* A module ships separately from the app, so Paraglide — which compiles only the app's
|
|
7
|
+
* `messages/*.json` — cannot see these. The shell merges them into the framework's message runtime
|
|
8
|
+
* when it registers the module, and `t()` resolves against the merged map. Keys are namespaced by
|
|
9
|
+
* module id, which is what keeps two modules from colliding in that one map.
|
|
10
|
+
*
|
|
11
|
+
* A **counted** message is not a string with `{count}` in it: give it a map of CLDR plural category
|
|
12
|
+
* to string and `t(key, { n })` picks the form. English has two and Arabic has six, and which one
|
|
13
|
+
* applies is `Intl.PluralRules`' answer rather than yours.
|
|
14
|
+
*
|
|
15
|
+
* Words every module needs and none owns — Save, Cancel, Retry — come from the framework's `common`
|
|
16
|
+
* bundle (`t('common.save')`). Do not copy them here; six translations of "Save" drift apart.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const en: Record<string, Message> = {
|
|
20
|
+
'inventory.nav': 'Inventory',
|
|
21
|
+
'inventory.title': 'Assets',
|
|
22
|
+
'inventory.empty': 'No assets yet',
|
|
23
|
+
'inventory.empty_desc': 'Add the first laptop, phone or desk — anything the company owns.',
|
|
24
|
+
'inventory.new': 'New asset',
|
|
25
|
+
'inventory.edit': 'Edit asset',
|
|
26
|
+
'inventory.code': 'Asset tag',
|
|
27
|
+
'inventory.name': 'Name',
|
|
28
|
+
'inventory.name_placeholder': 'MacBook Pro 14", office chair…',
|
|
29
|
+
'inventory.description': 'Description',
|
|
30
|
+
'inventory.category': 'Category',
|
|
31
|
+
'inventory.serial_number': 'Serial number',
|
|
32
|
+
'inventory.location': 'Location',
|
|
33
|
+
'inventory.purchased_on': 'Purchase date',
|
|
34
|
+
'inventory.purchased_from': 'Purchased from',
|
|
35
|
+
'inventory.price': 'Price',
|
|
36
|
+
'inventory.warranty_until': 'Warranty until',
|
|
37
|
+
'inventory.status_in_stock': 'In stock',
|
|
38
|
+
'inventory.status_assigned': 'Assigned',
|
|
39
|
+
'inventory.status_under_repair': 'Under repair',
|
|
40
|
+
'inventory.status_retired': 'Retired',
|
|
41
|
+
'inventory.count': { one: '{n} asset', other: '{n} assets' },
|
|
42
|
+
'inventory.search_placeholder': 'Search by name, tag or serial…',
|
|
43
|
+
'inventory.filter_all_statuses': 'All statuses',
|
|
44
|
+
'inventory.archived': 'Archived',
|
|
45
|
+
'inventory.archive': 'Archive',
|
|
46
|
+
'inventory.restore': 'Restore',
|
|
47
|
+
'inventory.widget_title': 'Recent assets',
|
|
48
|
+
'inventory.widget_desc': 'The most recently added assets in this workspace.',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type InventoryMessageKey = keyof typeof en
|
|
52
|
+
|
|
53
|
+
export const fa: Record<string, Message> = {
|
|
54
|
+
'inventory.nav': 'اموال',
|
|
55
|
+
'inventory.title': 'اقلام',
|
|
56
|
+
'inventory.empty': 'هنوز قلمی ثبت نشده است',
|
|
57
|
+
'inventory.empty_desc': 'اولین لپتاپ، گوشی یا میز را اضافه کنید — هر چیزی که شرکت دارد.',
|
|
58
|
+
'inventory.new': 'قلم جدید',
|
|
59
|
+
'inventory.edit': 'ویرایش قلم',
|
|
60
|
+
'inventory.code': 'کد قلم',
|
|
61
|
+
'inventory.name': 'نام',
|
|
62
|
+
'inventory.name_placeholder': 'مکبوک پرو ۱۴ اینچ، صندلی اداری…',
|
|
63
|
+
'inventory.description': 'توضیحات',
|
|
64
|
+
'inventory.category': 'دسته',
|
|
65
|
+
'inventory.serial_number': 'شماره سریال',
|
|
66
|
+
'inventory.location': 'محل استقرار',
|
|
67
|
+
'inventory.purchased_on': 'تاریخ خرید',
|
|
68
|
+
'inventory.purchased_from': 'خریداری از',
|
|
69
|
+
'inventory.price': 'قیمت',
|
|
70
|
+
'inventory.warranty_until': 'گارانتی تا',
|
|
71
|
+
'inventory.status_in_stock': 'در انبار',
|
|
72
|
+
'inventory.status_assigned': 'تحویلشده',
|
|
73
|
+
'inventory.status_under_repair': 'در تعمیر',
|
|
74
|
+
'inventory.status_retired': 'اسقاطشده',
|
|
75
|
+
'inventory.count': { one: '{n} قلم', other: '{n} قلم' },
|
|
76
|
+
'inventory.search_placeholder': 'جستجو بر اساس نام، کد یا سریال…',
|
|
77
|
+
'inventory.filter_all_statuses': 'همه وضعیتها',
|
|
78
|
+
'inventory.archived': 'بایگانیشده',
|
|
79
|
+
'inventory.archive': 'بایگانی',
|
|
80
|
+
'inventory.restore': 'بازگردانی',
|
|
81
|
+
'inventory.widget_title': 'اقلام اخیر',
|
|
82
|
+
'inventory.widget_desc': 'آخرین اقلامی که به این فضای کاری اضافه شدهاند.',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const ar: Record<string, Message> = {
|
|
86
|
+
'inventory.nav': 'الأصول',
|
|
87
|
+
'inventory.title': 'الأصول',
|
|
88
|
+
'inventory.empty': 'لا أصول بعد',
|
|
89
|
+
'inventory.empty_desc': 'أضف أول حاسوب أو هاتف أو مكتب — أي شيء تملكه الشركة.',
|
|
90
|
+
'inventory.new': 'أصل جديد',
|
|
91
|
+
'inventory.edit': 'تعديل الأصل',
|
|
92
|
+
'inventory.code': 'رمز الأصل',
|
|
93
|
+
'inventory.name': 'الاسم',
|
|
94
|
+
'inventory.name_placeholder': 'حاسوب ماك بوك برو ١٤، كرسي مكتبي…',
|
|
95
|
+
'inventory.description': 'الوصف',
|
|
96
|
+
'inventory.category': 'الفئة',
|
|
97
|
+
'inventory.serial_number': 'الرقم التسلسلي',
|
|
98
|
+
'inventory.location': 'الموقع',
|
|
99
|
+
'inventory.purchased_on': 'تاريخ الشراء',
|
|
100
|
+
'inventory.purchased_from': 'تم الشراء من',
|
|
101
|
+
'inventory.price': 'السعر',
|
|
102
|
+
'inventory.warranty_until': 'الضمان حتى',
|
|
103
|
+
'inventory.status_in_stock': 'في المخزن',
|
|
104
|
+
'inventory.status_assigned': 'مُسلَّم',
|
|
105
|
+
'inventory.status_under_repair': 'قيد الإصلاح',
|
|
106
|
+
'inventory.status_retired': 'مستبعد',
|
|
107
|
+
'inventory.count': {
|
|
108
|
+
zero: 'لا أصول',
|
|
109
|
+
one: 'أصل واحد',
|
|
110
|
+
two: 'أصلان',
|
|
111
|
+
few: '{n} أصول',
|
|
112
|
+
many: '{n} أصلاً',
|
|
113
|
+
other: '{n} أصل',
|
|
114
|
+
},
|
|
115
|
+
'inventory.search_placeholder': 'ابحث بالاسم أو الرمز أو الرقم التسلسلي…',
|
|
116
|
+
'inventory.filter_all_statuses': 'جميع الحالات',
|
|
117
|
+
'inventory.archived': 'مؤرشف',
|
|
118
|
+
'inventory.archive': 'أرشفة',
|
|
119
|
+
'inventory.restore': 'استعادة',
|
|
120
|
+
'inventory.widget_title': 'أصول حديثة',
|
|
121
|
+
'inventory.widget_desc': 'الأصول المضافة أخيرًا إلى مساحة العمل هذه.',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const de: Record<string, Message> = {
|
|
125
|
+
'inventory.nav': 'Inventar',
|
|
126
|
+
'inventory.title': 'Gegenstände',
|
|
127
|
+
'inventory.empty': 'Noch keine Gegenstände',
|
|
128
|
+
'inventory.empty_desc':
|
|
129
|
+
'Fügen Sie den ersten Laptop, das erste Telefon oder den ersten Schreibtisch hinzu — alles, was die Firma besitzt.',
|
|
130
|
+
'inventory.new': 'Neuer Gegenstand',
|
|
131
|
+
'inventory.edit': 'Gegenstand bearbeiten',
|
|
132
|
+
'inventory.code': 'Inventarnummer',
|
|
133
|
+
'inventory.name': 'Name',
|
|
134
|
+
'inventory.name_placeholder': 'MacBook Pro 14", Bürostuhl…',
|
|
135
|
+
'inventory.description': 'Beschreibung',
|
|
136
|
+
'inventory.category': 'Kategorie',
|
|
137
|
+
'inventory.serial_number': 'Seriennummer',
|
|
138
|
+
'inventory.location': 'Standort',
|
|
139
|
+
'inventory.purchased_on': 'Kaufdatum',
|
|
140
|
+
'inventory.purchased_from': 'Gekauft bei',
|
|
141
|
+
'inventory.price': 'Preis',
|
|
142
|
+
'inventory.warranty_until': 'Garantie bis',
|
|
143
|
+
'inventory.status_in_stock': 'Auf Lager',
|
|
144
|
+
'inventory.status_assigned': 'Zugewiesen',
|
|
145
|
+
'inventory.status_under_repair': 'In Reparatur',
|
|
146
|
+
'inventory.status_retired': 'Ausgemustert',
|
|
147
|
+
'inventory.count': { one: '{n} Gegenstand', other: '{n} Gegenstände' },
|
|
148
|
+
'inventory.search_placeholder': 'Nach Name, Nummer oder Seriennummer suchen…',
|
|
149
|
+
'inventory.filter_all_statuses': 'Alle Zustände',
|
|
150
|
+
'inventory.archived': 'Archiviert',
|
|
151
|
+
'inventory.archive': 'Archivieren',
|
|
152
|
+
'inventory.restore': 'Wiederherstellen',
|
|
153
|
+
'inventory.widget_title': 'Neue Gegenstände',
|
|
154
|
+
'inventory.widget_desc': 'Die zuletzt hinzugefügten Gegenstände dieses Arbeitsbereichs.',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Add a locale by adding a bundle here. Bundles are thunks so a locale is fetched only when it is
|
|
159
|
+
* the one in use; English is the fallback and is therefore always loaded.
|
|
160
|
+
*/
|
|
161
|
+
export const inventoryMessageBundles = {
|
|
162
|
+
ar: async () => ar,
|
|
163
|
+
de: async () => de,
|
|
164
|
+
en: async () => en,
|
|
165
|
+
fa: async () => fa,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `t('nav')` — the module id is implied. `t('common.save')` still reaches the shared bundle. */
|
|
169
|
+
export const t = scopedT('inventory')
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half.
|
|
3
|
+
*
|
|
4
|
+
* Published as **source**, not compiled: the consumer builds the TypeScript and Svelte with its own
|
|
5
|
+
* toolchain, which is what lets `$state` in a module store stay reactive inside the app.
|
|
6
|
+
*
|
|
7
|
+
* Everything the interface offers lives here — the manifest, the screens, this module's own strings
|
|
8
|
+
* and its permissions. The app registers `inventoryClientModule` and mounts whatever it declares; it
|
|
9
|
+
* holds no screens of its own. Deleting this package removes the feature completely.
|
|
10
|
+
*
|
|
11
|
+
* **Import the file, not this barrel, from inside the package.** This re-exports `module.js`, which
|
|
12
|
+
* reaches Svelte components and the framework's rune-backed singletons — so a pure-function test
|
|
13
|
+
* that goes through here fails with "$state is not defined", and a file that imports its own barrel
|
|
14
|
+
* is a cycle that resolves inside the app and breaks the moment the package is built alone. Both
|
|
15
|
+
* happened, in three modules, before this note existed.
|
|
16
|
+
*
|
|
17
|
+
* `files` in package.json must cover every directory this entry reaches, contract source included.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { type Asset, type AssetStatus, inventoryPermissions, MODULE_ID } from '../contract.js'
|
|
21
|
+
export { createInventoryClient, type InventoryApi } from './api.js'
|
|
22
|
+
export { __setInventoryApi, getInventoryApi } from './api-instance.js'
|
|
23
|
+
export { type InventoryMessageKey, inventoryMessageBundles, t } from './i18n.js'
|
|
24
|
+
export { inventoryClientModule, inventoryClientModule as default } from './module.js'
|
|
25
|
+
export { canInventory, INVENTORY_PERMISSIONS, type InventoryPermission } from './permissions.js'
|
|
26
|
+
export { inventoryKeys } from './query.js'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Asset } from '../contract.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory implementation of this module's API.
|
|
5
|
+
*
|
|
6
|
+
* It satisfies the same contract types as the real client, so no screen has a second code path for
|
|
7
|
+
* demos and end-to-end tests. The shell reports demo mode through `getHost().isMock` — a module
|
|
8
|
+
* never checks an env var itself.
|
|
9
|
+
*
|
|
10
|
+
* Keep it in step with the contract. A module whose mock is missing a procedure has a working page
|
|
11
|
+
* and a broken demo, in exactly the environment used to show the product.
|
|
12
|
+
*/
|
|
13
|
+
interface MockAsset extends Asset {}
|
|
14
|
+
|
|
15
|
+
export function createMockInventoryApi() {
|
|
16
|
+
let counter = 2
|
|
17
|
+
const assets: MockAsset[] = [
|
|
18
|
+
seed('01920000-0000-7000-8000-000000000001', 'INV-0001', 'MacBook Pro 14"', 'assigned'),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function seed(id: string, code: string, name: string, status: Asset['status']): MockAsset {
|
|
22
|
+
const now = new Date().toISOString()
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
26
|
+
code,
|
|
27
|
+
name,
|
|
28
|
+
description: '',
|
|
29
|
+
categoryId: null,
|
|
30
|
+
status,
|
|
31
|
+
custodianUserId: null,
|
|
32
|
+
custodySince: null,
|
|
33
|
+
serialNumber: null,
|
|
34
|
+
location: null,
|
|
35
|
+
purchasedOn: null,
|
|
36
|
+
purchasedFrom: null,
|
|
37
|
+
priceMinor: null,
|
|
38
|
+
currency: null,
|
|
39
|
+
warrantyUntil: null,
|
|
40
|
+
photoFileId: null,
|
|
41
|
+
createdAt: now,
|
|
42
|
+
updatedAt: now,
|
|
43
|
+
archivedAt: null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
assets: {
|
|
49
|
+
list: async ({ workspaceId, q }: { workspaceId: string; q?: string }) => ({
|
|
50
|
+
items: assets
|
|
51
|
+
.filter((a) => !q || a.name.includes(q) || a.code.includes(q) || (a.serialNumber ?? '').includes(q))
|
|
52
|
+
.map((a) => ({ ...a, workspaceId })),
|
|
53
|
+
nextCursor: null,
|
|
54
|
+
}),
|
|
55
|
+
get: async ({ assetId }: { assetId: string }) => {
|
|
56
|
+
const asset = assets.find((a) => a.id === assetId)
|
|
57
|
+
if (!asset) throw new Error('Asset not found')
|
|
58
|
+
return { ...asset }
|
|
59
|
+
},
|
|
60
|
+
create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
|
|
61
|
+
const asset: MockAsset = {
|
|
62
|
+
...seed(
|
|
63
|
+
crypto.randomUUID(),
|
|
64
|
+
`INV-${String(counter++).padStart(4, '0')}`,
|
|
65
|
+
String(rest.name),
|
|
66
|
+
'in_stock',
|
|
67
|
+
),
|
|
68
|
+
workspaceId: workspaceId as Asset['workspaceId'],
|
|
69
|
+
description: String(rest.description ?? ''),
|
|
70
|
+
serialNumber: (rest.serialNumber as string | undefined) ?? null,
|
|
71
|
+
location: (rest.location as string | undefined) ?? null,
|
|
72
|
+
purchasedFrom: (rest.purchasedFrom as string | undefined) ?? null,
|
|
73
|
+
purchasedOn: (rest.purchasedOn as string | undefined) ?? null,
|
|
74
|
+
warrantyUntil: (rest.warrantyUntil as string | undefined) ?? null,
|
|
75
|
+
priceMinor: (rest.priceMinor as number | undefined) ?? null,
|
|
76
|
+
currency: (rest.currency as string | undefined) ?? null,
|
|
77
|
+
}
|
|
78
|
+
assets.unshift(asset)
|
|
79
|
+
return { ...asset }
|
|
80
|
+
},
|
|
81
|
+
update: async ({ assetId, ...rest }: { assetId: string } & Record<string, unknown>) => {
|
|
82
|
+
const asset = assets.find((a) => a.id === assetId)
|
|
83
|
+
if (!asset) throw new Error('Asset not found')
|
|
84
|
+
Object.assign(asset, rest)
|
|
85
|
+
return { ...asset }
|
|
86
|
+
},
|
|
87
|
+
archive: async ({ assetId, archived }: { assetId: string; archived?: boolean }) => {
|
|
88
|
+
const asset = assets.find((a) => a.id === assetId)
|
|
89
|
+
if (!asset) throw new Error('Asset not found')
|
|
90
|
+
asset.archivedAt = archived === false ? null : new Date().toISOString()
|
|
91
|
+
return { ...asset }
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|