@kernhq/module-inventory 0.1.2 → 0.3.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 +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -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 +0 -387
- 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.d.ts +0 -427
- package/dist/server/_impl.d.ts.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
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
coreApi,
|
|
5
|
+
EmptyState,
|
|
6
|
+
Field,
|
|
7
|
+
Input,
|
|
8
|
+
keys,
|
|
9
|
+
navigation,
|
|
10
|
+
SettingsPage,
|
|
11
|
+
SettingsSection,
|
|
12
|
+
Skeleton,
|
|
13
|
+
session,
|
|
14
|
+
toast,
|
|
15
|
+
} from '@kernhq/ui'
|
|
16
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
17
|
+
import { MODULE_ID } from '../../contract/models.js'
|
|
18
|
+
import { InventorySettings } from '../../contract/settings.js'
|
|
19
|
+
import type { CoreApi } from '../core-api.js'
|
|
20
|
+
import { errorMessage } from '../errors.js'
|
|
21
|
+
import { t } from '../i18n.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Everything Inventory keeps at workspace level: what an asset tag looks like, and when the two
|
|
25
|
+
* nightly sweeps say something.
|
|
26
|
+
*
|
|
27
|
+
* `assetCodePrefix` and `assetCodePad` are enforced by the server on every `assets.create`, and
|
|
28
|
+
* `settingsPages` was `[]` — so the module declared two settings it honoured and gave nobody, at
|
|
29
|
+
* any permission level, a way to change either. A setting the server obeys and no screen exposes is
|
|
30
|
+
* a hard-coded value with extra steps. `warrantyNoticeDays` and `repairOverdueDays` arrive with the
|
|
31
|
+
* jobs that read them and are on this page in the same change, so that mistake is not repeated in
|
|
32
|
+
* the other direction.
|
|
33
|
+
*
|
|
34
|
+
* Settings live in core, not in this module's own schema, so they are read from
|
|
35
|
+
* `workspaces.modules.list` and written with `workspaces.modules.updateSettings` — the same
|
|
36
|
+
* mechanism HR's settings use, and the reason this module needs no settings table.
|
|
37
|
+
*
|
|
38
|
+
* **The write carries the whole `InventorySettings` object.** Core merges a partial write, so
|
|
39
|
+
* sending a subset would be safe — but this page owns the schema, and sending it whole is what
|
|
40
|
+
* makes the round trip self-evident. It is also why the repair window is sent from `stored` when
|
|
41
|
+
* the workspace has the capability off and its field is not on screen: a value nobody was shown is
|
|
42
|
+
* a value nobody meant to change.
|
|
43
|
+
*/
|
|
44
|
+
const api = coreApi<CoreApi>()
|
|
45
|
+
const queryClient = useQueryClient()
|
|
46
|
+
|
|
47
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
48
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
49
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
50
|
+
|
|
51
|
+
/** The permission core actually enforces on `workspaces.modules.updateSettings`. */
|
|
52
|
+
const canManage = $derived(session.can('core.modules.manage'))
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether this workspace records repairs at all.
|
|
56
|
+
*
|
|
57
|
+
* Read from the session — the set the *server* resolved, with defaults applied, `required` forced
|
|
58
|
+
* on and anything whose dependency is off pruned — for the reason `AssetDetailPanel` gives: working
|
|
59
|
+
* the closure out again here would be a second implementation of it, and two implementations
|
|
60
|
+
* eventually disagree. A workspace with `repairs` off never sees the overdue window, because the
|
|
61
|
+
* sweep behind it asks the same question first and does nothing.
|
|
62
|
+
*/
|
|
63
|
+
const hasRepairs = $derived(session.hasCapability(MODULE_ID, 'repairs'))
|
|
64
|
+
|
|
65
|
+
const modulesQuery = createQuery(() => ({
|
|
66
|
+
queryKey: keys.modules(workspaceId),
|
|
67
|
+
enabled: Boolean(workspaceId),
|
|
68
|
+
queryFn: () => api.workspaces.modules.list({ workspaceId }),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
const entry = $derived(modulesQuery.data?.find((m) => m.manifest.id === 'inventory'))
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What is stored, with the schema's defaults applied.
|
|
75
|
+
*
|
|
76
|
+
* `safeParse` rather than `parse`: a settings blob written before a field existed must not take the
|
|
77
|
+
* page down, and every field has a default, so the fallback is the shape a new workspace has. That
|
|
78
|
+
* is not hypothetical any more — every workspace that saved this page before the two notice windows
|
|
79
|
+
* existed has a blob with neither in it.
|
|
80
|
+
*/
|
|
81
|
+
const stored = $derived.by(() => {
|
|
82
|
+
const parsed = InventorySettings.safeParse(entry?.state.settings ?? {})
|
|
83
|
+
return parsed.success ? parsed.data : InventorySettings.parse({})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
let prefix = $state('')
|
|
87
|
+
/**
|
|
88
|
+
* Held as text, not as a number: an input somebody is half-way through clearing is empty, and
|
|
89
|
+
* `bind:value` on a number input turns that into `undefined` — which reads as "no change" and would
|
|
90
|
+
* save the old value behind their back.
|
|
91
|
+
*/
|
|
92
|
+
let padText = $state('')
|
|
93
|
+
/** The two notice windows, as text and for exactly the same reason. */
|
|
94
|
+
let warrantyDaysText = $state('')
|
|
95
|
+
let repairDaysText = $state('')
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What the form was last filled from, so the effect below can tell "already seeded" from
|
|
99
|
+
* "the server now says something different".
|
|
100
|
+
*
|
|
101
|
+
* A plain `loaded` boolean cannot tell those apart, and that difference is a bug rather than a
|
|
102
|
+
* nicety: re-arming it after a save re-runs the effect on the **next Svelte flush**, long before
|
|
103
|
+
* the refetch resolves, so the form was re-seeded from the *pre-save* cached value and then left
|
|
104
|
+
* alone when the real one arrived. An admin saw a green "saved" toast and watched the field snap
|
|
105
|
+
* back to what it had been — and saving again wrote the old value over the new one.
|
|
106
|
+
*/
|
|
107
|
+
let seededFrom = $state<string | null>(null)
|
|
108
|
+
const fingerprint = $derived(
|
|
109
|
+
[stored.assetCodePrefix, stored.assetCodePad, stored.warrantyNoticeDays, stored.repairOverdueDays].join(
|
|
110
|
+
' ',
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
const loaded = $derived(seededFrom !== null)
|
|
114
|
+
|
|
115
|
+
$effect(() => {
|
|
116
|
+
if (!entry || seededFrom === fingerprint) return
|
|
117
|
+
prefix = stored.assetCodePrefix
|
|
118
|
+
padText = String(stored.assetCodePad)
|
|
119
|
+
warrantyDaysText = String(stored.warrantyNoticeDays)
|
|
120
|
+
repairDaysText = String(stored.repairOverdueDays)
|
|
121
|
+
seededFrom = fingerprint
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/** Discard and re-seed are the same act: drop the edits and let the effect above refill them. */
|
|
125
|
+
function discard() {
|
|
126
|
+
seededFrom = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither — so somebody
|
|
131
|
+
* typing the digits of their own language would be told their input is not a whole number.
|
|
132
|
+
*/
|
|
133
|
+
const toLatinDigits = (value: string) =>
|
|
134
|
+
value
|
|
135
|
+
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
|
136
|
+
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
137
|
+
|
|
138
|
+
/** A whole number of days inside the range the contract accepts, or NaN. */
|
|
139
|
+
const days = (text: string): number => {
|
|
140
|
+
const digits = toLatinDigits(text.trim())
|
|
141
|
+
if (!/^\d{1,3}$/.test(digits)) return Number.NaN
|
|
142
|
+
const value = Number(digits)
|
|
143
|
+
return value >= 1 && value <= 365 ? value : Number.NaN
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const prefixError = $derived(prefix.length > 8 ? t('settings_prefix_too_long') : null)
|
|
147
|
+
|
|
148
|
+
const padDigits = $derived(toLatinDigits(padText.trim()))
|
|
149
|
+
const pad = $derived(/^\d{1,2}$/.test(padDigits) ? Number(padDigits) : Number.NaN)
|
|
150
|
+
const padError = $derived(pad >= 1 && pad <= 10 ? null : t('settings_pad_invalid'))
|
|
151
|
+
|
|
152
|
+
const warrantyDays = $derived(days(warrantyDaysText))
|
|
153
|
+
const warrantyError = $derived(Number.isNaN(warrantyDays) ? t('settings_days_invalid') : null)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The repair window is only validated when it is on screen.
|
|
157
|
+
*
|
|
158
|
+
* A workspace with `repairs` off never edits it, and holding the Save button hostage to a field
|
|
159
|
+
* nobody can see would be a form that cannot be submitted for a reason it does not show.
|
|
160
|
+
*/
|
|
161
|
+
const repairDays = $derived(days(repairDaysText))
|
|
162
|
+
const repairError = $derived(hasRepairs && Number.isNaN(repairDays) ? t('settings_days_invalid') : null)
|
|
163
|
+
|
|
164
|
+
const valid = $derived(!prefixError && !padError && !warrantyError && !repairError)
|
|
165
|
+
const dirty = $derived(
|
|
166
|
+
loaded &&
|
|
167
|
+
(prefix !== stored.assetCodePrefix ||
|
|
168
|
+
pad !== stored.assetCodePad ||
|
|
169
|
+
warrantyDays !== stored.warrantyNoticeDays ||
|
|
170
|
+
(hasRepairs && repairDays !== stored.repairOverdueDays)),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A shape, not a promise about the next number.
|
|
175
|
+
*
|
|
176
|
+
* The counter is a row in `mod_inventory.counters` rather than a setting — a number an
|
|
177
|
+
* administrator can edit is a number that produces a duplicate tag — so this page does not know
|
|
178
|
+
* what comes next and does not claim to. 42 is a sample that makes the padding visible.
|
|
179
|
+
*/
|
|
180
|
+
const EXAMPLE_NUMBER = 42
|
|
181
|
+
const example = $derived(Number.isNaN(pad) ? '—' : `${prefix}${String(EXAMPLE_NUMBER).padStart(pad, '0')}`)
|
|
182
|
+
|
|
183
|
+
let saving = $state(false)
|
|
184
|
+
let saveError = $state<string | null>(null)
|
|
185
|
+
|
|
186
|
+
const save = createMutation(() => ({
|
|
187
|
+
mutationFn: () =>
|
|
188
|
+
api.workspaces.modules.updateSettings({
|
|
189
|
+
workspaceId,
|
|
190
|
+
moduleId: 'inventory',
|
|
191
|
+
// Every field `InventorySettings` declares, which is what makes the round trip self-evident:
|
|
192
|
+
// nothing here can be a field somebody forgot to carry forward. Adding a field to the schema
|
|
193
|
+
// means adding it here, or core's merge silently decides its value instead of this page.
|
|
194
|
+
settings: {
|
|
195
|
+
assetCodePrefix: prefix,
|
|
196
|
+
assetCodePad: pad,
|
|
197
|
+
warrantyNoticeDays: warrantyDays,
|
|
198
|
+
// From `stored` when the field was never on screen: a workspace with `repairs` off must
|
|
199
|
+
// keep whatever it had rather than have this page decide for it.
|
|
200
|
+
repairOverdueDays: hasRepairs ? repairDays : stored.repairOverdueDays,
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
onSuccess: () => {
|
|
204
|
+
saveError = null
|
|
205
|
+
// Only invalidate. The form already holds what was just sent, so re-seeding it here would seed
|
|
206
|
+
// from the cache as it is *now* — the pre-save value. When the refetch lands, `fingerprint`
|
|
207
|
+
// changes and the effect above re-seeds from what the server actually stored, which is the
|
|
208
|
+
// truth. Until then the form shows what the admin typed, which is the next best thing.
|
|
209
|
+
void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
|
|
210
|
+
toast.success(t('settings_saved'))
|
|
211
|
+
},
|
|
212
|
+
// `err.message` is a sentence core wrote, in English, and this page's own reader chose
|
|
213
|
+
// otherwise. `errors.ts` gives it the sentence for whatever class of refusal it was — a
|
|
214
|
+
// capability switched off, a permission missing, a validation the form did not catch.
|
|
215
|
+
onError: (err: unknown) => {
|
|
216
|
+
saveError = errorMessage(err, t)
|
|
217
|
+
},
|
|
218
|
+
onSettled: () => {
|
|
219
|
+
saving = false
|
|
220
|
+
},
|
|
221
|
+
}))
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `disabled={save.isPending}` reaches the button a render too late, and two quick clicks are one
|
|
225
|
+
* render apart — so the guard is a plain flag set in the same tick as the click. It is deliberately
|
|
226
|
+
* not in the `disabled` expression: disabling the control somebody is standing on blurs it and
|
|
227
|
+
* hands focus nowhere, and `loading` already says "busy" through `aria-busy`.
|
|
228
|
+
*/
|
|
229
|
+
function requestSave() {
|
|
230
|
+
if (saving || !dirty || !valid) return
|
|
231
|
+
saving = true
|
|
232
|
+
saveError = null
|
|
233
|
+
save.mutate()
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
{#snippet saveBar()}
|
|
238
|
+
<Button size="sm" variant="secondary" onclick={discard} disabled={!dirty}>
|
|
239
|
+
{t('common.discard')}
|
|
240
|
+
</Button>
|
|
241
|
+
<Button size="sm" onclick={requestSave} disabled={!dirty || !valid} loading={saving}>
|
|
242
|
+
{t('common.save')}
|
|
243
|
+
</Button>
|
|
244
|
+
{/snippet}
|
|
245
|
+
|
|
246
|
+
<SettingsPage title={t('settings_general')} description={t('settings_general_desc')}>
|
|
247
|
+
{#if modulesQuery.isPending}
|
|
248
|
+
<Skeleton height="220px" radius="10px" />
|
|
249
|
+
{:else if modulesQuery.isError}
|
|
250
|
+
<EmptyState icon="triangle-alert" title={t('settings_error')} description={t('common.error')}>
|
|
251
|
+
{#snippet actions()}
|
|
252
|
+
<Button variant="secondary" onclick={() => void modulesQuery.refetch()}>
|
|
253
|
+
{t('common.retry')}
|
|
254
|
+
</Button>
|
|
255
|
+
{/snippet}
|
|
256
|
+
</EmptyState>
|
|
257
|
+
{:else if !entry}
|
|
258
|
+
<EmptyState icon="briefcase" title={t('settings_not_enabled')} />
|
|
259
|
+
{:else}
|
|
260
|
+
{#if !canManage}
|
|
261
|
+
<p class="readonly">{t('settings_readonly')}</p>
|
|
262
|
+
{/if}
|
|
263
|
+
|
|
264
|
+
<SettingsSection title={t('settings_numbering')} description={t('settings_numbering_desc')}>
|
|
265
|
+
<div class="rows">
|
|
266
|
+
<Field inline label={t('settings_prefix')} hint={t('settings_prefix_hint')}>
|
|
267
|
+
{#snippet children(id)}
|
|
268
|
+
<div class="narrow">
|
|
269
|
+
<Input
|
|
270
|
+
{id}
|
|
271
|
+
bind:value={prefix}
|
|
272
|
+
maxlength={8}
|
|
273
|
+
mono
|
|
274
|
+
disabled={!canManage}
|
|
275
|
+
error={prefixError}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
{/snippet}
|
|
279
|
+
</Field>
|
|
280
|
+
|
|
281
|
+
<Field inline label={t('settings_pad')} hint={t('settings_pad_hint')}>
|
|
282
|
+
{#snippet children(id)}
|
|
283
|
+
<div class="narrow">
|
|
284
|
+
<Input
|
|
285
|
+
{id}
|
|
286
|
+
bind:value={padText}
|
|
287
|
+
inputmode="numeric"
|
|
288
|
+
maxlength={2}
|
|
289
|
+
mono
|
|
290
|
+
disabled={!canManage}
|
|
291
|
+
error={padError}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
{/snippet}
|
|
295
|
+
</Field>
|
|
296
|
+
|
|
297
|
+
<div class="example">
|
|
298
|
+
<span class="example-label">{t('settings_preview')}</span>
|
|
299
|
+
<!--
|
|
300
|
+
Interpolated as text on purpose. A tag is an identifier the server builds by
|
|
301
|
+
concatenation, so a Persian reader has to see the same characters that are printed on
|
|
302
|
+
the sticker — this is the one place on the page where localised digits would be wrong.
|
|
303
|
+
-->
|
|
304
|
+
<span class="example-value">{example}</span>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</SettingsSection>
|
|
308
|
+
|
|
309
|
+
<!--
|
|
310
|
+
One save bar, on the last section, because there is one settings object and one request. Two
|
|
311
|
+
bars would suggest two independent saves and then write both halves anyway.
|
|
312
|
+
-->
|
|
313
|
+
<SettingsSection
|
|
314
|
+
title={t('settings_notices')}
|
|
315
|
+
description={t('settings_notices_desc')}
|
|
316
|
+
footer={canManage ? saveBar : undefined}
|
|
317
|
+
>
|
|
318
|
+
<div class="rows">
|
|
319
|
+
<Field inline label={t('settings_warranty_days')} hint={t('settings_warranty_days_hint')}>
|
|
320
|
+
{#snippet children(id)}
|
|
321
|
+
<div class="narrow">
|
|
322
|
+
<Input
|
|
323
|
+
{id}
|
|
324
|
+
bind:value={warrantyDaysText}
|
|
325
|
+
inputmode="numeric"
|
|
326
|
+
maxlength={3}
|
|
327
|
+
mono
|
|
328
|
+
disabled={!canManage}
|
|
329
|
+
error={warrantyError}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
{/snippet}
|
|
333
|
+
</Field>
|
|
334
|
+
|
|
335
|
+
{#if hasRepairs}
|
|
336
|
+
<Field inline label={t('settings_repair_days')} hint={t('settings_repair_days_hint')}>
|
|
337
|
+
{#snippet children(id)}
|
|
338
|
+
<div class="narrow">
|
|
339
|
+
<Input
|
|
340
|
+
{id}
|
|
341
|
+
bind:value={repairDaysText}
|
|
342
|
+
inputmode="numeric"
|
|
343
|
+
maxlength={3}
|
|
344
|
+
mono
|
|
345
|
+
disabled={!canManage}
|
|
346
|
+
error={repairError}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
{/snippet}
|
|
350
|
+
</Field>
|
|
351
|
+
{/if}
|
|
352
|
+
</div>
|
|
353
|
+
</SettingsSection>
|
|
354
|
+
|
|
355
|
+
{#if saveError}
|
|
356
|
+
<p class="save-error" role="alert">{saveError}</p>
|
|
357
|
+
{/if}
|
|
358
|
+
{/if}
|
|
359
|
+
</SettingsPage>
|
|
360
|
+
|
|
361
|
+
<style>
|
|
362
|
+
.readonly {
|
|
363
|
+
margin: 0;
|
|
364
|
+
font-size: 12.5px;
|
|
365
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
366
|
+
color: var(--kern-ink-500);
|
|
367
|
+
}
|
|
368
|
+
.rows {
|
|
369
|
+
display: grid;
|
|
370
|
+
gap: 14px;
|
|
371
|
+
}
|
|
372
|
+
.narrow {
|
|
373
|
+
max-inline-size: 180px;
|
|
374
|
+
}
|
|
375
|
+
.example {
|
|
376
|
+
display: flex;
|
|
377
|
+
align-items: baseline;
|
|
378
|
+
gap: 10px;
|
|
379
|
+
padding: 10px 12px;
|
|
380
|
+
border-radius: var(--kern-r-md);
|
|
381
|
+
background: var(--kern-surface-chip);
|
|
382
|
+
}
|
|
383
|
+
.example-label {
|
|
384
|
+
font-size: 12.5px;
|
|
385
|
+
color: var(--kern-ink-500);
|
|
386
|
+
}
|
|
387
|
+
.example-value {
|
|
388
|
+
font-family: var(--kern-font-mono);
|
|
389
|
+
font-size: 14px;
|
|
390
|
+
font-weight: 500;
|
|
391
|
+
color: var(--kern-ink-900);
|
|
392
|
+
/* A tag is read character by character, so the glyphs line up rather than reflow. */
|
|
393
|
+
font-variant-numeric: tabular-nums;
|
|
394
|
+
/* The identifier stays left-to-right inside a Persian or Arabic sentence. */
|
|
395
|
+
direction: ltr;
|
|
396
|
+
unicode-bidi: isolate;
|
|
397
|
+
}
|
|
398
|
+
.save-error {
|
|
399
|
+
margin: 0;
|
|
400
|
+
font-size: 12.5px;
|
|
401
|
+
color: var(--kern-danger);
|
|
402
|
+
}
|
|
403
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BadgeTone } from '@kernhq/ui'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What colour a status wears, in the one place both screens read it from.
|
|
5
|
+
*
|
|
6
|
+
* The list and the detail panel show the same badge for the same row, and a `switch` copied into
|
|
7
|
+
* two components is two switches that drift — the day `reserved` gets its own colour, one of them
|
|
8
|
+
* changes. `import type` is erased at build time, so this file still pulls no runtime dependency on
|
|
9
|
+
* `@kernhq/ui` and stays unit-testable.
|
|
10
|
+
*
|
|
11
|
+
* The tones say what a reader needs to act on rather than what is "good": `in_stock` is the
|
|
12
|
+
* ordinary resting state, `lost` is the one somebody has to do something about, and an archived row
|
|
13
|
+
* is drawn grey by the screens without asking here — it is not a status, it is the absence of one.
|
|
14
|
+
*/
|
|
15
|
+
export function statusTone(status: string): BadgeTone {
|
|
16
|
+
switch (status) {
|
|
17
|
+
case 'assigned':
|
|
18
|
+
case 'reserved':
|
|
19
|
+
return 'info'
|
|
20
|
+
case 'under_repair':
|
|
21
|
+
return 'warning'
|
|
22
|
+
case 'lost':
|
|
23
|
+
return 'danger'
|
|
24
|
+
case 'retired':
|
|
25
|
+
return 'grey'
|
|
26
|
+
default:
|
|
27
|
+
return 'success'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { AssetInput } from '../contract/models.js'
|
|
3
|
+
import { en } from './messages.js'
|
|
4
|
+
import {
|
|
5
|
+
actionKey,
|
|
6
|
+
changeLineKind,
|
|
7
|
+
changeShape,
|
|
8
|
+
fieldKey,
|
|
9
|
+
isKnownAction,
|
|
10
|
+
isLongText,
|
|
11
|
+
isOpaque,
|
|
12
|
+
noteOf,
|
|
13
|
+
personIdOf,
|
|
14
|
+
} from './timeline.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The decisions behind a readable timeline, checked where they can be checked.
|
|
18
|
+
*
|
|
19
|
+
* `Timeline.svelte` supplies the words; every judgement it makes — which sentence, about whom, with
|
|
20
|
+
* what label — is in `timeline.ts`, and each one has a right answer. The three that matter are the
|
|
21
|
+
* ones nothing else can catch:
|
|
22
|
+
*
|
|
23
|
+
* 1. an action the catalogue has no sentence for must not be described as something else;
|
|
24
|
+
* 2. a field added to the contract must not reach a screen as its own column name;
|
|
25
|
+
* 3. a return names who gave it back, not who received it — they are different keys in `data`,
|
|
26
|
+
* and reading the wrong one renders "took it back from nobody".
|
|
27
|
+
*/
|
|
28
|
+
describe('actionKey', () => {
|
|
29
|
+
it('names a key the catalogue actually has, for every action the server writes', () => {
|
|
30
|
+
// Exactly what `AssetService` and `CustodyService` write today.
|
|
31
|
+
for (const action of [
|
|
32
|
+
'created',
|
|
33
|
+
'updated',
|
|
34
|
+
'assigned',
|
|
35
|
+
'transferred',
|
|
36
|
+
'returned',
|
|
37
|
+
'retired',
|
|
38
|
+
'restored',
|
|
39
|
+
]) {
|
|
40
|
+
expect({ action, key: actionKey(action) }).toEqual({ action, key: `history_${action}` })
|
|
41
|
+
expect({ action, has: `inventory.${actionKey(action)}` in en }).toEqual({ action, has: true })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('falls back honestly for an action written by a newer image', () => {
|
|
46
|
+
// Reservations and stock counts write their own actions, and a browser tab open across a
|
|
47
|
+
// deploy will meet one. "Somebody changed it — reserved" is true; "edited it" would not be.
|
|
48
|
+
expect(actionKey('reserved')).toBe('history_unknown')
|
|
49
|
+
expect(isKnownAction('reserved')).toBe(false)
|
|
50
|
+
expect('inventory.history_unknown' in en).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('phrases what repairs and attachments write, rather than falling back', () => {
|
|
54
|
+
// These four were the fallback's example until the change that made them real. An action this
|
|
55
|
+
// client *does* know must never render as "changed it — repair_logged".
|
|
56
|
+
for (const action of ['repair_logged', 'repair_completed', 'attachment_added', 'attachment_removed']) {
|
|
57
|
+
expect({ action, key: actionKey(action) }).toEqual({ action, key: `history_${action}` })
|
|
58
|
+
expect({ action, has: `inventory.${actionKey(action)}` in en }).toEqual({ action, has: true })
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('fieldKey', () => {
|
|
64
|
+
it('labels every field the contract lets somebody edit', () => {
|
|
65
|
+
// `AssetInput` is the whole of what `assets.update` accepts, and therefore the whole of what a
|
|
66
|
+
// stored diff can name. A field added there with no key here would print `warrantyUntil` in the
|
|
67
|
+
// middle of a Persian sentence.
|
|
68
|
+
for (const field of Object.keys(AssetInput.shape)) {
|
|
69
|
+
const key = fieldKey(field)
|
|
70
|
+
expect({ field, key }).not.toEqual({ field, key: null })
|
|
71
|
+
expect({ field, has: `inventory.${key}` in en }).toEqual({ field, has: true })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('says it does not know a field rather than guessing at one', () => {
|
|
76
|
+
expect(fieldKey('somethingNewer')).toBe(null)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('changeShape', () => {
|
|
81
|
+
it('tells filling a field in from clearing it from moving it', () => {
|
|
82
|
+
expect(changeShape(null, 'Desk 4')).toBe('set')
|
|
83
|
+
expect(changeShape('Desk 4', null)).toBe('cleared')
|
|
84
|
+
expect(changeShape('Desk 4', 'Desk 5')).toBe('changed')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats an empty string as absent, because that is what clearing a description stores', () => {
|
|
88
|
+
// `description` is `not null` in the database, so emptying it writes `''` — and "changed from
|
|
89
|
+
// to x" is a sentence with a hole in it.
|
|
90
|
+
expect(changeShape('', 'A note')).toBe('set')
|
|
91
|
+
expect(changeShape('A note', '')).toBe('cleared')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('personIdOf', () => {
|
|
96
|
+
it('reads the recipient for a handover and the giver for a return', () => {
|
|
97
|
+
expect(personIdOf('assigned', { userId: 'ada' })).toBe('ada')
|
|
98
|
+
expect(personIdOf('transferred', { userId: 'bruno', previousUserId: 'ada' })).toBe('bruno')
|
|
99
|
+
// The one that would silently read "nobody": `returned` stores no `userId` at all.
|
|
100
|
+
expect(personIdOf('returned', { previousUserId: 'ada' })).toBe('ada')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('has no person for an entry that is not about one', () => {
|
|
104
|
+
expect(personIdOf('created', {})).toBe(null)
|
|
105
|
+
expect(personIdOf('assigned', { userId: 123 })).toBe(null)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('noteOf', () => {
|
|
110
|
+
it('takes a note only when somebody actually wrote one', () => {
|
|
111
|
+
expect(noteOf({ note: ' For the Berlin trip ' })).toBe('For the Berlin trip')
|
|
112
|
+
expect(noteOf({ note: ' ' })).toBe(null)
|
|
113
|
+
expect(noteOf({})).toBe(null)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The decision that keeps eight kilobytes of somebody's prose out of a row of the panel.
|
|
119
|
+
*
|
|
120
|
+
* `description` is `z.string().max(8000)` in the contract, and the timeline read a change to it out
|
|
121
|
+
* as "Description changed from … to …" with both versions inline — so one edit to one paragraph
|
|
122
|
+
* pushed every entry below it off a 440px sheet. Nothing failed: the string was correct, the
|
|
123
|
+
* translation was correct, and the row was unusable.
|
|
124
|
+
*/
|
|
125
|
+
describe('changeLineKind', () => {
|
|
126
|
+
it('says only that a description changed, and never what it says', () => {
|
|
127
|
+
const long = 'x'.repeat(8000)
|
|
128
|
+
expect(changeLineKind('description', 'before', long)).toBe('text_changed')
|
|
129
|
+
// Filling one in for the first time is the same sentence once the value is gone: two keys
|
|
130
|
+
// saying "Description changed" would be two translations to keep in step for no gain.
|
|
131
|
+
expect(changeLineKind('description', null, long)).toBe('text_changed')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('decides on the field, not on how long the value happens to be today', () => {
|
|
135
|
+
// A short description is still prose. Branching on length would make the timeline's shape
|
|
136
|
+
// depend on what somebody typed, so the same field would render two different ways.
|
|
137
|
+
expect(changeLineKind('description', 'a', 'b')).toBe('text_changed')
|
|
138
|
+
expect(isLongText('description')).toBe(true)
|
|
139
|
+
expect(isLongText('location')).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('still says "cleared" when a description is emptied — there is nothing to disclose', () => {
|
|
143
|
+
// `description` is `not null` in the database, so clearing it stores `''`.
|
|
144
|
+
expect(changeLineKind('description', 'A note', '')).toBe('cleared')
|
|
145
|
+
expect(changeLineKind('description', 'A note', null)).toBe('cleared')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('keeps reading an ordinary field out, which is the whole point of a timeline', () => {
|
|
149
|
+
expect(changeLineKind('location', null, 'Desk 4')).toBe('set')
|
|
150
|
+
expect(changeLineKind('location', 'Desk 4', 'Desk 5')).toBe('changed')
|
|
151
|
+
expect(changeLineKind('location', 'Desk 4', null)).toBe('cleared')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('reports an opaque id as a replacement rather than printing a uuid', () => {
|
|
155
|
+
expect(isOpaque('photoFileId')).toBe(true)
|
|
156
|
+
expect(changeLineKind('photoFileId', 'a', 'b')).toBe('replaced')
|
|
157
|
+
// Removing the photo is still "cleared": there is no id to avoid printing.
|
|
158
|
+
expect(changeLineKind('photoFileId', 'a', null)).toBe('cleared')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('names a key the catalogue has, for every kind it can return', () => {
|
|
162
|
+
const keys = {
|
|
163
|
+
cleared: 'history_cleared',
|
|
164
|
+
replaced: 'history_replaced',
|
|
165
|
+
set: 'history_set',
|
|
166
|
+
changed: 'history_changed',
|
|
167
|
+
text_changed: 'history_text_changed',
|
|
168
|
+
}
|
|
169
|
+
for (const key of Object.values(keys))
|
|
170
|
+
expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
|
|
171
|
+
// And the disclosure's own three, which `TimelineText.svelte` reads.
|
|
172
|
+
for (const key of ['history_show_text', 'history_text_before', 'history_text_after'])
|
|
173
|
+
expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
|
|
174
|
+
})
|
|
175
|
+
})
|