@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
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Button, Dialog, Field, Input, Select, Textarea, toast } from '@kernhq/ui'
|
|
2
|
+
import { Button, Dialog, Field, Input, messageLocale, Select, Textarea, toast } from '@kernhq/ui'
|
|
3
3
|
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import type { Asset } from '../../contract/index.js'
|
|
4
5
|
import { getInventoryApi } from '../api-instance.js'
|
|
5
6
|
import { t } from '../i18n.js'
|
|
6
7
|
import { canInventory } from '../permissions.js'
|
|
8
|
+
import { formatPrice, parsePrice } from '../price.js'
|
|
9
|
+
import { inventoryKeys } from '../query.js'
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
* Add an asset.
|
|
10
|
-
*
|
|
12
|
+
* Add or change an asset.
|
|
13
|
+
*
|
|
14
|
+
* One dialog for both, because they ask for exactly the same nine things and a second component
|
|
15
|
+
* would be nine fields to keep in step. `asset` is what decides which: null creates, a row edits.
|
|
16
|
+
* `assets.update` had no entry point anywhere in the module before this — the procedure existed,
|
|
17
|
+
* was tested, and no screen could reach it.
|
|
18
|
+
*
|
|
19
|
+
* The tag (`INV-…`) is the server's job — people never invent codes, they read them off stickers —
|
|
20
|
+
* so this form asks only for what a human actually knows.
|
|
11
21
|
*/
|
|
12
22
|
interface Props {
|
|
13
23
|
open: boolean
|
|
14
24
|
workspaceId: string
|
|
25
|
+
/** The row being changed, or null to add one. */
|
|
26
|
+
asset?: Asset | null
|
|
15
27
|
}
|
|
16
|
-
let { open = $bindable(false), workspaceId }: Props = $props()
|
|
28
|
+
let { open = $bindable(false), workspaceId, asset = null }: Props = $props()
|
|
17
29
|
|
|
18
30
|
const api = getInventoryApi()
|
|
19
31
|
const queryClient = useQueryClient()
|
|
20
32
|
|
|
33
|
+
const editing = $derived(asset !== null)
|
|
34
|
+
|
|
21
35
|
let shown = $state(false)
|
|
22
36
|
$effect(() => {
|
|
23
37
|
if (open) shown = true
|
|
@@ -29,63 +43,123 @@ let serialNumber = $state('')
|
|
|
29
43
|
let location = $state('')
|
|
30
44
|
let purchasedOn = $state('')
|
|
31
45
|
let purchasedFrom = $state('')
|
|
32
|
-
let
|
|
46
|
+
let priceText = $state('')
|
|
33
47
|
let currency = $state('')
|
|
34
48
|
let warrantyUntil = $state('')
|
|
35
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What the form was last filled from, so opening it on a different row re-seeds it.
|
|
52
|
+
*
|
|
53
|
+
* A plain variable rather than `$state`: the effect below writes it on every run, and a reactive
|
|
54
|
+
* flag it also reads is a dependency on its own write.
|
|
55
|
+
*/
|
|
56
|
+
let seededFrom: string | null = null
|
|
57
|
+
|
|
58
|
+
$effect(() => {
|
|
59
|
+
const key = open ? (asset?.id ?? 'new') : null
|
|
60
|
+
if (key === null) {
|
|
61
|
+
seededFrom = null
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (seededFrom === key) return
|
|
65
|
+
seededFrom = key
|
|
66
|
+
fill(asset)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
function fill(row: Asset | null) {
|
|
70
|
+
name = row?.name ?? ''
|
|
71
|
+
description = row?.description ?? ''
|
|
72
|
+
serialNumber = row?.serialNumber ?? ''
|
|
73
|
+
location = row?.location ?? ''
|
|
74
|
+
purchasedOn = row?.purchasedOn ?? ''
|
|
75
|
+
purchasedFrom = row?.purchasedFrom ?? ''
|
|
76
|
+
// Formatted for the reader's own locale, and `parsePrice` reads back exactly what this writes.
|
|
77
|
+
priceText = formatPrice(row?.priceMinor, messageLocale())
|
|
78
|
+
currency = row?.currency ?? ''
|
|
79
|
+
warrantyUntil = row?.warrantyUntil ?? ''
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
const close = () => {
|
|
37
83
|
shown = false
|
|
38
84
|
open = false
|
|
39
85
|
}
|
|
40
86
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
87
|
+
/**
|
|
88
|
+
* The price, parsed the way the reader's own language writes one.
|
|
89
|
+
*
|
|
90
|
+
* `Number.parseFloat(raw.replace(',', '.'))` replaced the first comma only, so `1.234,56` — German,
|
|
91
|
+
* Turkish and Persian for twelve hundred — was stored as €1.23, and `abc` and `-5` were stored as
|
|
92
|
+
* no price at all. Neither said anything. Now an amount this locale cannot read is an error on the
|
|
93
|
+
* field, and there is no path where a wrong number is saved quietly.
|
|
94
|
+
*/
|
|
95
|
+
const priceResult = $derived(parsePrice(priceText, messageLocale()))
|
|
96
|
+
const priceError = $derived(
|
|
97
|
+
priceResult.ok ? null : t('price_invalid', { example: formatPrice(123456, messageLocale()) }),
|
|
98
|
+
)
|
|
52
99
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
/**
|
|
101
|
+
* `disabled={save.isPending}` does not stop the second click.
|
|
102
|
+
*
|
|
103
|
+
* The attribute reaches the button on the next render and two quick clicks are one render apart, so
|
|
104
|
+
* a double-click files two assets — two rows, two tags, one of them a duplicate somebody has to go
|
|
105
|
+
* and find. A plain flag set in the same tick as the click is what actually guards it.
|
|
106
|
+
*/
|
|
107
|
+
let submitting = $state(false)
|
|
108
|
+
|
|
109
|
+
/** What both procedures take; `update` adds the id and takes it as a patch. */
|
|
110
|
+
const fields = () => ({
|
|
111
|
+
name: name.trim(),
|
|
112
|
+
description: description.trim(),
|
|
113
|
+
serialNumber: serialNumber.trim() || null,
|
|
114
|
+
location: location.trim() || null,
|
|
115
|
+
purchasedFrom: purchasedFrom.trim() || null,
|
|
116
|
+
purchasedOn: purchasedOn || null,
|
|
117
|
+
warrantyUntil: warrantyUntil || null,
|
|
118
|
+
priceMinor: priceResult.ok ? priceResult.minor : null,
|
|
119
|
+
currency: currency.trim().toUpperCase() || null,
|
|
120
|
+
})
|
|
59
121
|
|
|
60
|
-
const
|
|
61
|
-
mutationFn: () =>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
currency: currency.trim().toUpperCase() || null,
|
|
73
|
-
}),
|
|
74
|
-
onSuccess: () => {
|
|
75
|
-
toast.success(t('new'))
|
|
76
|
-
void queryClient.invalidateQueries({ queryKey: ['inventory', 'assets'] })
|
|
77
|
-
reset()
|
|
122
|
+
const save = createMutation(() => ({
|
|
123
|
+
mutationFn: () => {
|
|
124
|
+
const row = asset
|
|
125
|
+
return row
|
|
126
|
+
? api.assets.update({ workspaceId, assetId: row.id, ...fields() })
|
|
127
|
+
: api.assets.create({ workspaceId, ...fields() })
|
|
128
|
+
},
|
|
129
|
+
onSuccess: (saved: Asset) => {
|
|
130
|
+
// Not `t('new')`: that is the label on the button that opened this, so the toast used to
|
|
131
|
+
// congratulate somebody on the words "New asset" rather than telling them what happened.
|
|
132
|
+
toast.success(t(editing ? 'updated_toast' : 'created_toast', { name: saved.name }))
|
|
133
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
78
134
|
close()
|
|
79
135
|
},
|
|
80
|
-
onError: (error: Error) => toast.error(error.message),
|
|
136
|
+
onError: (error: Error) => toast.error(error.message || t('common.error')),
|
|
137
|
+
onSettled: () => {
|
|
138
|
+
submitting = false
|
|
139
|
+
},
|
|
81
140
|
}))
|
|
82
141
|
|
|
83
|
-
|
|
142
|
+
/**
|
|
143
|
+
* `submitting` is deliberately **not** in here.
|
|
144
|
+
*
|
|
145
|
+
* It is the guard `submit()` checks, and putting it in the disabled expression as well disables the
|
|
146
|
+
* button under the finger of whoever just pressed it — the browser blurs a focused element the
|
|
147
|
+
* moment it becomes disabled and hands focus nowhere, so a keyboard user loses their place on the
|
|
148
|
+
* page mid-save. `loading={submitting}` says the same thing to a screen reader through `aria-busy`
|
|
149
|
+
* and moves nothing.
|
|
150
|
+
*/
|
|
151
|
+
const canSubmit = $derived(Boolean(name.trim()) && priceResult.ok && canInventory('manage'))
|
|
152
|
+
|
|
153
|
+
function submit() {
|
|
154
|
+
if (submitting || !canSubmit) return
|
|
155
|
+
submitting = true
|
|
156
|
+
save.mutate()
|
|
157
|
+
}
|
|
84
158
|
</script>
|
|
85
159
|
|
|
86
160
|
<Dialog
|
|
87
161
|
bind:open={shown}
|
|
88
|
-
title={t('new')}
|
|
162
|
+
title={editing ? t('edit') : t('new')}
|
|
89
163
|
onOpenChange={(next) => {
|
|
90
164
|
if (!next) close()
|
|
91
165
|
}}
|
|
@@ -135,12 +209,17 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
135
209
|
{/snippet}
|
|
136
210
|
</Field>
|
|
137
211
|
<div class="price">
|
|
212
|
+
<!-- The error goes on the `Input`, not the `Field`: it is the half that also sets
|
|
213
|
+
`aria-invalid` and points `aria-describedby` at the message, and putting it on both
|
|
214
|
+
would print the sentence twice. -->
|
|
138
215
|
<Field label={t('price')} id="inv-price">
|
|
139
216
|
{#snippet children(id)}
|
|
140
|
-
<Input {id} type="text" inputmode="decimal" bind:value={
|
|
217
|
+
<Input {id} type="text" inputmode="decimal" bind:value={priceText} error={priceError} />
|
|
141
218
|
{/snippet}
|
|
142
219
|
</Field>
|
|
143
|
-
|
|
220
|
+
<!-- Was `label="ISO" hint="USD"`: an untranslated abbreviation labelling a control on a
|
|
221
|
+
Persian, Arabic, German or Turkish form, explained by an example that is not a hint. -->
|
|
222
|
+
<Field label={t('currency')} id="inv-currency">
|
|
144
223
|
{#snippet children(id)}
|
|
145
224
|
<Select
|
|
146
225
|
{id}
|
|
@@ -161,7 +240,7 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
161
240
|
|
|
162
241
|
{#snippet footer()}
|
|
163
242
|
<Button variant="ghost" onclick={close}>{t('common.cancel')}</Button>
|
|
164
|
-
<Button onclick={
|
|
243
|
+
<Button onclick={submit} disabled={!canSubmit} loading={submitting}>
|
|
165
244
|
{t('common.save')}
|
|
166
245
|
</Button>
|
|
167
246
|
{/snippet}
|
|
@@ -179,7 +258,11 @@ const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !cr
|
|
|
179
258
|
}
|
|
180
259
|
.price {
|
|
181
260
|
display: grid;
|
|
182
|
-
|
|
261
|
+
/* 90px fitted "ISO" and nothing else — "Para birimi" and «واحد پول» need room to sit on one
|
|
262
|
+
line, and a wrapped two-word label beside a one-line one knocks the two controls out of
|
|
263
|
+
alignment. */
|
|
264
|
+
grid-template-columns: minmax(0, 1fr) 124px;
|
|
183
265
|
gap: 8px;
|
|
266
|
+
align-items: start;
|
|
184
267
|
}
|
|
185
268
|
</style>
|
package/src/client/i18n.ts
CHANGED
|
@@ -1,169 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
}
|
|
1
|
+
import { scopedT } from '@kernhq/ui'
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
ar,
|
|
5
|
+
de,
|
|
6
|
+
en,
|
|
7
|
+
fa,
|
|
8
|
+
type InventoryMessageKey,
|
|
9
|
+
inventoryMessageBundles,
|
|
10
|
+
tr,
|
|
11
|
+
} from './messages.js'
|
|
167
12
|
|
|
168
13
|
/** `t('nav')` — the module id is implied. `t('common.save')` still reaches the shared bundle. */
|
|
169
14
|
export const t = scopedT('inventory')
|
package/src/client/index.ts
CHANGED
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
* `files` in package.json must cover every directory this entry reaches, contract source included.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
type Asset,
|
|
22
|
+
type AssetStatus,
|
|
23
|
+
InventorySettings,
|
|
24
|
+
inventoryCapabilities,
|
|
25
|
+
inventoryPermissions,
|
|
26
|
+
MODULE_ID,
|
|
27
|
+
} from '../contract/index.js'
|
|
21
28
|
export { createInventoryClient, type InventoryApi } from './api.js'
|
|
22
29
|
export { __setInventoryApi, getInventoryApi } from './api-instance.js'
|
|
23
30
|
export { type InventoryMessageKey, inventoryMessageBundles, t } from './i18n.js'
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ar, de, en, fa, inventoryMessageBundles, tr } from './messages.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bundles, structurally — and the counted messages, behaviourally.
|
|
6
|
+
*
|
|
7
|
+
* Nothing else looks at these. A key present in English and missing in Persian type-checks, builds,
|
|
8
|
+
* lints and ships, and the first person to see it is the one reading `inventory.status_lost` on a
|
|
9
|
+
* screen. Adding a status to `AssetStatus` and forgetting four translations is the exact shape of
|
|
10
|
+
* that mistake, and this file is what makes it fail here instead.
|
|
11
|
+
*/
|
|
12
|
+
const BUNDLES = { en, ar, de, fa, tr } as const
|
|
13
|
+
type Locale = keyof typeof BUNDLES
|
|
14
|
+
|
|
15
|
+
/** The count placeholder: the runtime accepts `count` or `n`, and this module's catalogue uses `n`. */
|
|
16
|
+
const COUNT = 'n'
|
|
17
|
+
const placeholders = (s: string) => new Set([...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!))
|
|
18
|
+
const forms = (v: unknown): string[] => (typeof v === 'string' ? [v] : Object.values(v as object))
|
|
19
|
+
|
|
20
|
+
describe('bundles', () => {
|
|
21
|
+
it('declares every locale the module claims to ship', () => {
|
|
22
|
+
expect(Object.keys(inventoryMessageBundles).sort()).toEqual(['ar', 'de', 'en', 'fa', 'tr'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('has the same key set in every locale', () => {
|
|
26
|
+
const keys = Object.keys(en).sort()
|
|
27
|
+
for (const [locale, bundle] of Object.entries(BUNDLES)) {
|
|
28
|
+
expect({ locale, keys: Object.keys(bundle).sort() }).toEqual({ locale, keys })
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('namespaces every key, so two modules cannot collide in the merged map', () => {
|
|
33
|
+
for (const key of Object.keys(en)) expect(key.startsWith('inventory.')).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A plural form may drop the numeral — "one asset" reads better than "1 asset" in some
|
|
38
|
+
* languages — but dropping any *other* placeholder loses information the sentence needed.
|
|
39
|
+
*/
|
|
40
|
+
it('keeps every non-count placeholder in every plural form', () => {
|
|
41
|
+
for (const [key, value] of Object.entries(en)) {
|
|
42
|
+
const expected = placeholders(forms(value).join(' '))
|
|
43
|
+
expected.delete(COUNT)
|
|
44
|
+
for (const [locale, bundle] of Object.entries(BUNDLES)) {
|
|
45
|
+
for (const form of forms(bundle[key as keyof typeof bundle])) {
|
|
46
|
+
const got = placeholders(form)
|
|
47
|
+
got.delete(COUNT)
|
|
48
|
+
expect({ key, locale, form, got: [...got].sort() }).toEqual({
|
|
49
|
+
key,
|
|
50
|
+
locale,
|
|
51
|
+
form,
|
|
52
|
+
got: [...expected].sort(),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('never invents a placeholder the English string does not have', () => {
|
|
60
|
+
for (const [key, value] of Object.entries(en)) {
|
|
61
|
+
const allowed = placeholders(forms(value).join(' '))
|
|
62
|
+
for (const [locale, bundle] of Object.entries(BUNDLES))
|
|
63
|
+
for (const form of forms(bundle[key as keyof typeof bundle]))
|
|
64
|
+
for (const name of placeholders(form))
|
|
65
|
+
expect({ key, locale, name, known: allowed.has(name) }).toEqual({
|
|
66
|
+
key,
|
|
67
|
+
locale,
|
|
68
|
+
name,
|
|
69
|
+
known: true,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** Arabic inflects six ways. A bundle with only one/other picks `other` for two, and reads wrong. */
|
|
75
|
+
it('gives every Arabic plural all six CLDR categories', () => {
|
|
76
|
+
const wanted = new Intl.PluralRules('ar').resolvedOptions().pluralCategories.sort()
|
|
77
|
+
for (const [key, value] of Object.entries(ar)) {
|
|
78
|
+
if (typeof value === 'string') continue
|
|
79
|
+
expect({ key, cats: Object.keys(value).sort() }).toEqual({ key, cats: wanted })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('makes a plural of a key wherever English has one', () => {
|
|
84
|
+
for (const [key, value] of Object.entries(en)) {
|
|
85
|
+
if (typeof value === 'string') continue
|
|
86
|
+
for (const [locale, bundle] of Object.entries(BUNDLES))
|
|
87
|
+
expect({ key, locale, plural: typeof bundle[key as keyof typeof bundle] }).toEqual({
|
|
88
|
+
key,
|
|
89
|
+
locale,
|
|
90
|
+
plural: 'object',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every status the contract can hold has a label, in every language.
|
|
98
|
+
*
|
|
99
|
+
* The screens render a status with `t(`status_${status}`)`, which cannot be type-checked: a status
|
|
100
|
+
* added to the enum and not to the catalogue renders the literal key `inventory.status_lost` in the
|
|
101
|
+
* middle of a table, in every locale at once.
|
|
102
|
+
*/
|
|
103
|
+
describe('status labels', () => {
|
|
104
|
+
// Not imported from the contract: this file is data-only on purpose, and a copy of six strings
|
|
105
|
+
// that must match is exactly what a test is for.
|
|
106
|
+
const STATUSES = ['in_stock', 'assigned', 'reserved', 'under_repair', 'lost', 'retired']
|
|
107
|
+
|
|
108
|
+
it('labels every asset status in every locale', () => {
|
|
109
|
+
for (const status of STATUSES)
|
|
110
|
+
for (const [locale, bundle] of Object.entries(BUNDLES))
|
|
111
|
+
expect({ status, locale, has: `inventory.status_${status}` in bundle }).toEqual({
|
|
112
|
+
status,
|
|
113
|
+
locale,
|
|
114
|
+
has: true,
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The counted messages, resolved the way the runtime resolves them.
|
|
121
|
+
*
|
|
122
|
+
* `t()` lives in `@kernhq/ui`, whose entry point pulls in Svelte components this package's test
|
|
123
|
+
* setup cannot transform. So these drive the *data* through the same `Intl.PluralRules` selection
|
|
124
|
+
* `selectPlural` performs, and `t()` itself is tested where it lives.
|
|
125
|
+
*/
|
|
126
|
+
describe('counted messages', () => {
|
|
127
|
+
const pick = (locale: Locale, key: string, count: number): string => {
|
|
128
|
+
const value = BUNDLES[locale][key as keyof (typeof BUNDLES)[Locale]] as
|
|
129
|
+
| string
|
|
130
|
+
| Partial<Record<Intl.LDMLPluralRule, string>>
|
|
131
|
+
if (typeof value === 'string') return value
|
|
132
|
+
const category = new Intl.PluralRules(locale).select(count)
|
|
133
|
+
const form = value[category] ?? value.other
|
|
134
|
+
if (form === undefined) throw new Error(`${locale} ${key} has no form for ${count}`)
|
|
135
|
+
return form.replace(/\{n\}/g, new Intl.NumberFormat(locale).format(count))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it('says "1 asset" and "2 assets" in English', () => {
|
|
139
|
+
expect(pick('en', 'inventory.count', 1)).toBe('1 asset')
|
|
140
|
+
expect(pick('en', 'inventory.count', 2)).toBe('2 assets')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('says "1 Gegenstand" and "2 Gegenstände" in German', () => {
|
|
144
|
+
expect(pick('de', 'inventory.count', 1)).toBe('1 Gegenstand')
|
|
145
|
+
expect(pick('de', 'inventory.count', 2)).toBe('2 Gegenstände')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('follows Arabic numeral agreement rather than one-or-many', () => {
|
|
149
|
+
expect(pick('ar', 'inventory.count', 0)).toBe('لا أصول')
|
|
150
|
+
expect(pick('ar', 'inventory.count', 1)).toBe('أصل واحد')
|
|
151
|
+
expect(pick('ar', 'inventory.count', 2)).toBe('أصلان')
|
|
152
|
+
expect(pick('ar', 'inventory.count', 5)).toContain('أصول')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('does not inflect the noun after a numeral in Turkish or Persian', () => {
|
|
156
|
+
// "5 demirbaş", not "5 demirbaşlar" — the plural suffix is wrong once a number is present.
|
|
157
|
+
expect(pick('tr', 'inventory.count', 1).replace('1', '')).toBe(
|
|
158
|
+
pick('tr', 'inventory.count', 5).replace('5', ''),
|
|
159
|
+
)
|
|
160
|
+
expect(pick('fa', 'inventory.count', 1).replace('۱', '')).toBe(
|
|
161
|
+
pick('fa', 'inventory.count', 5).replace('۵', ''),
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('formats the number in the reader’s own digits', () => {
|
|
166
|
+
expect(pick('fa', 'inventory.count', 5)).toContain('۵')
|
|
167
|
+
expect(pick('en', 'inventory.count', 5)).toContain('5')
|
|
168
|
+
})
|
|
169
|
+
})
|