@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
@@ -77,6 +77,59 @@ export function createMockHrApi() {
77
77
  let clockedInAt: number | null = null
78
78
  let onBreak = false
79
79
 
80
+ const delegations: Array<Record<string, unknown>> = []
81
+
82
+ const approvalRequests = [
83
+ {
84
+ id: '01920000-0000-7000-8000-00000000f001',
85
+ workspaceId: '',
86
+ subjectType: 'leave' as const,
87
+ subjectId: '01920000-0000-7000-8000-00000000c001',
88
+ summary: `5 day(s) from ${day(14)}`,
89
+ summaryParams: { days: 5, from: day(14), to: day(18) } as Record<string, string | number> | null,
90
+ status: 'pending' as string,
91
+ currentStep: 0,
92
+ requestedBy: null,
93
+ requesterPersonId: PEOPLE[1]!.id,
94
+ requesterName: PEOPLE[1]!.displayName,
95
+ requestedAt: iso(3600_000),
96
+ decidedAt: null as string | null,
97
+ steps: [] as Array<Record<string, unknown>>,
98
+ },
99
+ {
100
+ id: '01920000-0000-7000-8000-00000000f002',
101
+ subjectType: 'regularization' as const,
102
+ workspaceId: '',
103
+ subjectId: '01920000-0000-7000-8000-00000000c002',
104
+ summary: `Correction for ${day(-1)}`,
105
+ summaryParams: { date: day(-1) } as Record<string, string | number> | null,
106
+ status: 'pending' as string,
107
+ currentStep: 0,
108
+ requestedBy: null,
109
+ requesterPersonId: PEOPLE[2]!.id,
110
+ requesterName: PEOPLE[2]!.displayName,
111
+ requestedAt: iso(7200_000),
112
+ decidedAt: null as string | null,
113
+ steps: [{ stepIndex: 0 }, { stepIndex: 1 }] as Array<Record<string, unknown>>,
114
+ },
115
+ {
116
+ id: '01920000-0000-7000-8000-00000000f003',
117
+ workspaceId: '',
118
+ subjectType: 'leave' as const,
119
+ subjectId: '01920000-0000-7000-8000-00000000c003',
120
+ summary: `1 day(s) from ${day(-20)}`,
121
+ summaryParams: { days: 1, from: day(-20), to: day(-20) } as Record<string, string | number> | null,
122
+ status: 'approved' as string,
123
+ currentStep: 0,
124
+ requestedBy: null,
125
+ requesterPersonId: PEOPLE[0]!.id,
126
+ requesterName: PEOPLE[0]!.displayName,
127
+ requestedAt: iso(20 * 86_400_000),
128
+ decidedAt: iso(19 * 86_400_000) as string | null,
129
+ steps: [] as Array<Record<string, unknown>>,
130
+ },
131
+ ]
132
+
80
133
  const leaveRequests: Array<Record<string, unknown>> = [
81
134
  {
82
135
  id: '01920000-0000-7000-8000-00000000c001',
@@ -402,24 +455,88 @@ export function createMockHrApi() {
402
455
  },
403
456
 
404
457
  approvals: {
405
- inbox: async ({ workspaceId }: { workspaceId: string }) => ({
406
- items: [
407
- {
408
- id: '01920000-0000-7000-8000-00000000f001',
409
- workspaceId,
410
- subjectType: 'leave' as const,
411
- subjectId: leaveRequests[0]!.id as string,
412
- summary: `5 day(s) from ${day(14)}`,
413
- status: 'pending' as const,
414
- currentStep: 0,
415
- requestedBy: null,
416
- requestedAt: iso(3600_000),
417
- decidedAt: null,
418
- steps: [],
419
- },
420
- ],
458
+ /**
459
+ * Both tabs have something in them on purpose.
460
+ *
461
+ * A demo whose "Decided" tab is empty looks like a broken filter rather than an empty
462
+ * history, and the two-step request is what shows the step counter at all.
463
+ */
464
+ inbox: async ({
465
+ workspaceId,
466
+ includeDecided = false,
467
+ }: {
468
+ workspaceId: string
469
+ includeDecided?: boolean
470
+ }) => ({
471
+ items: approvalRequests
472
+ .filter((r) => (includeDecided ? r.status !== 'pending' : r.status === 'pending'))
473
+ .map((r) => ({ ...r, workspaceId })),
421
474
  nextCursor: null,
422
475
  }),
476
+
477
+ get: async ({ workspaceId, requestId }: { workspaceId: string; requestId: string }) => {
478
+ const found = approvalRequests.find((r) => r.id === requestId)
479
+ if (!found) throw new Error('Approval request not found')
480
+ return { ...found, workspaceId }
481
+ },
482
+
483
+ decide: async ({
484
+ workspaceId,
485
+ requestId,
486
+ decision,
487
+ }: {
488
+ workspaceId: string
489
+ requestId: string
490
+ decision: 'approve' | 'reject'
491
+ }) => {
492
+ const found = approvalRequests.find((r) => r.id === requestId)
493
+ if (!found) throw new Error('Approval request not found')
494
+ // A middle step advances rather than settling: the inbox has to be able to show that.
495
+ const last = found.currentStep >= Math.max(found.steps.length - 1, 0)
496
+ if (decision === 'reject' || last) found.status = decision === 'approve' ? 'approved' : 'rejected'
497
+ else found.currentStep += 1
498
+ found.decidedAt = found.status === 'pending' ? null : iso()
499
+ return { ...found, workspaceId }
500
+ },
501
+
502
+ delegations: async ({ workspaceId }: { workspaceId: string }) =>
503
+ delegations.map((d) => ({ ...d, workspaceId })),
504
+
505
+ delegate: async ({
506
+ workspaceId,
507
+ toPersonId,
508
+ startsOn,
509
+ endsOn,
510
+ subjectType = null,
511
+ reason = null,
512
+ }: {
513
+ workspaceId: string
514
+ toPersonId: string
515
+ startsOn: string
516
+ endsOn: string
517
+ subjectType?: string | null
518
+ reason?: string | null
519
+ }) => {
520
+ const created = {
521
+ id: crypto.randomUUID(),
522
+ workspaceId,
523
+ fromPersonId: PEOPLE[0]!.id,
524
+ toPersonId,
525
+ subjectType,
526
+ startsOn,
527
+ endsOn,
528
+ reason,
529
+ createdAt: iso(),
530
+ }
531
+ delegations.push(created)
532
+ return created
533
+ },
534
+
535
+ revokeDelegation: async ({ delegationId }: { delegationId: string }) => {
536
+ const at = delegations.findIndex((d) => d.id === delegationId)
537
+ if (at >= 0) delegations.splice(at, 1)
538
+ return { ok: true }
539
+ },
423
540
  },
424
541
  }
425
542
 
@@ -1,6 +1,7 @@
1
1
  import { defineClientModule } from '@kernhq/ui'
2
+ import { HR_PERMISSIONS } from '../contract/permissions.js'
3
+ import { HR_CAPABILITIES } from './capabilities.js'
2
4
  import { hrMessageBundles, t } from './i18n.js'
3
- import { HR_CAPABILITIES, HR_PERMISSIONS } from './permissions.js'
4
5
 
5
6
  /**
6
7
  * HR as the shell sees it.
@@ -1,9 +1,26 @@
1
1
  <script lang="ts">
2
- import { Badge, Button, Card, EmptyState, navigation, Page, PageHeader, Skeleton, session } from '@kernhq/ui'
2
+ import {
3
+ Badge,
4
+ type BadgeTone,
5
+ Button,
6
+ EmptyState,
7
+ navigation,
8
+ Page,
9
+ PageHeader,
10
+ SectionLabel,
11
+ SegmentedControl,
12
+ Skeleton,
13
+ session,
14
+ } from '@kernhq/ui'
3
15
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
16
  import { getHrApi } from '../api-instance.js'
17
+ import DecisionDialog from '../components/DecisionDialog.svelte'
18
+ import DelegationDialog from '../components/DelegationDialog.svelte'
5
19
  import { t } from '../i18n.js'
20
+ import type { ApprovalRequest } from '../index.js'
21
+ import { canHr } from '../permissions.js'
6
22
  import { hrKeys } from '../query.js'
23
+ import { dateRange, day, summarise } from '../summary.js'
7
24
 
8
25
  /**
9
26
  * Everything waiting on me, across every kind of request.
@@ -12,8 +29,13 @@ import { hrKeys } from '../query.js'
12
29
  * leave request and an attendance correction arrive here the same way, and so will overtime and
13
30
  * timesheets when they exist.
14
31
  *
15
- * No permission gate: an inbox of what *you* must decide is yours by definition, and the server only
16
- * ever lists steps you are named on.
32
+ * No permission gate on the list: an inbox of what *you* must decide is yours by definition, and the
33
+ * server only ever lists steps you are named on. Delegation is gated, because letting somebody else
34
+ * decide in your place is a claim about them as well as about you.
35
+ *
36
+ * Who signs what is not set here — `approvals.chains.*` is a workspace policy and lives in settings.
37
+ * `approvals.get` is not called either: the inbox already returns each request with its steps and
38
+ * decisions, so a detail fetch would re-ask a question this page has the answer to.
17
39
  */
18
40
  const api = getHrApi()
19
41
  const queryClient = useQueryClient()
@@ -22,108 +44,284 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
22
44
  const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
23
45
  const workspaceId = $derived(workspace?.id ?? '')
24
46
 
47
+ let tab = $state('waiting')
48
+ const includeDecided = $derived(tab === 'decided')
49
+
50
+ let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
51
+ let delegating = $state(false)
52
+ let decideError = $state<string | null>(null)
53
+
25
54
  const inboxQuery = createQuery(() => ({
26
- queryKey: hrKeys.approvalInbox(workspaceId),
55
+ queryKey: hrKeys.approvalInbox(workspaceId, includeDecided),
27
56
  enabled: Boolean(workspaceId),
28
- queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, includeDecided: false }),
57
+ queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, includeDecided }),
29
58
  }))
