@kernhq/module-hr 0.9.3 → 0.10.1
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/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +9 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +458 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- 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 +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +43 -0
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/approvals.ts +13 -1
- package/src/contract/capabilities.ts +17 -0
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
Badge,
|
|
4
4
|
Button,
|
|
5
5
|
Card,
|
|
6
|
+
Dialog,
|
|
6
7
|
EmptyState,
|
|
8
|
+
formatDateRange,
|
|
9
|
+
messageLocale,
|
|
7
10
|
navigation,
|
|
8
11
|
Page,
|
|
9
12
|
PageHeader,
|
|
@@ -16,6 +19,7 @@ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-qu
|
|
|
16
19
|
import { getHrApi } from '../api-instance.js'
|
|
17
20
|
import LeaveRequestDialog from '../components/LeaveRequestDialog.svelte'
|
|
18
21
|
import { t } from '../i18n.js'
|
|
22
|
+
import type { LeaveRequest } from '../index.js'
|
|
19
23
|
import { canHr } from '../permissions.js'
|
|
20
24
|
import { formatDays, hrKeys } from '../query.js'
|
|
21
25
|
|
|
@@ -26,6 +30,10 @@ import { formatDays, hrKeys } from '../query.js'
|
|
|
26
30
|
* this page with — and the number they need before deciding anything is `available`, not `balance`.
|
|
27
31
|
* Pending requests are already spoken for; showing the raw balance is how somebody books a week
|
|
28
32
|
* they do not have and finds out at approval.
|
|
33
|
+
*
|
|
34
|
+
* Neither half may fail quietly. A refused balance used to take the strip off the page with no
|
|
35
|
+
* message and leave "No time off booked" underneath it, which reads as a person with nothing —
|
|
36
|
+
* no days left and none booked — rather than as a screen that never loaded.
|
|
29
37
|
*/
|
|
30
38
|
const api = getHrApi()
|
|
31
39
|
const queryClient = useQueryClient()
|
|
@@ -49,17 +57,121 @@ const requestsQuery = createQuery(() => ({
|
|
|
49
57
|
}))
|
|
50
58
|
const requests = $derived(requestsQuery.data?.items ?? [])
|
|
51
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A disabled query is `pending` and not fetching, so it is not "loading" — without the workspace
|
|
62
|
+
* test the first frame of this page shows the empty state of both halves to somebody who has
|
|
63
|
+
* twenty days left.
|
|
64
|
+
*/
|
|
65
|
+
const balanceLoading = $derived(!workspaceId || balanceQuery.isLoading)
|
|
66
|
+
const requestsLoading = $derived(!workspaceId || requestsQuery.isLoading)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A failed refetch that still has an answer to show.
|
|
70
|
+
*
|
|
71
|
+
* Everything here is invalidated by a cancellation and by any decision an approver makes, so a
|
|
72
|
+
* refetch failing while the last good balance is still in `data` is the ordinary case — an error
|
|
73
|
+
* branch above the data would blank a working page for as long as core takes to come back. One
|
|
74
|
+
* strip for both queries rather than two: when core is unreachable both fail, and two identical
|
|
75
|
+
* warnings stacked on one page is noise.
|
|
76
|
+
*/
|
|
77
|
+
const stale = $derived(
|
|
78
|
+
(balanceQuery.isError && balances.length > 0) || (requestsQuery.isError && requests.length > 0),
|
|
79
|
+
)
|
|
80
|
+
const refetchAll = () => {
|
|
81
|
+
void balanceQuery.refetch()
|
|
82
|
+
void requestsQuery.refetch()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const days = (n: number) => formatDays(n, messageLocale())
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The request waiting on a confirmation, and what the last attempt said.
|
|
89
|
+
*
|
|
90
|
+
* `cancelInFlight` rather than `cancel.isPending`: the disabled attribute only reaches the button
|
|
91
|
+
* on the next render, so two quick clicks both fire — and the second one arrives at a request the
|
|
92
|
+
* first has already cancelled, which the server answers with a refusal the person did nothing to
|
|
93
|
+
* deserve.
|
|
94
|
+
*/
|
|
95
|
+
let cancelling = $state<LeaveRequest | null>(null)
|
|
96
|
+
let cancelInFlight = $state(false)
|
|
97
|
+
let cancelError = $state<string | null>(null)
|
|
98
|
+
|
|
52
99
|
const cancel = createMutation(() => ({
|
|
53
100
|
mutationFn: (requestId: string) => api.leave.requests.cancel({ workspaceId, requestId }),
|
|
54
101
|
onSuccess: () => {
|
|
102
|
+
cancelling = null
|
|
103
|
+
cancelError = null
|
|
55
104
|
toast.success(t('leave_cancelled_toast'))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
void queryClient.invalidateQueries({ queryKey: ['hr'
|
|
105
|
+
// Cancelling moves a balance and the team calendar as well as this list, so the whole module's
|
|
106
|
+
// cache is invalidated rather than guessing which keys moved.
|
|
107
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
108
|
+
},
|
|
109
|
+
onError: (error) => {
|
|
110
|
+
cancelError = cancelFailure(error)
|
|
111
|
+
// A refusal is the server saying its picture of this request is not the one on screen — most
|
|
112
|
+
// often because somebody decided it, or it was already cancelled from another device. Re-read
|
|
113
|
+
// all of HR exactly as a cancellation that landed does; without this the same dead row sits in
|
|
114
|
+
// the list and every retry earns the same sentence.
|
|
115
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
116
|
+
},
|
|
117
|
+
onSettled: () => {
|
|
118
|
+
cancelInFlight = false
|
|
59
119
|
},
|
|
60
|
-
onError: (error: Error) => toast.error(error.message),
|
|
61
120
|
}))
|
|
62
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The cancellation refusals this module has its own sentence for, keyed by the `reason` the router
|
|
124
|
+
* sends beside the refusal — never by the sentence, because a list of sentences is a list somebody
|
|
125
|
+
* has to keep in sync and the day it drifts the reader is told nothing.
|
|
126
|
+
*
|
|
127
|
+
* Empty on purpose. `leave.requests.cancel` refuses through `KernError.conflict`, whose reason
|
|
128
|
+
* argument reaches the client as `data.reason` — `kernErrorToORPC` serialises it — so a refusal
|
|
129
|
+
* names itself in a form this module can translate. Anything the router refuses without one still
|
|
130
|
+
* arrives as its own sentence, which is the fallback below and not a failure.
|
|
131
|
+
*/
|
|
132
|
+
const cancelRefusalMessages: Record<string, string> = {
|
|
133
|
+
'hr.leave.already_cancelled': 'leave_cancel_refused_cancelled',
|
|
134
|
+
'hr.leave.already_withdrawn': 'leave_cancel_refused_withdrawn',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What a refused cancellation says to the person who asked for it.
|
|
139
|
+
*
|
|
140
|
+
* A cancellation is refused when the request is no longer theirs to cancel — it is already
|
|
141
|
+
* cancelled, or withdrawn — and the router's sentence is the only thing that says which. It used to
|
|
142
|
+
* be `error.message` for everything, which puts a gateway's English at somebody who reads Persian
|
|
143
|
+
* and cannot act on it either way. Everything else that can fail here carries machine text, so it
|
|
144
|
+
* falls back to this module's own string.
|
|
145
|
+
*
|
|
146
|
+
* The test is the transport's `code`, never the sentence: `KernError.conflict` is what arrives as
|
|
147
|
+
* CONFLICT, so a refusal added to `cancel()` later reaches the reader without anyone editing this
|
|
148
|
+
* file. The same shape as `ClockControls.svelte` and the approvals inbox.
|
|
149
|
+
*/
|
|
150
|
+
function cancelFailure(error: unknown): string {
|
|
151
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
152
|
+
if (failure.code !== 'CONFLICT') return t('leave_cancel_error')
|
|
153
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
154
|
+
const key = reason ? cancelRefusalMessages[reason] : undefined
|
|
155
|
+
// `t()` answers a key it has no string for with the key itself, so both ways of not having one —
|
|
156
|
+
// a reason no key covers, and a key whose string has not been merged — land on the router's
|
|
157
|
+
// sentence rather than putting `hr.leave_cancel_refused_…` in front of somebody.
|
|
158
|
+
const translated = key ? t(key) : undefined
|
|
159
|
+
return (translated && translated !== key ? translated : failure.message) || t('leave_cancel_error')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const confirmCancel = () => {
|
|
163
|
+
if (!cancelling || cancelInFlight) return
|
|
164
|
+
cancelInFlight = true
|
|
165
|
+
cancelError = null
|
|
166
|
+
cancel.mutate(cancelling.id)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const closeCancel = () => {
|
|
170
|
+
if (cancelInFlight) return
|
|
171
|
+
cancelling = null
|
|
172
|
+
cancelError = null
|
|
173
|
+
}
|
|
174
|
+
|
|
63
175
|
const statusLabel = (s: string) =>
|
|
64
176
|
s === 'pending'
|
|
65
177
|
? t('leave_pending')
|
|
@@ -75,16 +187,17 @@ const statusTone = (s: string) =>
|
|
|
75
187
|
s === 'approved' ? 'done' : s === 'pending' ? 'upcoming' : s === 'rejected' ? 'declined' : 'grey'
|
|
76
188
|
|
|
77
189
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* ends up on the right — and `formatRange` collapses the parts the two dates share for free.
|
|
190
|
+
* `formatDateRange` rather than `Intl` directly: it formats in the reader's *interface* language,
|
|
191
|
+
* which is the one the rest of the row is written in, and it uses `formatRange` — a hand-built range
|
|
192
|
+
* reads backwards under `dir="rtl"`, with the earlier date on the right.
|
|
82
193
|
*/
|
|
83
|
-
|
|
84
|
-
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })
|
|
85
|
-
return fmt.formatRange(new Date(`${from}T00:00:00`), new Date(`${to}T00:00:00`))
|
|
86
|
-
}
|
|
194
|
+
const dateRange = (from: string, to: string) => formatDateRange(`${from}T00:00:00`, `${to}T00:00:00`)
|
|
87
195
|
|
|
196
|
+
/**
|
|
197
|
+
* An approved request can still be cancelled: the server reverses the ledger rather than deleting
|
|
198
|
+
* it, so the balance goes back up and the history still says what happened. What it must not be is
|
|
199
|
+
* one click away — see the dialog at the bottom of this file.
|
|
200
|
+
*/
|
|
88
201
|
const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pending' || status === 'approved')
|
|
89
202
|
</script>
|
|
90
203
|
|
|
@@ -100,40 +213,87 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
|
|
|
100
213
|
</PageHeader>
|
|
101
214
|
|
|
102
215
|
<Page>
|
|
103
|
-
{#if
|
|
104
|
-
<
|
|
216
|
+
{#if stale}
|
|
217
|
+
<p class="stale" role="status">
|
|
218
|
+
<span>{t('leave_stale')}</span>
|
|
219
|
+
<Button size="sm" variant="ghost" onclick={refetchAll}>{t('retry')}</Button>
|
|
220
|
+
</p>
|
|
221
|
+
{/if}
|
|
222
|
+
|
|
223
|
+
{#if balanceLoading}
|
|
224
|
+
<div class="tiles">
|
|
225
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="96px" />{/each}
|
|
226
|
+
</div>
|
|
105
227
|
{:else if balances.length}
|
|
106
228
|
<div class="tiles">
|
|
107
229
|
{#each balances as balance (balance.leaveTypeId)}
|
|
108
230
|
<StatTile
|
|
109
231
|
label={balance.leaveTypeName}
|
|
110
|
-
value={
|
|
111
|
-
note={`${t('available')} · ${t('days')}`}
|
|
232
|
+
value={days(balance.available)}
|
|
233
|
+
note={`${t('available')} · ${t('days', { count: balance.available })}`}
|
|
112
234
|
/>
|
|
113
235
|
{/each}
|
|
114
236
|
</div>
|
|
237
|
+
{:else if balanceQuery.isError}
|
|
238
|
+
<div class="tiles-slot">
|
|
239
|
+
<EmptyState icon="triangle-alert" title={t('balance_error')}>
|
|
240
|
+
{#snippet actions()}
|
|
241
|
+
<Button variant="secondary" onclick={() => void balanceQuery.refetch()}>{t('retry')}</Button>
|
|
242
|
+
{/snippet}
|
|
243
|
+
</EmptyState>
|
|
244
|
+
</div>
|
|
245
|
+
{:else}
|
|
246
|
+
<!--
|
|
247
|
+
No balances means no leave types: the server returns one row per type, so an empty answer is a
|
|
248
|
+
workspace nobody has configured yet rather than a person with nothing left. The way out is
|
|
249
|
+
only offered to somebody who has it — a member cannot create a leave type, and a button that
|
|
250
|
+
404s is worse than none.
|
|
251
|
+
-->
|
|
252
|
+
<div class="tiles-slot">
|
|
253
|
+
<EmptyState
|
|
254
|
+
icon="tree-palm"
|
|
255
|
+
compact
|
|
256
|
+
title={t('leave_types_none')}
|
|
257
|
+
description={canHr('leaveManage') ? t('leave_types_none_desc') : undefined}
|
|
258
|
+
>
|
|
259
|
+
{#snippet actions()}
|
|
260
|
+
{#if canHr('leaveManage')}
|
|
261
|
+
<Button
|
|
262
|
+
size="sm"
|
|
263
|
+
variant="secondary"
|
|
264
|
+
icon="settings"
|
|
265
|
+
href={`/${workspaceSlug}/settings/hr/leave`}
|
|
266
|
+
>
|
|
267
|
+
{t('leave_types')}
|
|
268
|
+
</Button>
|
|
269
|
+
{/if}
|
|
270
|
+
{/snippet}
|
|
271
|
+
</EmptyState>
|
|
272
|
+
</div>
|
|
115
273
|
{/if}
|
|
116
274
|
|
|
117
275
|
<h2>{t('leave_title')}</h2>
|
|
118
|
-
{#if
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
{:else}
|
|
276
|
+
{#if requestsLoading}
|
|
277
|
+
<div class="rows">
|
|
278
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="58px" />{/each}
|
|
279
|
+
</div>
|
|
280
|
+
{:else if requests.length}
|
|
123
281
|
<ul>
|
|
124
282
|
{#each requests as request (request.id)}
|
|
125
283
|
<li>
|
|
126
284
|
<Card>
|
|
127
285
|
<div class="row">
|
|
128
|
-
<span class="dates">{
|
|
129
|
-
<span class="meta">{
|
|
286
|
+
<span class="dates">{dateRange(request.startsOn, request.endsOn)}</span>
|
|
287
|
+
<span class="meta">{days(request.workingDays)} {t('days', { count: request.workingDays })}</span>
|
|
130
288
|
<Badge tone={statusTone(request.status)}>{statusLabel(request.status)}</Badge>
|
|
131
289
|
{#if canCancel(request.status)}
|
|
132
290
|
<Button
|
|
133
291
|
size="sm"
|
|
134
292
|
variant="ghost"
|
|
135
|
-
|
|
136
|
-
|
|
293
|
+
onclick={() => {
|
|
294
|
+
cancelError = null
|
|
295
|
+
cancelling = request
|
|
296
|
+
}}>{t('cancel_request')}</Button
|
|
137
297
|
>
|
|
138
298
|
{/if}
|
|
139
299
|
</div>
|
|
@@ -141,11 +301,63 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
|
|
|
141
301
|
</li>
|
|
142
302
|
{/each}
|
|
143
303
|
</ul>
|
|
304
|
+
{:else if requestsQuery.isError}
|
|
305
|
+
<EmptyState icon="triangle-alert" title={t('leave_requests_error')}>
|
|
306
|
+
{#snippet actions()}
|
|
307
|
+
<Button variant="secondary" onclick={() => void requestsQuery.refetch()}>{t('retry')}</Button>
|
|
308
|
+
{/snippet}
|
|
309
|
+
</EmptyState>
|
|
310
|
+
{:else}
|
|
311
|
+
<EmptyState icon="tree-palm" title={t('leave_none')} description={t('leave_none_desc')} />
|
|
144
312
|
{/if}
|
|
145
313
|
</Page>
|
|
146
314
|
|
|
147
315
|
<LeaveRequestDialog open={requesting} {workspaceId} {workspaceSlug} />
|
|
148
316
|
|
|
317
|
+
<!--
|
|
318
|
+
Cancelling is not undoing, and the person clicking has to know what it costs before it happens: a
|
|
319
|
+
small ghost button beside a status badge was wired straight to the mutation, so one misclick threw
|
|
320
|
+
away a granted week. The body names the dates, the days that come back and the fact that the team
|
|
321
|
+
stops seeing the absence — an approved request also says so in its own line, because those are the
|
|
322
|
+
days colleagues have already planned around.
|
|
323
|
+
-->
|
|
324
|
+
<Dialog
|
|
325
|
+
open={cancelling !== null}
|
|
326
|
+
size="sm"
|
|
327
|
+
title={t('leave_cancel_title')}
|
|
328
|
+
onOpenChange={(next) => {
|
|
329
|
+
if (!next) closeCancel()
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
{#if cancelling}
|
|
333
|
+
<p class="body">
|
|
334
|
+
{t('leave_cancel_body', {
|
|
335
|
+
count: cancelling.workingDays,
|
|
336
|
+
range: dateRange(cancelling.startsOn, cancelling.endsOn),
|
|
337
|
+
})}
|
|
338
|
+
</p>
|
|
339
|
+
{#if cancelling.status === 'approved'}
|
|
340
|
+
<p class="body note">{t('leave_cancel_approved')}</p>
|
|
341
|
+
{/if}
|
|
342
|
+
{/if}
|
|
343
|
+
{#if cancelError}
|
|
344
|
+
<p class="body failed" role="alert">{cancelError}</p>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
{#snippet footer()}
|
|
348
|
+
<!--
|
|
349
|
+
Secondary, as in `DecisionDialog`: on a destructive confirmation the way *out* must not be the
|
|
350
|
+
faintest control on it. And it says "Keep it booked" rather than `cancel` — the shared string
|
|
351
|
+
every other dialog here uses — because "Cancel" beside "Cancel request" asks somebody to work
|
|
352
|
+
out which of two identical words abandons the booking and which destroys it.
|
|
353
|
+
-->
|
|
354
|
+
<Button variant="secondary" onclick={closeCancel} disabled={cancelInFlight}>
|
|
355
|
+
{t('leave_cancel_keep')}
|
|
356
|
+
</Button>
|
|
357
|
+
<Button variant="danger" loading={cancelInFlight} onclick={confirmCancel}>{t('cancel_request')}</Button>
|
|
358
|
+
{/snippet}
|
|
359
|
+
</Dialog>
|
|
360
|
+
|
|
149
361
|
<style>
|
|
150
362
|
.tiles {
|
|
151
363
|
display: grid;
|
|
@@ -153,6 +365,33 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
|
|
|
153
365
|
gap: 12px;
|
|
154
366
|
margin-block-end: 20px;
|
|
155
367
|
}
|
|
368
|
+
/* Keeps an error or empty balance on the same rhythm as the tiles it stands in for. */
|
|
369
|
+
.tiles-slot {
|
|
370
|
+
margin-block-end: 20px;
|
|
371
|
+
}
|
|
372
|
+
.rows {
|
|
373
|
+
display: grid;
|
|
374
|
+
gap: 8px;
|
|
375
|
+
}
|
|
376
|
+
/*
|
|
377
|
+
* The warning ink is 4.37:1 on `--kern-canvas`, which is what this page sits on — under the 4.5 a
|
|
378
|
+
* 12.5px line has to clear. On its own tint it is 4.58:1 in light and 5.28:1 in dark, and the tint
|
|
379
|
+
* is what makes the strip read as a notice rather than as another row.
|
|
380
|
+
*/
|
|
381
|
+
.stale {
|
|
382
|
+
display: flex;
|
|
383
|
+
align-items: center;
|
|
384
|
+
justify-content: space-between;
|
|
385
|
+
flex-wrap: wrap;
|
|
386
|
+
gap: 8px;
|
|
387
|
+
margin-block: 0 12px;
|
|
388
|
+
padding-block: 6px;
|
|
389
|
+
padding-inline: 12px 8px;
|
|
390
|
+
border-radius: var(--kern-r-md);
|
|
391
|
+
background: var(--kern-warning-tint);
|
|
392
|
+
color: var(--kern-warning);
|
|
393
|
+
font-size: 12.5px;
|
|
394
|
+
}
|
|
156
395
|
h2 {
|
|
157
396
|
font-size: 13.5px;
|
|
158
397
|
margin: 0 0 12px;
|
|
@@ -177,4 +416,20 @@ ul {
|
|
|
177
416
|
color: var(--kern-ink-500);
|
|
178
417
|
font-size: 12px;
|
|
179
418
|
}
|
|
419
|
+
.body {
|
|
420
|
+
margin: 0;
|
|
421
|
+
font-size: 13.5px;
|
|
422
|
+
line-height: 1.55;
|
|
423
|
+
color: var(--kern-ink-700);
|
|
424
|
+
}
|
|
425
|
+
/* A colour, not opacity: opacity fades the text against the dialog whatever token it names. */
|
|
426
|
+
.note {
|
|
427
|
+
margin-block-start: 8px;
|
|
428
|
+
color: var(--kern-ink-500);
|
|
429
|
+
}
|
|
430
|
+
/* A dialog body sits on --kern-surface-raised, not the page: 6.33:1 there in light, 5.04:1 in dark. */
|
|
431
|
+
.failed {
|
|
432
|
+
margin-block-start: 8px;
|
|
433
|
+
color: var(--kern-danger);
|
|
434
|
+
}
|
|
180
435
|
</style>
|