@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
package/src/client/module.ts
CHANGED
|
@@ -87,6 +87,42 @@ export const inventoryClientModule = defineClientModule({
|
|
|
87
87
|
],
|
|
88
88
|
component: () => import('./widgets/OverviewWidget.svelte'),
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
/**
|
|
92
|
+
* A second card rather than a `view` option on the first, because this one is gated.
|
|
93
|
+
*
|
|
94
|
+
* `capability` is what the shell filters the dashboard by, so a workspace that does not
|
|
95
|
+
* record repairs is never offered this card at all. An option inside the other card's
|
|
96
|
+
* settings would offer the question and then answer it with an empty card — which is exactly
|
|
97
|
+
* the "a switch that changes nothing" failure the capability mechanism exists to avoid.
|
|
98
|
+
*/
|
|
99
|
+
id: 'inventory.repairs',
|
|
100
|
+
get title() {
|
|
101
|
+
return t('widget_repairs_title')
|
|
102
|
+
},
|
|
103
|
+
get description() {
|
|
104
|
+
return t('widget_repairs_desc')
|
|
105
|
+
},
|
|
106
|
+
icon: 'wrench',
|
|
107
|
+
permission: INVENTORY_PERMISSIONS.view,
|
|
108
|
+
capability: 'repairs',
|
|
109
|
+
sizes: ['m', 'l'],
|
|
110
|
+
defaultSize: 'm',
|
|
111
|
+
order: 56,
|
|
112
|
+
settings: [
|
|
113
|
+
{
|
|
114
|
+
kind: 'number',
|
|
115
|
+
key: 'limit',
|
|
116
|
+
get label() {
|
|
117
|
+
return t('common.setting_rows')
|
|
118
|
+
},
|
|
119
|
+
default: 5,
|
|
120
|
+
min: 3,
|
|
121
|
+
max: 20,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
component: () => import('./widgets/RepairsWidget.svelte'),
|
|
125
|
+
},
|
|
90
126
|
],
|
|
91
127
|
|
|
92
128
|
/**
|
|
@@ -110,6 +146,24 @@ export const inventoryClientModule = defineClientModule({
|
|
|
110
146
|
order: 1,
|
|
111
147
|
component: () => import('./settings/GeneralSettings.svelte'),
|
|
112
148
|
},
|
|
149
|
+
{
|
|
150
|
+
/**
|
|
151
|
+
* Categories are this module's own data rather than core's, so this one is gated on this
|
|
152
|
+
* module's own key — `inventory.category.manage`, which is exactly what `categories.create`,
|
|
153
|
+
* `.update` and `.archive` require on the server. General above is gated on
|
|
154
|
+
* `core.modules.manage` because *that* page writes through core's settings procedure; the two
|
|
155
|
+
* pages sit side by side and are deliberately not gated the same way.
|
|
156
|
+
*/
|
|
157
|
+
id: 'categories',
|
|
158
|
+
get label() {
|
|
159
|
+
return t('settings_categories')
|
|
160
|
+
},
|
|
161
|
+
icon: 'tag',
|
|
162
|
+
scope: 'workspace',
|
|
163
|
+
permission: INVENTORY_PERMISSIONS.categories,
|
|
164
|
+
order: 2,
|
|
165
|
+
component: () => import('./settings/CategoriesSettings.svelte'),
|
|
166
|
+
},
|
|
113
167
|
],
|
|
114
168
|
})
|
|
115
169
|
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import {
|
|
3
3
|
Badge,
|
|
4
|
-
type BadgeTone,
|
|
5
4
|
Button,
|
|
5
|
+
coreApi,
|
|
6
6
|
Dialog,
|
|
7
7
|
DropdownMenu,
|
|
8
8
|
EmptyState,
|
|
9
|
+
formatDate,
|
|
9
10
|
IconButton,
|
|
10
11
|
Input,
|
|
12
|
+
keys,
|
|
11
13
|
type MenuItem,
|
|
14
|
+
navigation,
|
|
12
15
|
Page,
|
|
13
16
|
PageHeader,
|
|
14
17
|
Select,
|
|
@@ -22,13 +25,20 @@ import {
|
|
|
22
25
|
TableRow,
|
|
23
26
|
toast,
|
|
24
27
|
} from '@kernhq/ui'
|
|
25
|
-
import { createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
26
|
-
import type { Asset, AssetStatus } from '../../contract/index.js'
|
|
28
|
+
import { createInfiniteQuery, createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
29
|
+
import type { Asset, AssetStatus, Category } from '../../contract/index.js'
|
|
27
30
|
import { getInventoryApi } from '../api-instance.js'
|
|
31
|
+
import { isolated } from '../bidi.js'
|
|
32
|
+
import AssetDetailPanel from '../components/AssetDetailPanel.svelte'
|
|
28
33
|
import AssetFormDialog from '../components/AssetFormDialog.svelte'
|
|
34
|
+
import type { CoreApi, CoreMember } from '../core-api.js'
|
|
35
|
+
import { errorMessage } from '../errors.js'
|
|
29
36
|
import { t } from '../i18n.js'
|
|
37
|
+
import { ASSET_PARAM } from '../links.js'
|
|
38
|
+
import { directory, directoryStatus, nameOf } from '../members.js'
|
|
30
39
|
import { canInventory } from '../permissions.js'
|
|
31
40
|
import { inventoryKeys } from '../query.js'
|
|
41
|
+
import { statusTone } from '../status.js'
|
|
32
42
|
|
|
33
43
|
/**
|
|
34
44
|
* This module's screen.
|
|
@@ -46,10 +56,12 @@ interface Props {
|
|
|
46
56
|
const { workspaceId }: Props = $props()
|
|
47
57
|
|
|
48
58
|
const api = getInventoryApi()
|
|
59
|
+
const core = coreApi<CoreApi>()
|
|
49
60
|
const queryClient = useQueryClient()
|
|
50
61
|
|
|
51
62
|
let searchText = $state('')
|
|
52
63
|
let statusFilter = $state<string>('')
|
|
64
|
+
let categoryFilter = $state<string>('')
|
|
53
65
|
let showArchived = $state(false)
|
|
54
66
|
let dialogOpen = $state(false)
|
|
55
67
|
/** The row the dialog is editing, or null when it is adding one. */
|
|
@@ -65,6 +77,18 @@ $effect(() => {
|
|
|
65
77
|
return () => clearTimeout(timer)
|
|
66
78
|
})
|
|
67
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The two filters that live in the URL rather than in this component.
|
|
82
|
+
*
|
|
83
|
+
* `?asset=<id>` is which panel is open, and `?custodian=<userId>` is "what is this person holding"
|
|
84
|
+
* — the return list an offboarding notification links to. Both are in the URL for the same reason:
|
|
85
|
+
* they are places somebody went, so they survive a reload, can be shared with the person who has to
|
|
86
|
+
* act on them, and close on Back. Built from `navigation` because a module cannot read `$app/state`
|
|
87
|
+
* — it is a SvelteKit alias, and this package is type-checked on its own.
|
|
88
|
+
*/
|
|
89
|
+
const params = $derived(new URLSearchParams(navigation.search))
|
|
90
|
+
const custodianFilter = $derived(params.get('custodian') ?? '')
|
|
91
|
+
|
|
68
92
|
/**
|
|
69
93
|
* Every filter is part of the key and part of the request.
|
|
70
94
|
*
|
|
@@ -75,6 +99,8 @@ $effect(() => {
|
|
|
75
99
|
const filters = $derived({
|
|
76
100
|
...(query ? { q: query } : {}),
|
|
77
101
|
...(statusFilter ? { status: statusFilter as AssetStatus } : {}),
|
|
102
|
+
...(categoryFilter ? { categoryId: categoryFilter } : {}),
|
|
103
|
+
...(custodianFilter ? { custodianUserId: custodianFilter } : {}),
|
|
78
104
|
archived: showArchived,
|
|
79
105
|
})
|
|
80
106
|
|
|
@@ -83,16 +109,57 @@ const filters = $derived({
|
|
|
83
109
|
*
|
|
84
110
|
* The archived switch is not in here: it widens the list rather than narrowing it, so a workspace
|
|
85
111
|
* with no assets and the switch on is still empty rather than filtered, and offering to "clear" it
|
|
86
|
-
* would hide rows somebody just asked to see.
|
|
112
|
+
* would hide rows somebody just asked to see. The custodian **is** in here, so the count line stops
|
|
113
|
+
* claiming the workspace total the moment the list is one person's.
|
|
87
114
|
*/
|
|
88
|
-
const narrowed = $derived(
|
|
115
|
+
const narrowed = $derived(
|
|
116
|
+
Boolean(query) || Boolean(statusFilter) || Boolean(categoryFilter) || Boolean(custodianFilter),
|
|
117
|
+
)
|
|
89
118
|
|
|
90
119
|
function clearFilters() {
|
|
91
120
|
searchText = ''
|
|
92
121
|
query = ''
|
|
93
122
|
statusFilter = ''
|
|
123
|
+
categoryFilter = ''
|
|
124
|
+
setParam('custodian', null)
|
|
94
125
|
}
|
|
95
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The people this workspace has, asked for only when there is a uuid on screen to turn into a name.
|
|
129
|
+
*
|
|
130
|
+
* The same key `AssetDetailPanel` uses, so opening a panel afterwards costs no second request — and
|
|
131
|
+
* no request at all in the ordinary case, where nothing is filtered by custodian.
|
|
132
|
+
*/
|
|
133
|
+
const membersQuery = createQuery(() => ({
|
|
134
|
+
queryKey: keys.members(workspaceId),
|
|
135
|
+
queryFn: () => core.workspaces.members.list({ workspaceId }),
|
|
136
|
+
enabled: Boolean(workspaceId) && Boolean(custodianFilter),
|
|
137
|
+
}))
|
|
138
|
+
const dir = $derived(
|
|
139
|
+
directory((membersQuery.data?.items ?? []) as CoreMember[], directoryStatus(membersQuery)),
|
|
140
|
+
)
|
|
141
|
+
/**
|
|
142
|
+
* Whose list this is.
|
|
143
|
+
*
|
|
144
|
+
* `nameOf` never answers with the uuid: somebody who has since been removed from the workspace
|
|
145
|
+
* reads as "a former member", which is exactly the case this filter exists for — the offboarding
|
|
146
|
+
* notification is *about* somebody who has left.
|
|
147
|
+
*
|
|
148
|
+
* It only says so once the member list has actually arrived. Before that this line claimed the
|
|
149
|
+
* person had left the workspace while their name was still being fetched, on the one screen whose
|
|
150
|
+
* whole purpose is to be *about* a named person.
|
|
151
|
+
*/
|
|
152
|
+
const custodianName = $derived(
|
|
153
|
+
custodianFilter
|
|
154
|
+
? nameOf(custodianFilter, dir, {
|
|
155
|
+
loading: t('member_loading'),
|
|
156
|
+
unknown: t('member_unknown'),
|
|
157
|
+
former: t('member_former'),
|
|
158
|
+
system: t('member_system'),
|
|
159
|
+
})
|
|
160
|
+
: null,
|
|
161
|
+
)
|
|
162
|
+
|
|
96
163
|
interface AssetPage {
|
|
97
164
|
items: Asset[]
|
|
98
165
|
nextCursor: string | null
|
|
@@ -115,30 +182,119 @@ const assetsQuery = createInfiniteQuery(() => ({
|
|
|
115
182
|
|
|
116
183
|
const assets = $derived(assetsQuery.data?.pages.flatMap((page) => page.items) ?? [])
|
|
117
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The workspace's categories, **archived ones included**.
|
|
187
|
+
*
|
|
188
|
+
* One query rather than two, and it asks for everything because the two things it feeds want
|
|
189
|
+
* different halves: the chip on a row has to name the category an asset carries even after somebody
|
|
190
|
+
* archived it — that is the whole point of archiving rather than deleting — while the filter offers
|
|
191
|
+
* only the live ones, because filtering by something nobody can file anything under is a dead end.
|
|
192
|
+
*
|
|
193
|
+
* The key is the same one `AssetDetailPanel` uses, so opening a panel costs no second request.
|
|
194
|
+
*/
|
|
195
|
+
const categoriesQuery = createQuery(() => ({
|
|
196
|
+
queryKey: inventoryKeys.categories(workspaceId, true),
|
|
197
|
+
queryFn: () => api.categories.list({ workspaceId, archived: true }),
|
|
198
|
+
enabled: Boolean(workspaceId),
|
|
199
|
+
}))
|
|
200
|
+
const categories = $derived<Category[]>(categoriesQuery.data ?? [])
|
|
201
|
+
const categoryNames = $derived(new Map(categories.map((row) => [row.id, row.name])))
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* What the filter offers: the live categories, plus whichever one is currently chosen.
|
|
205
|
+
*
|
|
206
|
+
* The second half matters exactly once, and it is not hypothetical — somebody filters by "Cameras",
|
|
207
|
+
* an administrator archives it in another tab, and without this the control would show an empty
|
|
208
|
+
* value while the list stayed filtered. A filter that does not say what it is filtering by is worse
|
|
209
|
+
* than one offering a category nobody can file anything new under.
|
|
210
|
+
*/
|
|
211
|
+
const categoryOptions = $derived<SelectOption[]>([
|
|
212
|
+
{ value: '', label: t('filter_all_categories') },
|
|
213
|
+
...categories
|
|
214
|
+
.filter((row) => !row.archivedAt || row.id === categoryFilter)
|
|
215
|
+
.map((row) => ({ value: row.id, label: row.name })),
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------------- the panel, and the URL
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Which asset is open lives in the URL, not in this component's state. See `params` above.
|
|
222
|
+
*
|
|
223
|
+
* The parameter's name comes from `links.ts`, which is also where a dashboard card builds the link
|
|
224
|
+
* that sets it — one constant, so a card cannot send somebody to a URL this page ignores.
|
|
225
|
+
*/
|
|
226
|
+
const openAssetId = $derived(params.get(ASSET_PARAM))
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Set or clear one search parameter, leaving every other one alone.
|
|
230
|
+
*
|
|
231
|
+
* One function rather than one per parameter, because "keep the rest of the URL" is the part that
|
|
232
|
+
* is easy to get wrong: clearing the custodian filter by rebuilding the query string from scratch
|
|
233
|
+
* would close the panel somebody has open.
|
|
234
|
+
*
|
|
235
|
+
* A push, deliberately — not the `replaceState` a filter change would use. Opening a panel, and
|
|
236
|
+
* arriving on somebody's return list from a notification, are both places somebody went, so Back
|
|
237
|
+
* should undo them; a keystroke in the search box is not, and a list that pushed every one of those
|
|
238
|
+
* would fill the back button with states nobody meant to visit.
|
|
239
|
+
*/
|
|
240
|
+
function setParam(name: string, value: string | null) {
|
|
241
|
+
const next = new URLSearchParams(navigation.search)
|
|
242
|
+
if (value) next.set(name, value)
|
|
243
|
+
else next.delete(name)
|
|
244
|
+
const search = next.toString()
|
|
245
|
+
void navigation.go(`${navigation.pathname}${search ? `?${search}` : ''}`, {
|
|
246
|
+
keepFocus: true,
|
|
247
|
+
noScroll: true,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const setAsset = (id: string | null) => setParam(ASSET_PARAM, id)
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The register in numbers, for the one line that has to be a number.
|
|
255
|
+
*
|
|
256
|
+
* One request for the whole workspace rather than a `count(*)` bolted onto every page of a keyset
|
|
257
|
+
* list — which is the expensive way to answer a question that does not change between pages.
|
|
258
|
+
*/
|
|
259
|
+
const statsQuery = createQuery(() => ({
|
|
260
|
+
queryKey: inventoryKeys.stats(workspaceId),
|
|
261
|
+
queryFn: () => api.stats.summary({ workspaceId }),
|
|
262
|
+
enabled: Boolean(workspaceId),
|
|
263
|
+
}))
|
|
264
|
+
|
|
118
265
|
/**
|
|
119
266
|
* A count that stays true while the list pages.
|
|
120
267
|
*
|
|
121
268
|
* `t('count', { n: assets.length })` counted the pages *loaded*, so 120 assets read "50 assets" and
|
|
122
269
|
* then "100 assets" after Load more — a number that is simply wrong, on the line whose whole job is
|
|
123
|
-
* to be the number.
|
|
124
|
-
*
|
|
270
|
+
* to be the number.
|
|
271
|
+
*
|
|
272
|
+
* Three answers, in order of how true they are. **The workspace total** when nothing is narrowing
|
|
273
|
+
* the list and archived rows are out, because then the list *is* every live asset and
|
|
274
|
+
* `stats.summary` has counted them. **What the server said** if it ever fills `total` on a page.
|
|
275
|
+
* Otherwise how many are being *shown*, which is honest about being a partial answer rather than
|
|
276
|
+
* claiming a filtered, half-loaded list is all there is.
|
|
125
277
|
*/
|
|
126
278
|
const reportedTotal = $derived(assetsQuery.data?.pages.at(-1)?.total)
|
|
279
|
+
const workspaceTotal = $derived(!narrowed && !showArchived ? statsQuery.data?.total : undefined)
|
|
127
280
|
const countLine = $derived(
|
|
128
|
-
|
|
129
|
-
? t('count', { n:
|
|
130
|
-
:
|
|
131
|
-
? t('
|
|
132
|
-
:
|
|
281
|
+
workspaceTotal !== undefined
|
|
282
|
+
? t('count', { n: workspaceTotal })
|
|
283
|
+
: reportedTotal !== undefined
|
|
284
|
+
? t('count', { n: reportedTotal })
|
|
285
|
+
: assetsQuery.hasNextPage
|
|
286
|
+
? t('count_showing', { n: assets.length })
|
|
287
|
+
: t('count', { n: assets.length }),
|
|
133
288
|
)
|
|
134
289
|
|
|
135
290
|
/**
|
|
136
291
|
* Every status the contract can hold, offered ahead of the features that set them.
|
|
137
292
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
293
|
+
* Three of the six are written now — `in_stock` and `assigned` by custody, `under_repair` by a
|
|
294
|
+
* repair — and `reserved`, `lost` and `retired` wait on the features that set them, so those three
|
|
295
|
+
* correctly return nothing. That is the data being uniform rather than the filter being broken, and
|
|
296
|
+
* the alternative — hiding options and adding them back one release later — teaches somebody the
|
|
297
|
+
* list is unstable. The README says the same thing in as many words.
|
|
142
298
|
*/
|
|
143
299
|
const statusOptions: SelectOption[] = [
|
|
144
300
|
{ value: '', label: t('filter_all_statuses') },
|
|
@@ -150,23 +306,6 @@ const statusOptions: SelectOption[] = [
|
|
|
150
306
|
{ value: 'retired', label: t('status_retired') },
|
|
151
307
|
]
|
|
152
308
|
|
|
153
|
-
function statusTone(status: string): BadgeTone {
|
|
154
|
-
switch (status) {
|
|
155
|
-
case 'assigned':
|
|
156
|
-
return 'info'
|
|
157
|
-
case 'reserved':
|
|
158
|
-
return 'info'
|
|
159
|
-
case 'under_repair':
|
|
160
|
-
return 'warning'
|
|
161
|
-
case 'lost':
|
|
162
|
-
return 'danger'
|
|
163
|
-
case 'retired':
|
|
164
|
-
return 'grey'
|
|
165
|
-
default:
|
|
166
|
-
return 'success'
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
309
|
// ---------------------------------------------------------------- row actions
|
|
171
310
|
|
|
172
311
|
/**
|
|
@@ -189,11 +328,15 @@ const setArchived = createMutation(() => ({
|
|
|
189
328
|
mutationFn: (vars: ArchiveVars) =>
|
|
190
329
|
api.assets.archive({ workspaceId, assetId: vars.assetId, archived: vars.archived }),
|
|
191
330
|
onSuccess: (_saved: Asset, vars: ArchiveVars) => {
|
|
192
|
-
toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', { name: vars.name }))
|
|
331
|
+
toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', isolated({ name: vars.name })))
|
|
193
332
|
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
194
333
|
archiving = null
|
|
195
334
|
},
|
|
196
|
-
|
|
335
|
+
// Not `error.message`: that is a sentence the *server* wrote, in English, and archiving something
|
|
336
|
+
// somebody is still holding is exactly the refusal a reader needs to understand. `errors.ts` maps
|
|
337
|
+
// the reason token this module's server sends — `inventory.asset.still_held` — to a translated
|
|
338
|
+
// sentence, and keeps the server's words only for a failure it does not recognise.
|
|
339
|
+
onError: (error: unknown) => toast.error(errorMessage(error, t)),
|
|
197
340
|
onSettled: () => {
|
|
198
341
|
acting = false
|
|
199
342
|
},
|
|
@@ -234,7 +377,12 @@ function openEdit(asset: Asset) {
|
|
|
234
377
|
* not there at all rather than being a door that will not open.
|
|
235
378
|
*/
|
|
236
379
|
function actionsFor(asset: Asset): MenuItem[] {
|
|
237
|
-
const items: MenuItem[] = [
|
|
380
|
+
const items: MenuItem[] = [
|
|
381
|
+
// The name in the row is already a button that opens this; the menu carries it too because a
|
|
382
|
+
// menu that lists everything a row can do is the one place somebody looks for what a row can do.
|
|
383
|
+
{ label: t('open'), icon: 'arrow-right', onSelect: () => setAsset(asset.id) },
|
|
384
|
+
{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(asset) },
|
|
385
|
+
]
|
|
238
386
|
if (asset.archivedAt) {
|
|
239
387
|
items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(asset) })
|
|
240
388
|
} else {
|
|
@@ -271,6 +419,12 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
271
419
|
/>
|
|
272
420
|
{/snippet}
|
|
273
421
|
{#snippet actions()}
|
|
422
|
+
<Select
|
|
423
|
+
bind:value={categoryFilter}
|
|
424
|
+
options={categoryOptions}
|
|
425
|
+
size="sm"
|
|
426
|
+
ariaLabel={t('category')}
|
|
427
|
+
/>
|
|
274
428
|
<Select bind:value={statusFilter} options={statusOptions} size="sm" ariaLabel={t('status')} />
|
|
275
429
|
{#if canManage}
|
|
276
430
|
<Button size="sm" icon="plus" onclick={openCreate}>{t('new')}</Button>
|
|
@@ -279,6 +433,21 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
279
433
|
</PageHeader>
|
|
280
434
|
|
|
281
435
|
<Page>
|
|
436
|
+
<!--
|
|
437
|
+
The return list, when somebody arrived here from an offboarding notification.
|
|
438
|
+
Named rather than silent: a list that has quietly dropped nine tenths of the register with no
|
|
439
|
+
sentence saying why is the single most confusing thing a filter can do, and this one is not set
|
|
440
|
+
by any control on the page — it came in on the URL.
|
|
441
|
+
-->
|
|
442
|
+
{#if custodianFilter}
|
|
443
|
+
<div class="held-by">
|
|
444
|
+
<span>{t('held_by', isolated({ name: custodianName ?? '' }))}</span>
|
|
445
|
+
<Button size="sm" variant="ghost" onclick={() => setParam('custodian', null)}>
|
|
446
|
+
{t('held_by_clear')}
|
|
447
|
+
</Button>
|
|
448
|
+
</div>
|
|
449
|
+
{/if}
|
|
450
|
+
|
|
282
451
|
<div class="bar">
|
|
283
452
|
<p class="count" aria-live="polite">{countLine}</p>
|
|
284
453
|
<Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
|
|
@@ -339,19 +508,50 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
339
508
|
<TableCell><code>{asset.code}</code></TableCell>
|
|
340
509
|
<TableCell>
|
|
341
510
|
<span class="stack">
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
511
|
+
<!--
|
|
512
|
+
A button rather than the whole row: a row carrying a menu cannot itself be a button
|
|
513
|
+
without nesting one inside the other, which is invalid and breaks the menu. The name
|
|
514
|
+
is what somebody aims at anyway, and it is reachable from the keyboard.
|
|
515
|
+
-->
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
class="name"
|
|
519
|
+
onclick={() => setAsset(asset.id)}
|
|
520
|
+
aria-label={t('open_asset', isolated({ name: asset.name }))}
|
|
521
|
+
>
|
|
522
|
+
{asset.name}
|
|
523
|
+
</button>
|
|
524
|
+
{#if asset.categoryId || asset.serialNumber}
|
|
346
525
|
<span class="sub">
|
|
347
|
-
{
|
|
348
|
-
|
|
526
|
+
{#if asset.categoryId}
|
|
527
|
+
<!-- Named even when the category has since been archived: `categories` is
|
|
528
|
+
fetched with archived rows for exactly this. -->
|
|
529
|
+
<span class="chip">
|
|
530
|
+
{categoryNames.get(asset.categoryId) ?? t('category_none')}
|
|
531
|
+
</span>
|
|
532
|
+
{/if}
|
|
533
|
+
<!-- `S/N` was a literal English abbreviation sitting in the middle of a Persian,
|
|
534
|
+
Arabic, German or Turkish table; `serial_number` is translated in all five. -->
|
|
535
|
+
{#if asset.serialNumber}
|
|
536
|
+
<span class="serial">
|
|
537
|
+
{t('serial_number')}:
|
|
538
|
+
<span class="ltr">{asset.serialNumber}</span>
|
|
539
|
+
</span>
|
|
540
|
+
{/if}
|
|
349
541
|
</span>
|
|
350
542
|
{/if}
|
|
351
543
|
</span>
|
|
352
544
|
</TableCell>
|
|
353
545
|
<TableCell><span class="muted">{asset.location ?? '—'}</span></TableCell>
|
|
354
|
-
|
|
546
|
+
<!-- `formatDate`, not the stored value. This column printed the ISO string — 2027-03-14
|
|
547
|
+
— beside a panel that renders the same field as "14 Mar 2027", so the same fact read
|
|
548
|
+
two ways on one screen, and a Persian reader got Gregorian Latin digits in a table
|
|
549
|
+
where every other date follows their calendar. -->
|
|
550
|
+
<TableCell>
|
|
551
|
+
<span class="muted">
|
|
552
|
+
{asset.warrantyUntil ? formatDate(asset.warrantyUntil) : '—'}
|
|
553
|
+
</span>
|
|
554
|
+
</TableCell>
|
|
355
555
|
<TableCell>
|
|
356
556
|
{#if asset.archivedAt}
|
|
357
557
|
<Badge tone="grey">{t('archived')}</Badge>
|
|
@@ -367,7 +567,7 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
367
567
|
{...props}
|
|
368
568
|
icon="ellipsis"
|
|
369
569
|
size={28}
|
|
370
|
-
label={t('row_actions', { name: asset.name })}
|
|
570
|
+
label={t('row_actions', isolated({ name: asset.name }))}
|
|
371
571
|
/>
|
|
372
572
|
{/snippet}
|
|
373
573
|
</DropdownMenu>
|
|
@@ -394,11 +594,23 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
394
594
|
|
|
395
595
|
<AssetFormDialog bind:open={dialogOpen} {workspaceId} asset={editingAsset} />
|
|
396
596
|
|
|
597
|
+
<!--
|
|
598
|
+
The panel sits over the list rather than replacing it: the scroll position, the filters and the
|
|
599
|
+
page cursor are all still there when it closes. Editing is the list's own dialog, so the panel
|
|
600
|
+
asks for it rather than growing a second copy of the form.
|
|
601
|
+
-->
|
|
602
|
+
<AssetDetailPanel
|
|
603
|
+
{workspaceId}
|
|
604
|
+
assetId={openAssetId}
|
|
605
|
+
onclose={() => setAsset(null)}
|
|
606
|
+
onedit={(asset) => openEdit(asset)}
|
|
607
|
+
/>
|
|
608
|
+
|
|
397
609
|
<!-- Archiving states what happens and to what, rather than asking "Are you sure?". -->
|
|
398
610
|
<Dialog
|
|
399
611
|
open={archiving !== null}
|
|
400
612
|
size="sm"
|
|
401
|
-
title={t('archive_title', { name: archiving?.name ?? '' })}
|
|
613
|
+
title={t('archive_title', isolated({ name: archiving?.name ?? '' }))}
|
|
402
614
|
onOpenChange={(next) => {
|
|
403
615
|
if (!next) archiving = null
|
|
404
616
|
}}
|
|
@@ -423,6 +635,24 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
423
635
|
font-size: 12px;
|
|
424
636
|
color: var(--kern-ink-280);
|
|
425
637
|
}
|
|
638
|
+
.held-by {
|
|
639
|
+
display: flex;
|
|
640
|
+
align-items: center;
|
|
641
|
+
justify-content: space-between;
|
|
642
|
+
gap: 12px;
|
|
643
|
+
flex-wrap: wrap;
|
|
644
|
+
margin-bottom: 10px;
|
|
645
|
+
/* Logical, never left/right: the extra room is beside the text, which in Persian is the other
|
|
646
|
+
side of the box. */
|
|
647
|
+
padding-block: 8px;
|
|
648
|
+
padding-inline: 12px 8px;
|
|
649
|
+
border-radius: var(--kern-r-md);
|
|
650
|
+
background: var(--kern-surface-chip);
|
|
651
|
+
font-size: 13px;
|
|
652
|
+
/* A colour rather than opacity, and 600 rather than 500: this sentence is the reason the list
|
|
653
|
+
below it is short, so it is read rather than skimmed. */
|
|
654
|
+
color: var(--kern-ink-600);
|
|
655
|
+
}
|
|
426
656
|
.muted {
|
|
427
657
|
color: var(--kern-ink-280);
|
|
428
658
|
}
|
|
@@ -446,16 +676,58 @@ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
|
446
676
|
min-width: 0;
|
|
447
677
|
}
|
|
448
678
|
.name {
|
|
679
|
+
/* A button that reads as the row's title: no chrome, the row's own type, and the pointer the
|
|
680
|
+
design system already puts on every `button`. */
|
|
681
|
+
appearance: none;
|
|
682
|
+
background: none;
|
|
683
|
+
border: 0;
|
|
684
|
+
font: inherit;
|
|
449
685
|
font-weight: 500;
|
|
450
686
|
color: var(--kern-ink-900);
|
|
687
|
+
text-align: start;
|
|
451
688
|
overflow: hidden;
|
|
452
689
|
text-overflow: ellipsis;
|
|
453
690
|
white-space: nowrap;
|
|
691
|
+
/* WCAG 2.5.8 wants 24px; a line of 13px text is about 18. The padding grows the hit area and
|
|
692
|
+
the equal negative margin gives the space back, so the margin box — and therefore the row —
|
|
693
|
+
is exactly where it was. */
|
|
694
|
+
padding-block: 4px;
|
|
695
|
+
margin-block: -4px;
|
|
696
|
+
/* So the global `:focus-visible` ring is rounded like every other control. */
|
|
697
|
+
border-radius: var(--kern-r-sm);
|
|
698
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
699
|
+
first strong character, so a Latin name inside a Persian screen reads left to right and
|
|
700
|
+
keeps its own trailing punctuation instead of donating it to the paragraph. */
|
|
701
|
+
unicode-bidi: plaintext;
|
|
702
|
+
}
|
|
703
|
+
.name:hover {
|
|
704
|
+
text-decoration: underline;
|
|
454
705
|
}
|
|
455
706
|
.sub {
|
|
707
|
+
display: flex;
|
|
708
|
+
align-items: center;
|
|
709
|
+
gap: 6px;
|
|
710
|
+
min-width: 0;
|
|
456
711
|
font-size: 11px;
|
|
457
712
|
/* Muted with a colour, never with opacity: a faded row at 0.5 is unreadable whatever its token. */
|
|
458
713
|
color: var(--kern-ink-280);
|
|
714
|
+
}
|
|
715
|
+
.chip {
|
|
716
|
+
flex: none;
|
|
717
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
718
|
+
first strong character, so a Latin name inside a Persian screen reads left to right and
|
|
719
|
+
keeps its own trailing punctuation instead of donating it to the paragraph. */
|
|
720
|
+
unicode-bidi: plaintext;
|
|
721
|
+
padding: 1px 6px;
|
|
722
|
+
border-radius: var(--kern-r-sm);
|
|
723
|
+
background: var(--kern-surface-chip);
|
|
724
|
+
color: var(--kern-ink-600);
|
|
725
|
+
max-inline-size: 140px;
|
|
726
|
+
overflow: hidden;
|
|
727
|
+
text-overflow: ellipsis;
|
|
728
|
+
white-space: nowrap;
|
|
729
|
+
}
|
|
730
|
+
.serial {
|
|
459
731
|
overflow: hidden;
|
|
460
732
|
text-overflow: ellipsis;
|
|
461
733
|
white-space: nowrap;
|
|
@@ -21,6 +21,13 @@ const key = (suffix: string) => {
|
|
|
21
21
|
export const INVENTORY_PERMISSIONS = {
|
|
22
22
|
view: key('asset.view'),
|
|
23
23
|
manage: key('asset.manage'),
|
|
24
|
+
/** Handing an item over and taking it back. Reading who holds what rides `view` — see the
|
|
25
|
+
* contract's `permissions.ts` for why that is a decision rather than an omission. */
|
|
26
|
+
custody: key('custody.manage'),
|
|
27
|
+
/** Sending an item away, correcting the record and logging it back. */
|
|
28
|
+
repairs: key('repair.manage'),
|
|
29
|
+
/** The categories settings page, and nothing else. */
|
|
30
|
+
categories: key('category.manage'),
|
|
24
31
|
} as const
|
|
25
32
|
|
|
26
33
|
export type InventoryPermission = keyof typeof INVENTORY_PERMISSIONS
|