30
59
  const items = $derived(inboxQuery.data?.items ?? [])
31
60
 
32
61
  const decide = createMutation(() => ({
33
- mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
34
- api.approvals.decide({ workspaceId, ...vars }),
62
+ mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
63
+ api.approvals.decide({
64
+ workspaceId,
65
+ requestId: vars.requestId,
66
+ decision: vars.decision,
67
+ comment: vars.comment.trim() || null,
68
+ }),
35
69
  onSuccess: () => {
70
+ deciding = null
71
+ decideError = null
36
72
  // Deciding changes a balance and a day sheet as well as the inbox, so the whole module's cache
37
73
  // is invalidated rather than guessing which keys moved.
38
74
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
39
75
  },
76
+ onError: () => {
77
+ decideError = t('decide_error')
78
+ },
40
79
  }))
41
80
 
42
- const subjectLabel = (subjectType: string) =>
43
- subjectType === 'leave' ? t('leave_title') : t('attendance_title')
81
+ const SUBJECT_LABELS: Record<string, () => string> = {
82
+ leave: () => t('leave_title'),
83
+ regularization: () => t('attendance_title'),
84
+ overtime: () => t('att_overtime'),
85
+ timesheet: () => t('approval_subject_timesheet'),
86
+ shift_swap: () => t('approval_subject_shift_swap'),
87
+ }
88
+ const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
89
+
90
+ const STATUS_TONES: Record<string, BadgeTone> = {
91
+ pending: 'upcoming',
92
+ approved: 'success',
93
+ rejected: 'danger',
94
+ cancelled: 'grey',
95
+ }
96
+ const STATUS_LABELS: Record<string, () => string> = {
97
+ pending: () => t('approval_status_pending'),
98
+ approved: () => t('approval_status_approved'),
99
+ rejected: () => t('approval_status_rejected'),
100
+ cancelled: () => t('approval_status_cancelled'),
101
+ }
44
102
 
