@kernhq/module-inventory 0.2.0 → 0.4.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 +106 -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 +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -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 +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -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 +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -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 +169 -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 +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -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/0008_category_order_unique.sql +71 -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 +49 -0
- package/package.json +2 -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 +395 -0
- package/src/client/errors.ts +229 -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 +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- 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/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -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 +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -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 +337 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatDate, type WidgetProps, WidgetState } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import type { RepairListItem } from '../../contract/index.js'
|
|
5
|
+
import { getInventoryApi } from '../api-instance.js'
|
|
6
|
+
import { isolated } from '../bidi.js'
|
|
7
|
+
import { t } from '../i18n.js'
|
|
8
|
+
import { assetHref } from '../links.js'
|
|
9
|
+
import { inventoryKeys } from '../query.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What is away being fixed, right now.
|
|
13
|
+
*
|
|
14
|
+
* A second card rather than a `view` setting on the first one, because this one is **behind a
|
|
15
|
+
* capability**: `capability: 'repairs'` in `module.ts` means the shell does not offer it at all to a
|
|
16
|
+
* workspace that does not record repairs, where an option inside a settings dropdown would offer
|
|
17
|
+
* the question and then answer it with an empty card. That is the whole difference between a switch
|
|
18
|
+
* that means something and a switch that does not.
|
|
19
|
+
*
|
|
20
|
+
* It asks `repairs.list` at workspace scope — the one question a per-asset list cannot answer — and
|
|
21
|
+
* the asset's tag and name arrive joined, so nothing here has to look an id up.
|
|
22
|
+
*
|
|
23
|
+
* A row opens the **asset**, not the repair: a repair has no screen of its own, and the panel's
|
|
24
|
+
* Repairs tab is where somebody logs it back in — which is the thing anybody reading "away since
|
|
25
|
+
* 3 March" actually wants to do next. `WidgetProps` is what carries the `workspaceSlug` that link
|
|
26
|
+
* needs; this file declared two props by hand and could therefore not build one.
|
|
27
|
+
*/
|
|
28
|
+
const { workspaceId, workspaceSlug, settings }: WidgetProps = $props()
|
|
29
|
+
|
|
30
|
+
const limit = $derived(Number(settings?.limit ?? 5))
|
|
31
|
+
const api = getInventoryApi()
|
|
32
|
+
|
|
33
|
+
const filters = $derived({ open: true, limit })
|
|
34
|
+
|
|
35
|
+
const repairs = createQuery(() => ({
|
|
36
|
+
queryKey: inventoryKeys.repairs(workspaceId, filters),
|
|
37
|
+
queryFn: () => api.repairs.list({ workspaceId, ...filters }),
|
|
38
|
+
enabled: Boolean(workspaceId),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const items = $derived<readonly RepairListItem[]>(repairs.data?.items ?? [])
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<WidgetState
|
|
45
|
+
pending={repairs.isPending}
|
|
46
|
+
error={repairs.error}
|
|
47
|
+
empty={!items.length}
|
|
48
|
+
emptyTitle={t('widget_repairs_empty')}
|
|
49
|
+
emptyIcon="wrench"
|
|
50
|
+
onRetry={() => repairs.refetch()}
|
|
51
|
+
>
|
|
52
|
+
<ul>
|
|
53
|
+
{#each items as repair (repair.id)}
|
|
54
|
+
<li>
|
|
55
|
+
<!-- An anchor, so the keyboard route and the pointer route are one route. -->
|
|
56
|
+
<a class="row" href={assetHref(workspaceSlug, repair.assetId)}
|
|
57
|
+
aria-label={t('open_asset', isolated({ name: repair.assetName }))}>
|
|
58
|
+
<span class="code">{repair.assetCode}</span>
|
|
59
|
+
<span class="name">{repair.assetName}</span>
|
|
60
|
+
<span class="since">
|
|
61
|
+
{t('repair_away_since', isolated({ date: formatDate(repair.sentOn) }))}
|
|
62
|
+
</span>
|
|
63
|
+
</a>
|
|
64
|
+
</li>
|
|
65
|
+
{/each}
|
|
66
|
+
</ul>
|
|
67
|
+
</WidgetState>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
ul {
|
|
71
|
+
list-style: none;
|
|
72
|
+
margin: 0;
|
|
73
|
+
padding: 0;
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 2px;
|
|
77
|
+
}
|
|
78
|
+
.row {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: baseline;
|
|
81
|
+
gap: 10px;
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
min-width: 0;
|
|
84
|
+
color: inherit;
|
|
85
|
+
text-decoration: none;
|
|
86
|
+
/* Ten vertical pixels take the row past the 24px WCAG 2.5.8 target height; the inline padding
|
|
87
|
+
is given back by an equal negative margin, so the card's own gutter is unchanged. */
|
|
88
|
+
padding-block: 5px;
|
|
89
|
+
padding-inline: 6px;
|
|
90
|
+
margin-inline: -6px;
|
|
91
|
+
border-radius: var(--kern-r-sm);
|
|
92
|
+
}
|
|
93
|
+
.row:hover {
|
|
94
|
+
background: var(--kern-surface-hover);
|
|
95
|
+
}
|
|
96
|
+
.code {
|
|
97
|
+
font-size: 11px;
|
|
98
|
+
color: var(--kern-ink-500);
|
|
99
|
+
font-variant-numeric: tabular-nums;
|
|
100
|
+
min-width: 64px;
|
|
101
|
+
/* A tag is read character by character off a sticker, so it stays left-to-right inside a
|
|
102
|
+
Persian or Arabic card and the bidi algorithm does not reorder its digits. */
|
|
103
|
+
direction: ltr;
|
|
104
|
+
unicode-bidi: isolate;
|
|
105
|
+
}
|
|
106
|
+
.name {
|
|
107
|
+
flex: 1;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
text-overflow: ellipsis;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
color: var(--kern-ink-900);
|
|
113
|
+
/* A value somebody typed decides its own direction: `plaintext` takes it from the value's
|
|
114
|
+
first strong character, so a Latin name inside a Persian screen reads left to right and
|
|
115
|
+
keeps its own trailing punctuation instead of donating it to the paragraph. */
|
|
116
|
+
unicode-bidi: plaintext;
|
|
117
|
+
}
|
|
118
|
+
.since {
|
|
119
|
+
flex: none;
|
|
120
|
+
font-size: 11px;
|
|
121
|
+
/* Muted with a colour, never opacity: a faded line is unreadable whatever its token says. */
|
|
122
|
+
color: var(--kern-ink-500);
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -22,22 +22,60 @@ import { defineCapabilities } from '@kernhq/contracts'
|
|
|
22
22
|
* the rows stay exactly where they are. Anything that would need data thrown away to reverse is
|
|
23
23
|
* not a capability, however much it looks like one.
|
|
24
24
|
*
|
|
25
|
-
* The rest arrive with the work that makes them mean something: `
|
|
26
|
-
* `
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
25
|
+
* The rest arrive with the work that makes them mean something: `labels` with the finished register;
|
|
26
|
+
* `custom_fields` and `locations` beside them; `stock` and `procurement` with stock control;
|
|
27
|
+
* `depreciation` and `reservations` last. Adding one here before its procedures exist is the mistake
|
|
28
|
+
* this comment is here to prevent.
|
|
29
29
|
*/
|
|
30
30
|
export const inventoryCapabilities = defineCapabilities([
|
|
31
31
|
{
|
|
32
32
|
id: 'core',
|
|
33
33
|
label: 'Assets',
|
|
34
|
-
// Says only what the module answers today
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
description:
|
|
34
|
+
// Says only what the module answers today, which is the rule this line has been held to in both
|
|
35
|
+
// directions: custody and the change history were taken *out* of it while neither had a
|
|
36
|
+
// procedure, and put back in the change that gave them one.
|
|
37
|
+
description:
|
|
38
|
+
'The asset register: what the company owns item by item, who is holding each one, and how each one got there',
|
|
38
39
|
required: true,
|
|
39
40
|
level: 1,
|
|
40
41
|
},
|
|
42
|
+
{
|
|
43
|
+
/**
|
|
44
|
+
* The first capability anyone can actually switch, and the reason it is one is the shape of the
|
|
45
|
+
* customers: an office that hands out laptops wants a register and never records a repair,
|
|
46
|
+
* while a company with a workshop wants little else. One answer for the whole workspace, not
|
|
47
|
+
* per person — so a capability rather than a permission — and switching it off writes a boolean
|
|
48
|
+
* into module settings and leaves every repair row exactly where it was.
|
|
49
|
+
*
|
|
50
|
+
* `dependsOn: ['core']` says the obvious out loud: a repair is a trip *an asset* made, so there
|
|
51
|
+
* is nothing to record without the register. It also means the closure prunes this
|
|
52
|
+
* automatically if `core` could ever be off, rather than each screen remembering to check two
|
|
53
|
+
* switches.
|
|
54
|
+
*/
|
|
55
|
+
id: 'repairs',
|
|
56
|
+
label: 'Repairs',
|
|
57
|
+
description:
|
|
58
|
+
'What went away to be fixed, to whom, what it cost and when it came back — and the `under repair` status that follows an open one',
|
|
59
|
+
dependsOn: ['core'],
|
|
60
|
+
defaultEnabled: true,
|
|
61
|
+
level: 1,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
/**
|
|
65
|
+
* Files against an asset or a repair — a purchase receipt, a warranty card, a repair invoice.
|
|
66
|
+
*
|
|
67
|
+
* Separate from `repairs` because plenty of workspaces want one without the other: an office
|
|
68
|
+
* that keeps receipts and never records a repair, and a workshop that records repairs and keeps
|
|
69
|
+
* no paperwork. Switching it off hides the Files tab and answers 404; the rows and the files in
|
|
70
|
+
* core's storage are untouched, which is what makes it a capability rather than a migration.
|
|
71
|
+
*/
|
|
72
|
+
id: 'attachments',
|
|
73
|
+
label: 'Files',
|
|
74
|
+
description: 'Receipts, warranties and manuals kept against an asset or one of its repairs',
|
|
75
|
+
dependsOn: ['core'],
|
|
76
|
+
defaultEnabled: true,
|
|
77
|
+
level: 1,
|
|
78
|
+
},
|
|
41
79
|
])
|
|
42
80
|
|
|
43
81
|
export type InventoryCapabilityId = (typeof inventoryCapabilities)[number]['id']
|
|
@@ -52,4 +90,10 @@ export type InventoryCapabilityId = (typeof inventoryCapabilities)[number]['id']
|
|
|
52
90
|
* A procedure absent from this map belongs to the module as a whole and is reachable whenever
|
|
53
91
|
* Inventory is on — which for `core` is always, because it is `required`.
|
|
54
92
|
*/
|
|
55
|
-
export const inventoryCapabilityProcedures: Record<string, readonly string[]> = {
|
|
93
|
+
export const inventoryCapabilityProcedures: Record<string, readonly string[]> = {
|
|
94
|
+
repairs: ['repairs.list', 'repairs.create', 'repairs.update', 'repairs.complete'],
|
|
95
|
+
attachments: ['attachments.list', 'attachments.add', 'attachments.remove'],
|
|
96
|
+
// `stats.summary` is deliberately absent. It counts assets, which is `core`, and it answers
|
|
97
|
+
// `outForRepair: null` rather than a number when the workspace has `repairs` off — a count line
|
|
98
|
+
// and a dashboard card must not disappear because a *different* feature is switched off.
|
|
99
|
+
}
|
package/src/contract/events.ts
CHANGED
|
@@ -31,4 +31,53 @@ export const inventoryEvents = {
|
|
|
31
31
|
'inventory.asset.restored',
|
|
32
32
|
z.object({ assetId: z.uuid(), workspaceId: WorkspaceId }),
|
|
33
33
|
),
|
|
34
|
+
/**
|
|
35
|
+
* One event for all three of assign, transfer and return, because a subscriber cares that the
|
|
36
|
+
* answer to "who has it" moved and not by which verb.
|
|
37
|
+
*
|
|
38
|
+
* `userId` is null when it came back to stock and `previousUserId` is null when it went out from
|
|
39
|
+
* stock, so the pair says which of the three happened without a fourth field claiming to. Both
|
|
40
|
+
* are ids, like everything else here: a subscriber that needs the person asks core with its own
|
|
41
|
+
* principal, so an event cannot become a way to read a name past a permission check.
|
|
42
|
+
*
|
|
43
|
+
* Nothing subscribes to it inside this module. It exists for what is outside — an offboarding
|
|
44
|
+
* automation that asks what somebody still holds, most obviously — which is the whole reason a
|
|
45
|
+
* module emits events rather than only writing its own history row.
|
|
46
|
+
*/
|
|
47
|
+
custodyChanged: defineEvent(
|
|
48
|
+
'inventory.custody.changed',
|
|
49
|
+
z.object({
|
|
50
|
+
assetId: z.uuid(),
|
|
51
|
+
workspaceId: WorkspaceId,
|
|
52
|
+
userId: z.uuid().nullable(),
|
|
53
|
+
previousUserId: z.uuid().nullable(),
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
/**
|
|
57
|
+
* An item went to a repairer, and an item came back.
|
|
58
|
+
*
|
|
59
|
+
* Two events rather than one, where custody has one for three verbs — because a subscriber to
|
|
60
|
+
* custody cares only that "who has it" moved, while these two are opposite facts with opposite
|
|
61
|
+
* reactions: something outside this module wants to tell the holder their laptop has gone, and
|
|
62
|
+
* something else wants to tell them it is back. A single `repair.changed` would make every
|
|
63
|
+
* subscriber re-read the row to find out which happened.
|
|
64
|
+
*
|
|
65
|
+
* No `repair.updated`: correcting a vendor or filling in an invoice a week later is not a fact
|
|
66
|
+
* anything outside this module reacts to, and the screens that do care are refreshed by
|
|
67
|
+
* `kernel.realtime.change`, which needs no declaration. Declaring one so the set looks symmetrical
|
|
68
|
+
* is the same lie as a capability nothing checks.
|
|
69
|
+
*/
|
|
70
|
+
repairOpened: defineEvent(
|
|
71
|
+
'inventory.repair.opened',
|
|
72
|
+
z.object({ assetId: z.uuid(), repairId: z.uuid(), workspaceId: WorkspaceId }),
|
|
73
|
+
),
|
|
74
|
+
repairCompleted: defineEvent(
|
|
75
|
+
'inventory.repair.completed',
|
|
76
|
+
z.object({ assetId: z.uuid(), repairId: z.uuid(), workspaceId: WorkspaceId }),
|
|
77
|
+
),
|
|
78
|
+
// No category and no attachment events, deliberately. An event is for something outside this
|
|
79
|
+
// module to react to, and nothing outside it has an opinion about a workspace renaming "Laptops"
|
|
80
|
+
// or filing a receipt; the screens that do care are refreshed by `kernel.realtime.change`, which
|
|
81
|
+
// needs no declaration. Declaring one here so the list looks complete is the same lie as a
|
|
82
|
+
// capability nothing checks.
|
|
34
83
|
}
|
package/src/contract/index.ts
CHANGED
package/src/contract/models.ts
CHANGED
|
@@ -18,6 +18,12 @@ export const MODULE_ID = 'inventory'
|
|
|
18
18
|
*
|
|
19
19
|
* Stored rather than derived, because every list filter asks for it — and kept in step inside the
|
|
20
20
|
* same transaction that writes the row it derives from, never by a job afterwards.
|
|
21
|
+
*
|
|
22
|
+
* **`under_repair` belongs to the `repairs` capability**, so a workspace with that switch off never
|
|
23
|
+
* has an asset in it: the procedure that ends a repair answers 404 there, and a status nothing can
|
|
24
|
+
* move an item out of is a register the workspace cannot correct. The one thing a job does here is
|
|
25
|
+
* bring the rows *nobody touches* back into step after that switch moves, in both directions —
|
|
26
|
+
* `deriveStatus` in `src/server/services/status.ts` argues it, and nothing is destroyed either way.
|
|
21
27
|
*/
|
|
22
28
|
export const AssetStatus = z.enum(['in_stock', 'assigned', 'reserved', 'under_repair', 'lost', 'retired'])
|
|
23
29
|
export type AssetStatus = z.infer<typeof AssetStatus>
|
|
@@ -32,6 +38,119 @@ export type AssetStatus = z.infer<typeof AssetStatus>
|
|
|
32
38
|
export const CustomValues = z.record(z.string(), z.unknown())
|
|
33
39
|
export type CustomValues = z.infer<typeof CustomValues>
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A workspace's own grouping of what it owns — Laptops, Furniture, Cameras.
|
|
43
|
+
*
|
|
44
|
+
* Archived rather than deleted, and `archivedAt` is the whole reason: `assets.category_id` is a
|
|
45
|
+
* plain uuid with no foreign key, so a deleted category leaves every asset filed under it pointing
|
|
46
|
+
* at a row that is not there. The list column goes blank and the timeline entry that recorded the
|
|
47
|
+
* move loses the name it recorded. Archiving takes it out of every picker and every filter and
|
|
48
|
+
* leaves each asset able to say what it is.
|
|
49
|
+
*/
|
|
50
|
+
export const Category = z.object({
|
|
51
|
+
id: z.uuid(),
|
|
52
|
+
workspaceId: WorkspaceId,
|
|
53
|
+
name: z.string().min(1).max(120),
|
|
54
|
+
/**
|
|
55
|
+
* Where it sits in the sequence. **Storage, not a setting.**
|
|
56
|
+
*
|
|
57
|
+
* Nobody thinks about their categories as integers, so no screen shows this number and no input
|
|
58
|
+
* accepts one: `categories.reorder` takes the ids in the order somebody dragged them into and
|
|
59
|
+
* renumbers the live ones `0…n-1` in one transaction. `create` and a restore append, so two live
|
|
60
|
+
* categories never share a value — which is the state a "position" field invited on every save.
|
|
61
|
+
*
|
|
62
|
+
* The name is still the tiebreak in `list`, because a workspace seeded before this existed, or a
|
|
63
|
+
* row written by hand, can still hold a duplicate; it just stops being the ordinary case.
|
|
64
|
+
*/
|
|
65
|
+
order: z.number().int(),
|
|
66
|
+
createdAt: z.string(),
|
|
67
|
+
updatedAt: z.string(),
|
|
68
|
+
archivedAt: z.string().nullable(),
|
|
69
|
+
})
|
|
70
|
+
export type Category = z.infer<typeof Category>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a person types when they add or rename a category: a name, and nothing else.
|
|
74
|
+
*
|
|
75
|
+
* `order` used to be here, optional, and it was the only way to move a category — a number field on
|
|
76
|
+
* a settings form, with a hint explaining that lower comes first and that ties fall back to names.
|
|
77
|
+
* The sequence is written by `categories.reorder` now, so an `order` on create or update would be a
|
|
78
|
+
* second way to set the same thing, disagreeing with the first the moment anybody used it.
|
|
79
|
+
*/
|
|
80
|
+
export const CategoryInput = z.object({
|
|
81
|
+
name: z.string().trim().min(1).max(120),
|
|
82
|
+
})
|
|
83
|
+
export type CategoryInput = z.infer<typeof CategoryInput>
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How many categories one workspace may have live at once — and the reason it is a *stated* limit.
|
|
87
|
+
*
|
|
88
|
+
* `categories.reorder` has to name every live category exactly once, so its input array needs a
|
|
89
|
+
* bound; every zod array that a client fills does, or a single request can ask the server to hold an
|
|
90
|
+
* arbitrary list in memory. A bound on that array alone is a **silent ceiling**: a workspace with
|
|
91
|
+
* more live categories than the number could still create, rename and archive them, and only
|
|
92
|
+
* reordering would fail — the one procedure with no other way to do the job.
|
|
93
|
+
*
|
|
94
|
+
* So the same number is enforced where somebody meets it. `categories.create` and a restore both
|
|
95
|
+
* refuse with `inventory.category.limit_reached` once a workspace holds this many live categories,
|
|
96
|
+
* which is a sentence naming the number at the moment it matters. Archiving one frees a place, which
|
|
97
|
+
* is why the limit counts the **live** rows rather than every row ever made: the advice the refusal
|
|
98
|
+
* gives has to be true.
|
|
99
|
+
*/
|
|
100
|
+
export const MAX_LIVE_CATEGORIES = 500
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One stretch of time during which one person held one asset.
|
|
104
|
+
*
|
|
105
|
+
* Effective-dated, exactly as HR keeps employments: nothing is ever updated in place, a change
|
|
106
|
+
* closes the open row and inserts a new one. `effectiveTo === null` means "still holding it", and
|
|
107
|
+
* `inventory_custody_no_overlap` — a GiST exclusion constraint on
|
|
108
|
+
* `(asset_id =, tstzrange(effective_from, effective_to, '[)') &&)` — makes two of those for one
|
|
109
|
+
* asset impossible in the database rather than merely unlikely in the service.
|
|
110
|
+
*/
|
|
111
|
+
export const CustodyPeriod = z.object({
|
|
112
|
+
id: z.uuid(),
|
|
113
|
+
workspaceId: WorkspaceId,
|
|
114
|
+
assetId: z.uuid(),
|
|
115
|
+
/**
|
|
116
|
+
* The member who held it. A plain uuid — a cross-schema foreign key is what the module boundary
|
|
117
|
+
* exists to prevent — resolved against core membership at read time, which is also why a person
|
|
118
|
+
* who has since left the workspace still has readable history.
|
|
119
|
+
*/
|
|
120
|
+
userId: z.uuid(),
|
|
121
|
+
note: z.string().max(500).nullable(),
|
|
122
|
+
effectiveFrom: z.string(),
|
|
123
|
+
effectiveTo: z.string().nullable(),
|
|
124
|
+
/** Who did the handing over, which is not always who received it. */
|
|
125
|
+
createdBy: z.uuid().nullable(),
|
|
126
|
+
createdAt: z.string(),
|
|
127
|
+
})
|
|
128
|
+
export type CustodyPeriod = z.infer<typeof CustodyPeriod>
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One entry in an asset's timeline.
|
|
132
|
+
*
|
|
133
|
+
* `action` is a **plain string, deliberately not an enum**. The rows are append-only and outlive
|
|
134
|
+
* the image that wrote them: an instance rolled back to a previous release would fail to parse its
|
|
135
|
+
* own history the moment a newer image had written an action the older enum does not list — a
|
|
136
|
+
* timeline that 500s rather than one that says a little less. The client renders a sentence per
|
|
137
|
+
* action it knows and a neutral one for anything else. What is written today is `created`,
|
|
138
|
+
* `updated`, `assigned`, `transferred`, `returned`, `retired`, `restored`, `repair_logged`,
|
|
139
|
+
* `repair_completed`, `attachment_added` and `attachment_removed`.
|
|
140
|
+
*/
|
|
141
|
+
export const AssetHistoryEntry = z.object({
|
|
142
|
+
id: z.uuid(),
|
|
143
|
+
assetId: z.uuid(),
|
|
144
|
+
/** Null for anything the platform did rather than a person. */
|
|
145
|
+
actorId: z.uuid().nullable(),
|
|
146
|
+
action: z.string().max(40),
|
|
147
|
+
changes: z.array(z.object({ field: z.string(), from: z.unknown(), to: z.unknown() })),
|
|
148
|
+
/** Action-specific ids — who received an item, who handed it over. Never a row. */
|
|
149
|
+
data: z.record(z.string(), z.unknown()),
|
|
150
|
+
occurredAt: z.string(),
|
|
151
|
+
})
|
|
152
|
+
export type AssetHistoryEntry = z.infer<typeof AssetHistoryEntry>
|
|
153
|
+
|
|
35
154
|
export const Asset = z.object({
|
|
36
155
|
id: z.uuid(),
|
|
37
156
|
workspaceId: WorkspaceId,
|
|
@@ -63,6 +182,157 @@ export const Asset = z.object({
|
|
|
63
182
|
})
|
|
64
183
|
export type Asset = z.infer<typeof Asset>
|
|
65
184
|
|
|
185
|
+
/**
|
|
186
|
+
* What a custody procedure answers with: the asset as it now stands, and the period this call
|
|
187
|
+
* opened.
|
|
188
|
+
*
|
|
189
|
+
* Both, because a screen needs both and fetching the asset again afterwards is a second round trip
|
|
190
|
+
* that can read a row somebody else has changed in between — the panel would then show a handover
|
|
191
|
+
* that has already been undone. `period` is null on a return: something closed, nothing opened.
|
|
192
|
+
*/
|
|
193
|
+
export const CustodyResult = z.object({
|
|
194
|
+
asset: Asset,
|
|
195
|
+
period: CustodyPeriod.nullable(),
|
|
196
|
+
})
|
|
197
|
+
export type CustodyResult = z.infer<typeof CustodyResult>
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One trip an item made to a repairer.
|
|
201
|
+
*
|
|
202
|
+
* `returnedOn === null` means it is still away, and that single fact is what puts the asset in
|
|
203
|
+
* `under_repair` — see `deriveStatus` in `src/server/services/status.ts`, which is the only place
|
|
204
|
+
* the three columns that drive a status are read together.
|
|
205
|
+
*
|
|
206
|
+
* **A repair is not custody, and neither one cancels the other.** A laptop assigned to Dan that goes
|
|
207
|
+
* to the repairer is still Dan's responsibility: the custody period stays open, `custodianUserId`
|
|
208
|
+
* stays set, and only `status` moves. That is why a repair does not touch custody and custody does
|
|
209
|
+
* not refuse a repaired item — the two answer different questions ("who is answerable for it" and
|
|
210
|
+
* "where is it"), and collapsing them would mean an item could not come back to the person who sent
|
|
211
|
+
* it.
|
|
212
|
+
*/
|
|
213
|
+
export const Repair = z.object({
|
|
214
|
+
id: z.uuid(),
|
|
215
|
+
workspaceId: WorkspaceId,
|
|
216
|
+
assetId: z.uuid(),
|
|
217
|
+
summary: z.string().min(1).max(200),
|
|
218
|
+
detail: z.string().max(4000).nullable(),
|
|
219
|
+
vendor: z.string().max(200).nullable(),
|
|
220
|
+
/** Minor units, like `Asset.priceMinor` — one convention for money across the module. */
|
|
221
|
+
costMinor: z.number().int().nullable(),
|
|
222
|
+
currency: z.string().length(3).nullable(),
|
|
223
|
+
sentOn: z.string(),
|
|
224
|
+
/** Null while it is still away. Set by `repairs.complete` and by nothing else. */
|
|
225
|
+
returnedOn: z.string().nullable(),
|
|
226
|
+
createdBy: z.uuid().nullable(),
|
|
227
|
+
createdAt: z.string(),
|
|
228
|
+
updatedAt: z.string(),
|
|
229
|
+
})
|
|
230
|
+
export type Repair = z.infer<typeof Repair>
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A repair in a list, with the two things a reader needs to know *which item* it is about.
|
|
234
|
+
*
|
|
235
|
+
* **Joined at read time, never stored here.** `mod_inventory.repairs` holds no asset name, so
|
|
236
|
+
* renaming an asset renames it everywhere at once — a copied label is a label that goes stale, and
|
|
237
|
+
* this module already refuses to copy a person's name for the same reason. The join is inside one
|
|
238
|
+
* schema, which is the kind a module is allowed to make; the kind it is not is a join across the
|
|
239
|
+
* boundary into another module's tables.
|
|
240
|
+
*
|
|
241
|
+
* It extends `Repair` rather than replacing it, so a component that renders a repair renders one of
|
|
242
|
+
* these without knowing the difference. `create`, `update` and `complete` answer with a plain
|
|
243
|
+
* `Repair`: the caller of those already knows which asset it asked about.
|
|
244
|
+
*/
|
|
245
|
+
export const RepairListItem = Repair.extend({
|
|
246
|
+
assetCode: z.string().min(1).max(40),
|
|
247
|
+
assetName: z.string().min(1).max(200),
|
|
248
|
+
})
|
|
249
|
+
export type RepairListItem = z.infer<typeof RepairListItem>
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Everything a person can say about a repair.
|
|
253
|
+
*
|
|
254
|
+
* **No field here carries `.default()`, for the reason `AssetInput` spells out**: `update` is built
|
|
255
|
+
* from `.partial()`, and `.partial()` does not strip a default — zod still substitutes it for a key
|
|
256
|
+
* the request never sent, so a patch that renames a repair would silently re-date it. `sentOn` is
|
|
257
|
+
* optional rather than defaulted for the same reason; `repairs.create` fills today's date on the
|
|
258
|
+
* server, which is the only clock this module trusts.
|
|
259
|
+
*/
|
|
260
|
+
export const RepairInput = z.object({
|
|
261
|
+
summary: z.string().trim().min(1).max(200),
|
|
262
|
+
detail: z.string().max(4000).nullish(),
|
|
263
|
+
vendor: z.string().max(200).nullish(),
|
|
264
|
+
costMinor: z.number().int().min(0).nullish(),
|
|
265
|
+
currency: z.string().length(3).nullish(),
|
|
266
|
+
/** `sent_on` is `not null` in the database, so this is optional but never nullable. */
|
|
267
|
+
sentOn: z.iso.date().optional(),
|
|
268
|
+
})
|
|
269
|
+
export type RepairInput = z.infer<typeof RepairInput>
|
|
270
|
+
|
|
271
|
+
export const RepairPatchInput = RepairInput.partial()
|
|
272
|
+
export type RepairPatchInput = z.infer<typeof RepairPatchInput>
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* What a repair mutation answers with: the repair, and the asset as it now stands.
|
|
276
|
+
*
|
|
277
|
+
* Both, for the reason `CustodyResult` carries both — sending an item away moves `assets.status`,
|
|
278
|
+
* and a screen that had to fetch the asset again afterwards would be reading a row somebody else
|
|
279
|
+
* may have changed in between.
|
|
280
|
+
*/
|
|
281
|
+
export const RepairResult = z.object({
|
|
282
|
+
repair: Repair,
|
|
283
|
+
asset: Asset,
|
|
284
|
+
})
|
|
285
|
+
export type RepairResult = z.infer<typeof RepairResult>
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A file recorded against an asset — a receipt, a warranty card, a manual.
|
|
289
|
+
*
|
|
290
|
+
* **The bytes are core's, not this module's.** A module does not upload: the browser uploads through
|
|
291
|
+
* core's file service and hands this module the id it was given, and reading one back is a download
|
|
292
|
+
* URL core signs. All that lives here is the fact that this asset has that file, plus the name and
|
|
293
|
+
* size copied at attach time so a list can be drawn without asking core once per row.
|
|
294
|
+
*
|
|
295
|
+
* `repairId` is what separates "the invoice for the screen replacement" from "the purchase receipt":
|
|
296
|
+
* null means it belongs to the asset itself.
|
|
297
|
+
*/
|
|
298
|
+
export const Attachment = z.object({
|
|
299
|
+
id: z.uuid(),
|
|
300
|
+
workspaceId: WorkspaceId,
|
|
301
|
+
assetId: z.uuid(),
|
|
302
|
+
repairId: z.uuid().nullable(),
|
|
303
|
+
fileId: z.uuid(),
|
|
304
|
+
name: z.string().max(300),
|
|
305
|
+
mimeType: z.string().max(200).nullable(),
|
|
306
|
+
/** Bytes, as core reported them when the file was attached. */
|
|
307
|
+
size: z.number().int().nullable(),
|
|
308
|
+
uploadedBy: z.uuid().nullable(),
|
|
309
|
+
createdAt: z.string(),
|
|
310
|
+
})
|
|
311
|
+
export type Attachment = z.infer<typeof Attachment>
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The register in numbers: what a page's count line and a dashboard card need in one request.
|
|
315
|
+
*
|
|
316
|
+
* `total` counts what the list shows by default — live rows — and `archived` is beside it rather
|
|
317
|
+
* than inside it, because a count line that silently included archived rows would disagree with the
|
|
318
|
+
* list under it.
|
|
319
|
+
*
|
|
320
|
+
* **`outForRepair` is null when the workspace does not track repairs.** Zero would be a claim
|
|
321
|
+
* ("nothing is away"), and a workspace with the `repairs` capability off has not made that claim —
|
|
322
|
+
* it has no opinion at all. The screens show the tile only when a number arrives, which is the same
|
|
323
|
+
* rule as hiding the Repairs tab, expressed in the data instead of in a second capability lookup.
|
|
324
|
+
*/
|
|
325
|
+
export const InventoryStats = z.object({
|
|
326
|
+
total: z.number().int().nonnegative(),
|
|
327
|
+
archived: z.number().int().nonnegative(),
|
|
328
|
+
/** Every status, zero-filled, so a screen can render the set without knowing which exist. */
|
|
329
|
+
byStatus: z.record(AssetStatus, z.number().int().nonnegative()),
|
|
330
|
+
outForRepair: z.number().int().nonnegative().nullable(),
|
|
331
|
+
/** Live assets nobody is holding — what is actually available to hand out. */
|
|
332
|
+
unassigned: z.number().int().nonnegative(),
|
|
333
|
+
})
|
|
334
|
+
export type InventoryStats = z.infer<typeof InventoryStats>
|
|
335
|
+
|
|
66
336
|
/**
|
|
67
337
|
* How a list is ordered, and therefore what a page cursor is a bookmark *into*.
|
|
68
338
|
*
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { core } from '@kernhq/contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What Inventory will interrupt somebody about.
|
|
5
|
+
*
|
|
6
|
+
* Four things, and each of them changes what somebody has to *do*. That is the test: a rename, a new
|
|
7
|
+
* category, an item coming back — those are facts a screen shows when somebody looks, and a
|
|
8
|
+
* notification type nobody wants is a notification type everybody switches off along with the one
|
|
9
|
+
* that mattered.
|
|
10
|
+
*
|
|
11
|
+
* A type declared here and never sent is the same lie as a permission nothing checks. Each of these
|
|
12
|
+
* names its sender, and every one of them exists:
|
|
13
|
+
*
|
|
14
|
+
* - `custody.assigned` — `CustodyService`, on `assign` and `transfer`.
|
|
15
|
+
* - `warranty.expiring` — the `warranty-sweep` job, once per asset.
|
|
16
|
+
* - `repair.overdue` — the `repair-overdue` job, once per repair.
|
|
17
|
+
* - `custody.return_due` — the offboarding subscription, when HR says somebody has left or core says
|
|
18
|
+
* they have been removed from the workspace.
|
|
19
|
+
*
|
|
20
|
+
* The keys keep their `inventory.custody.*` spelling rather than being renamed to
|
|
21
|
+
* `inventory.asset.*`: a notification type is the key a person's own preferences are stored under,
|
|
22
|
+
* so renaming one silently resets everybody who had switched it off.
|
|
23
|
+
*/
|
|
24
|
+
export const inventoryNotificationTypes: core.NotificationTypeDef[] = [
|
|
25
|
+
{
|
|
26
|
+
/**
|
|
27
|
+
* Everybody who has ever found a laptop on their desk with no idea it was now theirs is the
|
|
28
|
+
* reason this is `urgent` and reaches email — an asset register that records a handover the
|
|
29
|
+
* recipient never learns about has recorded an argument for later.
|
|
30
|
+
*/
|
|
31
|
+
type: 'inventory.custody.assigned',
|
|
32
|
+
label: 'An item was handed to you',
|
|
33
|
+
description: 'Somebody made you the holder of an asset in the register.',
|
|
34
|
+
defaults: { inapp: true, push: true, email: true },
|
|
35
|
+
urgent: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
/**
|
|
39
|
+
* Not urgent, and email is off by default. A warranty running out in a month is a thing to plan
|
|
40
|
+
* for, not a thing to stop for — and it arrives on a schedule rather than because somebody did
|
|
41
|
+
* something, which is exactly the kind of message that trains people to ignore the rest.
|
|
42
|
+
*/
|
|
43
|
+
type: 'inventory.warranty.expiring',
|
|
44
|
+
label: 'A warranty is about to run out',
|
|
45
|
+
description: "An item's warranty expires soon. Sent once per item, to whoever is holding it.",
|
|
46
|
+
defaults: { inapp: true, push: false, email: false },
|
|
47
|
+
urgent: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
/**
|
|
51
|
+
* Push, because this one is an action somebody has to take — ring the repairer — and nobody
|
|
52
|
+
* goes looking for it. Still not urgent: a laptop that has been away a fortnight can wait until
|
|
53
|
+
* the phone is picked up.
|
|
54
|
+
*/
|
|
55
|
+
type: 'inventory.repair.overdue',
|
|
56
|
+
label: 'A repair has been away too long',
|
|
57
|
+
description: 'An item sent for repair has not been logged as returned. Sent once per repair.',
|
|
58
|
+
defaults: { inapp: true, push: true, email: false },
|
|
59
|
+
urgent: false,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
/**
|
|
63
|
+
* Somebody is leaving and still has company property. Email, because it is a list of things to
|
|
64
|
+
* collect from a person who may not be at their desk much longer, and an in-app badge nobody
|
|
65
|
+
* opens until Monday is how a laptop leaves the building.
|
|
66
|
+
*/
|
|
67
|
+
type: 'inventory.custody.return_due',
|
|
68
|
+
label: 'Somebody leaving still holds company property',
|
|
69
|
+
description: 'A member has left, or is leaving, and items are still recorded as theirs.',
|
|
70
|
+
defaults: { inapp: true, push: true, email: true },
|
|
71
|
+
urgent: true,
|
|
72
|
+
},
|
|
73
|
+
]
|