@kernhq/module-hr 0.9.2 → 0.10.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/dist/contract/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.js.map +1 -1
- package/dist/contract/router.d.ts +9 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/server/router.d.ts +9 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +17 -0
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +36 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +20 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +2 -0
- package/dist/server/services/approvals.js.map +1 -1
- package/migrations/0005_approval_requester.sql +20 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +6 -4
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +210 -0
- package/src/client/i18n.ts +302 -0
- package/src/client/index.ts +1 -20
- package/src/client/mock.ts +133 -16
- package/src/client/module.ts +2 -1
- package/src/client/pages/ApprovalsPage.svelte +252 -54
- package/src/client/pages/OfficesPage.svelte +161 -37
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +10 -1
- package/src/client/summary.ts +36 -0
- package/src/contract/approvals.ts +13 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, Button, Dialog, EmptyState, Field, Input, Select, Skeleton } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { hrKeys } from '../query.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Who decides while I am away.
|
|
10
|
+
*
|
|
11
|
+
* A delegation does not move a request — it lets somebody else act on the one already addressed to
|
|
12
|
+
* me, and the decision records both names. That is why this lives beside the inbox rather than in
|
|
13
|
+
* settings: it is a personal arrangement about my own queue, not a policy about anyone else's.
|
|
14
|
+
*
|
|
15
|
+
* Existing delegations are listed with a way to revoke, because the failure mode of this feature is
|
|
16
|
+
* one somebody set up last summer and forgot.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
open: boolean
|
|
20
|
+
workspaceId: string
|
|
21
|
+
onClose: () => void
|
|
22
|
+
}
|
|
23
|
+
const { open, workspaceId, onClose }: Props = $props()
|
|
24
|
+
|
|
25
|
+
const api = getHrApi()
|
|
26
|
+
const queryClient = useQueryClient()
|
|
27
|
+
|
|
28
|
+
const delegationsQuery = createQuery(() => ({
|
|
29
|
+
queryKey: hrKeys.delegations(workspaceId),
|
|
30
|
+
enabled: open && Boolean(workspaceId),
|
|
31
|
+
queryFn: () => api.approvals.delegations({ workspaceId }),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
const peopleQuery = createQuery(() => ({
|
|
35
|
+
queryKey: hrKeys.people(workspaceId, { forDelegation: true }),
|
|
36
|
+
enabled: open && Boolean(workspaceId),
|
|
37
|
+
queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
|
|
38
|
+
}))
|
|
39
|
+
const people = $derived(peopleQuery.data?.items ?? [])
|
|
40
|
+
|
|
41
|
+
let toPersonId = $state('')
|
|
42
|
+
let startsOn = $state(new Date().toISOString().slice(0, 10))
|
|
43
|
+
let endsOn = $state('')
|
|
44
|
+
let formError = $state<string | null>(null)
|
|
45
|
+
|
|
46
|
+
const create = createMutation(() => ({
|
|
47
|
+
mutationFn: () => api.approvals.delegate({ workspaceId, toPersonId, startsOn, endsOn }),
|
|
48
|
+
onSuccess: () => {
|
|
49
|
+
toPersonId = ''
|
|
50
|
+
endsOn = ''
|
|
51
|
+
formError = null
|
|
52
|
+
void queryClient.invalidateQueries({ queryKey: hrKeys.delegations(workspaceId) })
|
|
53
|
+
},
|
|
54
|
+
onError: () => {
|
|
55
|
+
formError = t('delegate_error')
|
|
56
|
+
},
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
const revoke = createMutation(() => ({
|
|
60
|
+
mutationFn: (delegationId: string) => api.approvals.revokeDelegation({ workspaceId, delegationId }),
|
|
61
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: hrKeys.delegations(workspaceId) }),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The one thing worth refusing before the server does: a window that ends before it starts is a
|
|
66
|
+
* delegation that silently never applies, and the API's error for it reads like a bug report.
|
|
67
|
+
*/
|
|
68
|
+
const canSubmit = $derived(Boolean(toPersonId) && Boolean(startsOn) && Boolean(endsOn) && endsOn >= startsOn)
|
|
69
|
+
const datesInvalid = $derived(Boolean(startsOn) && Boolean(endsOn) && endsOn < startsOn)
|
|
70
|
+
|
|
71
|
+
const nameOf = (personId: string) => people.find((p) => p.id === personId)?.displayName ?? '—'
|
|
72
|
+
|
|
73
|
+
const range = (from: string, to: string) =>
|
|
74
|
+
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).formatRange(new Date(from), new Date(to))
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<Dialog
|
|
78
|
+
{open}
|
|
79
|
+
size="md"
|
|
80
|
+
title={t('delegate_title')}
|
|
81
|
+
description={t('delegate_desc')}
|
|
82
|
+
onOpenChange={(o) => {
|
|
83
|
+
if (!o) onClose()
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<div class="existing">
|
|
87
|
+
{#if delegationsQuery.isLoading}
|
|
88
|
+
<Skeleton height="44px" />
|
|
89
|
+
{:else if (delegationsQuery.data ?? []).length === 0}
|
|
90
|
+
<EmptyState compact icon="user" title={t('delegate_none')} />
|
|
91
|
+
{:else}
|
|
92
|
+
<ul>
|
|
93
|
+
{#each delegationsQuery.data ?? [] as d (d.id)}
|
|
94
|
+
<li>
|
|
95
|
+
<span class="stack">
|
|
96
|
+
<span class="name">{nameOf(d.toPersonId)}</span>
|
|
97
|
+
<span class="meta">
|
|
98
|
+
{range(d.startsOn, d.endsOn)}
|
|
99
|
+
<Badge tone="grey">{d.subjectType ? d.subjectType : t('delegate_all_types')}</Badge>
|
|
100
|
+
</span>
|
|
101
|
+
</span>
|
|
102
|
+
<Button
|
|
103
|
+
size="sm"
|
|
104
|
+
variant="ghost"
|
|
105
|
+
loading={revoke.isPending && revoke.variables === d.id}
|
|
106
|
+
onclick={() => revoke.mutate(d.id)}
|
|
107
|
+
>
|
|
108
|
+
{t('delegate_revoke')}
|
|
109
|
+
</Button>
|
|
110
|
+
</li>
|
|
111
|
+
{/each}
|
|
112
|
+
</ul>
|
|
113
|
+
{/if}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<form
|
|
117
|
+
class="form"
|
|
118
|
+
onsubmit={(e) => {
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
if (canSubmit) create.mutate()
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Field label={t('delegate_to')} required error={formError}>
|
|
124
|
+
{#snippet children(id)}
|
|
125
|
+
<Select
|
|
126
|
+
{id}
|
|
127
|
+
bind:value={toPersonId}
|
|
128
|
+
disabled={peopleQuery.isLoading}
|
|
129
|
+
options={[
|
|
130
|
+
{ value: '', label: '—' },
|
|
131
|
+
...people.map((p) => ({ value: p.id, label: p.displayName })),
|
|
132
|
+
]}
|
|
133
|
+
/>
|
|
134
|
+
{/snippet}
|
|
135
|
+
</Field>
|
|
136
|
+
<div class="dates">
|
|
137
|
+
<Field label={t('delegate_from_date')} required>
|
|
138
|
+
{#snippet children(id)}
|
|
139
|
+
<Input {id} type="date" bind:value={startsOn} />
|
|
140
|
+
{/snippet}
|
|
141
|
+
</Field>
|
|
142
|
+
<Field
|
|
143
|
+
label={t('delegate_to_date')}
|
|
144
|
+
required
|
|
145
|
+
error={datesInvalid ? t('delegate_dates_invalid') : null}
|
|
146
|
+
>
|
|
147
|
+
{#snippet children(id)}
|
|
148
|
+
<Input {id} type="date" bind:value={endsOn} min={startsOn} />
|
|
149
|
+
{/snippet}
|
|
150
|
+
</Field>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="submit">
|
|
153
|
+
<Button type="submit" disabled={!canSubmit} loading={create.isPending}>{t('delegate_add')}</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</form>
|
|
156
|
+
</Dialog>
|
|
157
|
+
|
|
158
|
+
<style>
|
|
159
|
+
.existing {
|
|
160
|
+
margin-block-end: 16px;
|
|
161
|
+
}
|
|
162
|
+
ul {
|
|
163
|
+
display: grid;
|
|
164
|
+
gap: 4px;
|
|
165
|
+
list-style: none;
|
|
166
|
+
margin: 0;
|
|
167
|
+
padding: 0;
|
|
168
|
+
}
|
|
169
|
+
li {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
justify-content: space-between;
|
|
173
|
+
gap: 12px;
|
|
174
|
+
padding: 8px 10px;
|
|
175
|
+
border: 1px solid var(--kern-border-hairline);
|
|
176
|
+
border-radius: var(--kern-r-md);
|
|
177
|
+
}
|
|
178
|
+
.stack {
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
gap: 2px;
|
|
182
|
+
min-width: 0;
|
|
183
|
+
}
|
|
184
|
+
.name {
|
|
185
|
+
font-size: 13.5px;
|
|
186
|
+
font-weight: 500;
|
|
187
|
+
}
|
|
188
|
+
.meta {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 8px;
|
|
192
|
+
font-size: 12px;
|
|
193
|
+
color: var(--kern-ink-500);
|
|
194
|
+
}
|
|
195
|
+
.form {
|
|
196
|
+
display: grid;
|
|
197
|
+
gap: 12px;
|
|
198
|
+
padding-block-start: 16px;
|
|
199
|
+
border-block-start: 1px solid var(--kern-border);
|
|
200
|
+
}
|
|
201
|
+
.dates {
|
|
202
|
+
display: grid;
|
|
203
|
+
grid-template-columns: 1fr 1fr;
|
|
204
|
+
gap: 12px;
|
|
205
|
+
}
|
|
206
|
+
.submit {
|
|
207
|
+
display: flex;
|
|
208
|
+
justify-content: flex-end;
|
|
209
|
+
}
|
|
210
|
+
</style>
|