@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.
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 +5 -4
  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
@@ -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. 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.
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 priceMinor = $state('')
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
- const reset = () => {
42
- name = ''
43
- description = ''
44
- serialNumber = ''
45
- location = ''
46
- purchasedOn = ''
47
- purchasedFrom = ''
48
- priceMinor = ''
49
- currency = ''
50
- warrantyUntil = ''
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
- /** 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
- }
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 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()
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
- const canSubmit = $derived(Boolean(name.trim()) && canInventory('manage') && !create.isPending)
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={priceMinor} />
217
+ <Input {id} type="text" inputmode="decimal" bind:value={priceText} error={priceError} />
141
218
  {/snippet}
142
219
  </Field>
143
- <Field label="ISO" id="inv-currency" hint="USD">
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={() => create.mutate()} disabled={!canSubmit} loading={create.isPending}>
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
- grid-template-columns: 1fr 90px;
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>
@@ -1,169 +1,14 @@
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
- }
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')
@@ -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 { type Asset, type AssetStatus, inventoryPermissions, MODULE_ID } from '../contract.js'
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
+ })