@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.
Files changed (34) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.js.map +1 -1
  5. package/dist/contract/router.d.ts +9 -0
  6. package/dist/contract/router.d.ts.map +1 -1
  7. package/dist/server/router.d.ts +9 -0
  8. package/dist/server/router.d.ts.map +1 -1
  9. package/dist/server/router.js +17 -0
  10. package/dist/server/router.js.map +1 -1
  11. package/dist/server/schema.d.ts +36 -0
  12. package/dist/server/schema.d.ts.map +1 -1
  13. package/dist/server/schema.js +20 -0
  14. package/dist/server/schema.js.map +1 -1
  15. package/dist/server/services/approvals.d.ts +14 -0
  16. package/dist/server/services/approvals.d.ts.map +1 -1
  17. package/dist/server/services/approvals.js +2 -0
  18. package/dist/server/services/approvals.js.map +1 -1
  19. package/migrations/0005_approval_requester.sql +20 -0
  20. package/migrations/meta/_journal.json +7 -0
  21. package/package.json +6 -4
  22. package/src/client/capabilities.ts +34 -0
  23. package/src/client/components/DecisionDialog.svelte +90 -0
  24. package/src/client/components/DelegationDialog.svelte +210 -0
  25. package/src/client/i18n.ts +302 -0
  26. package/src/client/index.ts +1 -20
  27. package/src/client/mock.ts +133 -16
  28. package/src/client/module.ts +2 -1
  29. package/src/client/pages/ApprovalsPage.svelte +252 -54
  30. package/src/client/pages/OfficesPage.svelte +161 -37
  31. package/src/client/permissions.ts +2 -1
  32. package/src/client/query.ts +10 -1
  33. package/src/client/summary.ts +36 -0
  34. 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>