45
103
  const when = (iso: string) =>
46
104
  new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
105
+
106
+ const stepOf = (request: ApprovalRequest) =>
107
+ t('approvals_step_of', { n: String(request.currentStep + 1), total: String(request.steps.length) })
47
108
  </script>
48
109
 
49
110
  <PageHeader
50
111
  crumbs={[{ label: workspace?.name ?? '' }, { label: t('approvals_title') }]}
51
112
  title={t('approvals_title')}
52
- />
113
+ >
114
+ {#snippet actions()}
115
+ {#if canHr('approvalDelegate')}
116
+ <Button size="sm" variant="secondary" icon="users" onclick={() => (delegating = true)}>
117
+ {t('delegate')}
118
+ </Button>
119
+ {/if}
120
+ {/snippet}
121
+ </PageHeader>
53
122
 
54
123
  <Page>
124
+ <div class="filters">
125
+ <SegmentedControl
126
+ size="sm"
127
+ label={t('approvals_title')}
128
+ bind:value={tab}
129
+ items={[
130
+ { value: 'waiting', label: t('approvals_waiting') },
131
+ { value: 'decided', label: t('approvals_decided') },
132
+ ]}
133
+ />
134
+ </div>
135
+
136
+ <SectionLabel
137
+ label={includeDecided ? t('approvals_decided') : t('approvals_waiting')}
138
+ count={items.length}
139
+ />
140
+
55
141
  {#if inboxQuery.isLoading}
56
- <Skeleton height="140px" />
142
+ <div class="rows">
143
+ {#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
144
+ </div>
145
+ {:else if inboxQuery.isError}
146
+ <EmptyState icon="triangle-alert" title={t('approvals_error')}>
147
+ {#snippet actions()}
148
+ <Button variant="secondary" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
149
+ {/snippet}
150
+ </EmptyState>
57
151
  {:else if items.length === 0}
58
152
  <EmptyState
59
153
  icon="check-check"
60
- title={t('approvals_none')}
61
- description={t('approvals_none_desc')}
154
+ title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
155
+ description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
62
156
  />
63
157
  {:else}
64
- <ul>
158
+ <div class="table" role="table" aria-label={t('approvals_title')}>
159
+ <div class="thead" role="row">
160
+ <span role="columnheader">{t('approvals_request')}</span>
161
+ <span role="columnheader">{t('approvals_requested_by')}</span>
162
+ <span role="columnheader">{t('approvals_requested')}</span>
163
+ <span role="columnheader">{includeDecided ? t('status') : t('approvals_step')}</span>
164
+ <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
165
+ </div>
65
166
  {#each items as item (item.id)}
66
- <li>
67
- <Card>
68
- <div class="row">
69
- <div class="what">
70
- <Badge tone="upcoming">{subjectLabel(item.subjectType)}</Badge>
71
- <span class="summary">{item.summary}</span>
72
- <span class="meta">{when(item.requestedAt)}</span>
73
- </div>
74
- <div class="actions">
75
- <Button
76
- size="sm"
77
- variant="secondary"
78
- disabled={decide.isPending}
79
- onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
80
- >{t('reject')}</Button
81
- >
82
- <Button
83
- size="sm"
84
- disabled={decide.isPending}
85
- onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
86
- >{t('approve')}</Button
87
- >
88
- </div>
89
- </div>
90
- </Card>
91
- </li>
167
+ <div class="trow" role="row">
168
+ <span class="cell what" role="cell">
169
+ <Badge tone="grey">{subjectLabel(item.subjectType)}</Badge>
170
+ <span class="summary">{summarise(item)}</span>
171
+ </span>
172
+ <span class="cell muted" role="cell">{item.requesterName ?? '—'}</span>
173
+ <span class="cell muted" role="cell">{when(item.requestedAt)}</span>
174
+ <span class="cell" role="cell">
175
+ {#if includeDecided}
176
+ <Badge tone={STATUS_TONES[item.status] ?? 'grey'}>
177
+ {STATUS_LABELS[item.status]?.() ?? item.status}
178
+ </Badge>
179
+ {:else if item.steps.length > 1}
180
+ <span class="muted">{stepOf(item)}</span>
181
+ {/if}
182
+ </span>
183
+ <span class="cell actions" role="cell">
184
+ {#if item.status === 'pending'}
185
+ <Button
186
+ size="sm"
187
+ variant="secondary"
188
+ onclick={() => {
189
+ decideError = null
190
+ deciding = { request: item, decision: 'reject' }
191
+ }}
192
+ >
193
+ {t('reject')}
194
+ </Button>
195
+ <Button
196
+ size="sm"
197
+ onclick={() => {
198
+ decideError = null
199
+ deciding = { request: item, decision: 'approve' }
200
+ }}
201
+ >
202
+ {t('approve')}
203
+ </Button>
204
+ {/if}
205
+ </span>
206
+ </div>
92
207
  {/each}
93
- </ul>
208
+ </div>
94
209
  {/if}
210
+
211
+ <p class="hint">{t('approvals_chains_hint')}</p>
95
212
  </Page>
96
213
 
214
+ <DecisionDialog
215
+ request={deciding?.request ?? null}
216
+ decision={deciding?.decision ?? 'approve'}
217
+ pending={decide.isPending}
218
+ error={decideError}
219
+ onConfirm={(comment) => {
220
+ if (deciding) decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
221
+ }}
222
+ onCancel={() => {
223
+ deciding = null
224
+ decideError = null
225
+ }}
226
+ />
227
+
228
+ <DelegationDialog open={delegating} {workspaceId} onClose={() => (delegating = false)} />
229
+
97
230
  <style>
98
- ul {
99
- display: grid;
100
- gap: 8px;
101
- list-style: none;
102
- margin: 0;
103
- padding: 0;
104
- }
105
- .row {
231
+ .filters {
106
232
  display: flex;
107
233
  align-items: center;
108
234
  gap: 12px;
109
- flex-wrap: wrap;
235
+ margin-block-end: 8px;
236
+ }
237
+ .rows {
238
+ display: grid;
239
+ gap: 4px;
240
+ }
241
+
242
+ /* One grid for the header and every row, so the columns line up down the page. */
243
+ .table {
244
+ --hr-approval-cols: minmax(220px, 1.6fr) minmax(120px, 0.7fr) 170px 150px max-content;
245
+ width: 100%;
246
+ }
247
+ .thead,
248
+ .trow {
249
+ display: grid;
250
+ grid-template-columns: var(--hr-approval-cols);
251
+ gap: 12px;
252
+ align-items: center;
253
+ padding-inline: 12px;
254
+ }
255
+ .thead {
256
+ height: 34px;
257
+ border-block-end: 1px solid var(--kern-border);
258
+ font-size: 11px;
259
+ font-weight: 600;
260
+ letter-spacing: 0.06em;
261
+ text-transform: uppercase;
262
+ color: var(--kern-ink-500);
263
+ }
264
+ .trow {
265
+ min-height: 56px;
266
+ border-block-end: 1px solid var(--kern-border-hairline);
267
+ border-radius: var(--kern-r-md);
268
+ }
269
+ .trow:hover {
270
+ background: var(--kern-surface-raised);
271
+ }
272
+ .cell {
273
+ min-width: 0;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
110
277
  }
111
278
  .what {
112
279
  display: flex;
113
280
  align-items: center;
114
281
  gap: 8px;
115
- flex: 1;
116
- min-width: 0;
117
282
  }
118
283
  .summary {
284
+ min-width: 0;
285
+ overflow: hidden;
286
+ text-overflow: ellipsis;
287
+ font-size: 13.5px;
119
288
  font-weight: 500;
120
289
  }
121
- .meta {
290
+ .muted {
291
+ font-size: 13px;
292
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
122
293
  color: var(--kern-ink-500);
123
- font-size: 12px;
124
294
  }
125
295
  .actions {
126
296
  display: flex;
127
297
  gap: 8px;
298
+ justify-content: flex-end;
299
+ overflow: visible;
300
+ }
301
+ .hint {
302
+ margin-block-start: 16px;
303
+ font-size: 12px;
304
+ color: var(--kern-ink-500);
305
+ }
306
+ .sr-only {
307
+ position: absolute;
308
+ width: 1px;
309
+ height: 1px;
310
+ overflow: hidden;
311
+ clip-path: inset(50%);
312
+ white-space: nowrap;
313
+ }
314
+
315
+ @media (max-width: 900px) {
316
+ .table {
317
+ --hr-approval-cols: minmax(160px, 1.6fr) minmax(100px, 0.7fr) max-content;
318
+ }
319
+ /* Requested-at and step survive on the row's second line rather than squeezing five columns. */
320
+ .thead > :nth-child(3),
321
+ .trow > :nth-child(3),
322
+ .thead > :nth-child(4),
323
+ .trow > :nth-child(4) {
324
+ display: none;
325
+ }
128
326
  }
129
327
  </style>