@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/mock.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Asset,
|
|
3
|
+
AssetHistoryEntry,
|
|
4
|
+
AssetStatus,
|
|
5
|
+
Attachment,
|
|
6
|
+
Category,
|
|
7
|
+
CustodyPeriod,
|
|
8
|
+
InventoryStats,
|
|
9
|
+
Repair,
|
|
10
|
+
} from '../contract/index.js'
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* The in-memory implementation of this module's API.
|
|
@@ -18,13 +27,59 @@ import type { Asset, AssetStatus } from '../contract/index.js'
|
|
|
18
27
|
* it ignored `sort`, `categoryId` and `custodianUserId` entirely. So the ordering, the paging and
|
|
19
28
|
* the cursor format below are deliberately the same as `src/server/services/assets.ts` — read them
|
|
20
29
|
* as one pair, and change them as one pair.
|
|
30
|
+
*
|
|
31
|
+
* Custody keeps the same invariant the server keeps, for the same reason: a handover closes the open
|
|
32
|
+
* period, opens a new one, moves `custodianUserId`/`custodySince`/`status` and appends a history
|
|
33
|
+
* row, and it refuses the three things the server refuses — assigning something already held,
|
|
34
|
+
* handing on something nobody holds, and touching an archived item. A demo that lets you assign the
|
|
35
|
+
* same laptop twice teaches somebody the product does.
|
|
36
|
+
*
|
|
37
|
+
* Repairs keep the same invariant too, including the one that is easiest to get wrong: **an item at
|
|
38
|
+
* a repairer is still whoever's it was**, so a repair moves `status` and never `custodianUserId`,
|
|
39
|
+
* and completing one returns the item to `assigned` rather than to `in_stock` when somebody still
|
|
40
|
+
* holds it. `statusFor` below is this file's copy of `deriveStatus` on the server; read them as one
|
|
41
|
+
* pair and change them as one pair.
|
|
21
42
|
*/
|
|
22
43
|
type AssetSort = 'recent' | 'name' | 'code'
|
|
23
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The people the **shell's** own mock puts in this workspace.
|
|
47
|
+
*
|
|
48
|
+
* Copied ids rather than a fetch, because this file has no client and no way to ask. The coupling is
|
|
49
|
+
* deliberate and narrow: without it every name in the custody tab and the timeline would resolve to
|
|
50
|
+
* "A former member", which is a demo of the fallback rather than a demo of the feature. If the
|
|
51
|
+
* shell's mock people ever change, this is where the demo stops naming them.
|
|
52
|
+
*/
|
|
53
|
+
const PEOPLE = {
|
|
54
|
+
maya: '01920000-0000-7000-8000-000000000001',
|
|
55
|
+
dan: '01920000-0000-7000-8000-000000000002',
|
|
56
|
+
ines: '01920000-0000-7000-8000-000000000003',
|
|
57
|
+
} as const
|
|
58
|
+
|
|
59
|
+
interface CategorySeed {
|
|
60
|
+
key: string
|
|
61
|
+
name: string
|
|
62
|
+
order: number
|
|
63
|
+
archived?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CATEGORY_SEEDS: CategorySeed[] = [
|
|
67
|
+
{ key: 'laptops', name: 'Laptops', order: 0 },
|
|
68
|
+
{ key: 'displays', name: 'Displays', order: 1 },
|
|
69
|
+
{ key: 'furniture', name: 'Furniture', order: 2 },
|
|
70
|
+
{ key: 'cameras', name: 'Cameras', order: 3 },
|
|
71
|
+
// One archived, so the demo shows what archiving actually does: it leaves the picker and the
|
|
72
|
+
// filter, and the asset filed under it goes on saying what it is.
|
|
73
|
+
{ key: 'phones', name: 'Phones', order: 4, archived: true },
|
|
74
|
+
]
|
|
75
|
+
|
|
24
76
|
interface Seed {
|
|
25
77
|
code: string
|
|
26
78
|
name: string
|
|
27
79
|
status: AssetStatus
|
|
80
|
+
categoryKey?: string
|
|
81
|
+
/** Who is holding it. Kept in step with `status` here exactly as the server keeps it. */
|
|
82
|
+
custodian?: string
|
|
28
83
|
serialNumber?: string | null
|
|
29
84
|
location?: string | null
|
|
30
85
|
warrantyUntil?: string | null
|
|
@@ -36,38 +91,73 @@ const SEEDS: Seed[] = [
|
|
|
36
91
|
code: 'INV-0001',
|
|
37
92
|
name: 'MacBook Pro 14"',
|
|
38
93
|
status: 'assigned',
|
|
94
|
+
categoryKey: 'laptops',
|
|
95
|
+
custodian: PEOPLE.dan,
|
|
39
96
|
serialNumber: 'C02X1234JGH7',
|
|
40
97
|
location: 'Istanbul · 3rd floor',
|
|
41
98
|
warrantyUntil: '2027-03-14',
|
|
42
99
|
},
|
|
43
|
-
{
|
|
100
|
+
{
|
|
101
|
+
code: 'INV-0002',
|
|
102
|
+
name: 'Dell UltraSharp 27"',
|
|
103
|
+
status: 'in_stock',
|
|
104
|
+
categoryKey: 'displays',
|
|
105
|
+
location: 'Istanbul · store room',
|
|
106
|
+
},
|
|
44
107
|
{
|
|
45
108
|
code: 'INV-0003',
|
|
46
109
|
name: 'iPhone 15',
|
|
47
110
|
status: 'under_repair',
|
|
111
|
+
// Filed under the archived category on purpose: the row still names "Phones".
|
|
112
|
+
categoryKey: 'phones',
|
|
48
113
|
serialNumber: 'F17GX9QKLM',
|
|
49
114
|
location: 'With the repairer',
|
|
50
115
|
},
|
|
51
|
-
{
|
|
52
|
-
|
|
116
|
+
{
|
|
117
|
+
code: 'INV-0004',
|
|
118
|
+
name: 'Herman Miller Aeron',
|
|
119
|
+
status: 'assigned',
|
|
120
|
+
categoryKey: 'furniture',
|
|
121
|
+
custodian: PEOPLE.ines,
|
|
122
|
+
location: 'Istanbul · 2nd floor',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
code: 'INV-0005',
|
|
126
|
+
name: 'Canon EOS R6',
|
|
127
|
+
status: 'reserved',
|
|
128
|
+
categoryKey: 'cameras',
|
|
129
|
+
location: 'Istanbul · store room',
|
|
130
|
+
},
|
|
53
131
|
{ code: 'INV-0006', name: 'ThinkPad X1 Carbon', status: 'retired', archived: true },
|
|
54
132
|
]
|
|
55
133
|
|
|
56
134
|
/**
|
|
57
135
|
* An error shaped like one the real client surfaces.
|
|
58
136
|
*
|
|
59
|
-
* oRPC hands a failure to the screen as an `Error` carrying the contract's `code`, and
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
137
|
+
* oRPC hands a failure to the screen as an `Error` carrying the contract's `code`, and a mock that
|
|
138
|
+
* throws a bare `Error` teaches a demo that every failure looks the same — hiding the two branches
|
|
139
|
+
* this file exists to reproduce, `BAD_REQUEST` on a stale page marker and `CONFLICT` on a handover
|
|
140
|
+
* somebody else got to first.
|
|
141
|
+
*
|
|
142
|
+
* **`data.reason` is part of that shape, and leaving it out made the demo lie in a new way.** The
|
|
143
|
+
* screens no longer render `error.message`: `errors.ts` reads the stable reason token each refusal
|
|
144
|
+
* carries and shows a translated sentence, keeping the server's English only for a failure it does
|
|
145
|
+
* not recognise. A mock that threw the message and no token would send every one of these down the
|
|
146
|
+
* fallback path — so the demo would show English prose where the product shows Persian, which is
|
|
147
|
+
* exactly the defect that was just fixed, reproduced by the thing meant to reproduce the product.
|
|
148
|
+
*
|
|
149
|
+
* The shape matches `kernErrorToORPC`: `{ reason, …details }` under `data`, not beside it.
|
|
63
150
|
*/
|
|
64
151
|
class MockApiError extends Error {
|
|
152
|
+
readonly data: { reason: string } | undefined
|
|
65
153
|
constructor(
|
|
66
|
-
readonly code: 'BAD_REQUEST' | 'NOT_FOUND',
|
|
154
|
+
readonly code: 'BAD_REQUEST' | 'NOT_FOUND' | 'CONFLICT',
|
|
67
155
|
message: string,
|
|
156
|
+
reason?: string,
|
|
68
157
|
) {
|
|
69
158
|
super(message)
|
|
70
159
|
this.name = 'MockApiError'
|
|
160
|
+
this.data = reason ? { reason } : undefined
|
|
71
161
|
}
|
|
72
162
|
}
|
|
73
163
|
|
|
@@ -130,32 +220,103 @@ function compare(a: Asset, b: Asset, sort: AssetSort): number {
|
|
|
130
220
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
131
221
|
}
|
|
132
222
|
|
|
133
|
-
|
|
223
|
+
/** Days ago, as an ISO instant — so a seeded timeline reads as history rather than as "just now". */
|
|
224
|
+
const daysAgo = (days: number) => new Date(Date.now() - days * 864e5).toISOString()
|
|
225
|
+
|
|
226
|
+
/** The same instant as a plain date, which is what a `date` column stores. */
|
|
227
|
+
const dateDaysAgo = (days: number) => daysAgo(days).slice(0, 10)
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* What core knows about a file, as far as this module is concerned.
|
|
231
|
+
*
|
|
232
|
+
* The **server** asks core for it before recording an attachment. This file has no core to ask —
|
|
233
|
+
* it is data with no client and no imports — so whoever builds the mock passes a resolver in.
|
|
234
|
+
* `api-instance.ts` supplies one backed by the shell's own mock file store, which is where the
|
|
235
|
+
* bytes an upload produced actually went; the default below is for the unit tests, which attach
|
|
236
|
+
* ids that were never uploaded anywhere.
|
|
237
|
+
*/
|
|
238
|
+
export interface MockFileFacts {
|
|
239
|
+
name: string
|
|
240
|
+
mimeType: string | null
|
|
241
|
+
size: number | null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface MockInventoryOptions {
|
|
245
|
+
describeFile?: (fileId: string) => Promise<MockFileFacts>
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function createMockInventoryApi(options: MockInventoryOptions = {}) {
|
|
249
|
+
const describeFile =
|
|
250
|
+
options.describeFile ?? (async () => ({ name: 'Uploaded file', mimeType: null, size: null }))
|
|
134
251
|
/**
|
|
135
|
-
*
|
|
252
|
+
* Four id sequences, and separate tag numbering, because they were one and it showed.
|
|
136
253
|
*
|
|
137
254
|
* `blank()` incremented the id counter and `create` then formatted a code out of it, so six seeds
|
|
138
255
|
* consumed six numbers and the seventh asset somebody added in a demo was filed as `INV-0013`
|
|
139
256
|
* beside `INV-0006`. An id and a human-readable tag are separate sequences on the server too.
|
|
257
|
+
*
|
|
258
|
+
* The four entity namespaces differ in the *third* group rather than the last, so no asset id can
|
|
259
|
+
* ever equal a category id, a period id — or one of the shell's mock user ids, which live in the
|
|
260
|
+
* same `…-8000-…` space and would otherwise collide with the first asset.
|
|
140
261
|
*/
|
|
141
|
-
|
|
142
|
-
|
|
262
|
+
const counters = { asset: 1, category: 1, period: 1, history: 1, repair: 1, attachment: 1 }
|
|
263
|
+
const newId = (kind: keyof typeof counters, group: string) =>
|
|
264
|
+
`01920000-0000-7000-${group}-${String(counters[kind]++).padStart(12, '0')}`
|
|
265
|
+
const assetId = () => newId('asset', '8001')
|
|
266
|
+
const categoryId = () => newId('category', '8002')
|
|
267
|
+
const periodId = () => newId('period', '8003')
|
|
268
|
+
const historyId = () => newId('history', '8004')
|
|
269
|
+
const repairId = () => newId('repair', '8005')
|
|
270
|
+
const attachmentId = () => newId('attachment', '8006')
|
|
143
271
|
|
|
144
|
-
|
|
272
|
+
let nextCodeNumber = SEEDS.length + 1
|
|
145
273
|
const pad = (n: number) => `INV-${String(n).padStart(4, '0')}`
|
|
146
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The workspace every seeded row belongs to, learned from the first call that names one.
|
|
277
|
+
*
|
|
278
|
+
* The seeds are built before any workspace exists, and only `list` used to stamp its argument
|
|
279
|
+
* onto what it returned — so the same asset came back with a real `workspaceId` from the list and
|
|
280
|
+
* an empty one from `get`, `update` and `archive`. A demo where one row disagrees with itself
|
|
281
|
+
* about which workspace it is in is the kind of thing nobody notices until a screen keys a cache
|
|
282
|
+
* off it.
|
|
283
|
+
*/
|
|
284
|
+
let workspace = '' as Asset['workspaceId']
|
|
285
|
+
const remember = (id?: string) => {
|
|
286
|
+
if (id) workspace = id as Asset['workspaceId']
|
|
287
|
+
}
|
|
288
|
+
const stamp = <T extends { workspaceId: Asset['workspaceId'] }>(row: T): T => ({
|
|
289
|
+
...row,
|
|
290
|
+
workspaceId: workspace,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------- categories
|
|
294
|
+
|
|
295
|
+
const categories: Category[] = CATEGORY_SEEDS.map((seed) => ({
|
|
296
|
+
id: categoryId(),
|
|
297
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
298
|
+
name: seed.name,
|
|
299
|
+
order: seed.order,
|
|
300
|
+
createdAt: daysAgo(60),
|
|
301
|
+
updatedAt: daysAgo(60),
|
|
302
|
+
archivedAt: seed.archived ? daysAgo(10) : null,
|
|
303
|
+
}))
|
|
304
|
+
const categoryByKey = new Map(CATEGORY_SEEDS.map((seed, i) => [seed.key, categories[i]!.id]))
|
|
305
|
+
|
|
306
|
+
// -------------------------------------------------------------------------------- assets
|
|
307
|
+
|
|
147
308
|
function blank(code: string, seed: Partial<Seed> & { name?: string } = {}): Asset {
|
|
148
309
|
const now = new Date().toISOString()
|
|
149
310
|
return {
|
|
150
|
-
id:
|
|
311
|
+
id: assetId(),
|
|
151
312
|
workspaceId: '' as Asset['workspaceId'],
|
|
152
313
|
code: seed.code ?? code,
|
|
153
314
|
name: seed.name ?? '',
|
|
154
315
|
description: '',
|
|
155
|
-
categoryId: null,
|
|
316
|
+
categoryId: seed.categoryKey ? (categoryByKey.get(seed.categoryKey) ?? null) : null,
|
|
156
317
|
status: seed.status ?? 'in_stock',
|
|
157
|
-
custodianUserId: null,
|
|
158
|
-
custodySince: null,
|
|
318
|
+
custodianUserId: seed.custodian ?? null,
|
|
319
|
+
custodySince: seed.custodian ? daysAgo(21) : null,
|
|
159
320
|
serialNumber: seed.serialNumber ?? null,
|
|
160
321
|
location: seed.location ?? null,
|
|
161
322
|
purchasedOn: null,
|
|
@@ -173,43 +334,308 @@ export function createMockInventoryApi() {
|
|
|
173
334
|
|
|
174
335
|
const assets: Asset[] = SEEDS.map((seed, i) => blank(pad(i + 1), seed))
|
|
175
336
|
|
|
337
|
+
// ------------------------------------------------------------------- custody and history
|
|
338
|
+
|
|
339
|
+
const periods: CustodyPeriod[] = []
|
|
340
|
+
const history: AssetHistoryEntry[] = []
|
|
341
|
+
|
|
342
|
+
const appendHistory = (
|
|
343
|
+
asset: Asset,
|
|
344
|
+
action: string,
|
|
345
|
+
opts: {
|
|
346
|
+
actorId?: string | null
|
|
347
|
+
changes?: AssetHistoryEntry['changes']
|
|
348
|
+
data?: Record<string, unknown>
|
|
349
|
+
occurredAt?: string
|
|
350
|
+
} = {},
|
|
351
|
+
): AssetHistoryEntry => {
|
|
352
|
+
const entry: AssetHistoryEntry = {
|
|
353
|
+
id: historyId(),
|
|
354
|
+
assetId: asset.id,
|
|
355
|
+
actorId: opts.actorId === undefined ? PEOPLE.maya : opts.actorId,
|
|
356
|
+
action,
|
|
357
|
+
changes: opts.changes ?? [],
|
|
358
|
+
data: opts.data ?? {},
|
|
359
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
360
|
+
}
|
|
361
|
+
history.push(entry)
|
|
362
|
+
return entry
|
|
363
|
+
}
|
|
364
|
+
|
|
176
365
|
/**
|
|
177
|
-
*
|
|
366
|
+
* A seeded past, so the timeline and "previous holders" have something to draw.
|
|
178
367
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* about which workspace it is in is the kind of thing nobody notices until a screen keys a cache
|
|
183
|
-
* off it.
|
|
368
|
+
* INV-0001 has been round the houses on purpose — created, handed to Maya, handed on to Dan, and
|
|
369
|
+
* edited — because that is the one asset a demo opens, and an empty Custody tab beside an empty
|
|
370
|
+
* History tab shows neither feature.
|
|
184
371
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
372
|
+
for (const asset of assets) {
|
|
373
|
+
appendHistory(asset, 'created', { occurredAt: daysAgo(45) })
|
|
374
|
+
}
|
|
375
|
+
{
|
|
376
|
+
const first = assets[0]!
|
|
377
|
+
const closed: CustodyPeriod = {
|
|
378
|
+
id: periodId(),
|
|
379
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
380
|
+
assetId: first.id,
|
|
381
|
+
userId: PEOPLE.maya,
|
|
382
|
+
note: 'For the Berlin trip',
|
|
383
|
+
effectiveFrom: daysAgo(40),
|
|
384
|
+
effectiveTo: daysAgo(21),
|
|
385
|
+
createdBy: PEOPLE.maya,
|
|
386
|
+
createdAt: daysAgo(40),
|
|
387
|
+
}
|
|
388
|
+
periods.push(closed)
|
|
389
|
+
appendHistory(first, 'assigned', {
|
|
390
|
+
occurredAt: daysAgo(40),
|
|
391
|
+
data: { userId: PEOPLE.maya, note: closed.note },
|
|
392
|
+
})
|
|
393
|
+
appendHistory(first, 'updated', {
|
|
394
|
+
occurredAt: daysAgo(30),
|
|
395
|
+
changes: [{ field: 'warrantyUntil', from: '2026-03-14', to: '2027-03-14' }],
|
|
396
|
+
})
|
|
397
|
+
appendHistory(first, 'transferred', {
|
|
398
|
+
occurredAt: daysAgo(21),
|
|
399
|
+
data: { userId: PEOPLE.dan, previousUserId: PEOPLE.maya },
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
for (const asset of assets) {
|
|
403
|
+
if (!asset.custodianUserId) continue
|
|
404
|
+
periods.push({
|
|
405
|
+
id: periodId(),
|
|
406
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
407
|
+
assetId: asset.id,
|
|
408
|
+
userId: asset.custodianUserId,
|
|
409
|
+
note: null,
|
|
410
|
+
effectiveFrom: asset.custodySince ?? daysAgo(21),
|
|
411
|
+
effectiveTo: null,
|
|
412
|
+
createdBy: PEOPLE.maya,
|
|
413
|
+
createdAt: asset.custodySince ?? daysAgo(21),
|
|
414
|
+
})
|
|
415
|
+
if (asset !== assets[0])
|
|
416
|
+
appendHistory(asset, 'assigned', {
|
|
417
|
+
occurredAt: asset.custodySince ?? daysAgo(21),
|
|
418
|
+
data: { userId: asset.custodianUserId },
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --------------------------------------------------------------- repairs and attachments
|
|
423
|
+
|
|
424
|
+
const repairs: Repair[] = []
|
|
425
|
+
const attachments: Attachment[] = []
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* This file's copy of the server's `deriveStatus`, and it has to stay a copy of it.
|
|
429
|
+
*
|
|
430
|
+
* Repair wins the status column, custody keeps the custodian column, and neither cancels the
|
|
431
|
+
* other — a laptop at the repairer is still whoever's it was. A mock that wrote `assigned`
|
|
432
|
+
* unconditionally after a handover would show a demo audience an item as back in the office while
|
|
433
|
+
* its repair card said it was in a workshop.
|
|
434
|
+
*/
|
|
435
|
+
const statusFor = (asset: Asset): AssetStatus => {
|
|
436
|
+
if (repairs.some((repair) => repair.assetId === asset.id && repair.returnedOn === null))
|
|
437
|
+
return 'under_repair'
|
|
438
|
+
return asset.custodianUserId ? 'assigned' : 'in_stock'
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const restamp = (asset: Asset) => {
|
|
442
|
+
asset.status = statusFor(asset)
|
|
443
|
+
asset.updatedAt = new Date().toISOString()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
{
|
|
447
|
+
/**
|
|
448
|
+
* A seeded repair for the one asset the seeds call `under_repair`, and a finished one for the
|
|
449
|
+
* asset a demo opens.
|
|
450
|
+
*
|
|
451
|
+
* Without the open row, INV-0003 would carry a status nothing in the demo could explain — a
|
|
452
|
+
* status column and a Repairs tab disagreeing about the same item is exactly the thing this
|
|
453
|
+
* file exists to prevent.
|
|
454
|
+
*/
|
|
455
|
+
const away = assets[2]!
|
|
456
|
+
repairs.push({
|
|
457
|
+
id: repairId(),
|
|
458
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
459
|
+
assetId: away.id,
|
|
460
|
+
summary: 'Cracked screen',
|
|
461
|
+
detail: null,
|
|
462
|
+
vendor: 'Kadıköy Teknik',
|
|
463
|
+
costMinor: null,
|
|
464
|
+
currency: null,
|
|
465
|
+
sentOn: dateDaysAgo(6),
|
|
466
|
+
returnedOn: null,
|
|
467
|
+
createdBy: PEOPLE.maya,
|
|
468
|
+
createdAt: daysAgo(6),
|
|
469
|
+
updatedAt: daysAgo(6),
|
|
470
|
+
})
|
|
471
|
+
appendHistory(away, 'repair_logged', {
|
|
472
|
+
occurredAt: daysAgo(6),
|
|
473
|
+
data: { repairId: repairs[0]!.id, summary: 'Cracked screen', vendor: 'Kadıköy Teknik' },
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
const first = assets[0]!
|
|
477
|
+
const past: Repair = {
|
|
478
|
+
id: repairId(),
|
|
479
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
480
|
+
assetId: first.id,
|
|
481
|
+
summary: 'Battery replacement',
|
|
482
|
+
detail: 'Swelling under the trackpad.',
|
|
483
|
+
vendor: 'Apple Authorised Service',
|
|
484
|
+
costMinor: 18900,
|
|
485
|
+
currency: 'EUR',
|
|
486
|
+
sentOn: dateDaysAgo(34),
|
|
487
|
+
returnedOn: dateDaysAgo(27),
|
|
488
|
+
createdBy: PEOPLE.maya,
|
|
489
|
+
createdAt: daysAgo(34),
|
|
490
|
+
updatedAt: daysAgo(27),
|
|
491
|
+
}
|
|
492
|
+
repairs.push(past)
|
|
493
|
+
appendHistory(first, 'repair_logged', {
|
|
494
|
+
occurredAt: daysAgo(34),
|
|
495
|
+
data: { repairId: past.id, summary: past.summary, vendor: past.vendor },
|
|
496
|
+
})
|
|
497
|
+
appendHistory(first, 'repair_completed', {
|
|
498
|
+
occurredAt: daysAgo(27),
|
|
499
|
+
data: { repairId: past.id, summary: past.summary, costMinor: 18900, currency: 'EUR' },
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
// Two files: the asset's own receipt, and the invoice for the repair above. The pair is what
|
|
503
|
+
// shows that a repair keeps its own paperwork rather than dropping it in with everything else.
|
|
504
|
+
attachments.push({
|
|
505
|
+
id: attachmentId(),
|
|
506
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
507
|
+
assetId: first.id,
|
|
508
|
+
repairId: null,
|
|
509
|
+
fileId: '01920000-0000-7000-8007-000000000001',
|
|
510
|
+
name: 'Purchase receipt.pdf',
|
|
511
|
+
mimeType: 'application/pdf',
|
|
512
|
+
size: 184_320,
|
|
513
|
+
uploadedBy: PEOPLE.maya,
|
|
514
|
+
createdAt: daysAgo(45),
|
|
515
|
+
})
|
|
516
|
+
attachments.push({
|
|
517
|
+
id: attachmentId(),
|
|
518
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
519
|
+
assetId: first.id,
|
|
520
|
+
repairId: past.id,
|
|
521
|
+
fileId: '01920000-0000-7000-8007-000000000002',
|
|
522
|
+
name: 'Repair invoice.pdf',
|
|
523
|
+
mimeType: 'application/pdf',
|
|
524
|
+
size: 96_140,
|
|
525
|
+
uploadedBy: PEOPLE.maya,
|
|
526
|
+
createdAt: daysAgo(27),
|
|
527
|
+
})
|
|
188
528
|
}
|
|
189
|
-
|
|
529
|
+
|
|
530
|
+
// -------------------------------------------------------------------------------- helpers
|
|
190
531
|
|
|
191
532
|
const matches = (a: Asset, q?: string) =>
|
|
192
533
|
!q ||
|
|
193
534
|
[a.name, a.code, a.serialNumber ?? ''].some((field) => field.toLowerCase().includes(q.toLowerCase()))
|
|
194
535
|
|
|
195
|
-
const find = (
|
|
196
|
-
const asset = assets.find((a) => a.id ===
|
|
536
|
+
const find = (id: string) => {
|
|
537
|
+
const asset = assets.find((a) => a.id === id)
|
|
197
538
|
if (!asset) throw new MockApiError('NOT_FOUND', 'Asset not found')
|
|
198
539
|
return asset
|
|
199
540
|
}
|
|
200
541
|
|
|
542
|
+
const findCategory = (id: string) => {
|
|
543
|
+
const category = categories.find((c) => c.id === id)
|
|
544
|
+
if (!category) throw new MockApiError('NOT_FOUND', 'Category not found')
|
|
545
|
+
return category
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const openPeriodFor = (id: string) =>
|
|
549
|
+
periods.find((period) => period.assetId === id && period.effectiveTo === null)
|
|
550
|
+
|
|
551
|
+
/** At most one, exactly as `inventory_repairs_one_open_uq` guarantees on the server. */
|
|
552
|
+
const openRepairFor = (id: string) =>
|
|
553
|
+
repairs.find((repair) => repair.assetId === id && repair.returnedOn === null)
|
|
554
|
+
|
|
555
|
+
const findRepair = (id: string) => {
|
|
556
|
+
const repair = repairs.find((row) => row.id === id)
|
|
557
|
+
if (!repair) throw new MockApiError('NOT_FOUND', 'Repair not found')
|
|
558
|
+
return repair
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Today as a plain date, which is what the server fills in when a caller sends none. */
|
|
562
|
+
const today = () => new Date().toISOString().slice(0, 10)
|
|
563
|
+
|
|
564
|
+
/** The one refusal a live asset can produce before any of the three verbs runs. */
|
|
565
|
+
const liveAsset = (id: string) => {
|
|
566
|
+
const asset = find(id)
|
|
567
|
+
if (asset.archivedAt)
|
|
568
|
+
throw new MockApiError(
|
|
569
|
+
'CONFLICT',
|
|
570
|
+
'This item is archived. Restore it before handing it over.',
|
|
571
|
+
'inventory.custody.archived',
|
|
572
|
+
)
|
|
573
|
+
return asset
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const listAssets = ({
|
|
577
|
+
q,
|
|
578
|
+
status,
|
|
579
|
+
categoryId: category,
|
|
580
|
+
custodianUserId,
|
|
581
|
+
archived = false,
|
|
582
|
+
sort = 'recent',
|
|
583
|
+
limit = 50,
|
|
584
|
+
cursor,
|
|
585
|
+
}: {
|
|
586
|
+
q?: string
|
|
587
|
+
status?: AssetStatus
|
|
588
|
+
categoryId?: string
|
|
589
|
+
custodianUserId?: string
|
|
590
|
+
archived?: boolean
|
|
591
|
+
sort?: AssetSort
|
|
592
|
+
limit?: number
|
|
593
|
+
cursor?: string
|
|
594
|
+
}) => {
|
|
595
|
+
// Every control the screen offers filters here, or the demo argues with its own toolbar.
|
|
596
|
+
const all = assets
|
|
597
|
+
.filter((a) => matches(a, q))
|
|
598
|
+
.filter((a) => (status ? a.status === status : true))
|
|
599
|
+
.filter((a) => (category ? a.categoryId === category : true))
|
|
600
|
+
.filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
|
|
601
|
+
.filter((a) => (archived ? true : !a.archivedAt))
|
|
602
|
+
.map(stamp)
|
|
603
|
+
|
|
604
|
+
// `recent` is the only descending order, and it is the contract's default — the list opens
|
|
605
|
+
// on what was added last, which is what the server does and what the old mock did not.
|
|
606
|
+
const descending = sort === 'recent'
|
|
607
|
+
all.sort((a, b) => (descending ? -compare(a, b, sort) : compare(a, b, sort)))
|
|
608
|
+
|
|
609
|
+
let rows = all
|
|
610
|
+
if (cursor) {
|
|
611
|
+
const mark = decodeCursor(cursor, sort)
|
|
612
|
+
const bookmarked = assets.find((a) => a.id === mark.i)
|
|
613
|
+
/**
|
|
614
|
+
* A row archived or removed between two pages leaves nothing to compare against and the
|
|
615
|
+
* page simply ends — the server's deliberate choice, because somebody else editing while
|
|
616
|
+
* you read is an ordinary race rather than a malformed request.
|
|
617
|
+
*/
|
|
618
|
+
if (!bookmarked) rows = []
|
|
619
|
+
else
|
|
620
|
+
rows = all.filter((a) => {
|
|
621
|
+
const order = compare(a, bookmarked, sort)
|
|
622
|
+
return descending ? order < 0 : order > 0
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// limit + 1 to learn whether there is a next page, exactly as the server does.
|
|
627
|
+
const window = rows.slice(0, limit + 1)
|
|
628
|
+
const items = window.slice(0, limit)
|
|
629
|
+
const last = items.at(-1)
|
|
630
|
+
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
|
|
631
|
+
return { items, nextCursor }
|
|
632
|
+
}
|
|
633
|
+
|
|
201
634
|
return {
|
|
202
635
|
assets: {
|
|
203
636
|
list: async ({
|
|
204
637
|
workspaceId,
|
|
205
|
-
|
|
206
|
-
status,
|
|
207
|
-
categoryId,
|
|
208
|
-
custodianUserId,
|
|
209
|
-
archived = false,
|
|
210
|
-
sort = 'recent',
|
|
211
|
-
limit = 50,
|
|
212
|
-
cursor,
|
|
638
|
+
...rest
|
|
213
639
|
}: {
|
|
214
640
|
workspaceId: string
|
|
215
641
|
q?: string
|
|
@@ -222,56 +648,48 @@ export function createMockInventoryApi() {
|
|
|
222
648
|
cursor?: string
|
|
223
649
|
}) => {
|
|
224
650
|
remember(workspaceId)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
.filter((a) => matches(a, q))
|
|
228
|
-
.filter((a) => (status ? a.status === status : true))
|
|
229
|
-
.filter((a) => (categoryId ? a.categoryId === categoryId : true))
|
|
230
|
-
.filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
|
|
231
|
-
.filter((a) => (archived ? true : !a.archivedAt))
|
|
232
|
-
.map(stamp)
|
|
651
|
+
return listAssets(rest)
|
|
652
|
+
},
|
|
233
653
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
let rows = all
|
|
240
|
-
if (cursor) {
|
|
241
|
-
const mark = decodeCursor(cursor, sort)
|
|
242
|
-
const bookmarked = assets.find((a) => a.id === mark.i)
|
|
243
|
-
/**
|
|
244
|
-
* A row archived or removed between two pages leaves nothing to compare against and the
|
|
245
|
-
* page simply ends — the server's deliberate choice, because somebody else editing while
|
|
246
|
-
* you read is an ordinary race rather than a malformed request.
|
|
247
|
-
*/
|
|
248
|
-
if (!bookmarked) rows = []
|
|
249
|
-
else
|
|
250
|
-
rows = all.filter((a) => {
|
|
251
|
-
const order = compare(a, bookmarked, sort)
|
|
252
|
-
return descending ? order < 0 : order > 0
|
|
253
|
-
})
|
|
254
|
-
}
|
|
654
|
+
get: async ({ workspaceId, assetId: id }: { workspaceId?: string; assetId: string }) => {
|
|
655
|
+
remember(workspaceId)
|
|
656
|
+
return stamp(find(id))
|
|
657
|
+
},
|
|
255
658
|
|
|
256
|
-
|
|
659
|
+
/**
|
|
660
|
+
* The timeline, newest first, paged by **id** — the server's ordering, for the server's
|
|
661
|
+
* reason: an id already carries the clock and is unique where a timestamp two rows written in
|
|
662
|
+
* one transaction share is not.
|
|
663
|
+
*/
|
|
664
|
+
history: async ({
|
|
665
|
+
workspaceId,
|
|
666
|
+
assetId: id,
|
|
667
|
+
limit = 50,
|
|
668
|
+
cursor,
|
|
669
|
+
}: {
|
|
670
|
+
workspaceId?: string
|
|
671
|
+
assetId: string
|
|
672
|
+
limit?: number
|
|
673
|
+
cursor?: string
|
|
674
|
+
}) => {
|
|
675
|
+
remember(workspaceId)
|
|
676
|
+
find(id)
|
|
677
|
+
const all = history.filter((entry) => entry.assetId === id).sort((a, b) => (a.id < b.id ? 1 : -1))
|
|
678
|
+
const rows = cursor ? all.filter((entry) => entry.id < decodeCursor(cursor, 'recent').i) : all
|
|
257
679
|
const window = rows.slice(0, limit + 1)
|
|
258
680
|
const items = window.slice(0, limit)
|
|
259
681
|
const last = items.at(-1)
|
|
260
|
-
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s:
|
|
682
|
+
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
|
|
261
683
|
return { items, nextCursor }
|
|
262
684
|
},
|
|
263
685
|
|
|
264
|
-
get: async ({ workspaceId, assetId }: { workspaceId?: string; assetId: string }) => {
|
|
265
|
-
remember(workspaceId)
|
|
266
|
-
return stamp(find(assetId))
|
|
267
|
-
},
|
|
268
|
-
|
|
269
686
|
create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
|
|
270
687
|
remember(workspaceId)
|
|
271
688
|
const asset: Asset = {
|
|
272
689
|
...blank(pad(nextCodeNumber++)),
|
|
273
690
|
name: String(rest.name ?? ''),
|
|
274
691
|
description: String(rest.description ?? ''),
|
|
692
|
+
categoryId: (rest.categoryId as string | undefined) ?? null,
|
|
275
693
|
serialNumber: (rest.serialNumber as string | undefined) ?? null,
|
|
276
694
|
location: (rest.location as string | undefined) ?? null,
|
|
277
695
|
purchasedFrom: (rest.purchasedFrom as string | undefined) ?? null,
|
|
@@ -281,25 +699,32 @@ export function createMockInventoryApi() {
|
|
|
281
699
|
currency: (rest.currency as string | undefined) ?? null,
|
|
282
700
|
}
|
|
283
701
|
assets.push(asset)
|
|
702
|
+
appendHistory(asset, 'created')
|
|
284
703
|
return stamp(asset)
|
|
285
704
|
},
|
|
286
705
|
|
|
287
706
|
update: async ({
|
|
288
|
-
assetId,
|
|
707
|
+
assetId: id,
|
|
289
708
|
workspaceId,
|
|
290
709
|
...rest
|
|
291
710
|
}: { assetId: string; workspaceId?: string } & Record<string, unknown>) => {
|
|
292
711
|
remember(workspaceId)
|
|
293
|
-
const asset = find(
|
|
712
|
+
const asset = find(id)
|
|
713
|
+
// The diff before the assignment, so the timeline records what actually moved — the server
|
|
714
|
+
// does the same and for the same reason: an update that changed nothing writes nothing.
|
|
715
|
+
const changes = Object.entries(rest)
|
|
716
|
+
.filter(([field, value]) => (asset as Record<string, unknown>)[field] !== value)
|
|
717
|
+
.map(([field, value]) => ({ field, from: (asset as Record<string, unknown>)[field], to: value }))
|
|
294
718
|
// `workspaceId` is not among the fields assigned: it is routing, not a field of the asset,
|
|
295
719
|
// and letting a patch carry it means a demo can move a row to a workspace that is not real.
|
|
296
720
|
Object.assign(asset, rest, { updatedAt: new Date().toISOString() })
|
|
721
|
+
if (changes.length) appendHistory(asset, 'updated', { changes })
|
|
297
722
|
return stamp(asset)
|
|
298
723
|
},
|
|
299
724
|
|
|
300
725
|
archive: async ({
|
|
301
726
|
workspaceId,
|
|
302
|
-
assetId,
|
|
727
|
+
assetId: id,
|
|
303
728
|
archived,
|
|
304
729
|
}: {
|
|
305
730
|
workspaceId?: string
|
|
@@ -307,11 +732,573 @@ export function createMockInventoryApi() {
|
|
|
307
732
|
archived?: boolean
|
|
308
733
|
}) => {
|
|
309
734
|
remember(workspaceId)
|
|
310
|
-
const asset = find(
|
|
311
|
-
|
|
735
|
+
const asset = find(id)
|
|
736
|
+
const archiving = archived !== false
|
|
737
|
+
// Somebody is still answerable for a held item; taking it out of the register does not
|
|
738
|
+
// change that, it only stops anybody being able to find out. The server refuses this too.
|
|
739
|
+
if (archiving && asset.custodianUserId)
|
|
740
|
+
throw new MockApiError(
|
|
741
|
+
'CONFLICT',
|
|
742
|
+
'Somebody is still holding this item. Take it back before archiving it.',
|
|
743
|
+
'inventory.asset.still_held',
|
|
744
|
+
)
|
|
745
|
+
// And an item at a repairer cannot leave the register either: money is committed and the
|
|
746
|
+
// thing is out of the building. The server refuses this for the same reason.
|
|
747
|
+
if (archiving && openRepairFor(id))
|
|
748
|
+
throw new MockApiError(
|
|
749
|
+
'CONFLICT',
|
|
750
|
+
'This item is away for repair. Log the repair as returned before archiving it.',
|
|
751
|
+
'inventory.asset.under_repair',
|
|
752
|
+
)
|
|
753
|
+
asset.archivedAt = archiving ? new Date().toISOString() : null
|
|
312
754
|
asset.updatedAt = new Date().toISOString()
|
|
755
|
+
appendHistory(asset, archiving ? 'retired' : 'restored')
|
|
313
756
|
return stamp(asset)
|
|
314
757
|
},
|
|
315
758
|
},
|
|
759
|
+
|
|
760
|
+
categories: {
|
|
761
|
+
list: async ({ workspaceId, archived = false }: { workspaceId: string; archived?: boolean }) => {
|
|
762
|
+
remember(workspaceId)
|
|
763
|
+
return categories
|
|
764
|
+
.filter((category) => (archived ? true : !category.archivedAt))
|
|
765
|
+
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
|
766
|
+
.map(stamp)
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
create: async ({
|
|
770
|
+
workspaceId,
|
|
771
|
+
name,
|
|
772
|
+
order = 0,
|
|
773
|
+
}: {
|
|
774
|
+
workspaceId: string
|
|
775
|
+
name: string
|
|
776
|
+
order?: number
|
|
777
|
+
}) => {
|
|
778
|
+
remember(workspaceId)
|
|
779
|
+
// The server's unique index is what actually decides, and it answers a duplicate with a
|
|
780
|
+
// sentence rather than a 500. The demo answers the same way.
|
|
781
|
+
if (categories.some((category) => category.name === name))
|
|
782
|
+
throw new MockApiError(
|
|
783
|
+
'CONFLICT',
|
|
784
|
+
`This workspace already has a category called “${name}”.`,
|
|
785
|
+
'inventory.category.name_taken',
|
|
786
|
+
)
|
|
787
|
+
const now = new Date().toISOString()
|
|
788
|
+
const category: Category = {
|
|
789
|
+
id: categoryId(),
|
|
790
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
791
|
+
name,
|
|
792
|
+
order,
|
|
793
|
+
createdAt: now,
|
|
794
|
+
updatedAt: now,
|
|
795
|
+
archivedAt: null,
|
|
796
|
+
}
|
|
797
|
+
categories.push(category)
|
|
798
|
+
return stamp(category)
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
update: async ({
|
|
802
|
+
workspaceId,
|
|
803
|
+
categoryId: id,
|
|
804
|
+
...rest
|
|
805
|
+
}: {
|
|
806
|
+
workspaceId?: string
|
|
807
|
+
categoryId: string
|
|
808
|
+
name?: string
|
|
809
|
+
order?: number
|
|
810
|
+
}) => {
|
|
811
|
+
remember(workspaceId)
|
|
812
|
+
const category = findCategory(id)
|
|
813
|
+
if (rest.name && categories.some((c) => c.id !== id && c.name === rest.name))
|
|
814
|
+
throw new MockApiError(
|
|
815
|
+
'CONFLICT',
|
|
816
|
+
`This workspace already has a category called “${rest.name}”.`,
|
|
817
|
+
'inventory.category.name_taken',
|
|
818
|
+
)
|
|
819
|
+
Object.assign(category, rest, { updatedAt: new Date().toISOString() })
|
|
820
|
+
return stamp(category)
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
archive: async ({
|
|
824
|
+
workspaceId,
|
|
825
|
+
categoryId: id,
|
|
826
|
+
archived,
|
|
827
|
+
}: {
|
|
828
|
+
workspaceId?: string
|
|
829
|
+
categoryId: string
|
|
830
|
+
archived?: boolean
|
|
831
|
+
}) => {
|
|
832
|
+
remember(workspaceId)
|
|
833
|
+
const category = findCategory(id)
|
|
834
|
+
// Nothing deletes: an asset filed under this category keeps naming it.
|
|
835
|
+
category.archivedAt = archived === false ? null : new Date().toISOString()
|
|
836
|
+
category.updatedAt = new Date().toISOString()
|
|
837
|
+
return stamp(category)
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The same invariant the server keeps, in one place rather than three.
|
|
843
|
+
*
|
|
844
|
+
* Each verb closes what has to close, opens what has to open, brings the three denormalised
|
|
845
|
+
* columns on the asset into step and appends a history row. `transfer` does it in one step
|
|
846
|
+
* rather than as a return followed by an assign, because two steps would leave the asset in
|
|
847
|
+
* stock in between — visible in the list, and permanently visible in the timeline as a return
|
|
848
|
+
* nobody performed.
|
|
849
|
+
*/
|
|
850
|
+
custody: {
|
|
851
|
+
history: async ({
|
|
852
|
+
workspaceId,
|
|
853
|
+
assetId: id,
|
|
854
|
+
limit = 100,
|
|
855
|
+
}: {
|
|
856
|
+
workspaceId?: string
|
|
857
|
+
assetId: string
|
|
858
|
+
limit?: number
|
|
859
|
+
}) => {
|
|
860
|
+
remember(workspaceId)
|
|
861
|
+
find(id)
|
|
862
|
+
return (
|
|
863
|
+
periods
|
|
864
|
+
.filter((period) => period.assetId === id)
|
|
865
|
+
/**
|
|
866
|
+
* `effective_from` and then the id, which is the server's ordering and needs both.
|
|
867
|
+
*
|
|
868
|
+
* A hand-on closes one period and opens another *at the same instant* — that is what
|
|
869
|
+
* makes them abut — so the timestamps are equal and there is no order between the two
|
|
870
|
+
* rows without the id. Sorting on the timestamp alone showed the handover before the
|
|
871
|
+
* return it replaced, at random, and the panel's "who had this before me" list opened
|
|
872
|
+
* with the row that is still open.
|
|
873
|
+
*/
|
|
874
|
+
.sort((a, b) => {
|
|
875
|
+
if (a.effectiveFrom !== b.effectiveFrom) return a.effectiveFrom < b.effectiveFrom ? 1 : -1
|
|
876
|
+
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0
|
|
877
|
+
})
|
|
878
|
+
.slice(0, limit)
|
|
879
|
+
.map(stamp)
|
|
880
|
+
)
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
byUser: async ({
|
|
884
|
+
workspaceId,
|
|
885
|
+
userId,
|
|
886
|
+
limit = 50,
|
|
887
|
+
cursor,
|
|
888
|
+
}: {
|
|
889
|
+
workspaceId: string
|
|
890
|
+
userId: string
|
|
891
|
+
limit?: number
|
|
892
|
+
cursor?: string
|
|
893
|
+
}) => {
|
|
894
|
+
remember(workspaceId)
|
|
895
|
+
return listAssets({ custodianUserId: userId, archived: false, sort: 'recent', limit, cursor })
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
assign: async ({
|
|
899
|
+
workspaceId,
|
|
900
|
+
assetId: id,
|
|
901
|
+
userId,
|
|
902
|
+
note,
|
|
903
|
+
}: {
|
|
904
|
+
workspaceId: string
|
|
905
|
+
assetId: string
|
|
906
|
+
userId: string
|
|
907
|
+
note?: string | null
|
|
908
|
+
}) => {
|
|
909
|
+
remember(workspaceId)
|
|
910
|
+
const asset = liveAsset(id)
|
|
911
|
+
const open = openPeriodFor(id)
|
|
912
|
+
if (open)
|
|
913
|
+
throw new MockApiError(
|
|
914
|
+
'CONFLICT',
|
|
915
|
+
open.userId === userId
|
|
916
|
+
? 'They are already holding this item.'
|
|
917
|
+
: 'Somebody else is holding this item. Hand it on, or take it back first.',
|
|
918
|
+
'inventory.custody.already_held',
|
|
919
|
+
)
|
|
920
|
+
const at = new Date().toISOString()
|
|
921
|
+
const period: CustodyPeriod = {
|
|
922
|
+
id: periodId(),
|
|
923
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
924
|
+
assetId: id,
|
|
925
|
+
userId,
|
|
926
|
+
note: note ?? null,
|
|
927
|
+
effectiveFrom: at,
|
|
928
|
+
effectiveTo: null,
|
|
929
|
+
createdBy: PEOPLE.maya,
|
|
930
|
+
createdAt: at,
|
|
931
|
+
}
|
|
932
|
+
periods.push(period)
|
|
933
|
+
// The status is derived, never assumed: an item at a repairer stays `under_repair` when it
|
|
934
|
+
// changes hands, because a repair does not release whoever is answerable for it.
|
|
935
|
+
Object.assign(asset, { custodianUserId: userId, custodySince: at })
|
|
936
|
+
restamp(asset)
|
|
937
|
+
appendHistory(asset, 'assigned', { data: { userId, ...(note ? { note } : {}) } })
|
|
938
|
+
return { asset: stamp(asset), period: stamp(period) }
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
transfer: async ({
|
|
942
|
+
workspaceId,
|
|
943
|
+
assetId: id,
|
|
944
|
+
userId,
|
|
945
|
+
note,
|
|
946
|
+
}: {
|
|
947
|
+
workspaceId: string
|
|
948
|
+
assetId: string
|
|
949
|
+
userId: string
|
|
950
|
+
note?: string | null
|
|
951
|
+
}) => {
|
|
952
|
+
remember(workspaceId)
|
|
953
|
+
const asset = liveAsset(id)
|
|
954
|
+
const open = openPeriodFor(id)
|
|
955
|
+
if (!open)
|
|
956
|
+
throw new MockApiError(
|
|
957
|
+
'CONFLICT',
|
|
958
|
+
'Nobody is holding this item, so there is nothing to hand on. Assign it instead.',
|
|
959
|
+
'inventory.custody.not_held',
|
|
960
|
+
)
|
|
961
|
+
if (open.userId === userId)
|
|
962
|
+
throw new MockApiError(
|
|
963
|
+
'CONFLICT',
|
|
964
|
+
'They are already holding this item.',
|
|
965
|
+
'inventory.custody.already_held',
|
|
966
|
+
)
|
|
967
|
+
// One instant for both halves, so the closing period and the opening one abut exactly and
|
|
968
|
+
// the asset is never held by nobody for a microsecond.
|
|
969
|
+
const at = new Date().toISOString()
|
|
970
|
+
open.effectiveTo = at
|
|
971
|
+
const period: CustodyPeriod = {
|
|
972
|
+
id: periodId(),
|
|
973
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
974
|
+
assetId: id,
|
|
975
|
+
userId,
|
|
976
|
+
note: note ?? null,
|
|
977
|
+
effectiveFrom: at,
|
|
978
|
+
effectiveTo: null,
|
|
979
|
+
createdBy: PEOPLE.maya,
|
|
980
|
+
createdAt: at,
|
|
981
|
+
}
|
|
982
|
+
periods.push(period)
|
|
983
|
+
Object.assign(asset, { custodianUserId: userId, custodySince: at })
|
|
984
|
+
restamp(asset)
|
|
985
|
+
appendHistory(asset, 'transferred', {
|
|
986
|
+
data: { userId, previousUserId: open.userId, ...(note ? { note } : {}) },
|
|
987
|
+
})
|
|
988
|
+
return { asset: stamp(asset), period: stamp(period) }
|
|
989
|
+
},
|
|
990
|
+
|
|
991
|
+
return: async ({
|
|
992
|
+
workspaceId,
|
|
993
|
+
assetId: id,
|
|
994
|
+
note,
|
|
995
|
+
}: {
|
|
996
|
+
workspaceId: string
|
|
997
|
+
assetId: string
|
|
998
|
+
note?: string | null
|
|
999
|
+
}) => {
|
|
1000
|
+
remember(workspaceId)
|
|
1001
|
+
const asset = liveAsset(id)
|
|
1002
|
+
const open = openPeriodFor(id)
|
|
1003
|
+
if (!open)
|
|
1004
|
+
throw new MockApiError(
|
|
1005
|
+
'CONFLICT',
|
|
1006
|
+
'Nobody is holding this item, so there is nothing to take back.',
|
|
1007
|
+
'inventory.custody.not_held',
|
|
1008
|
+
)
|
|
1009
|
+
const at = new Date().toISOString()
|
|
1010
|
+
open.effectiveTo = at
|
|
1011
|
+
Object.assign(asset, { custodianUserId: null, custodySince: null })
|
|
1012
|
+
restamp(asset)
|
|
1013
|
+
appendHistory(asset, 'returned', {
|
|
1014
|
+
data: { previousUserId: open.userId, ...(note ? { note } : {}) },
|
|
1015
|
+
})
|
|
1016
|
+
// Null, like the server: something closed and nothing opened.
|
|
1017
|
+
return { asset: stamp(asset), period: null }
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Repairs, with the refusals the server makes and the status rule it keeps.
|
|
1023
|
+
*
|
|
1024
|
+
* A demo that lets you send the same laptop away twice, or that shows an item as back in the
|
|
1025
|
+
* office the moment its repair is logged as finished while somebody still has it, teaches an
|
|
1026
|
+
* audience that the product does those things.
|
|
1027
|
+
*/
|
|
1028
|
+
repairs: {
|
|
1029
|
+
list: async ({
|
|
1030
|
+
workspaceId,
|
|
1031
|
+
assetId: forAsset,
|
|
1032
|
+
open,
|
|
1033
|
+
limit = 50,
|
|
1034
|
+
cursor,
|
|
1035
|
+
}: {
|
|
1036
|
+
workspaceId: string
|
|
1037
|
+
assetId?: string
|
|
1038
|
+
open?: boolean
|
|
1039
|
+
limit?: number
|
|
1040
|
+
cursor?: string
|
|
1041
|
+
}) => {
|
|
1042
|
+
remember(workspaceId)
|
|
1043
|
+
if (forAsset) find(forAsset)
|
|
1044
|
+
const all = repairs
|
|
1045
|
+
.filter((repair) => (forAsset ? repair.assetId === forAsset : true))
|
|
1046
|
+
.filter((repair) =>
|
|
1047
|
+
open === undefined ? true : open ? repair.returnedOn === null : repair.returnedOn !== null,
|
|
1048
|
+
)
|
|
1049
|
+
// Newest logged first, by id — the server's ordering, for the server's reason: an id is
|
|
1050
|
+
// uuidv7 and already carries the clock, where `sent_on` is a date two repairs share.
|
|
1051
|
+
.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
|
1052
|
+
const rows = cursor ? all.filter((repair) => repair.id < decodeCursor(cursor, 'recent').i) : all
|
|
1053
|
+
const window = rows.slice(0, limit + 1)
|
|
1054
|
+
const items = window.slice(0, limit).map((repair) => {
|
|
1055
|
+
const asset = assets.find((row) => row.id === repair.assetId)
|
|
1056
|
+
// Joined, never stored: renaming an asset renames it here at once.
|
|
1057
|
+
return {
|
|
1058
|
+
...stamp(repair),
|
|
1059
|
+
assetCode: asset?.code ?? '',
|
|
1060
|
+
assetName: asset?.name ?? '',
|
|
1061
|
+
}
|
|
1062
|
+
})
|
|
1063
|
+
const last = window.slice(0, limit).at(-1)
|
|
1064
|
+
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
|
|
1065
|
+
return { items, nextCursor }
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
create: async ({
|
|
1069
|
+
workspaceId,
|
|
1070
|
+
assetId: id,
|
|
1071
|
+
summary,
|
|
1072
|
+
detail,
|
|
1073
|
+
vendor,
|
|
1074
|
+
costMinor,
|
|
1075
|
+
currency,
|
|
1076
|
+
sentOn,
|
|
1077
|
+
}: {
|
|
1078
|
+
workspaceId: string
|
|
1079
|
+
assetId: string
|
|
1080
|
+
summary: string
|
|
1081
|
+
detail?: string | null
|
|
1082
|
+
vendor?: string | null
|
|
1083
|
+
costMinor?: number | null
|
|
1084
|
+
currency?: string | null
|
|
1085
|
+
sentOn?: string
|
|
1086
|
+
}) => {
|
|
1087
|
+
remember(workspaceId)
|
|
1088
|
+
const asset = find(id)
|
|
1089
|
+
if (asset.archivedAt)
|
|
1090
|
+
throw new MockApiError(
|
|
1091
|
+
'CONFLICT',
|
|
1092
|
+
'This item is archived. Restore it before sending it for repair.',
|
|
1093
|
+
'inventory.repair.archived',
|
|
1094
|
+
)
|
|
1095
|
+
if (openRepairFor(id))
|
|
1096
|
+
throw new MockApiError(
|
|
1097
|
+
'CONFLICT',
|
|
1098
|
+
'This item is already away for repair. Log that one as returned first.',
|
|
1099
|
+
'inventory.repair.already_open',
|
|
1100
|
+
)
|
|
1101
|
+
const now = new Date().toISOString()
|
|
1102
|
+
const repair: Repair = {
|
|
1103
|
+
id: repairId(),
|
|
1104
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
1105
|
+
assetId: id,
|
|
1106
|
+
summary,
|
|
1107
|
+
detail: detail ?? null,
|
|
1108
|
+
vendor: vendor ?? null,
|
|
1109
|
+
costMinor: costMinor ?? null,
|
|
1110
|
+
// A cost with no currency inherits the asset's, exactly as the server does — a number
|
|
1111
|
+
// with no unit is not a cost.
|
|
1112
|
+
currency: currency ?? (costMinor !== null && costMinor !== undefined ? asset.currency : null),
|
|
1113
|
+
sentOn: sentOn ?? today(),
|
|
1114
|
+
returnedOn: null,
|
|
1115
|
+
createdBy: PEOPLE.maya,
|
|
1116
|
+
createdAt: now,
|
|
1117
|
+
updatedAt: now,
|
|
1118
|
+
}
|
|
1119
|
+
repairs.push(repair)
|
|
1120
|
+
restamp(asset)
|
|
1121
|
+
appendHistory(asset, 'repair_logged', {
|
|
1122
|
+
data: { repairId: repair.id, summary, ...(repair.vendor ? { vendor: repair.vendor } : {}) },
|
|
1123
|
+
})
|
|
1124
|
+
return { repair: stamp(repair), asset: stamp(asset) }
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
update: async ({
|
|
1128
|
+
workspaceId,
|
|
1129
|
+
repairId: id,
|
|
1130
|
+
...rest
|
|
1131
|
+
}: {
|
|
1132
|
+
workspaceId: string
|
|
1133
|
+
repairId: string
|
|
1134
|
+
} & Record<string, unknown>) => {
|
|
1135
|
+
remember(workspaceId)
|
|
1136
|
+
const repair = findRepair(id)
|
|
1137
|
+
// `returnedOn` is not patchable on the server: one column decides the derived status, so
|
|
1138
|
+
// exactly one procedure moves it.
|
|
1139
|
+
const { returnedOn: _ignored, ...patch } = rest
|
|
1140
|
+
Object.assign(repair, patch, { updatedAt: new Date().toISOString() })
|
|
1141
|
+
const asset = find(repair.assetId)
|
|
1142
|
+
// Same inheritance rule as `complete` and the server: only when an amount is arriving and
|
|
1143
|
+
// the repair has no currency at all.
|
|
1144
|
+
if (
|
|
1145
|
+
patch.currency === undefined &&
|
|
1146
|
+
repair.currency === null &&
|
|
1147
|
+
patch.costMinor !== undefined &&
|
|
1148
|
+
patch.costMinor !== null
|
|
1149
|
+
)
|
|
1150
|
+
repair.currency = asset.currency
|
|
1151
|
+
return { repair: stamp(repair), asset: stamp(asset) }
|
|
1152
|
+
},
|
|
1153
|
+
|
|
1154
|
+
complete: async ({
|
|
1155
|
+
workspaceId,
|
|
1156
|
+
repairId: id,
|
|
1157
|
+
returnedOn,
|
|
1158
|
+
costMinor,
|
|
1159
|
+
currency,
|
|
1160
|
+
}: {
|
|
1161
|
+
workspaceId: string
|
|
1162
|
+
repairId: string
|
|
1163
|
+
returnedOn?: string
|
|
1164
|
+
costMinor?: number | null
|
|
1165
|
+
currency?: string | null
|
|
1166
|
+
}) => {
|
|
1167
|
+
remember(workspaceId)
|
|
1168
|
+
const repair = findRepair(id)
|
|
1169
|
+
if (repair.returnedOn)
|
|
1170
|
+
throw new MockApiError(
|
|
1171
|
+
'CONFLICT',
|
|
1172
|
+
'This repair is already logged as finished.',
|
|
1173
|
+
'inventory.repair.already_complete',
|
|
1174
|
+
)
|
|
1175
|
+
const back = returnedOn ?? today()
|
|
1176
|
+
if (back < repair.sentOn)
|
|
1177
|
+
throw new MockApiError(
|
|
1178
|
+
'CONFLICT',
|
|
1179
|
+
'A repair cannot come back before it was sent.',
|
|
1180
|
+
'inventory.repair.returned_before_sent',
|
|
1181
|
+
)
|
|
1182
|
+
const asset = find(repair.assetId)
|
|
1183
|
+
repair.returnedOn = back
|
|
1184
|
+
if (costMinor !== undefined) repair.costMinor = costMinor
|
|
1185
|
+
// The server's one inheritance rule: an amount is *arriving* and the repair has no currency
|
|
1186
|
+
// at all. Not "a cost exists", which would give a deliberately cleared currency back.
|
|
1187
|
+
if (currency !== undefined) repair.currency = currency ?? null
|
|
1188
|
+
else if (repair.currency === null && costMinor !== undefined && costMinor !== null)
|
|
1189
|
+
repair.currency = asset.currency
|
|
1190
|
+
repair.updatedAt = new Date().toISOString()
|
|
1191
|
+
// Back to whoever still holds it, or into stock — never blindly into stock.
|
|
1192
|
+
restamp(asset)
|
|
1193
|
+
appendHistory(asset, 'repair_completed', {
|
|
1194
|
+
data: {
|
|
1195
|
+
repairId: repair.id,
|
|
1196
|
+
summary: repair.summary,
|
|
1197
|
+
...(repair.costMinor !== null ? { costMinor: repair.costMinor, currency: repair.currency } : {}),
|
|
1198
|
+
},
|
|
1199
|
+
})
|
|
1200
|
+
return { repair: stamp(repair), asset: stamp(asset) }
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Files, recorded against an asset or one of its repairs.
|
|
1206
|
+
*
|
|
1207
|
+
* The bytes went to the shell's own mock file store through `uploadFile`; this only records
|
|
1208
|
+
* that the asset has them. The name comes from `describeFile`, because the real server asks
|
|
1209
|
+
* core for it and this file has no core to ask.
|
|
1210
|
+
*/
|
|
1211
|
+
attachments: {
|
|
1212
|
+
list: async ({ workspaceId, assetId: id }: { workspaceId: string; assetId: string }) => {
|
|
1213
|
+
remember(workspaceId)
|
|
1214
|
+
find(id)
|
|
1215
|
+
return attachments
|
|
1216
|
+
.filter((row) => row.assetId === id)
|
|
1217
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
|
|
1218
|
+
.map(stamp)
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
add: async ({
|
|
1222
|
+
workspaceId,
|
|
1223
|
+
assetId: id,
|
|
1224
|
+
fileIds,
|
|
1225
|
+
repairId: forRepair,
|
|
1226
|
+
}: {
|
|
1227
|
+
workspaceId: string
|
|
1228
|
+
assetId: string
|
|
1229
|
+
fileIds: string[]
|
|
1230
|
+
repairId?: string | null
|
|
1231
|
+
}) => {
|
|
1232
|
+
remember(workspaceId)
|
|
1233
|
+
find(id)
|
|
1234
|
+
if (forRepair) findRepair(forRepair)
|
|
1235
|
+
const added: Attachment[] = []
|
|
1236
|
+
for (const fileId of [...new Set(fileIds)]) {
|
|
1237
|
+
// The same file twice on one asset adds nothing rather than failing, exactly as the
|
|
1238
|
+
// server's `on conflict do nothing` does.
|
|
1239
|
+
if (attachments.some((row) => row.assetId === id && row.fileId === fileId)) continue
|
|
1240
|
+
const facts = await describeFile(fileId)
|
|
1241
|
+
const row: Attachment = {
|
|
1242
|
+
id: attachmentId(),
|
|
1243
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
1244
|
+
assetId: id,
|
|
1245
|
+
repairId: forRepair ?? null,
|
|
1246
|
+
fileId,
|
|
1247
|
+
name: facts.name,
|
|
1248
|
+
mimeType: facts.mimeType,
|
|
1249
|
+
size: facts.size,
|
|
1250
|
+
uploadedBy: PEOPLE.maya,
|
|
1251
|
+
createdAt: new Date().toISOString(),
|
|
1252
|
+
}
|
|
1253
|
+
attachments.push(row)
|
|
1254
|
+
added.push(row)
|
|
1255
|
+
appendHistory(find(id), 'attachment_added', {
|
|
1256
|
+
data: { attachmentId: row.id, name: row.name, ...(forRepair ? { repairId: forRepair } : {}) },
|
|
1257
|
+
})
|
|
1258
|
+
}
|
|
1259
|
+
return added.map(stamp)
|
|
1260
|
+
},
|
|
1261
|
+
|
|
1262
|
+
remove: async ({ workspaceId, attachmentId: id }: { workspaceId: string; attachmentId: string }) => {
|
|
1263
|
+
remember(workspaceId)
|
|
1264
|
+
const index = attachments.findIndex((row) => row.id === id)
|
|
1265
|
+
if (index < 0) throw new MockApiError('NOT_FOUND', 'Attachment not found')
|
|
1266
|
+
const [row] = attachments.splice(index, 1)
|
|
1267
|
+
appendHistory(find(row!.assetId), 'attachment_removed', {
|
|
1268
|
+
data: { attachmentId: row!.id, name: row!.name },
|
|
1269
|
+
})
|
|
1270
|
+
return { id: row!.id }
|
|
1271
|
+
},
|
|
1272
|
+
},
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* The register in numbers.
|
|
1276
|
+
*
|
|
1277
|
+
* `outForRepair` is a number here rather than null because the shell's mock has every
|
|
1278
|
+
* capability on; the *shape* is what matters — a screen that reads it has to handle both.
|
|
1279
|
+
*/
|
|
1280
|
+
stats: {
|
|
1281
|
+
summary: async ({ workspaceId }: { workspaceId: string }) => {
|
|
1282
|
+
remember(workspaceId)
|
|
1283
|
+
const live = assets.filter((asset) => !asset.archivedAt)
|
|
1284
|
+
const byStatus = {
|
|
1285
|
+
in_stock: 0,
|
|
1286
|
+
assigned: 0,
|
|
1287
|
+
reserved: 0,
|
|
1288
|
+
under_repair: 0,
|
|
1289
|
+
lost: 0,
|
|
1290
|
+
retired: 0,
|
|
1291
|
+
} satisfies Record<AssetStatus, number>
|
|
1292
|
+
for (const asset of live) byStatus[asset.status] += 1
|
|
1293
|
+
const summary: InventoryStats = {
|
|
1294
|
+
total: live.length,
|
|
1295
|
+
archived: assets.length - live.length,
|
|
1296
|
+
byStatus,
|
|
1297
|
+
outForRepair: repairs.filter((repair) => repair.returnedOn === null).length,
|
|
1298
|
+
unassigned: live.filter((asset) => !asset.custodianUserId).length,
|
|
1299
|
+
}
|
|
1300
|
+
return summary
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
316
1303
|
}
|
|
317
1304
|
}
|