@kernhq/module-hr 0.10.5 → 0.11.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.
@@ -0,0 +1,858 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ type BadgeTone,
5
+ Button,
6
+ Dialog,
7
+ EmptyState,
8
+ Field,
9
+ formatDateRange,
10
+ formatDateTime,
11
+ Input,
12
+ navigation,
13
+ Select,
14
+ SettingsPage,
15
+ SettingsSection,
16
+ Skeleton,
17
+ session,
18
+ Textarea,
19
+ toast,
20
+ } from '@kernhq/ui'
21
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
22
+ // The client barrel re-exports the models the screens have needed so far, and the period was not
23
+ // one of them. Straight from the contract rather than widening a barrel another screen shares.
24
+ import type { Period } from '../../contract/policies.js'
25
+ import { getHrApi } from '../api-instance.js'
26
+ import { HR_CAPABILITIES } from '../capabilities.js'
27
+ import { t } from '../i18n.js'
28
+ import { canHr } from '../permissions.js'
29
+ import { isoDate } from '../query.js'
30
+
31
+ /**
32
+ * The lock that makes a filed month stay filed.
33
+ *
34
+ * Everything else in this module respects a locked period — `PolicyService.assertOpen` refuses a
35
+ * write into one, and `attendance.days.recompute` skips the days inside it — and until this screen
36
+ * existed nothing could put one there. `periods.list`, `create`, `lock` and `unlock` were all
37
+ * implemented with no caller, so the mechanism the module is built around was unreachable.
38
+ *
39
+ * Three things about periods that the screen has to keep visible, because getting any of them
40
+ * wrong is a payroll problem rather than a display problem:
41
+ *
42
+ * **A period scopes to its legal entity.** `isLocked` reads a period with no entity as closing the
43
+ * whole workspace and one with an entity as closing only the people that entity employed *on that
44
+ * date*. So the list names what each period applies to, and the create form only offers the choice
45
+ * where the `legal_entities` capability is on — but a period that names an entity is still labelled
46
+ * as such when the capability is later switched off, because a screen that drew it as
47
+ * workspace-wide would be lying about who is frozen.
48
+ *
49
+ * **The kind is a label, not a switch.** `isLocked` does not filter on `kind`: an *attendance*
50
+ * period closes the same dates a *payroll* one would. The kind records why the month was closed,
51
+ * and the sections say so rather than implying two independent locks.
52
+ *
53
+ * **Reopening is the consequential act.** Locking is the safe direction — it stops figures moving.
54
+ * Unlocking lets a month move underneath a payroll somebody has already filed, which is why the
55
+ * contract makes the reason mandatory, the server writes it onto the period, and this screen shows
56
+ * the days that come back rather than a bare "done".
57
+ */
58
+ const api = getHrApi()
59
+ const queryClient = useQueryClient()
60
+
61
+ const workspaceSlug = $derived(navigation.workspaceSlug)
62
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
63
+ const workspaceId = $derived(workspace?.id ?? '')
64
+
65
+ /**
66
+ * `hr.period.manage` is dangerous and owner-only by default, and the settings page is registered
67
+ * behind it — but the check is repeated here rather than assumed, because a role edit takes effect
68
+ * on the next render and a button that 403s is worse than no button.
69
+ */
70
+ const manage = $derived(canHr('periodManage'))
71
+ const hasEntities = $derived(session.hasCapability('hr', HR_CAPABILITIES.legalEntities))
72
+
73
+ type Kind = Period['kind']
74
+ const KINDS: Kind[] = ['payroll', 'attendance']
75
+
76
+ /**
77
+ * `[module, entity, …scope]`, the shape `hrKeys` uses. Spelled here rather than in `query.ts`
78
+ * because this is the only screen that asks.
79
+ */
80
+ const periodsKey = (ws: string) => ['hr', 'periods', ws] as const
81
+ const entitiesKey = (ws: string) => ['hr', 'entities', ws] as const
82
+
83
+ /**
84
+ * Every period in one read.
85
+ *
86
+ * `periods.list` takes a `kind` but always answers `nextCursor: null`, so two filtered queries
87
+ * would be two round trips for one list that is already whole. 200 is the contract's maximum and
88
+ * some years of months; the overlap check below is only as good as what came back, which is why
89
+ * the server's exclusion constraint is still the thing that decides.
90
+ */
91
+ const periodsQuery = createQuery(() => ({
92
+ queryKey: periodsKey(workspaceId),
93
+ enabled: Boolean(workspaceId),
94
+ queryFn: () => api.periods.list({ workspaceId, limit: 200 }),
95
+ }))
96
+
97
+ /**
98
+ * The retained value, never the query status. Locking invalidates the whole module, so a refetch
99
+ * failing while the last good list is still in `data` is the ordinary case — an error branch above
100
+ * the data would blank a working screen for as long as core takes to come back.
101
+ */
102
+ const periods = $derived(periodsQuery.data?.items ?? [])
103
+ /** A disabled query is `pending` and not fetching, so it is not loading. */
104
+ const loading = $derived(!workspaceId || periodsQuery.isLoading)
105
+ const stale = $derived(periodsQuery.isError && periods.length > 0)
106
+
107
+ const entitiesQuery = createQuery(() => ({
108
+ queryKey: entitiesKey(workspaceId),
109
+ enabled: Boolean(workspaceId) && hasEntities,
110
+ queryFn: () => api.entities.list({ workspaceId, includeArchived: true }),
111
+ }))
112
+ const entities = $derived(entitiesQuery.data ?? [])
113
+
114
+ const byKind = (kind: Kind) => periods.filter((p) => p.kind === kind)
115
+
116
+ /**
117
+ * The scope column appears when the workspace has entities — and also when it does not but a period
118
+ * names one anyway, which is what a workspace that switched `legal_entities` off leaves behind.
119
+ * Those rows cannot be named (the procedure that names them answers 404 with the capability off),
120
+ * so they say "one legal entity" rather than being drawn as if they closed everybody.
121
+ */
122
+ const showScope = $derived(hasEntities || periods.some((p) => p.legalEntityId !== null))
123
+
124
+ const scopeLabel = (legalEntityId: string | null): string =>
125
+ legalEntityId === null
126
+ ? t('periods_scope_all')
127
+ : (entities.find((e) => e.id === legalEntityId)?.name ?? t('periods_scope_entity'))
128
+
129
+ /** The range as one string: `formatRange` collapses the shared parts and reads correctly in RTL. */
130
+ const rangeOf = (period: { startsOn: string; endsOn: string }) =>
131
+ formatDateRange(`${period.startsOn}T00:00:00`, `${period.endsOn}T00:00:00`)
132
+
133
+ const statusTone = (status: Period['status']): BadgeTone => (status === 'locked' ? 'done' : 'grey')
134
+ const statusLabel = (status: Period['status']) =>
135
+ status === 'locked' ? t('periods_locked') : t('periods_open')
136
+
137
+ /**
138
+ * A period change moves the attendance day sheet, the balances computed from it and anything a
139
+ * policy would have recomputed, so the module's cache is dropped whole rather than guessing which
140
+ * keys a lock touched.
141
+ */
142
+ const refresh = () => {
143
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
144
+ }
145
+
146
+ /**
147
+ * One click, one write.
148
+ *
149
+ * `disabled={mutation.isPending}` reaches the button on the next render, and two quick clicks are
150
+ * one render apart — which here means two periods, or a lock and its own reopen.
151
+ */
152
+ let firing = $state(false)
153
+ function once(run: () => void) {
154
+ if (firing) return
155
+ firing = true
156
+ run()
157
+ }
158
+ const settled = () => {
159
+ firing = false
160
+ }
161
+
162
+ /**
163
+ * What a refusal says to the person who asked for it.
164
+ *
165
+ * The map is empty because none of the four period procedures carries a machine-readable reason
166
+ * yet: `lock` refuses an already-locked period through `KernError.conflict` with a sentence and no
167
+ * code, and `create` is refused by the `hr_periods_no_overlap` exclusion constraint rather than by
168
+ * the router. So the router's own sentence is what reaches the reader, and this is where a reason
169
+ * goes the day one is added — never a match on the sentence, which drifts silently.
170
+ */
171
+ const refusalMessages: Record<string, string> = {}
172
+
173
+ function failureText(error: unknown, fallbackKey: string): string {
174
+ const failure = error as { message?: string; data?: { reason?: unknown } }
175
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
176
+ const key = reason ? refusalMessages[reason] : undefined
177
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
178
+ // a reason no key covers, and a key whose string has not been merged — land on the router's
179
+ // sentence rather than putting `hr.periods_…` in front of somebody.
180
+ const translated = key ? t(key) : undefined
181
+ return (translated && translated !== key ? translated : failure.message) || t(fallbackKey)
182
+ }
183
+
184
+ // ---------------------------------------------------------------- create
185
+
186
+ type Draft = { kind: Kind; legalEntityId: string; startsOn: string; endsOn: string }
187
+
188
+ /**
189
+ * The form is open or shut on its own flag, and the draft is never null.
190
+ *
191
+ * `bind:value` needs an assignable expression, and TypeScript does not carry an `{#if draft}`
192
+ * narrowing into the snippet the field is written in — so a nullable draft turns four date and
193
+ * select bindings into hand-written change handlers for nothing.
194
+ */
195
+ let createOpen = $state(false)
196
+ let draft = $state<Draft>({ kind: 'payroll', legalEntityId: '', ...lastMonth() })
197
+ let createError = $state<string | null>(null)
198
+
199
+ /**
200
+ * The month somebody has just finished is the one they came to close, so that is what the form
201
+ * opens on — not today's month, which is still running.
202
+ */
203
+ function lastMonth(): { startsOn: string; endsOn: string } {
204
+ const now = new Date()
205
+ return {
206
+ startsOn: isoDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
207
+ endsOn: isoDate(new Date(now.getFullYear(), now.getMonth(), 0)),
208
+ }
209
+ }
210
+
211
+ function openCreate(kind: Kind) {
212
+ createError = null
213
+ draft = { kind, legalEntityId: '', ...lastMonth() }
214
+ createOpen = true
215
+ }
216
+
217
+ const rangeValid = $derived(draft.startsOn !== '' && draft.endsOn !== '' && draft.endsOn >= draft.startsOn)
218
+
219
+ /**
220
+ * The period this one would overlap, found here rather than at the server.
221
+ *
222
+ * `hr_periods_no_overlap` excludes two periods of one kind covering the same day for the same
223
+ * employer, and a constraint violation arrives as an opaque failure with a Postgres sentence in it.
224
+ * Naming the period in the way is the difference between "that did not work" and "January is
225
+ * already there".
226
+ */
227
+ const clash = $derived.by(() => {
228
+ if (!rangeValid) return null
229
+ const entity = draft.legalEntityId || null
230
+ return (
231
+ periods.find(
232
+ (p) =>
233
+ p.kind === draft.kind &&
234
+ p.legalEntityId === entity &&
235
+ p.startsOn <= draft.endsOn &&
236
+ p.endsOn >= draft.startsOn,
237
+ ) ?? null
238
+ )
239
+ })
240
+
241
+ const canCreate = $derived(manage && rangeValid && clash === null)
242
+
243
+ const create = createMutation(() => ({
244
+ mutationFn: (input: Draft) =>
245
+ api.periods.create({
246
+ workspaceId,
247
+ kind: input.kind,
248
+ legalEntityId: input.legalEntityId || null,
249
+ startsOn: input.startsOn,
250
+ endsOn: input.endsOn,
251
+ }),
252
+ onSuccess: (period: Period) => {
253
+ toast.success(t('periods_created', { range: rangeOf(period) }))
254
+ createOpen = false
255
+ createError = null
256
+ refresh()
257
+ },
258
+ onError: (error: Error) => {
259
+ createError = failureText(error, 'periods_create_error')
260
+ },
261
+ onSettled: settled,
262
+ }))
263
+
264
+ // ---------------------------------------------------------------- lock
265
+
266
+ let locking = $state<Period | null>(null)
267
+ let lockNote = $state('')
268
+ let lockError = $state<string | null>(null)
269
+
270
+ function openLock(period: Period) {
271
+ locking = period
272
+ lockNote = ''
273
+ lockError = null
274
+ }
275
+
276
+ const lock = createMutation(() => ({
277
+ mutationFn: (period: Period) =>
278
+ api.periods.lock({ workspaceId, periodId: period.id, note: lockNote.trim() || null }),
279
+ onSuccess: (result: Period & { lockedDays: number }) => {
280
+ // The count is the point of the response: "closed" says nothing about how much stopped moving.
281
+ toast.success(
282
+ result.lockedDays === 0
283
+ ? t('periods_locked_none')
284
+ : t('periods_locked_toast', { count: result.lockedDays }),
285
+ )
286
+ locking = null
287
+ lockError = null
288
+ refresh()
289
+ },
290
+ onError: (error: Error) => {
291
+ lockError = failureText(error, 'periods_lock_error')
292
+ },
293
+ onSettled: settled,
294
+ }))
295
+
296
+ // ---------------------------------------------------------------- reopen
297
+
298
+ let reopening = $state<Period | null>(null)
299
+ let reason = $state('')
300
+ let reopenError = $state<string | null>(null)
301
+
302
+ function openReopen(period: Period) {
303
+ reopening = period
304
+ reason = ''
305
+ reopenError = null
306
+ }
307
+
308
+ const reasonValid = $derived(reason.trim().length > 0)
309
+
310
+ /**
311
+ * The other closed periods that would keep some of these days shut.
312
+ *
313
+ * `setPeriodLock` asks `isLocked` about every day it is about to reopen and leaves the ones another
314
+ * period still closes — so reopening an entity's January while the workspace's January is closed
315
+ * changes nothing at all. Two periods overlap in *people* unless both name an entity and the
316
+ * entities differ; either one naming nobody covers everybody the other does.
317
+ */
318
+ const stillClosedBy = $derived.by(() => {
319
+ const period = reopening
320
+ if (!period) return []
321
+ return periods.filter(
322
+ (other) =>
323
+ other.id !== period.id &&
324
+ other.status === 'locked' &&
325
+ other.startsOn <= period.endsOn &&
326
+ other.endsOn >= period.startsOn &&
327
+ (other.legalEntityId === null ||
328
+ period.legalEntityId === null ||
329
+ other.legalEntityId === period.legalEntityId),
330
+ )
331
+ })
332
+
333
+ const reopen = createMutation(() => ({
334
+ mutationFn: (period: Period) =>
335
+ api.periods.unlock({ workspaceId, periodId: period.id, reason: reason.trim() }),
336
+ onSuccess: (result: Period & { unlockedDays: number }) => {
337
+ toast.success(
338
+ result.unlockedDays === 0
339
+ ? t('periods_reopened_none')
340
+ : t('periods_reopened_toast', { count: result.unlockedDays }),
341
+ )
342
+ reopening = null
343
+ reopenError = null
344
+ refresh()
345
+ },
346
+ onError: (error: Error) => {
347
+ reopenError = failureText(error, 'periods_reopen_error')
348
+ },
349
+ onSettled: settled,
350
+ }))
351
+
352
+ const kindOptions = $derived([
353
+ { value: 'payroll', label: t('periods_payroll') },
354
+ { value: 'attendance', label: t('periods_attendance') },
355
+ ])
356
+
357
+ /** Archived entities are included: a period filed under one is still a period somebody must name. */
358
+ const entityOptions = $derived([
359
+ { value: '', label: t('periods_scope_all') },
360
+ ...entities.map((e) => ({ value: e.id, label: e.name })),
361
+ ])
362
+
363
+ const sectionTitle = (kind: Kind) => (kind === 'payroll' ? t('periods_payroll') : t('periods_attendance'))
364
+ const sectionDesc = (kind: Kind) =>
365
+ kind === 'payroll' ? t('periods_payroll_desc') : t('periods_attendance_desc')
366
+ </script>
367
+
368
+ <SettingsPage title={t('settings_periods')} description={t('periods_desc')}>
369
+ {#if stale}
370
+ <p class="stale" role="status">
371
+ <span>{t('periods_stale')}</span>
372
+ <Button size="sm" variant="ghost" onclick={() => void periodsQuery.refetch()}>{t('retry')}</Button>
373
+ </p>
374
+ {/if}
375
+
376
+ {#if !manage}
377
+ <!-- The list is readable without the permission; nothing on it is actionable, and a screen that
378
+ simply omits every control reads as broken rather than as restricted. -->
379
+ <p class="note">{t('periods_read_only')}</p>
380
+ {/if}
381
+
382
+ {#if !loading && periodsQuery.isError && periods.length === 0}
383
+ <!--
384
+ One error, not one per section. Nothing was retained, so both lists below would say the same
385
+ thing twice — and the retry belongs to the single read that failed, which fills both.
386
+ -->
387
+ <SettingsSection title={t('settings_periods')}>
388
+ <EmptyState icon="triangle-alert" title={t('periods_error')}>
389
+ {#snippet actions()}
390
+ <Button variant="secondary" onclick={() => void periodsQuery.refetch()}>{t('retry')}</Button>
391
+ {/snippet}
392
+ </EmptyState>
393
+ </SettingsSection>
394
+ {:else}
395
+ {#each KINDS as kind (kind)}
396
+ {@const rows = byKind(kind)}
397
+ <SettingsSection title={sectionTitle(kind)} description={sectionDesc(kind)} flush={rows.length > 0}>
398
+ {#snippet action()}
399
+ {#if manage}
400
+ <Button size="sm" icon="plus" variant="secondary" onclick={() => openCreate(kind)}>
401
+ {t('periods_new')}
402
+ </Button>
403
+ {/if}
404
+ {/snippet}
405
+
406
+ {#if loading}
407
+ <div class="rows pad">
408
+ {#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
409
+ </div>
410
+ {:else if rows.length > 0}
411
+ <!-- The action column is dropped rather than left empty when the viewer cannot act: a 120px
412
+ gutter beside every row reads as a column that failed to load. -->
413
+ <div
414
+ class="table"
415
+ class:scoped={showScope}
416
+ class:readonly={!manage}
417
+ role="table"
418
+ aria-label={sectionTitle(kind)}
419
+ >
420
+ <div class="thead" role="row">
421
+ <span role="columnheader">{t('periods_range')}</span>
422
+ {#if showScope}<span role="columnheader">{t('periods_scope')}</span>{/if}
423
+ <span role="columnheader">{t('status')}</span>
424
+ {#if manage}<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>{/if}
425
+ </div>
426
+ {#each rows as period (period.id)}
427
+ <div class="trow" role="row">
428
+ <span class="cell what" role="cell">
429
+ <span class="strong">{rangeOf(period)}</span>
430
+ {#if period.lockedAt}
431
+ <span class="meta">{t('periods_closed_on', { date: formatDateTime(period.lockedAt) })}</span>
432
+ {/if}
433
+ {#if period.note}
434
+ <span class="meta">{period.note}</span>
435
+ {/if}
436
+ </span>
437
+ {#if showScope}
438
+ <span class="cell muted" role="cell">{scopeLabel(period.legalEntityId)}</span>
439
+ {/if}
440
+ <span class="cell" role="cell">
441
+ <Badge tone={statusTone(period.status)}>{statusLabel(period.status)}</Badge>
442
+ </span>
443
+ {#if manage}
444
+ <span class="cell actions" role="cell">
445
+ {#if period.status === 'locked'}
446
+ <Button size="sm" variant="secondary" icon="lock-open" onclick={() => openReopen(period)}>
447
+ {t('periods_reopen')}
448
+ </Button>
449
+ {:else}
450
+ <Button size="sm" variant="secondary" icon="lock" onclick={() => openLock(period)}>
451
+ {t('periods_lock')}
452
+ </Button>
453
+ {/if}
454
+ </span>
455
+ {/if}
456
+ </div>
457
+ {/each}
458
+ </div>
459
+ {:else}
460
+ <EmptyState icon="calendar-days" title={t('periods_none')} description={t('periods_none_desc')}>
461
+ {#snippet actions()}
462
+ {#if manage}
463
+ <Button icon="plus" onclick={() => openCreate(kind)}>{t('periods_new')}</Button>
464
+ {/if}
465
+ {/snippet}
466
+ </EmptyState>
467
+ {/if}
468
+ </SettingsSection>
469
+ {/each}
470
+ {/if}
471
+ </SettingsPage>
472
+
473
+ <!-- ---------------------------------------------------------------- a new period -->
474
+ <Dialog
475
+ open={createOpen}
476
+ title={t('periods_create_title')}
477
+ description={t('periods_create_desc')}
478
+ onOpenChange={(o) => {
479
+ if (!o && !create.isPending) createOpen = false
480
+ }}
481
+ >
482
+ <div class="form">
483
+ <Field label={t('periods_kind')} hint={t('periods_kind_hint')}>
484
+ {#snippet children(id)}
485
+ <Select
486
+ {id}
487
+ value={draft.kind}
488
+ onValueChange={(v) => {
489
+ draft.kind = v as Kind
490
+ }}
491
+ options={kindOptions}
492
+ />
493
+ {/snippet}
494
+ </Field>
495
+
496
+ {#if hasEntities}
497
+ <Field label={t('office_entity')} hint={t('periods_entity_hint')}>
498
+ {#snippet children(id)}
499
+ <Select
500
+ {id}
501
+ bind:value={draft.legalEntityId}
502
+ options={entityOptions}
503
+ disabled={entitiesQuery.isLoading}
504
+ />
505
+ {/snippet}
506
+ </Field>
507
+ {/if}
508
+
509
+ <div class="pair">
510
+ <Field label={t('periods_starts')} required>
511
+ {#snippet children(id)}
512
+ <Input {id} type="date" bind:value={draft.startsOn} />
513
+ {/snippet}
514
+ </Field>
515
+ <Field
516
+ label={t('periods_ends')}
517
+ required
518
+ error={draft.endsOn && !rangeValid ? t('periods_range_invalid') : null}
519
+ >
520
+ {#snippet children(id)}
521
+ <Input {id} type="date" bind:value={draft.endsOn} />
522
+ {/snippet}
523
+ </Field>
524
+ </div>
525
+
526
+ {#if clash}
527
+ <!-- Stated where the button is refused rather than only in the tooltip nobody opens: two
528
+ periods of one kind may not cover the same day for the same employer. -->
529
+ <p class="failed" role="alert">{t('periods_overlap', { range: rangeOf(clash) })}</p>
530
+ {/if}
531
+ {#if createError}
532
+ <p class="failed" role="alert">{createError}</p>
533
+ {/if}
534
+ </div>
535
+
536
+ {#snippet footer()}
537
+ <Button variant="secondary" onclick={() => (createOpen = false)} disabled={create.isPending}>
538
+ {t('cancel')}
539
+ </Button>
540
+ <Button
541
+ loading={create.isPending}
542
+ disabled={!canCreate}
543
+ onclick={() => once(() => create.mutate($state.snapshot(draft)))}
544
+ >
545
+ {t('common.create')}
546
+ </Button>
547
+ {/snippet}
548
+ </Dialog>
549
+
550
+ <!-- ---------------------------------------------------------------- close a period -->
551
+ <Dialog
552
+ open={locking !== null}
553
+ size="sm"
554
+ title={locking ? t('periods_lock_title', { range: rangeOf(locking) }) : ''}
555
+ onOpenChange={(o) => {
556
+ if (!o && !lock.isPending) locking = null
557
+ }}
558
+ >
559
+ {#if locking}
560
+ <p class="body">{t('periods_lock_body', { scope: scopeLabel(locking.legalEntityId) })}</p>
561
+ <p class="body muted">{t('periods_lock_adjustments')}</p>
562
+
563
+ <Field label={t('periods_lock_note')} hint={t('periods_lock_note_hint')}>
564
+ {#snippet children(id)}
565
+ <Textarea {id} bind:value={lockNote} rows={2} maxlength={500} />
566
+ {/snippet}
567
+ </Field>
568
+ {/if}
569
+ {#if lockError}
570
+ <p class="failed" role="alert">{lockError}</p>
571
+ {/if}
572
+
573
+ {#snippet footer()}
574
+ <Button variant="secondary" onclick={() => (locking = null)} disabled={lock.isPending}>
575
+ {t('cancel')}
576
+ </Button>
577
+ <Button
578
+ loading={lock.isPending}
579
+ onclick={() => {
580
+ if (locking) once(() => locking && lock.mutate(locking))
581
+ }}
582
+ >
583
+ {t('periods_lock_confirm')}
584
+ </Button>
585
+ {/snippet}
586
+ </Dialog>
587
+
588
+ <!-- ---------------------------------------------------------------- reopen one -->
589
+ <!--
590
+ The most consequential thing anyone does on this screen, so the dialog says what it costs before
591
+ it happens: the days come back, a payroll filed against them can move underneath the figures
592
+ somebody has already sent out, and the reason is kept on the period so the list says why.
593
+ -->
594
+ <Dialog
595
+ open={reopening !== null}
596
+ size="sm"
597
+ title={reopening ? t('periods_reopen_title', { range: rangeOf(reopening) }) : ''}
598
+ onOpenChange={(o) => {
599
+ if (!o && !reopen.isPending) reopening = null
600
+ }}
601
+ >
602
+ {#if reopening}
603
+ <p class="body">{t('periods_reopen_body', { scope: scopeLabel(reopening.legalEntityId) })}</p>
604
+ <p class="body warn">{t('periods_reopen_filed')}</p>
605
+
606
+ {#if stillClosedBy.length > 0}
607
+ <!-- `setPeriodLock` leaves a day that another closed period still covers, so reopening this
608
+ one can change nothing at all. Better said here than discovered in the count afterwards. -->
609
+ <div class="body">
610
+ <p class="note-line">{t('periods_reopen_overlap')}</p>
611
+ <ul class="overlaps">
612
+ {#each stillClosedBy as other (other.id)}
613
+ <li>
614
+ <span>{rangeOf(other)}</span>
615
+ {#if showScope}<span class="muted">{scopeLabel(other.legalEntityId)}</span>{/if}
616
+ </li>
617
+ {/each}
618
+ </ul>
619
+ </div>
620
+ {/if}
621
+
622
+ <Field label={t('periods_reopen_reason')} hint={t('periods_reopen_reason_hint')} required>
623
+ {#snippet children(id)}
624
+ <Textarea {id} bind:value={reason} rows={3} maxlength={500} />
625
+ {/snippet}
626
+ </Field>
627
+ {#if !reasonValid}
628
+ <!-- Why the button below is dead. Plain text rather than a live region: it is a standing
629
+ condition, and announcing it on every keystroke is noise. -->
630
+ <p class="hint">{t('periods_reopen_reason_required')}</p>
631
+ {/if}
632
+ {/if}
633
+ {#if reopenError}
634
+ <p class="failed" role="alert">{reopenError}</p>
635
+ {/if}
636
+
637
+ {#snippet footer()}
638
+ <!-- Secondary, as in `DecisionDialog`: on a destructive confirmation the way out must not be the
639
+ faintest control on it. And it says "Keep it closed" rather than the shared "Cancel", which
640
+ beside a period screen reads as cancelling the period itself. -->
641
+ <Button variant="secondary" onclick={() => (reopening = null)} disabled={reopen.isPending}>
642
+ {t('periods_reopen_keep')}
643
+ </Button>
644
+ <Button
645
+ variant="danger"
646
+ loading={reopen.isPending}
647
+ disabled={!reasonValid}
648
+ onclick={() => {
649
+ if (reopening) once(() => reopening && reopen.mutate(reopening))
650
+ }}
651
+ >
652
+ {t('periods_reopen')}
653
+ </Button>
654
+ {/snippet}
655
+ </Dialog>
656
+
657
+ <style>
658
+ .rows {
659
+ display: grid;
660
+ gap: 4px;
661
+ }
662
+ .pad {
663
+ padding: 2px 0;
664
+ }
665
+
666
+ /* One grid for the header and every row, so the columns line up down the page. */
667
+ .table {
668
+ --hr-period-cols: minmax(180px, 1fr) 92px 120px;
669
+ width: 100%;
670
+ }
671
+ .table.scoped {
672
+ --hr-period-cols: minmax(180px, 1fr) minmax(120px, 0.7fr) 92px 120px;
673
+ }
674
+ .table.readonly {
675
+ --hr-period-cols: minmax(180px, 1fr) 92px;
676
+ }
677
+ .table.scoped.readonly {
678
+ --hr-period-cols: minmax(180px, 1fr) minmax(120px, 0.7fr) 92px;
679
+ }
680
+ .thead,
681
+ .trow {
682
+ display: grid;
683
+ grid-template-columns: var(--hr-period-cols);
684
+ gap: 12px;
685
+ align-items: center;
686
+ padding-inline: 18px;
687
+ }
688
+ .thead {
689
+ height: 32px;
690
+ border-block-end: 1px solid var(--kern-border);
691
+ font-size: 11px;
692
+ font-weight: 600;
693
+ letter-spacing: 0.06em;
694
+ text-transform: uppercase;
695
+ color: var(--kern-ink-500);
696
+ }
697
+ .trow {
698
+ min-height: 52px;
699
+ padding-block: 8px;
700
+ border-block-end: 1px solid var(--kern-border-hairline);
701
+ }
702
+ .trow:last-child {
703
+ border-block-end: none;
704
+ }
705
+ .cell {
706
+ min-width: 0;
707
+ overflow: hidden;
708
+ text-overflow: ellipsis;
709
+ }
710
+ .what {
711
+ display: grid;
712
+ gap: 2px;
713
+ }
714
+ .strong {
715
+ overflow: hidden;
716
+ text-overflow: ellipsis;
717
+ white-space: nowrap;
718
+ font-size: 13.5px;
719
+ font-weight: 500;
720
+ }
721
+ .meta {
722
+ overflow: hidden;
723
+ text-overflow: ellipsis;
724
+ white-space: nowrap;
725
+ font-size: 12px;
726
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
727
+ color: var(--kern-ink-500);
728
+ }
729
+ .muted {
730
+ font-size: 13px;
731
+ color: var(--kern-ink-500);
732
+ }
733
+ .actions {
734
+ display: flex;
735
+ justify-content: flex-end;
736
+ overflow: visible;
737
+ }
738
+
739
+ /*
740
+ * The warning ink is 4.37:1 on `--kern-canvas` and 4.58:1 in light / 5.28:1 in dark on its own
741
+ * tint, which is what a 12.5px line has to clear — and the tint is what makes the strip read as a
742
+ * notice rather than as another card.
743
+ */
744
+ .stale {
745
+ display: flex;
746
+ align-items: center;
747
+ justify-content: space-between;
748
+ flex-wrap: wrap;
749
+ gap: 8px;
750
+ margin: 0;
751
+ padding-block: 6px;
752
+ padding-inline: 12px 8px;
753
+ border-radius: var(--kern-r-md);
754
+ background: var(--kern-warning-tint);
755
+ color: var(--kern-warning);
756
+ font-size: 12.5px;
757
+ }
758
+ .note {
759
+ margin: 0;
760
+ padding: 10px 12px;
761
+ border-radius: var(--kern-r-md2);
762
+ background: var(--kern-info-tint);
763
+ color: var(--kern-ink-700);
764
+ font-size: 12.5px;
765
+ line-height: 1.5;
766
+ }
767
+ .body {
768
+ margin: 0 0 12px;
769
+ font-size: 13.5px;
770
+ line-height: 1.5;
771
+ }
772
+ .body.muted {
773
+ font-size: 12.5px;
774
+ }
775
+ .warn {
776
+ padding: 10px 12px;
777
+ border-radius: var(--kern-r-md2);
778
+ background: var(--kern-warning-tint);
779
+ color: var(--kern-warning);
780
+ font-size: 12.5px;
781
+ }
782
+ .note-line {
783
+ margin: 0 0 4px;
784
+ font-size: 12.5px;
785
+ color: var(--kern-ink-700);
786
+ }
787
+ .overlaps {
788
+ display: grid;
789
+ gap: 2px;
790
+ margin: 0;
791
+ padding: 0;
792
+ list-style: none;
793
+ font-size: 12.5px;
794
+ }
795
+ .overlaps li {
796
+ display: flex;
797
+ align-items: baseline;
798
+ gap: 10px;
799
+ min-width: 0;
800
+ }
801
+ .hint {
802
+ margin: 6px 0 0;
803
+ font-size: 12px;
804
+ color: var(--kern-ink-500);
805
+ }
806
+ .failed {
807
+ margin: 8px 0 0;
808
+ font-size: 12.5px;
809
+ color: var(--kern-danger);
810
+ }
811
+
812
+ .form {
813
+ display: grid;
814
+ gap: 14px;
815
+ }
816
+ .pair {
817
+ display: grid;
818
+ grid-template-columns: 1fr 1fr;
819
+ gap: 12px;
820
+ align-items: start;
821
+ }
822
+
823
+ .sr-only {
824
+ position: absolute;
825
+ width: 1px;
826
+ height: 1px;
827
+ overflow: hidden;
828
+ clip-path: inset(50%);
829
+ white-space: nowrap;
830
+ }
831
+
832
+ @media (max-width: 640px) {
833
+ .table,
834
+ .table.scoped {
835
+ --hr-period-cols: minmax(140px, 1fr) 108px;
836
+ }
837
+ .table.readonly,
838
+ .table.scoped.readonly {
839
+ --hr-period-cols: minmax(140px, 1fr) 92px;
840
+ }
841
+ /*
842
+ * Two columns, and which one survives depends on whether the viewer can act. With an action the
843
+ * status badge goes, because the action already states it — a row offering "Reopen" is a closed
844
+ * one. Without an action the badge is the only signal there is, so the scope goes instead.
845
+ */
846
+ .table.scoped .thead > :nth-child(2),
847
+ .table.scoped .trow > :nth-child(2),
848
+ .table:not(.readonly) .thead > :nth-child(2),
849
+ .table:not(.readonly) .trow > :nth-child(2),
850
+ .table.scoped:not(.readonly) .thead > :nth-child(3),
851
+ .table.scoped:not(.readonly) .trow > :nth-child(3) {
852
+ display: none;
853
+ }
854
+ .pair {
855
+ grid-template-columns: 1fr;
856
+ }
857
+ }
858
+ </style>