@kernhq/module-inventory 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
|
@@ -1,23 +1,44 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import {
|
|
3
3
|
Badge,
|
|
4
|
-
type BadgeTone,
|
|
5
4
|
Button,
|
|
5
|
+
coreApi,
|
|
6
|
+
Dialog,
|
|
7
|
+
DropdownMenu,
|
|
6
8
|
EmptyState,
|
|
9
|
+
formatDate,
|
|
10
|
+
IconButton,
|
|
7
11
|
Input,
|
|
12
|
+
keys,
|
|
13
|
+
type MenuItem,
|
|
14
|
+
navigation,
|
|
15
|
+
Page,
|
|
16
|
+
PageHeader,
|
|
8
17
|
Select,
|
|
9
18
|
type SelectOption,
|
|
10
19
|
Skeleton,
|
|
11
|
-
|
|
12
|
-
StatTile,
|
|
20
|
+
Switch,
|
|
13
21
|
session,
|
|
22
|
+
Table,
|
|
23
|
+
TableCell,
|
|
24
|
+
TableHeader,
|
|
25
|
+
TableRow,
|
|
26
|
+
toast,
|
|
14
27
|
} from '@kernhq/ui'
|
|
15
|
-
import { createQuery } from '@tanstack/svelte-query'
|
|
28
|
+
import { createInfiniteQuery, createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
29
|
+
import type { Asset, AssetStatus, Category } from '../../contract/index.js'
|
|
16
30
|
import { getInventoryApi } from '../api-instance.js'
|
|
31
|
+
import { isolated } from '../bidi.js'
|
|
32
|
+
import AssetDetailPanel from '../components/AssetDetailPanel.svelte'
|
|
17
33
|
import AssetFormDialog from '../components/AssetFormDialog.svelte'
|
|
34
|
+
import type { CoreApi, CoreMember } from '../core-api.js'
|
|
35
|
+
import { errorMessage } from '../errors.js'
|
|
18
36
|
import { t } from '../i18n.js'
|
|
19
|
-
import {
|
|
37
|
+
import { ASSET_PARAM } from '../links.js'
|
|
38
|
+
import { directory, directoryStatus, nameOf } from '../members.js'
|
|
39
|
+
import { canInventory } from '../permissions.js'
|
|
20
40
|
import { inventoryKeys } from '../query.js'
|
|
41
|
+
import { statusTone } from '../status.js'
|
|
21
42
|
|
|
22
43
|
/**
|
|
23
44
|
* This module's screen.
|
|
@@ -35,232 +56,704 @@ interface Props {
|
|
|
35
56
|
const { workspaceId }: Props = $props()
|
|
36
57
|
|
|
37
58
|
const api = getInventoryApi()
|
|
59
|
+
const core = coreApi<CoreApi>()
|
|
60
|
+
const queryClient = useQueryClient()
|
|
38
61
|
|
|
39
|
-
let
|
|
62
|
+
let searchText = $state('')
|
|
40
63
|
let statusFilter = $state<string>('')
|
|
64
|
+
let categoryFilter = $state<string>('')
|
|
41
65
|
let showArchived = $state(false)
|
|
42
66
|
let dialogOpen = $state(false)
|
|
67
|
+
/** The row the dialog is editing, or null when it is adding one. */
|
|
68
|
+
let editingAsset = $state<Asset | null>(null)
|
|
69
|
+
/** The row awaiting an archive confirmation. */
|
|
70
|
+
let archiving = $state<Asset | null>(null)
|
|
43
71
|
|
|
44
72
|
// Debounce the search into the cache key, so typing does not refetch per keystroke.
|
|
45
|
-
let
|
|
73
|
+
let query = $state('')
|
|
46
74
|
$effect(() => {
|
|
47
|
-
const value =
|
|
48
|
-
const timer = setTimeout(() => (
|
|
75
|
+
const value = searchText
|
|
76
|
+
const timer = setTimeout(() => (query = value), 250)
|
|
49
77
|
return () => clearTimeout(timer)
|
|
50
78
|
})
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every filter is part of the key and part of the request.
|
|
94
|
+
*
|
|
95
|
+
* Filtering in the browser is only ever right for a list that is entirely loaded, and this one is
|
|
96
|
+
* paged: dropping archived rows client-side returned twenty rows, showed eleven, and made the list
|
|
97
|
+
* look short rather than paged.
|
|
98
|
+
*/
|
|
99
|
+
const filters = $derived({
|
|
100
|
+
...(query ? { q: query } : {}),
|
|
101
|
+
...(statusFilter ? { status: statusFilter as AssetStatus } : {}),
|
|
102
|
+
...(categoryFilter ? { categoryId: categoryFilter } : {}),
|
|
103
|
+
...(custodianFilter ? { custodianUserId: custodianFilter } : {}),
|
|
104
|
+
archived: showArchived,
|
|
58
105
|
})
|
|
59
106
|
|
|
60
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Whether anything is *narrowing* the list.
|
|
109
|
+
*
|
|
110
|
+
* The archived switch is not in here: it widens the list rather than narrowing it, so a workspace
|
|
111
|
+
* with no assets and the switch on is still empty rather than filtered, and offering to "clear" it
|
|
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.
|
|
114
|
+
*/
|
|
115
|
+
const narrowed = $derived(
|
|
116
|
+
Boolean(query) || Boolean(statusFilter) || Boolean(categoryFilter) || Boolean(custodianFilter),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
function clearFilters() {
|
|
120
|
+
searchText = ''
|
|
121
|
+
query = ''
|
|
122
|
+
statusFilter = ''
|
|
123
|
+
categoryFilter = ''
|
|
124
|
+
setParam('custodian', null)
|
|
125
|
+
}
|
|
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
|
+
|
|
163
|
+
interface AssetPage {
|
|
164
|
+
items: Asset[]
|
|
165
|
+
nextCursor: string | null
|
|
166
|
+
/** `page()` in `@kernhq/contracts` declares it; this module's server does not fill it yet. */
|
|
167
|
+
total?: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const assetsQuery = createInfiniteQuery(() => ({
|
|
61
171
|
queryKey: inventoryKeys.assets(workspaceId, filters),
|
|
62
|
-
queryFn: () =>
|
|
172
|
+
queryFn: ({ pageParam }): Promise<AssetPage> =>
|
|
63
173
|
api.assets.list({
|
|
64
174
|
workspaceId,
|
|
65
|
-
...
|
|
66
|
-
...(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
175
|
+
...filters,
|
|
176
|
+
...(pageParam ? { cursor: pageParam as string } : {}),
|
|
177
|
+
}) as Promise<AssetPage>,
|
|
178
|
+
initialPageParam: undefined as string | undefined,
|
|
179
|
+
getNextPageParam: (last: AssetPage) => last.nextCursor ?? undefined,
|
|
70
180
|
enabled: Boolean(workspaceId),
|
|
71
181
|
}))
|
|
72
182
|
|
|
73
|
-
const assets = $derived(assetsQuery.data?.items ?? [])
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
183
|
+
const assets = $derived(assetsQuery.data?.pages.flatMap((page) => page.items) ?? [])
|
|
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
|
|
81
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
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A count that stays true while the list pages.
|
|
267
|
+
*
|
|
268
|
+
* `t('count', { n: assets.length })` counted the pages *loaded*, so 120 assets read "50 assets" and
|
|
269
|
+
* then "100 assets" after Load more — a number that is simply wrong, on the line whose whole job is
|
|
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.
|
|
277
|
+
*/
|
|
278
|
+
const reportedTotal = $derived(assetsQuery.data?.pages.at(-1)?.total)
|
|
279
|
+
const workspaceTotal = $derived(!narrowed && !showArchived ? statsQuery.data?.total : undefined)
|
|
280
|
+
const countLine = $derived(
|
|
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 }),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Every status the contract can hold, offered ahead of the features that set them.
|
|
292
|
+
*
|
|
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.
|
|
298
|
+
*/
|
|
82
299
|
const statusOptions: SelectOption[] = [
|
|
83
300
|
{ value: '', label: t('filter_all_statuses') },
|
|
84
301
|
{ value: 'in_stock', label: t('status_in_stock') },
|
|
85
302
|
{ value: 'assigned', label: t('status_assigned') },
|
|
303
|
+
{ value: 'reserved', label: t('status_reserved') },
|
|
86
304
|
{ value: 'under_repair', label: t('status_under_repair') },
|
|
305
|
+
{ value: 'lost', label: t('status_lost') },
|
|
87
306
|
{ value: 'retired', label: t('status_retired') },
|
|
88
307
|
]
|
|
89
308
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
309
|
+
// ---------------------------------------------------------------- row actions
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `assets.update` and `assets.archive` had no entry point anywhere in this module.
|
|
313
|
+
*
|
|
314
|
+
* Three of five procedures were unreachable and the module had one screen, so an asset could be
|
|
315
|
+
* created and then never corrected — a typo in a name was permanent, and a laptop that left the
|
|
316
|
+
* company stayed in the register for ever.
|
|
317
|
+
*/
|
|
318
|
+
let acting = $state(false)
|
|
319
|
+
|
|
320
|
+
/** Archive and restore are one procedure, so they are one mutation and one busy flag. */
|
|
321
|
+
interface ArchiveVars {
|
|
322
|
+
assetId: string
|
|
323
|
+
archived: boolean
|
|
324
|
+
name: string
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const setArchived = createMutation(() => ({
|
|
328
|
+
mutationFn: (vars: ArchiveVars) =>
|
|
329
|
+
api.assets.archive({ workspaceId, assetId: vars.assetId, archived: vars.archived }),
|
|
330
|
+
onSuccess: (_saved: Asset, vars: ArchiveVars) => {
|
|
331
|
+
toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', isolated({ name: vars.name })))
|
|
332
|
+
void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
|
|
333
|
+
archiving = null
|
|
334
|
+
},
|
|
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)),
|
|
340
|
+
onSettled: () => {
|
|
341
|
+
acting = false
|
|
342
|
+
},
|
|
343
|
+
}))
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The guard is a plain flag set in the same tick as the click.
|
|
347
|
+
*
|
|
348
|
+
* `disabled={mutation.isPending}` reaches the button one render later, and two quick clicks are one
|
|
349
|
+
* render apart — so on a confirmation it files two decisions.
|
|
350
|
+
*/
|
|
351
|
+
function confirmArchive() {
|
|
352
|
+
const target = archiving
|
|
353
|
+
if (!target || acting) return
|
|
354
|
+
acting = true
|
|
355
|
+
setArchived.mutate({ assetId: target.id, archived: true, name: target.name })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function restore(asset: Asset) {
|
|
359
|
+
if (acting) return
|
|
360
|
+
acting = true
|
|
361
|
+
setArchived.mutate({ assetId: asset.id, archived: false, name: asset.name })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function openCreate() {
|
|
365
|
+
editingAsset = null
|
|
366
|
+
dialogOpen = true
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function openEdit(asset: Asset) {
|
|
370
|
+
editingAsset = asset
|
|
371
|
+
dialogOpen = true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Restoring is not destructive and needs no confirmation; archiving is, and states what happens.
|
|
376
|
+
* Hidden rather than disabled for somebody without `manage`: they may never do it, so the menu is
|
|
377
|
+
* not there at all rather than being a door that will not open.
|
|
378
|
+
*/
|
|
379
|
+
function actionsFor(asset: Asset): 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
|
+
]
|
|
386
|
+
if (asset.archivedAt) {
|
|
387
|
+
items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(asset) })
|
|
388
|
+
} else {
|
|
389
|
+
items.push({ type: 'separator' })
|
|
390
|
+
items.push({
|
|
391
|
+
label: t('common.archive'),
|
|
392
|
+
icon: 'archive',
|
|
393
|
+
danger: true,
|
|
394
|
+
onSelect: () => (archiving = asset),
|
|
395
|
+
})
|
|
100
396
|
}
|
|
397
|
+
return items
|
|
101
398
|
}
|
|
102
|
-
|
|
399
|
+
|
|
400
|
+
const workspaceName = $derived(session.workspaces.find((w) => w.id === workspaceId)?.name ?? '')
|
|
401
|
+
const canManage = $derived(canInventory('manage'))
|
|
402
|
+
/** The last column carries the row menu, and only exists when there is a menu to carry. */
|
|
403
|
+
const COLUMNS = $derived(
|
|
404
|
+
canManage
|
|
405
|
+
? 'minmax(96px, 110px) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 132px 44px'
|
|
406
|
+
: 'minmax(96px, 110px) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 132px',
|
|
407
|
+
)
|
|
408
|
+
const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
|
|
103
409
|
</script>
|
|
104
410
|
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
411
|
+
<PageHeader crumbs={[{ label: workspaceName }, { label: t('title') }]} title={t('title')}>
|
|
412
|
+
{#snippet search()}
|
|
413
|
+
<Input
|
|
414
|
+
bind:value={searchText}
|
|
415
|
+
type="search"
|
|
416
|
+
size="sm"
|
|
417
|
+
placeholder={t('search_placeholder')}
|
|
418
|
+
aria-label={t('search_placeholder')}
|
|
419
|
+
/>
|
|
420
|
+
{/snippet}
|
|
421
|
+
{#snippet actions()}
|
|
422
|
+
<Select
|
|
423
|
+
bind:value={categoryFilter}
|
|
424
|
+
options={categoryOptions}
|
|
425
|
+
size="sm"
|
|
426
|
+
ariaLabel={t('category')}
|
|
427
|
+
/>
|
|
428
|
+
<Select bind:value={statusFilter} options={statusOptions} size="sm" ariaLabel={t('status')} />
|
|
429
|
+
{#if canManage}
|
|
430
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('new')}</Button>
|
|
431
|
+
{/if}
|
|
432
|
+
{/snippet}
|
|
433
|
+
</PageHeader>
|
|
434
|
+
|
|
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>
|
|
116
448
|
</div>
|
|
117
|
-
|
|
449
|
+
{/if}
|
|
118
450
|
|
|
119
|
-
<div class="
|
|
120
|
-
<
|
|
121
|
-
<
|
|
122
|
-
<StatTile size="md" label={t('status_assigned')} value={String(counts.assigned)} />
|
|
123
|
-
<StatTile size="md" label={t('status_under_repair')} value={String(counts.repair)} />
|
|
451
|
+
<div class="bar">
|
|
452
|
+
<p class="count" aria-live="polite">{countLine}</p>
|
|
453
|
+
<Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
|
|
124
454
|
</div>
|
|
125
455
|
|
|
126
456
|
{#if assetsQuery.isPending}
|
|
127
|
-
|
|
457
|
+
<!-- Shaped like the table it is standing in for, not a spinner in the middle of a content
|
|
458
|
+
area: the page does not jump when the rows arrive, and the shape says what is coming. -->
|
|
459
|
+
<div class="skeleton">
|
|
460
|
+
{#each SKELETON_ROWS as row (row)}
|
|
461
|
+
<div class="srow" style:grid-template-columns={COLUMNS}>
|
|
462
|
+
<Skeleton height="12px" width="72%" />
|
|
463
|
+
<Skeleton height="12px" width="58%" />
|
|
464
|
+
<Skeleton height="12px" width="46%" />
|
|
465
|
+
<Skeleton height="12px" width="40%" />
|
|
466
|
+
<Skeleton height="18px" width="76px" radius="999px" />
|
|
467
|
+
{#if canManage}<Skeleton height="12px" width="16px" />{/if}
|
|
468
|
+
</div>
|
|
469
|
+
{/each}
|
|
470
|
+
</div>
|
|
128
471
|
{:else if assetsQuery.isError}
|
|
129
|
-
|
|
130
|
-
|
|
472
|
+
<!-- An error needs a message *and* a way out of it. This was a bare red sentence. -->
|
|
473
|
+
<EmptyState icon="triangle-alert" title={t('load_error')} description={t('common.error')}>
|
|
474
|
+
{#snippet actions()}
|
|
475
|
+
<Button variant="secondary" onclick={() => void assetsQuery.refetch()}>
|
|
476
|
+
{t('common.retry')}
|
|
477
|
+
</Button>
|
|
478
|
+
{/snippet}
|
|
479
|
+
</EmptyState>
|
|
480
|
+
{:else if assets.length === 0 && narrowed}
|
|
481
|
+
<!-- A search that matched nothing used to say "No assets yet · Add the first laptop…" beside a
|
|
482
|
+
New asset button — the wrong sentence and the wrong action for a workspace full of them. -->
|
|
483
|
+
<EmptyState icon="search" title={t('no_matches')} description={t('no_matches_desc')}>
|
|
484
|
+
{#snippet actions()}
|
|
485
|
+
<Button variant="secondary" onclick={clearFilters}>{t('clear_filters')}</Button>
|
|
486
|
+
{/snippet}
|
|
487
|
+
</EmptyState>
|
|
488
|
+
{:else if assets.length === 0}
|
|
131
489
|
<EmptyState icon="briefcase" title={t('empty')} description={t('empty_desc')}>
|
|
132
490
|
{#snippet actions()}
|
|
133
|
-
{#if
|
|
134
|
-
<Button onclick={
|
|
491
|
+
{#if canManage}
|
|
492
|
+
<Button onclick={openCreate}>{t('new')}</Button>
|
|
135
493
|
{/if}
|
|
136
494
|
{/snippet}
|
|
137
495
|
</EmptyState>
|
|
138
496
|
{:else}
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
<
|
|
146
|
-
<
|
|
147
|
-
</
|
|
148
|
-
{#each
|
|
149
|
-
<
|
|
150
|
-
<
|
|
151
|
-
<
|
|
497
|
+
<Table columns={COLUMNS} ariaLabel={t('title')}>
|
|
498
|
+
<TableHeader>
|
|
499
|
+
<TableCell header>{t('code')}</TableCell>
|
|
500
|
+
<TableCell header>{t('name')}</TableCell>
|
|
501
|
+
<TableCell header>{t('location')}</TableCell>
|
|
502
|
+
<TableCell header>{t('warranty_until')}</TableCell>
|
|
503
|
+
<TableCell header>{t('status')}</TableCell>
|
|
504
|
+
{#if canManage}<TableCell header end></TableCell>{/if}
|
|
505
|
+
</TableHeader>
|
|
506
|
+
{#each assets as asset (asset.id)}
|
|
507
|
+
<TableRow>
|
|
508
|
+
<TableCell><code>{asset.code}</code></TableCell>
|
|
509
|
+
<TableCell>
|
|
152
510
|
<span class="stack">
|
|
153
|
-
|
|
154
|
-
|
|
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}
|
|
525
|
+
<span class="sub">
|
|
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}
|
|
541
|
+
</span>
|
|
542
|
+
{/if}
|
|
543
|
+
</span>
|
|
544
|
+
</TableCell>
|
|
545
|
+
<TableCell><span class="muted">{asset.location ?? '—'}</span></TableCell>
|
|
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) : '—'}
|
|
155
553
|
</span>
|
|
156
|
-
</
|
|
157
|
-
<
|
|
158
|
-
<span class="cell muted" role="cell">{asset.warrantyUntil ?? '—'}</span>
|
|
159
|
-
<span class="cell" role="cell">
|
|
554
|
+
</TableCell>
|
|
555
|
+
<TableCell>
|
|
160
556
|
{#if asset.archivedAt}
|
|
161
557
|
<Badge tone="grey">{t('archived')}</Badge>
|
|
162
558
|
{:else}
|
|
163
|
-
<Badge tone={statusTone(asset.status)}>{
|
|
559
|
+
<Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
|
|
164
560
|
{/if}
|
|
165
|
-
</
|
|
166
|
-
|
|
561
|
+
</TableCell>
|
|
562
|
+
{#if canManage}
|
|
563
|
+
<TableCell end>
|
|
564
|
+
<DropdownMenu items={actionsFor(asset)} align="end">
|
|
565
|
+
{#snippet trigger(props)}
|
|
566
|
+
<IconButton
|
|
567
|
+
{...props}
|
|
568
|
+
icon="ellipsis"
|
|
569
|
+
size={28}
|
|
570
|
+
label={t('row_actions', isolated({ name: asset.name }))}
|
|
571
|
+
/>
|
|
572
|
+
{/snippet}
|
|
573
|
+
</DropdownMenu>
|
|
574
|
+
</TableCell>
|
|
575
|
+
{/if}
|
|
576
|
+
</TableRow>
|
|
167
577
|
{/each}
|
|
168
|
-
</
|
|
578
|
+
</Table>
|
|
579
|
+
|
|
580
|
+
{#if assetsQuery.hasNextPage}
|
|
581
|
+
<div class="more">
|
|
582
|
+
<Button
|
|
583
|
+
size="sm"
|
|
584
|
+
variant="secondary"
|
|
585
|
+
disabled={assetsQuery.isFetchingNextPage}
|
|
586
|
+
onclick={() => assetsQuery.fetchNextPage()}
|
|
587
|
+
>
|
|
588
|
+
{assetsQuery.isFetchingNextPage ? t('common.loading') : t('load_more')}
|
|
589
|
+
</Button>
|
|
590
|
+
</div>
|
|
591
|
+
{/if}
|
|
169
592
|
{/if}
|
|
170
|
-
</
|
|
593
|
+
</Page>
|
|
171
594
|
|
|
172
|
-
<AssetFormDialog bind:open={dialogOpen} {workspaceId} />
|
|
595
|
+
<AssetFormDialog bind:open={dialogOpen} {workspaceId} asset={editingAsset} />
|
|
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
|
+
|
|
609
|
+
<!-- Archiving states what happens and to what, rather than asking "Are you sure?". -->
|
|
610
|
+
<Dialog
|
|
611
|
+
open={archiving !== null}
|
|
612
|
+
size="sm"
|
|
613
|
+
title={t('archive_title', isolated({ name: archiving?.name ?? '' }))}
|
|
614
|
+
onOpenChange={(next) => {
|
|
615
|
+
if (!next) archiving = null
|
|
616
|
+
}}
|
|
617
|
+
>
|
|
618
|
+
<p class="dialog-body">{t('archive_body')}</p>
|
|
619
|
+
{#snippet footer()}
|
|
620
|
+
<Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
|
|
621
|
+
<Button variant="danger" onclick={confirmArchive} loading={acting}>{t('common.archive')}</Button>
|
|
622
|
+
{/snippet}
|
|
623
|
+
</Dialog>
|
|
173
624
|
|
|
174
625
|
<style>
|
|
175
|
-
|
|
626
|
+
.bar {
|
|
176
627
|
display: flex;
|
|
177
628
|
align-items: center;
|
|
178
629
|
justify-content: space-between;
|
|
179
630
|
gap: 12px;
|
|
180
631
|
flex-wrap: wrap;
|
|
181
|
-
|
|
182
|
-
h1 {
|
|
183
|
-
font-size: 18px;
|
|
184
|
-
font-weight: 600;
|
|
185
|
-
color: var(--kern-ink-900);
|
|
186
|
-
}
|
|
187
|
-
.actions {
|
|
188
|
-
display: flex;
|
|
189
|
-
align-items: center;
|
|
190
|
-
gap: 8px;
|
|
191
|
-
}
|
|
192
|
-
.tiles {
|
|
193
|
-
display: grid;
|
|
194
|
-
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
195
|
-
gap: 10px;
|
|
196
|
-
margin-top: 14px;
|
|
632
|
+
margin-bottom: 8px;
|
|
197
633
|
}
|
|
198
634
|
.count {
|
|
199
|
-
margin: 14px 0 6px;
|
|
200
635
|
font-size: 12px;
|
|
201
|
-
color: var(--kern-ink-
|
|
636
|
+
color: var(--kern-ink-280);
|
|
202
637
|
}
|
|
203
|
-
.
|
|
204
|
-
|
|
205
|
-
font-size: 13px;
|
|
206
|
-
}
|
|
207
|
-
.table {
|
|
208
|
-
margin-top: 4px;
|
|
209
|
-
border: 1px solid var(--kern-line);
|
|
210
|
-
border-radius: 10px;
|
|
211
|
-
overflow: hidden;
|
|
212
|
-
background: var(--kern-paper);
|
|
213
|
-
}
|
|
214
|
-
.thead,
|
|
215
|
-
.trow {
|
|
216
|
-
display: grid;
|
|
217
|
-
grid-template-columns: 110px minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 120px;
|
|
218
|
-
gap: 10px;
|
|
638
|
+
.held-by {
|
|
639
|
+
display: flex;
|
|
219
640
|
align-items: center;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
border-
|
|
229
|
-
|
|
230
|
-
.trow {
|
|
231
|
-
border-bottom: 1px solid var(--kern-line);
|
|
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);
|
|
232
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);
|
|
233
655
|
}
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
.cell {
|
|
238
|
-
min-width: 0;
|
|
656
|
+
.muted {
|
|
657
|
+
color: var(--kern-ink-280);
|
|
239
658
|
}
|
|
240
659
|
code {
|
|
241
660
|
font-size: 12px;
|
|
242
|
-
color: var(--kern-ink-
|
|
661
|
+
color: var(--kern-ink-600);
|
|
662
|
+
}
|
|
663
|
+
/* A tag and a serial are Latin identifiers read character by character. Inside a Persian or
|
|
664
|
+
Arabic row the bidi algorithm will happily reorder a mixed letter-and-digit run, so they are
|
|
665
|
+
isolated and left-to-right — the characters have to match what is printed on the sticker. */
|
|
666
|
+
code,
|
|
667
|
+
.ltr {
|
|
668
|
+
direction: ltr;
|
|
669
|
+
/* `isolate` works on an inline box; `inline-block` would also work and would cost the
|
|
670
|
+
ellipsis on the line above it. */
|
|
671
|
+
unicode-bidi: isolate;
|
|
243
672
|
}
|
|
244
|
-
.
|
|
673
|
+
.stack {
|
|
245
674
|
display: flex;
|
|
246
675
|
flex-direction: column;
|
|
247
676
|
min-width: 0;
|
|
248
677
|
}
|
|
249
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;
|
|
250
685
|
font-weight: 500;
|
|
251
686
|
color: var(--kern-ink-900);
|
|
687
|
+
text-align: start;
|
|
252
688
|
overflow: hidden;
|
|
253
689
|
text-overflow: ellipsis;
|
|
254
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;
|
|
255
705
|
}
|
|
256
706
|
.sub {
|
|
707
|
+
display: flex;
|
|
708
|
+
align-items: center;
|
|
709
|
+
gap: 6px;
|
|
710
|
+
min-width: 0;
|
|
257
711
|
font-size: 11px;
|
|
258
|
-
|
|
712
|
+
/* Muted with a colour, never with opacity: a faded row at 0.5 is unreadable whatever its token. */
|
|
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;
|
|
259
726
|
overflow: hidden;
|
|
260
727
|
text-overflow: ellipsis;
|
|
261
728
|
white-space: nowrap;
|
|
262
729
|
}
|
|
263
|
-
.
|
|
264
|
-
|
|
730
|
+
.serial {
|
|
731
|
+
overflow: hidden;
|
|
732
|
+
text-overflow: ellipsis;
|
|
733
|
+
white-space: nowrap;
|
|
734
|
+
}
|
|
735
|
+
.skeleton {
|
|
736
|
+
display: flex;
|
|
737
|
+
flex-direction: column;
|
|
738
|
+
gap: 1px;
|
|
739
|
+
}
|
|
740
|
+
.srow {
|
|
741
|
+
display: grid;
|
|
742
|
+
align-items: center;
|
|
743
|
+
gap: 12px;
|
|
744
|
+
/* The row heights of `Table`, so nothing shifts when the real rows replace these. */
|
|
745
|
+
padding: 14px 12px;
|
|
746
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
747
|
+
}
|
|
748
|
+
.more {
|
|
749
|
+
display: flex;
|
|
750
|
+
justify-content: center;
|
|
751
|
+
padding: 16px 0;
|
|
752
|
+
}
|
|
753
|
+
.dialog-body {
|
|
754
|
+
margin: 0;
|
|
755
|
+
font-size: 13.5px;
|
|
756
|
+
line-height: 1.55;
|
|
757
|
+
color: var(--kern-ink-700);
|
|
265
758
|
}
|
|
266
759
|
</style>
|