@kernhq/module-inventory 0.2.0 → 0.4.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 +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- 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 +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- 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 +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- 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 +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -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 +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- 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 +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- 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/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/0008_category_order_unique.sql +71 -0
- 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 +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- 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 +71 -22
- 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 +395 -0
- package/src/client/errors.ts +229 -0
- 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 +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -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 +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -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 +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- 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/src/client/settings/core-api.ts +0 -32
|
Binary file
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a stored history row into something a person reads.
|
|
3
|
+
*
|
|
4
|
+
* `asset_history` holds `{ action, changes: [{field, from, to}], data: {…ids} }`, which is a shape
|
|
5
|
+
* for a database and not a sentence. A panel that renders it raw shows somebody a JSON diff of
|
|
6
|
+
* their own laptop; the point of a timeline is that it reads as "Ada handed it to Bruno".
|
|
7
|
+
*
|
|
8
|
+
* **"it", not the asset tag** — and this note exists because the README claimed otherwise for a
|
|
9
|
+
* release. The timeline only ever appears inside that asset's own panel, under a header already
|
|
10
|
+
* showing `INV-0042`, so naming the tag in all fifty entries would repeat on every line the one
|
|
11
|
+
* thing that cannot change while somebody is reading it.
|
|
12
|
+
*
|
|
13
|
+
* The decisions live here rather than inside the component for the reason `price.ts` does: a
|
|
14
|
+
* `.svelte` file drags a compiler behind it and cannot be unit-tested, and every one of these is a
|
|
15
|
+
* fact with a right answer. The component supplies the words — this file only says which words and
|
|
16
|
+
* about whom.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here imports `@kernhq/ui`. That entry point reaches the Svelte components, and a helper
|
|
19
|
+
* that pulls a compiler into whatever imports it is a helper nothing can test.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Every action this client knows how to phrase. Anything else is honest about not knowing. */
|
|
23
|
+
const KNOWN = new Set([
|
|
24
|
+
'created',
|
|
25
|
+
'updated',
|
|
26
|
+
'assigned',
|
|
27
|
+
'transferred',
|
|
28
|
+
'returned',
|
|
29
|
+
'retired',
|
|
30
|
+
'restored',
|
|
31
|
+
'repair_logged',
|
|
32
|
+
'repair_completed',
|
|
33
|
+
'attachment_added',
|
|
34
|
+
'attachment_removed',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The message key for one entry's headline.
|
|
39
|
+
*
|
|
40
|
+
* An unknown action is **not** silently rendered as "changed it". The rows outlive the client that
|
|
41
|
+
* reads them: repairs and attachments write their own actions, and a browser tab left open across
|
|
42
|
+
* a deploy will meet one. `history_unknown` says somebody changed it and names the action, which is
|
|
43
|
+
* honest about the gap; a generic "changed it" would quietly describe a repair as an edit for as
|
|
44
|
+
* long as the tab stayed open.
|
|
45
|
+
*/
|
|
46
|
+
export function actionKey(action: string): string {
|
|
47
|
+
return KNOWN.has(action) ? `history_${action}` : 'history_unknown'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const isKnownAction = (action: string): boolean => KNOWN.has(action)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which sentence a single field change wants.
|
|
54
|
+
*
|
|
55
|
+
* "from nothing to Desk 4" is not a change a person recognises — they filled a field in. The three
|
|
56
|
+
* shapes are the three sentences: filled in, cleared, and moved from one value to another.
|
|
57
|
+
*
|
|
58
|
+
* An empty string counts as absent, because that is what the asset form writes for a field somebody
|
|
59
|
+
* emptied: `description` is `not null` in the database, so clearing it stores `''` and never
|
|
60
|
+
* `null`, and `'' → 'x'` reported as "changed from to x" is a sentence with a hole in it.
|
|
61
|
+
*/
|
|
62
|
+
export type ChangeShape = 'set' | 'cleared' | 'changed'
|
|
63
|
+
|
|
64
|
+
const absent = (value: unknown): boolean => value === null || value === undefined || value === ''
|
|
65
|
+
|
|
66
|
+
export function changeShape(from: unknown, to: unknown): ChangeShape {
|
|
67
|
+
if (absent(to)) return 'cleared'
|
|
68
|
+
if (absent(from)) return 'set'
|
|
69
|
+
return 'changed'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Who did it is `resolveName` in `members.ts`, and deliberately not here.
|
|
74
|
+
*
|
|
75
|
+
* This file used to carry an `actorKind(actorId, known)` that answered `person`, `former` or
|
|
76
|
+
* `system` from a bare set of ids. It was a second implementation of a question `members.ts`
|
|
77
|
+
* already answers, and it made the same mistake `nameOf` did: a set that has not been fetched yet
|
|
78
|
+
* is indistinguishable from a set somebody has left, so an id absent from it came back as `former`
|
|
79
|
+
* while the request was still in flight. A set cannot say which — only the request's own state can
|
|
80
|
+
* — so the question belongs where the directory is, with the five answers `NameKind` names.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The message key naming a field, for the one sentence per change.
|
|
85
|
+
*
|
|
86
|
+
* A stored diff names its column (`serialNumber`, `warrantyUntil`), which is a word for a database
|
|
87
|
+
* and not for a reader — «warrantyUntil از ۲۰۲۷ به ۲۰۲۸ تغییر کرد» is English leaking into a Persian
|
|
88
|
+
* sentence in exactly the place a translation was supposed to be. The map is the whole of
|
|
89
|
+
* `AssetInput`, and `timeline.test.ts` holds it to that: a field added to the contract with no key
|
|
90
|
+
* here shows up as a failing test rather than as a column name on somebody's screen.
|
|
91
|
+
*
|
|
92
|
+
* `null` for a field this client has never heard of. The rows outlive the image that wrote them, so
|
|
93
|
+
* a browser tab open across a deploy will meet one, and the component prints the raw name — which is
|
|
94
|
+
* ugly and true, where a guessed label would be neither.
|
|
95
|
+
*/
|
|
96
|
+
const FIELD_KEYS: Record<string, string> = {
|
|
97
|
+
name: 'name',
|
|
98
|
+
description: 'description',
|
|
99
|
+
categoryId: 'category',
|
|
100
|
+
serialNumber: 'serial_number',
|
|
101
|
+
location: 'location',
|
|
102
|
+
purchasedFrom: 'purchased_from',
|
|
103
|
+
purchasedOn: 'purchased_on',
|
|
104
|
+
warrantyUntil: 'warranty_until',
|
|
105
|
+
priceMinor: 'price',
|
|
106
|
+
currency: 'currency',
|
|
107
|
+
photoFileId: 'photo',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function fieldKey(field: string): string | null {
|
|
111
|
+
return FIELD_KEYS[field] ?? null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which fields hold prose rather than a value, and therefore cannot be read out in a sentence.
|
|
116
|
+
*
|
|
117
|
+
* `description` is `z.string().max(8000)` in the contract, and the timeline rendered a change to it
|
|
118
|
+
* as "Description changed from … to …" with both versions inline — up to sixteen kilobytes of
|
|
119
|
+
* somebody's prose in a single row of a 440px panel, and every row below it pushed off the screen.
|
|
120
|
+
* A diff line is a sentence about a value; eight thousand characters is not a value.
|
|
121
|
+
*
|
|
122
|
+
* So a long-text change says **that** it changed and offers the text behind a disclosure, which is
|
|
123
|
+
* `TimelineText.svelte` — the two versions are only put in the document when somebody asks for
|
|
124
|
+
* them. The list stays a list, and nothing is hidden: the text is one keystroke away, labelled
|
|
125
|
+
* before and after.
|
|
126
|
+
*
|
|
127
|
+
* A set rather than a length check on the value, because the decision is about the *field*: a short
|
|
128
|
+
* description is still prose, and rendering it inline on the days it happens to be short would make
|
|
129
|
+
* the timeline's shape depend on what somebody typed.
|
|
130
|
+
*/
|
|
131
|
+
const LONG_TEXT = new Set(['description'])
|
|
132
|
+
|
|
133
|
+
export const isLongText = (field: string): boolean => LONG_TEXT.has(field)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The sentence a change to one field wants, as a key name.
|
|
137
|
+
*
|
|
138
|
+
* One function rather than the component branching on `isLongText` and `changeShape` separately,
|
|
139
|
+
* because the combination is the decision: clearing a description is still just "Description
|
|
140
|
+
* cleared" — short, complete, and nothing to disclose — while setting or changing one is the case
|
|
141
|
+
* that must not read its own value out.
|
|
142
|
+
*
|
|
143
|
+
* `text_changed` covers both `set` and `changed` for prose. "Description set to …" and "Description
|
|
144
|
+
* changed from … to …" are the same sentence once the values are gone, and two keys saying the same
|
|
145
|
+
* thing are two translations to keep in step for no gain.
|
|
146
|
+
*/
|
|
147
|
+
export type ChangeLine = 'cleared' | 'replaced' | 'set' | 'changed' | 'text_changed'
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A field whose value is an opaque id is reported as a replacement rather than with a uuid in it.
|
|
151
|
+
*
|
|
152
|
+
* `photoFileId` is the only one: the value is a file id, and "Photo changed from 0192… to 0193…"
|
|
153
|
+
* says less than "Photo replaced" while being longer and uglier.
|
|
154
|
+
*/
|
|
155
|
+
const OPAQUE = new Set(['photoFileId'])
|
|
156
|
+
|
|
157
|
+
export const isOpaque = (field: string): boolean => OPAQUE.has(field)
|
|
158
|
+
|
|
159
|
+
export function changeLineKind(field: string, from: unknown, to: unknown): ChangeLine {
|
|
160
|
+
const shape = changeShape(from, to)
|
|
161
|
+
if (shape === 'cleared') return 'cleared'
|
|
162
|
+
if (isOpaque(field)) return 'replaced'
|
|
163
|
+
return isLongText(field) ? 'text_changed' : shape
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The other person in a custody sentence, out of the entry's `data`.
|
|
168
|
+
*
|
|
169
|
+
* `assigned` and `transferred` name who received it; `returned` names who gave it back, and both are
|
|
170
|
+
* stored under different keys because they are different facts. Reading `userId` for all three would
|
|
171
|
+
* make a return read "took it back from nobody" — the key is simply absent there.
|
|
172
|
+
*/
|
|
173
|
+
export function personIdOf(action: string, data: Record<string, unknown>): string | null {
|
|
174
|
+
const value = action === 'returned' ? data.previousUserId : data.userId
|
|
175
|
+
return typeof value === 'string' && value ? value : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The handover note, if whoever handed the item over left one. */
|
|
179
|
+
export function noteOf(data: Record<string, unknown>): string | null {
|
|
180
|
+
const note = data.note
|
|
181
|
+
return typeof note === 'string' && note.trim() ? note.trim() : null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The thing an entry is *about*, when the sentence alone does not say it.
|
|
186
|
+
*
|
|
187
|
+
* "Ada sent it for repair" is a sentence; which repair is the summary somebody typed, and "Ada
|
|
188
|
+
* attached a file" is missing the only interesting word in it. Both are rendered as a second line
|
|
189
|
+
* under the headline rather than interpolated into it, and neither goes through the catalogue —
|
|
190
|
+
* this is text a person typed or a file they named, so translating around it would be translating
|
|
191
|
+
* a placeholder.
|
|
192
|
+
*
|
|
193
|
+
* Two keys rather than one because they are two facts: a repair's `summary` and a file's `name`.
|
|
194
|
+
* Reading one key for both would print a file id where a summary belongs the day a third action
|
|
195
|
+
* arrives. Null for anything else, and for a row written before this client knew the key — the rows
|
|
196
|
+
* outlive the image that wrote them.
|
|
197
|
+
*/
|
|
198
|
+
export function subjectOf(action: string, data: Record<string, unknown>): string | null {
|
|
199
|
+
const value =
|
|
200
|
+
action === 'repair_logged' || action === 'repair_completed'
|
|
201
|
+
? data.summary
|
|
202
|
+
: action === 'attachment_added' || action === 'attachment_removed'
|
|
203
|
+
? data.name
|
|
204
|
+
: null
|
|
205
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
206
|
+
}
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Badge, type
|
|
2
|
+
import { Badge, messageLocale, type WidgetProps, WidgetState } from '@kernhq/ui'
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
4
|
import { getInventoryApi } from '../api-instance.js'
|
|
5
|
+
import { isolated } from '../bidi.js'
|
|
5
6
|
import { t } from '../i18n.js'
|
|
7
|
+
import { assetHref } from '../links.js'
|
|
6
8
|
import { inventoryKeys } from '../query.js'
|
|
9
|
+
import { statusTone } from '../status.js'
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* A dashboard card.
|
|
10
13
|
*
|
|
11
14
|
* `WidgetState` draws loading, failed and empty so every card on the board reports those three the
|
|
12
15
|
* same way — and so a module does not have to translate "Retry" to show a widget that failed.
|
|
16
|
+
*
|
|
17
|
+
* `WidgetProps` rather than a hand-written pair, and that is what makes the rows work: the shell
|
|
18
|
+
* passes `workspaceSlug`, which is the half a link to another screen cannot be built without.
|
|
19
|
+
* Declaring two of the eight props by hand is how a card ends up unable to act on anything.
|
|
20
|
+
*
|
|
21
|
+
* `kern-widget` §3 also says to hide row actions while `editing`, and there is nothing to hide
|
|
22
|
+
* here: the action *is* the row, and `WidgetFrame` drags from its grip button rather than from the
|
|
23
|
+
* card's body — so an anchor in the body is never what somebody is holding.
|
|
13
24
|
*/
|
|
14
|
-
|
|
15
|
-
workspaceId: string
|
|
16
|
-
settings?: Record<string, string | number | boolean | null>
|
|
17
|
-
}
|
|
18
|
-
const { workspaceId, settings }: Props = $props()
|
|
25
|
+
const { workspaceId, workspaceSlug, settings }: WidgetProps = $props()
|
|
19
26
|
|
|
20
27
|
const limit = $derived(Number(settings?.limit ?? 5))
|
|
21
28
|
const api = getInventoryApi()
|
|
@@ -37,21 +44,46 @@ const assets = createQuery(() => ({
|
|
|
37
44
|
|
|
38
45
|
const items = $derived(assets.data?.items ?? [])
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The three numbers that make this card worth a place on a dashboard.
|
|
49
|
+
*
|
|
50
|
+
* A list of five recent rows says what somebody added last week, which is rarely the question. How
|
|
51
|
+
* many things there are, how many are sitting unclaimed, and how many are away being fixed is the
|
|
52
|
+
* question — and one request answers all three, so the strip costs a query rather than a screen.
|
|
53
|
+
*
|
|
54
|
+
* It fails **soft**: the card is a list of recent assets first, and a numbers strip that could not
|
|
55
|
+
* be loaded simply is not drawn. A dashboard where one card reports an error because a secondary
|
|
56
|
+
* query failed is a worse dashboard than one where a strip is missing.
|
|
57
|
+
*/
|
|
58
|
+
const stats = createQuery(() => ({
|
|
59
|
+
queryKey: inventoryKeys.stats(workspaceId),
|
|
60
|
+
queryFn: () => api.stats.summary({ workspaceId }),
|
|
61
|
+
enabled: Boolean(workspaceId),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `outForRepair` is null for a workspace that does not track repairs — not zero, which would be a
|
|
66
|
+
* claim it has not made. A null tile is simply absent.
|
|
67
|
+
*/
|
|
68
|
+
const tiles = $derived(
|
|
69
|
+
stats.data
|
|
70
|
+
? [
|
|
71
|
+
{ label: t('stats_total'), value: stats.data.total },
|
|
72
|
+
{ label: t('stats_unassigned'), value: stats.data.unassigned },
|
|
73
|
+
...(stats.data.outForRepair !== null
|
|
74
|
+
? [{ label: t('stats_out_for_repair'), value: stats.data.outForRepair }]
|
|
75
|
+
: []),
|
|
76
|
+
]
|
|
77
|
+
: [],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A count in the reader's own digits — «۱۲» in Persian, not "12".
|
|
82
|
+
*
|
|
83
|
+
* Not `formatCount`, which caps at "99+": that is a badge helper, and a workspace with 214 assets
|
|
84
|
+
* would be told it has 99+ of them on the line whose job is to be the number.
|
|
85
|
+
*/
|
|
86
|
+
const number = (n: number) => new Intl.NumberFormat(messageLocale()).format(n)
|
|
55
87
|
</script>
|
|
56
88
|
|
|
57
89
|
<WidgetState
|
|
@@ -62,37 +94,102 @@ function statusTone(status: string): BadgeTone {
|
|
|
62
94
|
emptyIcon="briefcase"
|
|
63
95
|
onRetry={() => assets.refetch()}
|
|
64
96
|
>
|
|
97
|
+
{#if tiles.length}
|
|
98
|
+
<dl class="stats">
|
|
99
|
+
{#each tiles as tile (tile.label)}
|
|
100
|
+
<div class="stat">
|
|
101
|
+
<dt>{tile.label}</dt>
|
|
102
|
+
<dd>{number(tile.value)}</dd>
|
|
103
|
+
</div>
|
|
104
|
+
{/each}
|
|
105
|
+
</dl>
|
|
106
|
+
{/if}
|
|
65
107
|
<ul>
|
|
66
108
|
{#each items as asset (asset.id)}
|
|
67
109
|
<li>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
110
|
+
<!--
|
|
111
|
+
The row *is* the action. `kern-widget` §3: a card that only shows rows is a table of
|
|
112
|
+
contents, and this one showed five assets with nothing to do about any of them. An anchor
|
|
113
|
+
rather than a click handler, so the keyboard route is the same route — Tab reaches it,
|
|
114
|
+
Enter follows it, and the browser offers "open in a new tab" for free. It lands on the
|
|
115
|
+
list with the asset's own panel open, which is exactly where clicking the row on that
|
|
116
|
+
screen puts somebody.
|
|
117
|
+
-->
|
|
118
|
+
<a class="row" href={assetHref(workspaceSlug, asset.id)}
|
|
119
|
+
aria-label={t('open_asset', isolated({ name: asset.name }))}>
|
|
120
|
+
<span class="code">{asset.code}</span>
|
|
121
|
+
<span class="name">{asset.name}</span>
|
|
122
|
+
<Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
|
|
123
|
+
</a>
|
|
71
124
|
</li>
|
|
72
125
|
{/each}
|
|
73
126
|
</ul>
|
|
74
127
|
</WidgetState>
|
|
75
128
|
|
|
76
129
|
<style>
|
|
130
|
+
.stats {
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-wrap: wrap;
|
|
133
|
+
gap: 6px 18px;
|
|
134
|
+
margin: 0 0 12px;
|
|
135
|
+
padding: 0 0 12px;
|
|
136
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
137
|
+
}
|
|
138
|
+
.stat {
|
|
139
|
+
display: flex;
|
|
140
|
+
flex-direction: column;
|
|
141
|
+
gap: 1px;
|
|
142
|
+
min-width: 0;
|
|
143
|
+
}
|
|
144
|
+
dt {
|
|
145
|
+
font-size: 11px;
|
|
146
|
+
/* Muted with a colour rather than opacity, which fades the label against the card. */
|
|
147
|
+
color: var(--kern-ink-500);
|
|
148
|
+
}
|
|
149
|
+
dd {
|
|
150
|
+
margin: 0;
|
|
151
|
+
font-size: 18px;
|
|
152
|
+
font-weight: 600;
|
|
153
|
+
line-height: 1.15;
|
|
154
|
+
letter-spacing: -0.02em;
|
|
155
|
+
color: var(--kern-ink-900);
|
|
156
|
+
font-variant-numeric: tabular-nums;
|
|
157
|
+
}
|
|
77
158
|
ul {
|
|
78
159
|
list-style: none;
|
|
79
160
|
margin: 0;
|
|
80
161
|
padding: 0;
|
|
81
162
|
display: flex;
|
|
82
163
|
flex-direction: column;
|
|
83
|
-
gap:
|
|
164
|
+
gap: 2px;
|
|
84
165
|
}
|
|
85
|
-
|
|
166
|
+
.row {
|
|
86
167
|
display: flex;
|
|
87
168
|
align-items: center;
|
|
88
169
|
gap: 10px;
|
|
89
170
|
font-size: 13px;
|
|
171
|
+
color: inherit;
|
|
172
|
+
text-decoration: none;
|
|
173
|
+
/* Logical, so the row's breathing room is on the reading-start edge in Persian too. Six
|
|
174
|
+
vertical pixels take the row past the 24px WCAG 2.5.8 target height on its own. */
|
|
175
|
+
padding-block: 5px;
|
|
176
|
+
padding-inline: 6px;
|
|
177
|
+
margin-inline: -6px;
|
|
178
|
+
border-radius: var(--kern-r-sm);
|
|
179
|
+
}
|
|
180
|
+
.row:hover {
|
|
181
|
+
background: var(--kern-surface-hover);
|
|
90
182
|
}
|
|
91
183
|
.code {
|
|
92
184
|
font-size: 11px;
|
|
93
185
|
color: var(--kern-ink-500);
|
|
94
186
|
font-variant-numeric: tabular-nums;
|
|
95
187
|
min-width: 64px;
|
|
188
|
+
/* A tag is read character by character off a sticker, so it stays left-to-right inside a
|
|
189
|
+
Persian or Arabic card and the bidi algorithm does not reorder its digits — the same rule
|
|
190
|
+
the repairs card next to it already follows. */
|
|
191
|
+
direction: ltr;
|
|
192
|
+
unicode-bidi: isolate;
|
|
96
193
|
}
|
|
97
194
|
.name {
|
|
98
195
|
flex: 1;
|
|
@@ -101,5 +198,9 @@ function statusTone(status: string): BadgeTone {
|
|
|
101
198
|
text-overflow: ellipsis;
|
|
102
199
|
white-space: nowrap;
|
|
103
200
|
color: var(--kern-ink-900);
|
|
201
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
202
|
+
first strong character, so a Latin name inside a Persian screen reads left to right and
|
|
203
|
+
keeps its own trailing punctuation instead of donating it to the paragraph. */
|
|
204
|
+
unicode-bidi: plaintext;
|
|
104
205
|
}
|
|
105
206
|
</style>
|