@kernhq/module-inventory 0.2.0 → 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 +104 -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 +322 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +213 -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 +1052 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +225 -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 +1337 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +363 -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 +158 -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 +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 +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/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 +42 -0
- package/package.json +1 -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 +365 -0
- package/src/client/errors.ts +201 -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 +1043 -18
- package/src/client/mock.test.ts +395 -1
- package/src/client/mock.ts +1066 -79
- package/src/client/module.ts +54 -0
- 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/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 +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 +237 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +255 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +194 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +3573 -18
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +113 -0
- package/src/server/router.ts +462 -6
- package/src/server/schema.ts +160 -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 +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 +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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
EmptyState,
|
|
8
|
+
Field,
|
|
9
|
+
IconButton,
|
|
10
|
+
Input,
|
|
11
|
+
type MenuItem,
|
|
12
|
+
navigation,
|
|
13
|
+
SettingsPage,
|
|
14
|
+
SettingsSection,
|
|
15
|
+
Skeleton,
|
|
16
|
+
Switch,
|
|
17
|
+
session,
|
|
18
|
+
Table,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableHeader,
|
|
21
|
+
TableRow,
|
|
22
|
+
toast,
|
|
23
|
+
} from '@kernhq/ui'
|
|
24
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
25
|
+
import type { Category } from '../../contract/index.js'
|
|
26
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
27
|
+
import { isolated } from '../bidi.js'
|
|
28
|
+
import { errorMessage } from '../errors.js'
|
|
29
|
+
import { t } from '../i18n.js'
|
|
30
|
+
import { INVENTORY_PERMISSIONS } from '../permissions.js'
|
|
31
|
+
import { inventoryKeys } from '../query.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a workspace groups what it owns.
|
|
35
|
+
*
|
|
36
|
+
* `assets.list` has taken a `categoryId` filter since the module existed and nothing could create a
|
|
37
|
+
* category, so the filter had exactly one possible answer — this page is the other half of it.
|
|
38
|
+
*
|
|
39
|
+
* **Nothing here deletes.** `assets.category_id` carries no foreign key, so removing a row would
|
|
40
|
+
* leave every asset filed under it pointing at nothing: a blank column, and a timeline entry saying
|
|
41
|
+
* the category changed *to* a name it can no longer print. Archiving takes it out of every picker
|
|
42
|
+
* and every filter and leaves each asset able to say what it is; the same procedure restores it.
|
|
43
|
+
*/
|
|
44
|
+
const api = getInventoryApi()
|
|
45
|
+
const queryClient = useQueryClient()
|
|
46
|
+
|
|
47
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
48
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === workspaceSlug)?.id ?? '')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The permission the server enforces on every write here.
|
|
52
|
+
*
|
|
53
|
+
* The page is already gated on it in `module.ts`, so somebody without it never sees the entry —
|
|
54
|
+
* but a settings URL can be typed, and a read-only view of the list is a better answer than a form
|
|
55
|
+
* whose every button 403s.
|
|
56
|
+
*/
|
|
57
|
+
const canManage = $derived(session.can(INVENTORY_PERMISSIONS.categories))
|
|
58
|
+
|
|
59
|
+
let showArchived = $state(false)
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One query for every category, archived ones included, filtered here.
|
|
63
|
+
*
|
|
64
|
+
* Two things come out of that. It is the key `AssetsPage` and `AssetDetailPanel` already use, so
|
|
65
|
+
* arriving at this page after either of them costs no request — and, the reason it changed, it is
|
|
66
|
+
* the only way this page can tell **"no categories yet"** from **"every category is archived"**.
|
|
67
|
+
* With `archived: showArchived` in the request the two are the same empty array, and the page told
|
|
68
|
+
* a workspace that had archived all five of its categories that it had never made one — beside a
|
|
69
|
+
* *New category* button, with the row that would have fixed it one switch away.
|
|
70
|
+
*/
|
|
71
|
+
const categoriesQuery = createQuery(() => ({
|
|
72
|
+
queryKey: inventoryKeys.categories(workspaceId, true),
|
|
73
|
+
queryFn: () => api.categories.list({ workspaceId, archived: true }),
|
|
74
|
+
enabled: Boolean(workspaceId),
|
|
75
|
+
}))
|
|
76
|
+
const everything = $derived<Category[]>(categoriesQuery.data ?? [])
|
|
77
|
+
const categories = $derived(showArchived ? everything : everything.filter((row) => !row.archivedAt))
|
|
78
|
+
/** Rows exist, and the switch is hiding all of them. A different sentence from having none. */
|
|
79
|
+
const allArchived = $derived(everything.length > 0 && categories.length === 0)
|
|
80
|
+
|
|
81
|
+
// ------------------------------------------------------------------ the add / rename dialog
|
|
82
|
+
|
|
83
|
+
let editing = $state<Category | null>(null)
|
|
84
|
+
let dialogOpen = $state(false)
|
|
85
|
+
let name = $state('')
|
|
86
|
+
let orderText = $state('')
|
|
87
|
+
|
|
88
|
+
function openCreate() {
|
|
89
|
+
editing = null
|
|
90
|
+
name = ''
|
|
91
|
+
orderText = '0'
|
|
92
|
+
dialogOpen = true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function openEdit(category: Category) {
|
|
96
|
+
editing = category
|
|
97
|
+
name = category.name
|
|
98
|
+
orderText = String(category.order)
|
|
99
|
+
dialogOpen = true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither — so somebody
|
|
104
|
+
* typing the digits of their own language would be told their input is not a whole number.
|
|
105
|
+
*/
|
|
106
|
+
const toLatinDigits = (value: string) =>
|
|
107
|
+
value
|
|
108
|
+
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
|
109
|
+
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
110
|
+
|
|
111
|
+
const order = $derived.by(() => {
|
|
112
|
+
const digits = toLatinDigits(orderText.trim())
|
|
113
|
+
return /^\d{1,4}$/.test(digits) ? Number(digits) : Number.NaN
|
|
114
|
+
})
|
|
115
|
+
/**
|
|
116
|
+
* A hint and an error are two different sentences, and this field passed the hint as both.
|
|
117
|
+
*
|
|
118
|
+
* `hint` explains how positions sort — "Lower comes first. Categories sharing a position fall back
|
|
119
|
+
* to their names." — and typing `x` restated exactly that, in red, under `aria-invalid`. It told
|
|
120
|
+
* somebody nothing about what was wrong with what they had typed, and it made the field's ordinary
|
|
121
|
+
* hint look like a failure the first time they read it. The error says what this box will accept.
|
|
122
|
+
*/
|
|
123
|
+
const orderError = $derived(Number.isNaN(order) ? t('category_order_invalid') : null)
|
|
124
|
+
const canSubmit = $derived(canManage && Boolean(name.trim()) && !Number.isNaN(order))
|
|
125
|
+
|
|
126
|
+
/** Set in the same tick as the click: `disabled={mutation.isPending}` is one render too late. */
|
|
127
|
+
let saving = $state(false)
|
|
128
|
+
|
|
129
|
+
const save = createMutation(() => ({
|
|
130
|
+
mutationFn: () => {
|
|
131
|
+
const row = editing
|
|
132
|
+
const values = { name: name.trim(), order }
|
|
133
|
+
return row
|
|
134
|
+
? api.categories.update({ workspaceId, categoryId: row.id, ...values })
|
|
135
|
+
: api.categories.create({ workspaceId, ...values })
|
|
136
|
+
},
|
|
137
|
+
onSuccess: (saved: Category) => {
|
|
138
|
+
toast.success(
|
|
139
|
+
t(editing ? 'category_updated_toast' : 'category_created_toast', isolated({ name: saved.name })),
|
|
140
|
+
)
|
|
141
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
142
|
+
dialogOpen = false
|
|
143
|
+
},
|
|
144
|
+
// The server's sentence is the actionable one and it is in English — "This workspace already has
|
|
145
|
+
// a category called “Laptops”." was shown verbatim to a reader who chose Persian. The token it
|
|
146
|
+
// carries, `inventory.category.name_taken`, is the part a client can translate; the name is on
|
|
147
|
+
// screen in the box directly above the message, so the sentence does not need to repeat it.
|
|
148
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
149
|
+
onSettled: () => {
|
|
150
|
+
saving = false
|
|
151
|
+
},
|
|
152
|
+
}))
|
|
153
|
+
|
|
154
|
+
function submit() {
|
|
155
|
+
if (saving || !canSubmit) return
|
|
156
|
+
saving = true
|
|
157
|
+
save.mutate()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ------------------------------------------------------------------------ archive and restore
|
|
161
|
+
|
|
162
|
+
let archiving = $state<Category | null>(null)
|
|
163
|
+
let acting = $state(false)
|
|
164
|
+
|
|
165
|
+
interface ArchiveVars {
|
|
166
|
+
categoryId: string
|
|
167
|
+
archived: boolean
|
|
168
|
+
name: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const setArchived = createMutation(() => ({
|
|
172
|
+
mutationFn: (vars: ArchiveVars) =>
|
|
173
|
+
api.categories.archive({ workspaceId, categoryId: vars.categoryId, archived: vars.archived }),
|
|
174
|
+
onSuccess: (_saved: Category, vars: ArchiveVars) => {
|
|
175
|
+
toast.success(
|
|
176
|
+
t(vars.archived ? 'category_archived_toast' : 'category_restored_toast', isolated({ name: vars.name })),
|
|
177
|
+
)
|
|
178
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
179
|
+
archiving = null
|
|
180
|
+
},
|
|
181
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
182
|
+
onSettled: () => {
|
|
183
|
+
acting = false
|
|
184
|
+
},
|
|
185
|
+
}))
|
|
186
|
+
|
|
187
|
+
function confirmArchive() {
|
|
188
|
+
const target = archiving
|
|
189
|
+
if (!target || acting) return
|
|
190
|
+
acting = true
|
|
191
|
+
setArchived.mutate({ categoryId: target.id, archived: true, name: target.name })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function restore(category: Category) {
|
|
195
|
+
if (acting) return
|
|
196
|
+
acting = true
|
|
197
|
+
setArchived.mutate({ categoryId: category.id, archived: false, name: category.name })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Restoring takes nothing away and needs no confirmation; archiving states what happens to the
|
|
202
|
+
* assets already filed under it. Hidden rather than disabled without the permission.
|
|
203
|
+
*/
|
|
204
|
+
function actionsFor(category: Category): MenuItem[] {
|
|
205
|
+
const items: MenuItem[] = [
|
|
206
|
+
{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(category) },
|
|
207
|
+
]
|
|
208
|
+
if (category.archivedAt) {
|
|
209
|
+
items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(category) })
|
|
210
|
+
} else {
|
|
211
|
+
items.push({ type: 'separator' })
|
|
212
|
+
items.push({
|
|
213
|
+
label: t('common.archive'),
|
|
214
|
+
icon: 'archive',
|
|
215
|
+
danger: true,
|
|
216
|
+
onSelect: () => (archiving = category),
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
return items
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const COLUMNS = $derived(canManage ? 'minmax(0, 1fr) 96px 44px' : 'minmax(0, 1fr) 96px')
|
|
223
|
+
const SKELETON_ROWS = [0, 1, 2, 3]
|
|
224
|
+
</script>
|
|
225
|
+
|
|
226
|
+
<SettingsPage title={t('settings_categories')} description={t('settings_categories_desc')}>
|
|
227
|
+
{#snippet actions()}
|
|
228
|
+
{#if canManage}
|
|
229
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('category_new')}</Button>
|
|
230
|
+
{/if}
|
|
231
|
+
{/snippet}
|
|
232
|
+
|
|
233
|
+
<SettingsSection flush>
|
|
234
|
+
<div class="bar">
|
|
235
|
+
<Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{#if categoriesQuery.isPending}
|
|
239
|
+
<div class="skeleton">
|
|
240
|
+
{#each SKELETON_ROWS as row (row)}
|
|
241
|
+
<div class="srow" style:grid-template-columns={COLUMNS}>
|
|
242
|
+
<Skeleton height="12px" width="52%" />
|
|
243
|
+
<Skeleton height="12px" width="30%" />
|
|
244
|
+
{#if canManage}<Skeleton height="12px" width="16px" />{/if}
|
|
245
|
+
</div>
|
|
246
|
+
{/each}
|
|
247
|
+
</div>
|
|
248
|
+
{:else if categoriesQuery.isError}
|
|
249
|
+
<EmptyState icon="triangle-alert" title={t('categories_error')} description={t('common.error')}>
|
|
250
|
+
{#snippet actions()}
|
|
251
|
+
<Button variant="secondary" onclick={() => void categoriesQuery.refetch()}>
|
|
252
|
+
{t('common.retry')}
|
|
253
|
+
</Button>
|
|
254
|
+
{/snippet}
|
|
255
|
+
</EmptyState>
|
|
256
|
+
{:else if allArchived}
|
|
257
|
+
<!--
|
|
258
|
+
Not "No categories yet": this workspace has categories and is looking at none of them. The
|
|
259
|
+
action is the one that fixes it — show the archived rows, which is where Restore lives —
|
|
260
|
+
rather than a New category button that would leave the archived ones exactly as they are.
|
|
261
|
+
-->
|
|
262
|
+
<EmptyState
|
|
263
|
+
icon="archive"
|
|
264
|
+
title={t('categories_all_archived')}
|
|
265
|
+
description={t('categories_all_archived_desc')}
|
|
266
|
+
>
|
|
267
|
+
{#snippet actions()}
|
|
268
|
+
<Button variant="secondary" onclick={() => (showArchived = true)}>
|
|
269
|
+
{t('show_archived')}
|
|
270
|
+
</Button>
|
|
271
|
+
{/snippet}
|
|
272
|
+
</EmptyState>
|
|
273
|
+
{:else if categories.length === 0}
|
|
274
|
+
<!--
|
|
275
|
+
Reachable, and worth keeping: `onWorkspaceEnabled` seeds five categories, but it only runs
|
|
276
|
+
when a workspace switches the module on — an instance upgraded with Inventory already
|
|
277
|
+
enabled never ran it, and neither does a workspace whose seeding half failed.
|
|
278
|
+
-->
|
|
279
|
+
<EmptyState icon="tag" title={t('categories_empty')} description={t('categories_empty_desc')}>
|
|
280
|
+
{#snippet actions()}
|
|
281
|
+
{#if canManage}
|
|
282
|
+
<Button onclick={openCreate}>{t('category_new')}</Button>
|
|
283
|
+
{/if}
|
|
284
|
+
{/snippet}
|
|
285
|
+
</EmptyState>
|
|
286
|
+
{:else}
|
|
287
|
+
<Table columns={COLUMNS} ariaLabel={t('settings_categories')}>
|
|
288
|
+
<TableHeader>
|
|
289
|
+
<TableCell header>{t('category')}</TableCell>
|
|
290
|
+
<TableCell header>{t('category_order')}</TableCell>
|
|
291
|
+
{#if canManage}<TableCell header end></TableCell>{/if}
|
|
292
|
+
</TableHeader>
|
|
293
|
+
{#each categories as category (category.id)}
|
|
294
|
+
<TableRow>
|
|
295
|
+
<TableCell>
|
|
296
|
+
<span class="cell">
|
|
297
|
+
<span class="name">{category.name}</span>
|
|
298
|
+
<!-- Only the archived state gets a badge. A "live" badge on every other row would
|
|
299
|
+
be a column of the same word, and the one it would have to borrow —
|
|
300
|
+
`status_in_stock` — describes an asset sitting in a cupboard, not a category. -->
|
|
301
|
+
{#if category.archivedAt}<Badge tone="grey">{t('archived')}</Badge>{/if}
|
|
302
|
+
</span>
|
|
303
|
+
</TableCell>
|
|
304
|
+
<TableCell><span class="muted">{category.order}</span></TableCell>
|
|
305
|
+
{#if canManage}
|
|
306
|
+
<TableCell end>
|
|
307
|
+
<DropdownMenu items={actionsFor(category)} align="end">
|
|
308
|
+
{#snippet trigger(props)}
|
|
309
|
+
<IconButton
|
|
310
|
+
{...props}
|
|
311
|
+
icon="ellipsis"
|
|
312
|
+
size={28}
|
|
313
|
+
label={t('row_actions', isolated({ name: category.name }))}
|
|
314
|
+
/>
|
|
315
|
+
{/snippet}
|
|
316
|
+
</DropdownMenu>
|
|
317
|
+
</TableCell>
|
|
318
|
+
{/if}
|
|
319
|
+
</TableRow>
|
|
320
|
+
{/each}
|
|
321
|
+
</Table>
|
|
322
|
+
{/if}
|
|
323
|
+
</SettingsSection>
|
|
324
|
+
</SettingsPage>
|
|
325
|
+
|
|
326
|
+
<Dialog
|
|
327
|
+
bind:open={dialogOpen}
|
|
328
|
+
size="sm"
|
|
329
|
+
title={editing ? t('category_edit') : t('category_new')}
|
|
330
|
+
>
|
|
331
|
+
<div class="form">
|
|
332
|
+
<Field label={t('category')} id="inv-cat-name" required hint={t('category_name_placeholder')}>
|
|
333
|
+
{#snippet children(id)}
|
|
334
|
+
<Input {id} bind:value={name} maxlength={120} />
|
|
335
|
+
{/snippet}
|
|
336
|
+
</Field>
|
|
337
|
+
<Field label={t('category_order')} id="inv-cat-order" hint={t('category_order_hint')}>
|
|
338
|
+
{#snippet children(id)}
|
|
339
|
+
<div class="narrow">
|
|
340
|
+
<Input {id} bind:value={orderText} inputmode="numeric" maxlength={4} mono error={orderError} />
|
|
341
|
+
</div>
|
|
342
|
+
{/snippet}
|
|
343
|
+
</Field>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{#snippet footer()}
|
|
347
|
+
<Button variant="ghost" onclick={() => (dialogOpen = false)}>{t('common.cancel')}</Button>
|
|
348
|
+
<Button onclick={submit} disabled={!canSubmit} loading={saving}>{t('common.save')}</Button>
|
|
349
|
+
{/snippet}
|
|
350
|
+
</Dialog>
|
|
351
|
+
|
|
352
|
+
<!-- States what happens to the assets already filed under it, rather than asking "Are you sure?". -->
|
|
353
|
+
<Dialog
|
|
354
|
+
open={archiving !== null}
|
|
355
|
+
size="sm"
|
|
356
|
+
title={t('category_archive_title', isolated({ name: archiving?.name ?? '' }))}
|
|
357
|
+
onOpenChange={(next) => {
|
|
358
|
+
if (!next) archiving = null
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
<p class="dialog-body">{t('category_archive_body')}</p>
|
|
362
|
+
{#snippet footer()}
|
|
363
|
+
<Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
|
|
364
|
+
<Button variant="danger" onclick={confirmArchive} loading={acting}>{t('common.archive')}</Button>
|
|
365
|
+
{/snippet}
|
|
366
|
+
</Dialog>
|
|
367
|
+
|
|
368
|
+
<style>
|
|
369
|
+
.bar {
|
|
370
|
+
display: flex;
|
|
371
|
+
justify-content: flex-end;
|
|
372
|
+
padding: 10px 12px;
|
|
373
|
+
}
|
|
374
|
+
.cell {
|
|
375
|
+
display: flex;
|
|
376
|
+
align-items: center;
|
|
377
|
+
gap: 8px;
|
|
378
|
+
min-width: 0;
|
|
379
|
+
}
|
|
380
|
+
.name {
|
|
381
|
+
font-weight: 500;
|
|
382
|
+
color: var(--kern-ink-900);
|
|
383
|
+
overflow: hidden;
|
|
384
|
+
text-overflow: ellipsis;
|
|
385
|
+
white-space: nowrap;
|
|
386
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
387
|
+
first strong character, so a Latin name inside a Persian screen reads left to right and
|
|
388
|
+
keeps its own trailing punctuation instead of donating it to the paragraph. */
|
|
389
|
+
unicode-bidi: plaintext;
|
|
390
|
+
}
|
|
391
|
+
.muted {
|
|
392
|
+
/* A colour rather than opacity, which fades text against the page whatever token it names. */
|
|
393
|
+
color: var(--kern-ink-500);
|
|
394
|
+
font-variant-numeric: tabular-nums;
|
|
395
|
+
}
|
|
396
|
+
.skeleton {
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-direction: column;
|
|
399
|
+
gap: 1px;
|
|
400
|
+
}
|
|
401
|
+
.srow {
|
|
402
|
+
display: grid;
|
|
403
|
+
align-items: center;
|
|
404
|
+
gap: 12px;
|
|
405
|
+
padding: 14px 12px;
|
|
406
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
407
|
+
}
|
|
408
|
+
.form {
|
|
409
|
+
display: grid;
|
|
410
|
+
gap: 14px;
|
|
411
|
+
}
|
|
412
|
+
.narrow {
|
|
413
|
+
max-inline-size: 140px;
|
|
414
|
+
}
|
|
415
|
+
.dialog-body {
|
|
416
|
+
margin: 0;
|
|
417
|
+
font-size: 13.5px;
|
|
418
|
+
line-height: 1.55;
|
|
419
|
+
color: var(--kern-ink-700);
|
|
420
|
+
}
|
|
421
|
+
</style>
|
|
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
|
+
})
|