@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
package/src/client/query.ts
CHANGED
|
@@ -3,10 +3,59 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `[module, entity, …scope]`, so a realtime `change` event invalidates precisely what it touched.
|
|
5
5
|
* Filters are part of the key wherever a screen can ask the same question two ways — a cached list
|
|
6
|
-
* for "all" must not be served when somebody asked for "assigned"
|
|
6
|
+
* for "all" must not be served when somebody asked for "assigned", and the archived list is a
|
|
7
|
+
* different question from the live one rather than the same answer filtered afterwards.
|
|
8
|
+
*
|
|
9
|
+
* **The entity segment is singular, and that is not a style choice.** `realtime.svelte.ts`
|
|
10
|
+
* invalidates `[module, entity]` with whatever the server put in the change event, and
|
|
11
|
+
* `src/server/router.ts` emits `entity: 'asset'` — so the prefix that arrives is
|
|
12
|
+
* `['inventory', 'asset']`. This file spelled the list key `['inventory', 'assets', …]`, which that
|
|
13
|
+
* prefix never matches: `partialMatchKey` compares segment by segment and `'assets' !== 'asset'`.
|
|
14
|
+
* The detail key was already singular, so a change refreshed the panel nobody was looking at while
|
|
15
|
+
* the list and the dashboard card behind it stayed stale until a reload. Every other module keys
|
|
16
|
+
* the list and the row off one singular entity name (`['tracker', 'issue', …]`); so does this one
|
|
17
|
+
* now.
|
|
18
|
+
*
|
|
19
|
+
* **The timeline and the custody list hang off the asset's own key**, one segment deeper. That is
|
|
20
|
+
* what lets custody announce itself as `entity: 'asset'` and refresh all four things it changed —
|
|
21
|
+
* the row, the list, the detail panel and both of its tabs — from one change event, because
|
|
22
|
+
* `partialMatchKey` matches on the prefix. A separate `['inventory', 'custody', …]` would need the
|
|
23
|
+
* server to emit a second entity for the same write.
|
|
7
24
|
*/
|
|
8
25
|
export const inventoryKeys = {
|
|
26
|
+
/** Everything under this module, for the blunt invalidation after a write. */
|
|
27
|
+
all: ['inventory'] as const,
|
|
9
28
|
assets: (ws: string, filters?: Record<string, unknown>) =>
|
|
10
|
-
filters ? (['inventory', '
|
|
29
|
+
filters ? (['inventory', 'asset', ws, filters] as const) : (['inventory', 'asset', ws] as const),
|
|
11
30
|
asset: (ws: string, id: string) => ['inventory', 'asset', ws, id] as const,
|
|
31
|
+
/** The asset's own timeline. Under the asset, so one `asset` change refreshes it. */
|
|
32
|
+
assetHistory: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'history'] as const,
|
|
33
|
+
/** Every custody period for one asset. Under the asset, for the same reason. */
|
|
34
|
+
assetCustody: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'custody'] as const,
|
|
35
|
+
/** One asset's repairs. Under the asset, so a repair announced as an `asset` change refreshes it. */
|
|
36
|
+
assetRepairs: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'repairs'] as const,
|
|
37
|
+
/** One asset's files, its repairs' included — the panel groups them, so there is one key. */
|
|
38
|
+
assetAttachments: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'attachments'] as const,
|
|
39
|
+
/**
|
|
40
|
+
* The workspace's repairs, which belong to no single asset — the "what is away right now" card.
|
|
41
|
+
*
|
|
42
|
+
* Its own entity, because it cannot hang under one asset's key: `src/server/router.ts` therefore
|
|
43
|
+
* announces a repair twice, once as the asset that changed and once as the repair itself.
|
|
44
|
+
*/
|
|
45
|
+
repairs: (ws: string, filters?: Record<string, unknown>) =>
|
|
46
|
+
filters ? (['inventory', 'repair', ws, filters] as const) : (['inventory', 'repair', ws] as const),
|
|
47
|
+
/**
|
|
48
|
+
* The register in numbers.
|
|
49
|
+
*
|
|
50
|
+
* Under the **asset** prefix on purpose, one segment deep: every number it holds is a count of
|
|
51
|
+
* assets, so every write that changes one of those counts already announces `entity: 'asset'` and
|
|
52
|
+
* this is refreshed by the same event. `'stats'` cannot collide with `asset(ws, id)` — that
|
|
53
|
+
* segment is always a uuid.
|
|
54
|
+
*/
|
|
55
|
+
stats: (ws: string) => ['inventory', 'asset', ws, 'stats'] as const,
|
|
56
|
+
/**
|
|
57
|
+
* The workspace's categories. Its own entity because it has its own change event: a rename
|
|
58
|
+
* touches every asset row on screen, and `src/server/router.ts` therefore emits both.
|
|
59
|
+
*/
|
|
60
|
+
categories: (ws: string, archived = false) => ['inventory', 'category', ws, { archived }] as const,
|
|
12
61
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { repairActions } from './repairs.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the Repairs section is allowed to offer.
|
|
6
|
+
*
|
|
7
|
+
* A decision rather than rendering, and wrong in a way nothing else would catch: a *Send for repair*
|
|
8
|
+
* button on an item that is already at a repairer produces a 409 every single time it is pressed —
|
|
9
|
+
* the unique index refuses the second open repair — so the interface must not offer it at all.
|
|
10
|
+
*/
|
|
11
|
+
describe('repairActions', () => {
|
|
12
|
+
it('offers sending it away when nothing is open', () => {
|
|
13
|
+
expect(repairActions({ open: false, archived: false, may: true })).toEqual(['create'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('offers completing and correcting while one is open, and never a second send', () => {
|
|
17
|
+
const actions = repairActions({ open: true, archived: false, may: true })
|
|
18
|
+
expect(actions).toEqual(['complete', 'edit'])
|
|
19
|
+
// The one combination that would conflict on every press.
|
|
20
|
+
expect(actions).not.toContain('create')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('offers nothing without the permission — hidden, not disabled', () => {
|
|
24
|
+
expect(repairActions({ open: false, archived: false, may: false })).toEqual([])
|
|
25
|
+
expect(repairActions({ open: true, archived: false, may: false })).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('offers nothing new on an archived item, which the server refuses anyway', () => {
|
|
29
|
+
expect(repairActions({ open: false, archived: true, may: true })).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps the way out of an open repair reachable even on an archived item', () => {
|
|
33
|
+
// Archiving is refused while a repair is open, so this state should be unreachable — and if
|
|
34
|
+
// the data ever says otherwise, hiding *complete* would leave the item stuck at `under_repair`
|
|
35
|
+
// for ever with no control that could finish it.
|
|
36
|
+
expect(repairActions({ open: true, archived: true, may: true })).toEqual(['complete', 'edit'])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a repair section can actually offer right now.
|
|
3
|
+
*
|
|
4
|
+
* Three verbs and not one, because the server has three procedures and refuses the wrong one with
|
|
5
|
+
* its own sentence: sending something that is already at a repairer is a mistake worth naming, and
|
|
6
|
+
* completing a repair that is already finished is a different one. A button that always produces a
|
|
7
|
+
* conflict is a door that will not open.
|
|
8
|
+
*
|
|
9
|
+
* A `.ts` file rather than a `const` inside the component, for the reason `custody.ts`, `price.ts`
|
|
10
|
+
* and `timeline.ts` are: this is a fact with a right answer, and a `.svelte` file drags a compiler
|
|
11
|
+
* behind it and cannot be unit-tested. It also has to be a plain module because the type is shared
|
|
12
|
+
* with the dialog — a type exported from a component's instance script is a prop, not a type.
|
|
13
|
+
*/
|
|
14
|
+
export type RepairAction = 'create' | 'edit' | 'complete'
|
|
15
|
+
|
|
16
|
+
export interface RepairState {
|
|
17
|
+
/** A repair for this asset with no return date — at most one exists, by unique index. */
|
|
18
|
+
open: boolean
|
|
19
|
+
/** The workspace has taken the item out of the register; the server refuses a new repair. */
|
|
20
|
+
archived: boolean
|
|
21
|
+
/** This person holds `inventory.repair.manage`. */
|
|
22
|
+
may: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to offer, in the order the controls appear.
|
|
27
|
+
*
|
|
28
|
+
* Empty for somebody without the permission — hide what a person may never do — and empty for an
|
|
29
|
+
* archived item with nothing open, where the section says *why* in one sentence instead. An
|
|
30
|
+
* archived item whose repair is still open keeps `complete` and `edit`: archiving is refused while
|
|
31
|
+
* a repair is open, so the only way to be in that state is to have archived it first and opened the
|
|
32
|
+
* repair never — but if the data ever says otherwise, the way out has to stay reachable.
|
|
33
|
+
*/
|
|
34
|
+
export function repairActions(state: RepairState): RepairAction[] {
|
|
35
|
+
if (!state.may) return []
|
|
36
|
+
if (state.open) return ['complete', 'edit']
|
|
37
|
+
return state.archived ? [] : ['create']
|
|
38
|
+
}
|
|
@@ -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>
|