@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
package/src/client/mock.ts
CHANGED
|
@@ -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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
package/src/client/module.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
43
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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>
|