@kernhq/module-hr 0.10.5 → 0.12.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/package.json +3 -3
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1895 -102
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Field,
|
|
8
|
+
formatDate,
|
|
9
|
+
Input,
|
|
10
|
+
messageLocale,
|
|
11
|
+
SegmentedControl,
|
|
12
|
+
Sheet,
|
|
13
|
+
Skeleton,
|
|
14
|
+
Textarea,
|
|
15
|
+
toast,
|
|
16
|
+
} from '@kernhq/ui'
|
|
17
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
18
|
+
// `LedgerKind` is not one of the models the client barrel re-exports, and widening a barrel every
|
|
19
|
+
// screen shares to name one union is the wrong direction. Straight from the contract.
|
|
20
|
+
import type { LedgerKind } from '../../contract/leave.js'
|
|
21
|
+
import { getHrApi } from '../api-instance.js'
|
|
22
|
+
import { t } from '../i18n.js'
|
|
23
|
+
import type { LeaveBalance, LeaveLedgerEntry } from '../index.js'
|
|
24
|
+
import { canHr } from '../permissions.js'
|
|
25
|
+
import { formatDays, hrKeys, isoDate } from '../query.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How a balance adds up — and the one place it can be changed by hand.
|
|
29
|
+
*
|
|
30
|
+
* The ledger is append-only, and that is the whole point of showing it: a cancelled booking is a
|
|
31
|
+
* **reversal beside the consumption it reverses**, not a gap where a row used to be. So a row that
|
|
32
|
+
* has been undone is marked rather than removed, and the row that undid it says which one it was.
|
|
33
|
+
* Somebody reconciling reads down the running balance; somebody arguing about a number reads the
|
|
34
|
+
* reason column.
|
|
35
|
+
*
|
|
36
|
+
* The running balance is anchored at the *top*, not built up from zero at the bottom. The server
|
|
37
|
+
* returns the newest entries first and caps the page, so counting up from the oldest row on screen
|
|
38
|
+
* would be counting up from an arbitrary point in the year and every figure would be wrong by
|
|
39
|
+
* whatever was cut off. Anchored at the balance the server already computed for this year, each row
|
|
40
|
+
* shows what the balance was immediately after it — which stays true however much is missing below.
|
|
41
|
+
*
|
|
42
|
+
* No `hasCapability` check here: the ledger lives behind `leave`, exactly the capability the route
|
|
43
|
+
* carrying this page already declares. A second check would be the only one of its kind in this
|
|
44
|
+
* module and would say nothing new.
|
|
45
|
+
*/
|
|
46
|
+
interface Props {
|
|
47
|
+
/** The tile that was opened. `null` closes the panel — and keeps it live: it is the parent's own
|
|
48
|
+
* query row, so an adjustment moves the figure at the top of this panel without a second fetch. */
|
|
49
|
+
balance: LeaveBalance | null
|
|
50
|
+
workspaceId: string
|
|
51
|
+
/** Whose balance this is. Named in the confirmation, because an adjustment is done *to* somebody. */
|
|
52
|
+
personName: string
|
|
53
|
+
onClose: () => void
|
|
54
|
+
}
|
|
55
|
+
const { balance, workspaceId, personName, onClose }: Props = $props()
|
|
56
|
+
|
|
57
|
+
const api = getHrApi()
|
|
58
|
+
const queryClient = useQueryClient()
|
|
59
|
+
|
|
60
|
+
const open = $derived(balance !== null)
|
|
61
|
+
const personId = $derived(balance?.personId ?? '')
|
|
62
|
+
const leaveTypeId = $derived(balance?.leaveTypeId ?? '')
|
|
63
|
+
const periodYear = $derived(balance?.periodYear ?? new Date().getFullYear())
|
|
64
|
+
const typeName = $derived(balance?.leaveTypeName ?? '')
|
|
65
|
+
/** Half-days are still counted in days; only an hourly type is read in hours. */
|
|
66
|
+
const hourly = $derived(balance?.unit === 'hour')
|
|
67
|
+
const minutesPerUnit = $derived(hourly ? 60 : 8 * 60)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The cap the contract allows, asked for in full.
|
|
71
|
+
*
|
|
72
|
+
* `leave.ledger.list` answers `nextCursor: null` whatever it returns, so there is no second page to
|
|
73
|
+
* fetch: a year longer than this is truncated, and the note under the table says so rather than
|
|
74
|
+
* letting somebody reconcile against a list that quietly stops.
|
|
75
|
+
*/
|
|
76
|
+
const LIMIT = 200
|
|
77
|
+
|
|
78
|
+
const ledgerQuery = createQuery(() => ({
|
|
79
|
+
queryKey: hrKeys.leaveLedger(workspaceId, personId, leaveTypeId, periodYear),
|
|
80
|
+
enabled: open && Boolean(workspaceId) && Boolean(personId),
|
|
81
|
+
queryFn: () => api.leave.ledger.list({ workspaceId, personId, leaveTypeId, periodYear, limit: LIMIT }),
|
|
82
|
+
}))
|
|
83
|
+
const entries = $derived(ledgerQuery.data?.items ?? [])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A disabled query is `pending` and not fetching, so a closed panel is not "loading" — without the
|
|
87
|
+
* `open` test the first frame after every close would draw skeletons behind the closing animation.
|
|
88
|
+
*/
|
|
89
|
+
const loading = $derived(open && ledgerQuery.isLoading)
|
|
90
|
+
const empty = $derived(open && !loading && entries.length === 0 && !ledgerQuery.isError)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A failed refetch that still has an answer to show. Every adjustment and every decision invalidates
|
|
94
|
+
* the whole module, so a refetch failing while the last good list is still in `data` is the ordinary
|
|
95
|
+
* case — an error branch above the data would blank a table somebody is reading numbers off.
|
|
96
|
+
*/
|
|
97
|
+
const stale = $derived(ledgerQuery.isError && entries.length > 0)
|
|
98
|
+
|
|
99
|
+
type LedgerRow = {
|
|
100
|
+
entry: LeaveLedgerEntry
|
|
101
|
+
/** The balance immediately after this entry. */
|
|
102
|
+
afterMinutes: number
|
|
103
|
+
/** The entry this one undoes, when it is on screen. */
|
|
104
|
+
reverses: LeaveLedgerEntry | null
|
|
105
|
+
/** Whether a later entry undid this one. */
|
|
106
|
+
reversed: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rows = $derived.by<LedgerRow[]>(() => {
|
|
110
|
+
const items = entries
|
|
111
|
+
const byId = new Map(items.map((e) => [e.id, e]))
|
|
112
|
+
const reversedIds = new Set(items.map((e) => e.reversesEntryId).filter((id): id is string => id !== null))
|
|
113
|
+
let running = balance?.balanceMinutes ?? 0
|
|
114
|
+
return items.map((entry) => {
|
|
115
|
+
const afterMinutes = running
|
|
116
|
+
running -= entry.amountMinutes
|
|
117
|
+
return {
|
|
118
|
+
entry,
|
|
119
|
+
afterMinutes,
|
|
120
|
+
reverses: entry.reversesEntryId ? (byId.get(entry.reversesEntryId) ?? null) : null,
|
|
121
|
+
reversed: reversedIds.has(entry.id),
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/** The same arithmetic the server does on the way out, so the two never disagree by a rounding step. */
|
|
127
|
+
const inUnit = (minutes: number) =>
|
|
128
|
+
hourly ? Math.round((minutes / 60) * 10) / 10 : Math.round((minutes / (60 * 8)) * 100) / 100
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `signDisplay: 'always'` rather than a '+' glued in front of a formatted number: a ledger is read
|
|
132
|
+
* for its signs, and which glyph a sign is — and which side of the digits it goes — belongs to the
|
|
133
|
+
* locale. The interface language is passed explicitly for the same reason every formatter in
|
|
134
|
+
* `@kernhq/ui` takes it: the browser's own language is not the one this screen is written in.
|
|
135
|
+
*/
|
|
136
|
+
const signedNumber = $derived(
|
|
137
|
+
new Intl.NumberFormat(messageLocale(), { signDisplay: 'always', maximumFractionDigits: 2 }),
|
|
138
|
+
)
|
|
139
|
+
/** A year, in the reader's digits — `formatCount` would group it into "2,026". */
|
|
140
|
+
const yearLabel = $derived(new Intl.NumberFormat(messageLocale(), { useGrouping: false }).format(periodYear))
|
|
141
|
+
|
|
142
|
+
const unitWord = (value: number) => t(hourly ? 'hours' : 'days', { count: Math.abs(value) })
|
|
143
|
+
const change = (minutes: number) => `${signedNumber.format(inUnit(minutes))} ${unitWord(inUnit(minutes))}`
|
|
144
|
+
const plain = (minutes: number) => formatDays(inUnit(minutes), messageLocale())
|
|
145
|
+
const amountWithUnit = (value: number) => `${formatDays(value, messageLocale())} ${unitWord(value)}`
|
|
146
|
+
|
|
147
|
+
/** A `YYYY-MM-DD` read as a local day. `new Date('2026-03-01')` is UTC midnight, which is the day
|
|
148
|
+
* before in every zone west of Greenwich. */
|
|
149
|
+
const day = (iso: string) => formatDate(`${iso}T00:00:00`)
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every kind the contract can produce, named the way somebody reconciling would say it.
|
|
153
|
+
*
|
|
154
|
+
* The set is closed in `LedgerKind`, so this switch is exhaustive by construction — a kind added to
|
|
155
|
+
* the contract stops compiling here rather than rendering a raw `carry_out` at a person.
|
|
156
|
+
*/
|
|
157
|
+
const kindLabel = (kind: LedgerKind): string =>
|
|
158
|
+
kind === 'grant'
|
|
159
|
+
? t('leave_kind_grant')
|
|
160
|
+
: kind === 'accrual'
|
|
161
|
+
? t('leave_kind_accrual')
|
|
162
|
+
: kind === 'consumption'
|
|
163
|
+
? t('leave_kind_consumption')
|
|
164
|
+
: kind === 'reversal'
|
|
165
|
+
? t('leave_kind_reversal')
|
|
166
|
+
: kind === 'expiry'
|
|
167
|
+
? t('leave_kind_expiry')
|
|
168
|
+
: kind === 'adjustment'
|
|
169
|
+
? t('leave_kind_adjustment')
|
|
170
|
+
: kind === 'carry_in'
|
|
171
|
+
? t('leave_kind_carry_in')
|
|
172
|
+
: kind === 'carry_out'
|
|
173
|
+
? t('leave_kind_carry_out')
|
|
174
|
+
: t('leave_kind_encashment')
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- adjusting by hand
|
|
177
|
+
|
|
178
|
+
const canAdjust = $derived(canHr('leaveAdjust'))
|
|
179
|
+
|
|
180
|
+
let adjusting = $state(false)
|
|
181
|
+
/** A plain string, because that is what `SegmentedControl` binds; only 'remove' is ever tested. */
|
|
182
|
+
let direction = $state('add')
|
|
183
|
+
let amountText = $state('')
|
|
184
|
+
let effectiveOn = $state(isoDate())
|
|
185
|
+
let reason = $state('')
|
|
186
|
+
/**
|
|
187
|
+
* Errors appear once somebody has pressed the button, and the button is never disabled.
|
|
188
|
+
*
|
|
189
|
+
* A greyed-out control that does not say what is missing is the defect this avoids: the reason is
|
|
190
|
+
* required by the contract, so an empty form would otherwise be a dead button with no explanation.
|
|
191
|
+
* Pressing it names every field that is not ready instead.
|
|
192
|
+
*/
|
|
193
|
+
let attempted = $state(false)
|
|
194
|
+
let adjustError = $state<string | null>(null)
|
|
195
|
+
/** Not `adjust.isPending`: the disabled attribute reaches the button on the next render, so two
|
|
196
|
+
* quick clicks both fire — and both would be recorded, which for a ledger means two adjustments. */
|
|
197
|
+
let adjustInFlight = $state(false)
|
|
198
|
+
|
|
199
|
+
const amountValue = $derived(Number(amountText))
|
|
200
|
+
const amountOk = $derived(amountText.trim().length > 0 && Number.isFinite(amountValue) && amountValue > 0)
|
|
201
|
+
const reasonOk = $derived(reason.trim().length > 0)
|
|
202
|
+
const dateOk = $derived(/^\d{4}-\d{2}-\d{2}$/.test(effectiveOn))
|
|
203
|
+
const signedMinutes = $derived(Math.round(amountValue * minutesPerUnit) * (direction === 'remove' ? -1 : 1))
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Which entitlement year the entry lands in is decided by `effectiveOn`, server-side.
|
|
207
|
+
*
|
|
208
|
+
* So an adjustment dated in another year moves a balance this panel is not showing, and the list
|
|
209
|
+
* behind the dialog would not move at all — which reads as a write that failed. Said out loud
|
|
210
|
+
* instead, before it happens.
|
|
211
|
+
*/
|
|
212
|
+
const otherYear = $derived(dateOk ? Number(effectiveOn.slice(0, 4)) : periodYear)
|
|
213
|
+
const yearMismatch = $derived(dateOk && otherYear !== periodYear)
|
|
214
|
+
const otherYearLabel = $derived(
|
|
215
|
+
new Intl.NumberFormat(messageLocale(), { useGrouping: false }).format(otherYear),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const openAdjust = () => {
|
|
219
|
+
direction = 'add'
|
|
220
|
+
amountText = ''
|
|
221
|
+
effectiveOn = isoDate()
|
|
222
|
+
reason = ''
|
|
223
|
+
attempted = false
|
|
224
|
+
adjustError = null
|
|
225
|
+
adjusting = true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const closeAdjust = () => {
|
|
229
|
+
if (adjustInFlight) return
|
|
230
|
+
adjusting = false
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const adjust = createMutation(() => ({
|
|
234
|
+
mutationFn: () =>
|
|
235
|
+
api.leave.adjust({
|
|
236
|
+
workspaceId,
|
|
237
|
+
personId,
|
|
238
|
+
leaveTypeId,
|
|
239
|
+
// `kind` is left at the contract's default, `adjustment`. The other eight are the engine's
|
|
240
|
+
// own words for what it did — an accrual it computed, an expiry it swept — and letting a
|
|
241
|
+
// person write one by hand would put a sentence in the ledger that nothing else agrees with.
|
|
242
|
+
amountMinutes: signedMinutes,
|
|
243
|
+
effectiveOn,
|
|
244
|
+
reason: reason.trim(),
|
|
245
|
+
}),
|
|
246
|
+
onSuccess: () => {
|
|
247
|
+
adjusting = false
|
|
248
|
+
adjustError = null
|
|
249
|
+
toast.success(t('leave_adjusted_toast'))
|
|
250
|
+
// An adjustment moves a balance, this ledger, and anything showing days left — the widget on
|
|
251
|
+
// the dashboard included. The whole module's cache goes rather than a guess at which keys moved.
|
|
252
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
253
|
+
},
|
|
254
|
+
onError: (error) => {
|
|
255
|
+
adjustError = adjustFailure(error)
|
|
256
|
+
// A refusal means the server's picture is not the one on screen. Re-read exactly as a write
|
|
257
|
+
// that landed does, so the figures behind the dialog are the ones a retry would act on.
|
|
258
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
259
|
+
},
|
|
260
|
+
onSettled: () => {
|
|
261
|
+
adjustInFlight = false
|
|
262
|
+
},
|
|
263
|
+
}))
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The refusals this screen has its own sentence for, keyed by the `reason` beside the refusal —
|
|
267
|
+
* never by the sentence, because a list of sentences is a list somebody has to keep in sync and the
|
|
268
|
+
* day it drifts the reader is told nothing.
|
|
269
|
+
*
|
|
270
|
+
* Empty on purpose: `leave.adjust` refuses nothing by name today — it appends, and the only things
|
|
271
|
+
* that can stop it are the permission and the capability, neither of which this button is offered
|
|
272
|
+
* without. The shape is here so that a refusal added to the router later (a locked payroll period is
|
|
273
|
+
* the obvious one) reaches a reader by having a key written, not by this file being restructured.
|
|
274
|
+
*/
|
|
275
|
+
const adjustRefusalMessages: Record<string, string> = {}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What a refused adjustment says to the person who asked for it.
|
|
279
|
+
*
|
|
280
|
+
* The test is the transport's `code`, never the sentence: `KernError.conflict` arrives as CONFLICT
|
|
281
|
+
* with its `reason` at `data.reason`. Anything else — a network drop, a 500, a gateway — carries
|
|
282
|
+
* machine text in English, which is the last thing to paste in front of somebody. The same shape as
|
|
283
|
+
* `ClockControls.svelte` and the cancellation on `LeavePage.svelte`; there is no third.
|
|
284
|
+
*/
|
|
285
|
+
function adjustFailure(error: unknown): string {
|
|
286
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
287
|
+
if (failure.code !== 'CONFLICT') return t('leave_adjust_error')
|
|
288
|
+
const reason_ = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
289
|
+
const key = reason_ ? adjustRefusalMessages[reason_] : undefined
|
|
290
|
+
// `t()` answers a key it has no string for with the key itself, so both ways of not having one —
|
|
291
|
+
// a reason no key covers, and a key whose string has not been merged — land on the router's
|
|
292
|
+
// sentence rather than putting `hr.leave_adjust_refused_…` in front of somebody.
|
|
293
|
+
const translated = key ? t(key) : undefined
|
|
294
|
+
return (translated && translated !== key ? translated : failure.message) || t('leave_adjust_error')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const submitAdjust = () => {
|
|
298
|
+
attempted = true
|
|
299
|
+
if (adjustInFlight || !amountOk || !reasonOk || !dateOk) return
|
|
300
|
+
adjustInFlight = true
|
|
301
|
+
adjustError = null
|
|
302
|
+
adjust.mutate()
|
|
303
|
+
}
|
|
304
|
+
</script>
|
|
305
|
+
|
|
306
|
+
<Sheet
|
|
307
|
+
{open}
|
|
308
|
+
width={560}
|
|
309
|
+
title={t('leave_ledger_title', { name: typeName })}
|
|
310
|
+
onOpenChange={(next) => {
|
|
311
|
+
if (!next) onClose()
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
{#snippet actions()}
|
|
315
|
+
<!--
|
|
316
|
+
The way in to an adjustment, except when the ledger is empty — there it is the empty state's
|
|
317
|
+
own action, so the offer sits where somebody is already looking and never twice on one panel.
|
|
318
|
+
-->
|
|
319
|
+
{#if canAdjust && !empty}
|
|
320
|
+
<Button size="sm" variant="secondary" icon="sliders-vertical" onclick={openAdjust}>
|
|
321
|
+
{t('leave_adjust')}
|
|
322
|
+
</Button>
|
|
323
|
+
{/if}
|
|
324
|
+
{/snippet}
|
|
325
|
+
|
|
326
|
+
<p class="anchor">
|
|
327
|
+
<span>{t('leave_ledger_year', { year: yearLabel })}</span>
|
|
328
|
+
<span class="figure">
|
|
329
|
+
<span>{t('leave_ledger_balance')}</span>
|
|
330
|
+
<strong>{amountWithUnit(inUnit(balance?.balanceMinutes ?? 0))}</strong>
|
|
331
|
+
</span>
|
|
332
|
+
</p>
|
|
333
|
+
|
|
334
|
+
{#if stale}
|
|
335
|
+
<p class="stale" role="status">
|
|
336
|
+
<span>{t('leave_stale')}</span>
|
|
337
|
+
<Button size="sm" variant="ghost" onclick={() => void ledgerQuery.refetch()}>{t('retry')}</Button>
|
|
338
|
+
</p>
|
|
339
|
+
{/if}
|
|
340
|
+
|
|
341
|
+
{#if loading}
|
|
342
|
+
<div class="rows">
|
|
343
|
+
{#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="44px" />{/each}
|
|
344
|
+
</div>
|
|
345
|
+
{:else if rows.length}
|
|
346
|
+
<div class="table" role="table" aria-label={t('leave_ledger_title', { name: typeName })}>
|
|
347
|
+
<div class="thead" role="row">
|
|
348
|
+
<span role="columnheader">{t('leave_ledger_when')}</span>
|
|
349
|
+
<span role="columnheader">{t('leave_ledger_what')}</span>
|
|
350
|
+
<span role="columnheader" class="num">{t('leave_ledger_change')}</span>
|
|
351
|
+
<span role="columnheader" class="num">{t('leave_ledger_after')}</span>
|
|
352
|
+
</div>
|
|
353
|
+
{#each rows as row (row.entry.id)}
|
|
354
|
+
<div class="trow" role="row">
|
|
355
|
+
<span class="cell when" role="cell">{day(row.entry.effectiveOn)}</span>
|
|
356
|
+
<span class="cell what" role="cell">
|
|
357
|
+
<span class="kind">
|
|
358
|
+
{kindLabel(row.entry.kind)}
|
|
359
|
+
{#if row.reversed}<Badge tone="grey">{t('leave_ledger_reversed')}</Badge>{/if}
|
|
360
|
+
</span>
|
|
361
|
+
{#if row.reverses}
|
|
362
|
+
<span class="note">{t('leave_ledger_reverses', { date: day(row.reverses.effectiveOn) })}</span>
|
|
363
|
+
{/if}
|
|
364
|
+
{#if row.entry.reason}<span class="note">{row.entry.reason}</span>{/if}
|
|
365
|
+
</span>
|
|
366
|
+
<span class="cell num" class:credit={row.entry.amountMinutes > 0} class:debit={row.entry.amountMinutes < 0} role="cell">
|
|
367
|
+
{change(row.entry.amountMinutes)}
|
|
368
|
+
</span>
|
|
369
|
+
<span class="cell num after" role="cell">{plain(row.afterMinutes)}</span>
|
|
370
|
+
</div>
|
|
371
|
+
{/each}
|
|
372
|
+
</div>
|
|
373
|
+
{#if entries.length >= LIMIT}
|
|
374
|
+
<p class="capped">{t('leave_ledger_capped', { count: LIMIT })}</p>
|
|
375
|
+
{/if}
|
|
376
|
+
{:else if ledgerQuery.isError}
|
|
377
|
+
<EmptyState compact icon="triangle-alert" title={t('leave_ledger_error')}>
|
|
378
|
+
{#snippet actions()}
|
|
379
|
+
<Button size="sm" variant="secondary" onclick={() => void ledgerQuery.refetch()}>{t('retry')}</Button>
|
|
380
|
+
{/snippet}
|
|
381
|
+
</EmptyState>
|
|
382
|
+
{:else}
|
|
383
|
+
<!--
|
|
384
|
+
Nothing has moved in this entitlement year. That is a real state — a type added in March has
|
|
385
|
+
an empty ledger until the first grant — so it names what would fill it, and offers the one
|
|
386
|
+
thing a person on this panel can do about it.
|
|
387
|
+
-->
|
|
388
|
+
<EmptyState
|
|
389
|
+
compact
|
|
390
|
+
icon="scroll-text"
|
|
391
|
+
title={t('leave_ledger_none')}
|
|
392
|
+
description={t('leave_ledger_none_desc')}
|
|
393
|
+
>
|
|
394
|
+
{#snippet actions()}
|
|
395
|
+
{#if canAdjust}
|
|
396
|
+
<Button size="sm" variant="secondary" icon="sliders-vertical" onclick={openAdjust}>
|
|
397
|
+
{t('leave_adjust')}
|
|
398
|
+
</Button>
|
|
399
|
+
{/if}
|
|
400
|
+
{/snippet}
|
|
401
|
+
</EmptyState>
|
|
402
|
+
{/if}
|
|
403
|
+
</Sheet>
|
|
404
|
+
|
|
405
|
+
<!--
|
|
406
|
+
Adjusting is writing a row nobody can take out again, so the dialog says whose balance moves, by
|
|
407
|
+
how much and in which direction — live, as the amount is typed, because the mistake this catches
|
|
408
|
+
is a 20 where a 2 was meant and no amount of "are you sure" catches that.
|
|
409
|
+
-->
|
|
410
|
+
<Dialog
|
|
411
|
+
open={adjusting}
|
|
412
|
+
title={t('leave_adjust_title')}
|
|
413
|
+
description={t('leave_adjust_desc')}
|
|
414
|
+
onOpenChange={(next) => {
|
|
415
|
+
if (!next) closeAdjust()
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
<div class="form">
|
|
419
|
+
<!--
|
|
420
|
+
No `Field` around this one: `Field` renders a `<label for>`, and a segmented control is a
|
|
421
|
+
radiogroup rather than one focusable input for a label to point at. Its own `label` names the
|
|
422
|
+
group for a screen reader, and the two buttons say what they do on the face of them.
|
|
423
|
+
-->
|
|
424
|
+
<SegmentedControl
|
|
425
|
+
label={t('leave_adjust_direction')}
|
|
426
|
+
bind:value={direction}
|
|
427
|
+
items={[
|
|
428
|
+
{ value: 'add', label: t('leave_adjust_add'), icon: 'plus' },
|
|
429
|
+
{ value: 'remove', label: t('leave_adjust_remove'), icon: 'minus' },
|
|
430
|
+
]}
|
|
431
|
+
/>
|
|
432
|
+
|
|
433
|
+
<Field
|
|
434
|
+
label={hourly ? t('leave_adjust_amount_hours') : t('leave_adjust_amount_days')}
|
|
435
|
+
required
|
|
436
|
+
error={attempted && !amountOk ? t('leave_adjust_amount_invalid') : null}
|
|
437
|
+
>
|
|
438
|
+
{#snippet children(id)}
|
|
439
|
+
<Input {id} type="number" min="0" step="0.5" inputmode="decimal" bind:value={amountText} />
|
|
440
|
+
{/snippet}
|
|
441
|
+
</Field>
|
|
442
|
+
|
|
443
|
+
<Field
|
|
444
|
+
label={t('leave_adjust_effective')}
|
|
445
|
+
required
|
|
446
|
+
hint={t('leave_adjust_effective_hint')}
|
|
447
|
+
error={attempted && !dateOk ? t('leave_adjust_effective_invalid') : null}
|
|
448
|
+
>
|
|
449
|
+
{#snippet children(id)}
|
|
450
|
+
<Input {id} type="date" bind:value={effectiveOn} />
|
|
451
|
+
{/snippet}
|
|
452
|
+
</Field>
|
|
453
|
+
|
|
454
|
+
<Field
|
|
455
|
+
label={t('leave_reason')}
|
|
456
|
+
required
|
|
457
|
+
hint={t('leave_adjust_reason_hint')}
|
|
458
|
+
error={attempted && !reasonOk ? t('leave_adjust_reason_required') : null}
|
|
459
|
+
>
|
|
460
|
+
{#snippet children(id)}
|
|
461
|
+
<Textarea {id} rows={3} bind:value={reason} />
|
|
462
|
+
{/snippet}
|
|
463
|
+
</Field>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
{#if amountOk && dateOk}
|
|
467
|
+
<p class="consequence">
|
|
468
|
+
{direction === 'add'
|
|
469
|
+
? t('leave_adjust_confirm_add', {
|
|
470
|
+
name: personName,
|
|
471
|
+
type: typeName,
|
|
472
|
+
amount: amountWithUnit(amountValue),
|
|
473
|
+
date: day(effectiveOn),
|
|
474
|
+
})
|
|
475
|
+
: t('leave_adjust_confirm_remove', {
|
|
476
|
+
name: personName,
|
|
477
|
+
type: typeName,
|
|
478
|
+
amount: amountWithUnit(amountValue),
|
|
479
|
+
date: day(effectiveOn),
|
|
480
|
+
})}
|
|
481
|
+
</p>
|
|
482
|
+
{#if yearMismatch}
|
|
483
|
+
<p class="warn">{t('leave_adjust_other_year', { year: otherYearLabel })}</p>
|
|
484
|
+
{/if}
|
|
485
|
+
{/if}
|
|
486
|
+
<p class="permanent">{t('leave_adjust_irreversible')}</p>
|
|
487
|
+
{#if adjustError}<p class="failed" role="alert">{adjustError}</p>{/if}
|
|
488
|
+
|
|
489
|
+
{#snippet footer()}
|
|
490
|
+
<!-- Secondary, as in `DecisionDialog`: on a dialog that writes something permanent the way out
|
|
491
|
+
must not be the faintest control on it. -->
|
|
492
|
+
<Button variant="secondary" onclick={closeAdjust} disabled={adjustInFlight}>{t('cancel')}</Button>
|
|
493
|
+
<Button variant="danger" loading={adjustInFlight} onclick={submitAdjust}>{t('leave_adjust')}</Button>
|
|
494
|
+
{/snippet}
|
|
495
|
+
</Dialog>
|
|
496
|
+
|
|
497
|
+
<style>
|
|
498
|
+
.anchor {
|
|
499
|
+
display: flex;
|
|
500
|
+
align-items: baseline;
|
|
501
|
+
justify-content: space-between;
|
|
502
|
+
flex-wrap: wrap;
|
|
503
|
+
gap: 8px;
|
|
504
|
+
margin: 0 0 14px;
|
|
505
|
+
font-size: 12.5px;
|
|
506
|
+
color: var(--kern-ink-500);
|
|
507
|
+
}
|
|
508
|
+
.figure {
|
|
509
|
+
display: flex;
|
|
510
|
+
align-items: baseline;
|
|
511
|
+
gap: 8px;
|
|
512
|
+
}
|
|
513
|
+
.figure strong {
|
|
514
|
+
font-size: 15px;
|
|
515
|
+
color: var(--kern-ink-900);
|
|
516
|
+
font-variant-numeric: tabular-nums;
|
|
517
|
+
}
|
|
518
|
+
/*
|
|
519
|
+
* The warning ink is 4.58:1 on its own tint in light and 5.28:1 in dark, and the tint is what makes
|
|
520
|
+
* the strip read as a notice rather than as another row of the table.
|
|
521
|
+
*/
|
|
522
|
+
.stale {
|
|
523
|
+
display: flex;
|
|
524
|
+
align-items: center;
|
|
525
|
+
justify-content: space-between;
|
|
526
|
+
flex-wrap: wrap;
|
|
527
|
+
gap: 8px;
|
|
528
|
+
margin-block: 0 12px;
|
|
529
|
+
padding-block: 6px;
|
|
530
|
+
padding-inline: 12px 8px;
|
|
531
|
+
border-radius: var(--kern-r-md);
|
|
532
|
+
background: var(--kern-warning-tint);
|
|
533
|
+
color: var(--kern-warning);
|
|
534
|
+
font-size: 12.5px;
|
|
535
|
+
}
|
|
536
|
+
.rows {
|
|
537
|
+
display: grid;
|
|
538
|
+
gap: 4px;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* One grid for the header and every row, so the two number columns line up down the panel. */
|
|
542
|
+
.table {
|
|
543
|
+
--hr-ledger-cols: 96px minmax(0, 1fr) 92px 76px;
|
|
544
|
+
width: 100%;
|
|
545
|
+
}
|
|
546
|
+
.thead,
|
|
547
|
+
.trow {
|
|
548
|
+
display: grid;
|
|
549
|
+
grid-template-columns: var(--hr-ledger-cols);
|
|
550
|
+
gap: 10px;
|
|
551
|
+
align-items: start;
|
|
552
|
+
}
|
|
553
|
+
.thead {
|
|
554
|
+
height: 30px;
|
|
555
|
+
align-items: center;
|
|
556
|
+
border-block-end: 1px solid var(--kern-border);
|
|
557
|
+
font-size: 11px;
|
|
558
|
+
font-weight: 600;
|
|
559
|
+
letter-spacing: 0.06em;
|
|
560
|
+
text-transform: uppercase;
|
|
561
|
+
color: var(--kern-ink-500);
|
|
562
|
+
}
|
|
563
|
+
.trow {
|
|
564
|
+
padding-block: 9px;
|
|
565
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
566
|
+
}
|
|
567
|
+
.cell {
|
|
568
|
+
min-width: 0;
|
|
569
|
+
font-size: 13px;
|
|
570
|
+
}
|
|
571
|
+
.when {
|
|
572
|
+
color: var(--kern-ink-500);
|
|
573
|
+
}
|
|
574
|
+
.what {
|
|
575
|
+
display: flex;
|
|
576
|
+
flex-direction: column;
|
|
577
|
+
gap: 3px;
|
|
578
|
+
}
|
|
579
|
+
.kind {
|
|
580
|
+
display: flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
gap: 6px;
|
|
583
|
+
font-weight: 500;
|
|
584
|
+
color: var(--kern-ink-900);
|
|
585
|
+
}
|
|
586
|
+
/* A colour, not opacity: opacity fades the text against the panel whatever token it names. */
|
|
587
|
+
.note {
|
|
588
|
+
font-size: 12px;
|
|
589
|
+
color: var(--kern-ink-500);
|
|
590
|
+
overflow-wrap: anywhere;
|
|
591
|
+
}
|
|
592
|
+
.num {
|
|
593
|
+
text-align: end;
|
|
594
|
+
font-variant-numeric: tabular-nums;
|
|
595
|
+
white-space: nowrap;
|
|
596
|
+
}
|
|
597
|
+
/* 7.00:1 in light and 8.50:1 in dark on --kern-surface-raised, which is what a sheet sits on. */
|
|
598
|
+
.credit {
|
|
599
|
+
color: var(--kern-success-ink);
|
|
600
|
+
}
|
|
601
|
+
/* 6.33:1 in light, 5.04:1 in dark on the same surface. */
|
|
602
|
+
.debit {
|
|
603
|
+
color: var(--kern-danger);
|
|
604
|
+
}
|
|
605
|
+
.after {
|
|
606
|
+
color: var(--kern-ink-700);
|
|
607
|
+
}
|
|
608
|
+
.capped {
|
|
609
|
+
margin: 10px 0 0;
|
|
610
|
+
font-size: 12px;
|
|
611
|
+
color: var(--kern-ink-500);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.form {
|
|
615
|
+
display: grid;
|
|
616
|
+
gap: 14px;
|
|
617
|
+
}
|
|
618
|
+
.consequence {
|
|
619
|
+
margin: 14px 0 0;
|
|
620
|
+
padding: 10px 12px;
|
|
621
|
+
border-radius: var(--kern-r-md);
|
|
622
|
+
background: var(--kern-surface-chip);
|
|
623
|
+
font-size: 13.5px;
|
|
624
|
+
line-height: 1.55;
|
|
625
|
+
color: var(--kern-ink-900);
|
|
626
|
+
}
|
|
627
|
+
.warn {
|
|
628
|
+
margin: 8px 0 0;
|
|
629
|
+
padding: 8px 12px;
|
|
630
|
+
border-radius: var(--kern-r-md);
|
|
631
|
+
background: var(--kern-warning-tint);
|
|
632
|
+
color: var(--kern-warning);
|
|
633
|
+
font-size: 12.5px;
|
|
634
|
+
line-height: 1.5;
|
|
635
|
+
}
|
|
636
|
+
.permanent {
|
|
637
|
+
margin: 10px 0 0;
|
|
638
|
+
font-size: 12.5px;
|
|
639
|
+
line-height: 1.5;
|
|
640
|
+
color: var(--kern-ink-500);
|
|
641
|
+
}
|
|
642
|
+
/* A dialog body sits on --kern-surface-raised, not the page: 6.33:1 there in light, 5.04:1 in dark. */
|
|
643
|
+
.failed {
|
|
644
|
+
margin: 8px 0 0;
|
|
645
|
+
font-size: 13px;
|
|
646
|
+
color: var(--kern-danger);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
@media (max-width: 768px) {
|
|
650
|
+
.table {
|
|
651
|
+
--hr-ledger-cols: 84px minmax(0, 1fr) 82px 66px;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
</style>
|