@kernhq/module-hr 0.10.4 → 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,1779 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Field,
9
+ formatCount,
10
+ formatDate,
11
+ formatDateRange,
12
+ IconButton,
13
+ Input,
14
+ type MenuItem,
15
+ messageLocale,
16
+ navigation,
17
+ SectionLabel,
18
+ Select,
19
+ SettingsPage,
20
+ SettingsSection,
21
+ Skeleton,
22
+ StatTile,
23
+ Switch,
24
+ session,
25
+ toast,
26
+ } from '@kernhq/ui'
27
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
28
+ // Straight from the contract rather than through the client barrel: policies were never one of the
29
+ // models the barrel re-exports, and widening it is a shared file three other screens are editing.
30
+ // `AccrualConfig` is imported as a value on purpose — see `configOf`.
31
+ import {
32
+ AccrualConfig,
33
+ type Policy,
34
+ type PolicyAssignment,
35
+ type PolicySubjectKind,
36
+ SUBJECT_PRIORITY,
37
+ } from '../../contract/policies.js'
38
+ import { getHrApi } from '../api-instance.js'
39
+ import { HR_CAPABILITIES } from '../capabilities.js'
40
+ import { t } from '../i18n.js'
41
+ import { canHr } from '../permissions.js'
42
+ import { formatDays, formatDuration, hrKeys, isoDate, monthRange } from '../query.js'
43
+
44
+ /**
45
+ * How leave is earned over time.
46
+ *
47
+ * Switching `leave_accrual` on used to start an hourly job and give nobody a way to look at it: the
48
+ * policies existed, the ladder existed, the preview existed, and none of the three had a caller. So
49
+ * three things justify this screen, and everything on it serves one of them.
50
+ *
51
+ * **A policy is data, and the data has to say when leave appears.** All four frequencies are
52
+ * implemented and each answers a different question — a twelfth every month, the whole year on one
53
+ * date, a year on each anniversary, or a share scaled by hours actually worked. Naming them is not
54
+ * enough; the form says what the one you picked will do, because "anniversary" and "annual" sound
55
+ * alike and credit on entirely different days.
56
+ *
57
+ * **The ladder is the module's load-bearing abstraction, and this is where somebody sees it.**
58
+ * Person → office → legal entity → org unit → position → workspace, nearest wins. The rungs are
59
+ * drawn in that order with every accrual assignment in the workspace on them, rather than one
60
+ * policy's assignments at a time: "which policy applies to her" is answered by what is *nearest*,
61
+ * so a view that hides the other policies hides the answer. The order comes from the contract's own
62
+ * `SUBJECT_PRIORITY` rather than being retyped here.
63
+ *
64
+ * **Nothing reaches the ledger unpreviewed.** `accrual.preview` runs the same code the run does, so
65
+ * the run button lives inside the preview dialog and is disabled until there are numbers on screen.
66
+ * A credit cannot be undone from here — it is corrected with a leave adjustment — which is exactly
67
+ * why an admin gets to read it first.
68
+ */
69
+ const api = getHrApi()
70
+ const queryClient = useQueryClient()
71
+
72
+ const workspaceSlug = $derived(navigation.workspaceSlug)
73
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
74
+ const workspaceId = $derived(workspace?.id ?? '')
75
+
76
+ /**
77
+ * The page is contributed under `hr.policy.manage`, so this is normally true — it is read anyway
78
+ * because a settings URL can be typed, and a screen full of live buttons the server will refuse is
79
+ * worse than a screen that says so.
80
+ */
81
+ const manage = $derived(canHr('policyManage'))
82
+
83
+ /** Which rungs can be *named*: two of them are behind their own capability and their own read. */
84
+ const hasOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices) && canHr('officeView'))
85
+ const hasEntities = $derived(
86
+ session.hasCapability('hr', HR_CAPABILITIES.legalEntities) && canHr('entityView'),
87
+ )
88
+ const hasOrg = $derived(canHr('orgView'))
89
+ const hasPeople = $derived(canHr('personView'))
90
+ /** `per_hour_worked` reads attendance day sheets; without them it accrues nothing at all. */
91
+ const hasAttendance = $derived(session.hasCapability('hr', HR_CAPABILITIES.attendance))
92
+
93
+ type PolicyRow = Policy & { assignments: PolicyAssignment[] }
94
+ type Frequency = AccrualConfig['frequency']
95
+ type Tier = { afterYears: string; daysPerYear: string }
96
+
97
+ const FREQUENCIES: Frequency[] = ['monthly', 'annual', 'anniversary', 'per_hour_worked']
98
+
99
+ const freqLabel = (frequency: Frequency): string =>
100
+ frequency === 'monthly'
101
+ ? t('accr_freq_monthly')
102
+ : frequency === 'annual'
103
+ ? t('accr_freq_annual')
104
+ : frequency === 'anniversary'
105
+ ? t('accr_freq_anniversary')
106
+ : t('accr_freq_per_hour')
107
+
108
+ /** What the frequency actually does. Copied from `grantForPeriod`, which is what performs it. */
109
+ const freqDesc = (frequency: Frequency): string =>
110
+ frequency === 'monthly'
111
+ ? t('accr_freq_monthly_desc')
112
+ : frequency === 'annual'
113
+ ? t('accr_freq_annual_desc')
114
+ : frequency === 'anniversary'
115
+ ? t('accr_freq_anniversary_desc')
116
+ : t('accr_freq_per_hour_desc')
117
+
118
+ /**
119
+ * The ladder, ordered by the contract's own numbers.
120
+ *
121
+ * Written out, the sequence would be a second place to keep it — and the day the two disagree the
122
+ * screen draws a precedence the resolver does not use, which is the one thing this page exists to
123
+ * make honest.
124
+ */
125
+ const LADDER: PolicySubjectKind[] = (Object.keys(SUBJECT_PRIORITY) as PolicySubjectKind[]).sort(
126
+ (a, b) => SUBJECT_PRIORITY[b] - SUBJECT_PRIORITY[a],
127
+ )
128
+
129
+ const rungLabel = (kind: PolicySubjectKind): string =>
130
+ kind === 'person'
131
+ ? t('accr_rung_person')
132
+ : kind === 'office'
133
+ ? t('accr_rung_office')
134
+ : kind === 'legal_entity'
135
+ ? t('accr_rung_legal_entity')
136
+ : kind === 'org_unit'
137
+ ? t('accr_rung_org_unit')
138
+ : kind === 'position'
139
+ ? t('accr_rung_position')
140
+ : t('accr_rung_workspace')
141
+
142
+ const rungHint = (kind: PolicySubjectKind): string =>
143
+ kind === 'person'
144
+ ? t('accr_rung_person_hint')
145
+ : kind === 'office'
146
+ ? t('accr_rung_office_hint')
147
+ : kind === 'legal_entity'
148
+ ? t('accr_rung_legal_entity_hint')
149
+ : kind === 'org_unit'
150
+ ? t('accr_rung_org_unit_hint')
151
+ : kind === 'position'
152
+ ? t('accr_rung_position_hint')
153
+ : t('accr_rung_workspace_hint')
154
+
155
+ const durationWords = {
156
+ hours: (n: string) => t('hours_short', { n }),
157
+ minutes: (n: string) => t('minutes_short', { n }),
158
+ }
159
+ const duration = (minutes: number) => formatDuration(minutes, durationWords, messageLocale())
160
+ const days = (n: number) => formatDays(n, messageLocale())
161
+
162
+ // ---------------------------------------------------------------- the policies
163
+
164
+ let showArchived = $state(false)
165
+
166
+ const policiesQuery = createQuery(() => ({
167
+ queryKey: ['hr', 'policies', workspaceId, 'accrual', showArchived] as const,
168
+ enabled: Boolean(workspaceId),
169
+ queryFn: () => api.policies.list({ workspaceId, kind: 'accrual', includeArchived: showArchived }),
170
+ }))
171
+ const policies = $derived((policiesQuery.data ?? []) as PolicyRow[])
172
+
173
+ /**
174
+ * A disabled query is `pending` and not fetching, so it is not loading — without the workspace test
175
+ * the first frame offers "no policies yet" to a workspace that has six.
176
+ */
177
+ const policiesLoading = $derived(!workspaceId || policiesQuery.isLoading)
178
+ /**
179
+ * A failed refetch that still has an answer. Every write here invalidates the whole module, so a
180
+ * refetch failing while the last good list is on screen is the ordinary case — an error branch
181
+ * above the data would blank a working page.
182
+ */
183
+ const stale = $derived(policiesQuery.isError && policies.length > 0)
184
+
185
+ /**
186
+ * The config, parsed rather than cast.
187
+ *
188
+ * `config` is a `jsonb` record on the wire, and the schema is the only thing that knows which keys
189
+ * belong to an accrual policy. A row the server wrote is valid by construction — but a row written
190
+ * by an older release, or by hand, is not, and a cast would render `undefined d/yr` instead of
191
+ * saying the row cannot be read.
192
+ */
193
+ function configOf(policy: PolicyRow): AccrualConfig | null {
194
+ const parsed = AccrualConfig.safeParse(policy.config)
195
+ return parsed.success ? parsed.data : null
196
+ }
197
+
198
+ const leaveTypesQuery = createQuery(() => ({
199
+ queryKey: hrKeys.leaveTypes(workspaceId),
200
+ enabled: Boolean(workspaceId) && canHr('leaveView'),
201
+ queryFn: () => api.leave.types.list({ workspaceId, includeArchived: false }),
202
+ }))
203
+ const leaveTypes = $derived(leaveTypesQuery.data ?? [])
204
+ const leaveTypeName = (key: string): string => leaveTypes.find((type) => type.key === key)?.name ?? key
205
+
206
+ const liveAssignments = $derived(policies.filter((p) => !p.archivedAt).flatMap((p) => p.assignments))
207
+ /** The rung everybody with nothing nearer falls to. Its absence is the interesting case. */
208
+ const workspaceDefault = $derived(
209
+ policies.find((p) => !p.archivedAt && p.assignments.some((a) => a.subjectKind === 'workspace')) ?? null,
210
+ )
211
+
212
+ /**
213
+ * A policy change moves leave balances, the accrual preview and every resolution beneath it, so the
214
+ * module's cache is dropped whole rather than guessing which keys a recomputation touched.
215
+ */
216
+ const refresh = () => {
217
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
218
+ }
219
+
220
+ /**
221
+ * One click, one write.
222
+ *
223
+ * `disabled={mutation.isPending}` reaches the button on the next render and two quick clicks are one
224
+ * render apart — which on this screen means two policies, two assignments, or an accrual run fired
225
+ * twice. The flag is set in the same tick as the click and cleared when the call settles.
226
+ */
227
+ let firing = $state(false)
228
+ function once(run: () => void) {
229
+ if (firing) return
230
+ firing = true
231
+ run()
232
+ }
233
+ const settled = () => {
234
+ firing = false
235
+ }
236
+
237
+ /**
238
+ * The refusals this module has its own sentence for, keyed by the `reason` the router sends beside
239
+ * the refusal — never by the sentence, because a list of sentences is a list somebody has to keep
240
+ * in sync and the day it drifts the reader is told nothing.
241
+ *
242
+ * Empty on purpose. The policy router refuses a bad config through `KernError.badRequest` with an
243
+ * `issues` list rather than a reason, and that list is the only thing that says *which* field the
244
+ * server disagreed with — so it is what `failureText` shows. A reason added to the router later
245
+ * gets its string here without touching a call site.
246
+ */
247
+ const policyRefusalMessages: Record<string, string> = {}
248
+
249
+ /**
250
+ * What a refused write says.
251
+ *
252
+ * Same shape as `LeavePage.svelte` and `ClockControls.svelte`: a machine-readable `reason` first,
253
+ * then the server's own words. `t()` answers a key it has no string for with the key itself, so a
254
+ * reason no key covers falls through to the router's sentence rather than putting `hr.accr_…` in
255
+ * front of somebody.
256
+ */
257
+ function failureText(error: unknown, fallbackKey: string): string {
258
+ const failure = error as {
259
+ message?: string
260
+ data?: { reason?: unknown; issues?: unknown }
261
+ }
262
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
263
+ const key = reason ? policyRefusalMessages[reason] : undefined
264
+ const translated = key ? t(key) : undefined
265
+ if (translated && translated !== key) return translated
266
+ const issues = failure.data?.issues
267
+ if (Array.isArray(issues) && issues.length > 0) return issues.map(String).join(' · ')
268
+ return failure.message || t(fallbackKey)
269
+ }
270
+
271
+ // ---------------------------------------------------------------- the policy form
272
+
273
+ let policyDialog = $state<'create' | 'edit' | null>(null)
274
+ let policyId = $state('')
275
+ let policyName = $state('')
276
+ let policyFrom = $state(isoDate())
277
+ let policyTo = $state('')
278
+ let frequency = $state<Frequency>('monthly')
279
+ let leaveTypeKey = $state('')
280
+ let daysPerYear = $state('20')
281
+ let minutesPerDay = $state('480')
282
+ let waitingMonths = $state('0')
283
+ let roundTo = $state('0')
284
+ let tiers = $state<Tier[]>([])
285
+ let policyError = $state<string | null>(null)
286
+
287
+ function openCreate() {
288
+ policyDialog = 'create'
289
+ policyId = ''
290
+ policyName = ''
291
+ policyFrom = `${new Date().getFullYear()}-01-01`
292
+ policyTo = ''
293
+ frequency = 'monthly'
294
+ leaveTypeKey = leaveTypes[0]?.key ?? ''
295
+ daysPerYear = '20'
296
+ minutesPerDay = '480'
297
+ waitingMonths = '0'
298
+ roundTo = '0'
299
+ tiers = []
300
+ policyError = null
301
+ }
302
+
303
+ function fillFrom(policy: PolicyRow) {
304
+ const config = configOf(policy)
305
+ policyName = policy.name
306
+ policyTo = policy.effectiveTo ?? ''
307
+ frequency = config?.frequency ?? 'monthly'
308
+ leaveTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
309
+ daysPerYear = String(config?.daysPerYear ?? 20)
310
+ minutesPerDay = String(config?.minutesPerDay ?? 480)
311
+ waitingMonths = String(config?.waitingPeriodMonths ?? 0)
312
+ roundTo = String(config?.roundToMinutes ?? 0)
313
+ tiers = (config?.seniorityTiers ?? []).map((tier) => ({
314
+ afterYears: String(tier.afterYears),
315
+ daysPerYear: String(tier.daysPerYear),
316
+ }))
317
+ policyError = null
318
+ }
319
+
320
+ function openEdit(policy: PolicyRow) {
321
+ policyDialog = 'edit'
322
+ policyId = policy.id
323
+ policyFrom = policy.effectiveFrom
324
+ fillFrom(policy)
325
+ }
326
+
327
+ /**
328
+ * The affordance behind the rule the contract states: a change that should apply from a date is a
329
+ * *new* policy with a later `effectiveFrom`, not an edit. Saying that in the edit dialog and then
330
+ * making somebody retype eleven fields is how the rule gets ignored.
331
+ */
332
+ function openDuplicate(policy: PolicyRow) {
333
+ policyDialog = 'create'
334
+ policyId = ''
335
+ fillFrom(policy)
336
+ policyName = t('accr_copy_of', { name: policy.name })
337
+ policyFrom = isoDate()
338
+ policyTo = ''
339
+ }
340
+
341
+ const clamped = (value: string, min: number, max: number) =>
342
+ Math.min(Math.max(Math.round(Number(value) || 0), min), max)
343
+ const clampedDays = (value: string, min: number, max: number) => {
344
+ const parsed = Math.round((Number(value) || 0) * 100) / 100
345
+ return Math.min(Math.max(parsed, min), max)
346
+ }
347
+
348
+ /**
349
+ * Rounding offered as the steps somebody actually means, half and whole days included — those are
350
+ * derived from the day length in the same form, because "half a day" is 210 minutes on a
351
+ * seven-hour day. The contract caps a step at 480 minutes, so a long day's whole-day option is
352
+ * dropped rather than offered and refused.
353
+ */
354
+ const roundOptions = $derived.by(() => {
355
+ const dayMinutes = clamped(minutesPerDay, 1, 1440)
356
+ const steps = [
357
+ { minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
358
+ { minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
359
+ { minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
360
+ { minutes: Math.round(dayMinutes / 2), label: t('accr_round_half_day') },
361
+ { minutes: dayMinutes, label: t('accr_round_day') },
362
+ ].filter((step) => step.minutes > 0 && step.minutes <= 480)
363
+
364
+ const seen = new Set<number>()
365
+ const options = [{ value: '0', label: t('accr_round_exact') }]
366
+ for (const step of steps) {
367
+ if (seen.has(step.minutes)) continue
368
+ seen.add(step.minutes)
369
+ options.push({ value: String(step.minutes), label: step.label })
370
+ }
371
+ // A stored step none of these produce would otherwise vanish the moment somebody opened the
372
+ // policy to change its name.
373
+ if (!options.some((option) => option.value === roundTo))
374
+ options.push({ value: roundTo, label: t('accr_round_minutes', { count: Number(roundTo) || 0 }) })
375
+ return options.sort((a, b) => Number(a.value) - Number(b.value))
376
+ })
377
+
378
+ const tierYears = $derived(tiers.map((tier) => clamped(tier.afterYears, 0, 60)))
379
+ const tiersClash = $derived(new Set(tierYears).size !== tierYears.length)
380
+
381
+ const formProblem = $derived.by(() => {
382
+ if (!policyName.trim()) return t('accr_error_name')
383
+ if (!leaveTypeKey) return t('accr_error_leave_type')
384
+ if (!policyFrom) return t('accr_error_from')
385
+ if (policyTo && policyTo < policyFrom) return t('accr_error_to_before_from')
386
+ if (tiersClash) return t('accr_error_tier_clash')
387
+ return null
388
+ })
389
+
390
+ const configDraft = $derived({
391
+ frequency,
392
+ daysPerYear: clampedDays(daysPerYear, 0, 365),
393
+ minutesPerDay: clamped(minutesPerDay, 1, 1440),
394
+ seniorityTiers: tiers.map((tier) => ({
395
+ afterYears: clamped(tier.afterYears, 0, 60),
396
+ daysPerYear: clampedDays(tier.daysPerYear, 0, 365),
397
+ })),
398
+ waitingPeriodMonths: clamped(waitingMonths, 0, 24),
399
+ roundToMinutes: clamped(roundTo, 0, 480),
400
+ leaveTypeKey,
401
+ })
402
+
403
+ const savePolicy = createMutation(() => ({
404
+ mutationFn: () =>
405
+ policyDialog === 'edit'
406
+ ? api.policies.update({
407
+ workspaceId,
408
+ policyId,
409
+ name: policyName.trim(),
410
+ // `$state.snapshot` because the tiers are a state proxy, and a proxy cannot be cloned on
411
+ // its way into the request — the call throws instead of saving.
412
+ config: $state.snapshot(configDraft),
413
+ effectiveTo: policyTo || null,
414
+ })
415
+ : api.policies.create({
416
+ workspaceId,
417
+ kind: 'accrual',
418
+ name: policyName.trim(),
419
+ config: $state.snapshot(configDraft),
420
+ effectiveFrom: policyFrom,
421
+ effectiveTo: policyTo || null,
422
+ }),
423
+ onSuccess: (policy: Policy) => {
424
+ toast.success(policyDialog === 'edit' ? t('accr_saved') : t('accr_created', { name: policy.name }))
425
+ policyDialog = null
426
+ policyError = null
427
+ refresh()
428
+ },
429
+ onError: (error: Error) => {
430
+ policyError = failureText(error, 'accr_save_error')
431
+ },
432
+ onSettled: settled,
433
+ }))
434
+
435
+ let archiving = $state<PolicyRow | null>(null)
436
+
437
+ const archivePolicy = createMutation(() => ({
438
+ mutationFn: (policy: PolicyRow) => api.policies.archive({ workspaceId, policyId: policy.id }),
439
+ onSuccess: (_ok, policy: PolicyRow) => {
440
+ toast.success(t('accr_archived', { name: policy.name }))
441
+ archiving = null
442
+ refresh()
443
+ },
444
+ onError: (error: Error) => toast.error(failureText(error, 'accr_archive_error')),
445
+ onSettled: settled,
446
+ }))
447
+
448
+ function policyMenu(policy: PolicyRow): MenuItem[] {
449
+ return [
450
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(policy) },
451
+ { label: t('accr_duplicate'), icon: 'copy', onSelect: () => openDuplicate(policy) },
452
+ {
453
+ label: t('accr_assign'),
454
+ icon: 'user-plus',
455
+ onSelect: () => openAssign(policy.id),
456
+ },
457
+ { type: 'separator' },
458
+ {
459
+ label: t('common.archive'),
460
+ icon: 'archive',
461
+ danger: true,
462
+ disabled: Boolean(policy.archivedAt),
463
+ hint: policy.archivedAt ? t('accr_already_archived') : undefined,
464
+ onSelect: () => {
465
+ archiving = policy
466
+ },
467
+ },
468
+ ]
469
+ }
470
+
471
+ // ---------------------------------------------------------------- the ladder
472
+
473
+ /** Every rung needs a name for its subjects, and each name costs a request — so each is asked for
474
+ * only when a rung carries an assignment or the assign dialog is open on it. */
475
+ const rungInUse = (kind: PolicySubjectKind) => liveAssignments.some((a) => a.subjectKind === kind)
476
+
477
+ let assignOpen = $state(false)
478
+ let assignRung = $state<PolicySubjectKind>('workspace')
479
+
480
+ const needsSubjects = (kind: PolicySubjectKind) => rungInUse(kind) || (assignOpen && assignRung === kind)
481
+
482
+ const peopleQuery = createQuery(() => ({
483
+ queryKey: hrKeys.people(workspaceId, { forAccrual: true }),
484
+ enabled: Boolean(workspaceId) && hasPeople && needsSubjects('person'),
485
+ queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active', 'on_leave'] }),
486
+ }))
487
+ const officesQuery = createQuery(() => ({
488
+ queryKey: hrKeys.offices(workspaceId),
489
+ enabled: Boolean(workspaceId) && hasOffices && needsSubjects('office'),
490
+ queryFn: () => api.offices.list({ workspaceId, includeArchived: false }),
491
+ }))
492
+ const entitiesQuery = createQuery(() => ({
493
+ queryKey: hrKeys.entities(workspaceId),
494
+ enabled: Boolean(workspaceId) && hasEntities && needsSubjects('legal_entity'),
495
+ queryFn: () => api.entities.list({ workspaceId, includeArchived: false }),
496
+ }))
497
+ const unitsQuery = createQuery(() => ({
498
+ queryKey: hrKeys.orgUnits(workspaceId),
499
+ enabled: Boolean(workspaceId) && hasOrg && needsSubjects('org_unit'),
500
+ queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
501
+ }))
502
+ const positionsQuery = createQuery(() => ({
503
+ queryKey: ['hr', 'positions', workspaceId] as const,
504
+ enabled: Boolean(workspaceId) && hasOrg && needsSubjects('position'),
505
+ queryFn: () => api.org.positions.list({ workspaceId, includeArchived: false }),
506
+ }))
507
+
508
+ /** `{ id: name }` per rung, so a row and the picker read the same names from the same answer. */
509
+ const subjectNames = $derived.by(() => {
510
+ const map: Record<PolicySubjectKind, Record<string, string>> = {
511
+ person: {},
512
+ office: {},
513
+ legal_entity: {},
514
+ org_unit: {},
515
+ position: {},
516
+ workspace: {},
517
+ }
518
+ for (const person of peopleQuery.data?.items ?? []) map.person[person.id] = person.displayName
519
+ for (const office of officesQuery.data ?? []) map.office[office.id] = office.name
520
+ for (const entity of entitiesQuery.data ?? []) map.legal_entity[entity.id] = entity.name
521
+ for (const unit of unitsQuery.data ?? []) map.org_unit[unit.id] = unit.name
522
+ for (const position of positionsQuery.data ?? []) map.position[position.id] = position.title
523
+ return map
524
+ })
525
+
526
+ /** Whether this rung can be *named* at all, and why not when it cannot. */
527
+ const rungBlocked = (kind: PolicySubjectKind): string | null => {
528
+ if (kind === 'workspace') return null
529
+ if (kind === 'person') return hasPeople ? null : t('accr_rung_needs_people')
530
+ if (kind === 'office') return hasOffices ? null : t('accr_rung_needs_offices')
531
+ if (kind === 'legal_entity') return hasEntities ? null : t('accr_rung_needs_entities')
532
+ return hasOrg ? null : t('accr_rung_needs_org')
533
+ }
534
+
535
+ /**
536
+ * A subject's name, or an honest gap.
537
+ *
538
+ * A person who has left is not in the directory this page pulls, and an admin looking at the rung
539
+ * needs to see that the assignment is still there rather than a blank cell — so the row says the
540
+ * subject cannot be named and keeps its short id, which is what a support conversation needs.
541
+ */
542
+ function subjectName(kind: PolicySubjectKind, id: string | null): string {
543
+ if (kind === 'workspace') return workspace?.name ?? t('accr_rung_workspace')
544
+ if (!id) return t('accr_subject_missing')
545
+ return subjectNames[kind][id] ?? t('accr_subject_unknown', { id: id.slice(0, 8) })
546
+ }
547
+
548
+ type LadderRow = { assignment: PolicyAssignment; policy: PolicyRow }
549
+
550
+ const ladder = $derived(
551
+ LADDER.map((kind) => ({
552
+ kind,
553
+ rows: policies
554
+ .filter((policy) => !policy.archivedAt)
555
+ .flatMap((policy) =>
556
+ policy.assignments
557
+ .filter((assignment) => assignment.subjectKind === kind)
558
+ .map((assignment): LadderRow => ({ assignment, policy })),
559
+ )
560
+ .sort((a, b) =>
561
+ subjectName(kind, a.assignment.subjectId).localeCompare(
562
+ subjectName(kind, b.assignment.subjectId),
563
+ messageLocale(),
564
+ ),
565
+ ),
566
+ })),
567
+ )
568
+
569
+ const rangeLabel = (from: string, to: string | null): string =>
570
+ to ? formatDateRange(from, to) : t('accr_from_date', { date: formatDate(from) })
571
+
572
+ // ---------------------------------------------------------------- assigning
573
+
574
+ let assignPolicyId = $state('')
575
+ let assignSubjectId = $state('')
576
+ let assignFrom = $state(isoDate())
577
+ let assignTo = $state('')
578
+ let assignError = $state<string | null>(null)
579
+
580
+ const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
581
+
582
+ function openAssign(preferredId?: string) {
583
+ assignOpen = true
584
+ assignPolicyId = preferredId ?? livePolicies[0]?.id ?? ''
585
+ assignRung = 'workspace'
586
+ assignSubjectId = ''
587
+ assignFrom = isoDate()
588
+ assignTo = ''
589
+ assignError = null
590
+ }
591
+
592
+ function pickRung(next: string) {
593
+ assignRung = next as PolicySubjectKind
594
+ assignSubjectId = ''
595
+ }
596
+
597
+ const subjectChoices = $derived(
598
+ Object.entries(subjectNames[assignRung])
599
+ .map(([value, label]) => ({ value, label }))
600
+ .sort((a, b) => a.label.localeCompare(b.label, messageLocale())),
601
+ )
602
+
603
+ const subjectsLoading = $derived(
604
+ assignRung === 'person'
605
+ ? peopleQuery.isLoading
606
+ : assignRung === 'office'
607
+ ? officesQuery.isLoading
608
+ : assignRung === 'legal_entity'
609
+ ? entitiesQuery.isLoading
610
+ : assignRung === 'org_unit'
611
+ ? unitsQuery.isLoading
612
+ : assignRung === 'position'
613
+ ? positionsQuery.isLoading
614
+ : false,
615
+ )
616
+
617
+ /** The rungs that still beat the one being chosen. Drawn as chips rather than written into a
618
+ * sentence, so the order reads down the ladder in every writing direction. */
619
+ const beatenBy = $derived(LADDER.slice(0, LADDER.indexOf(assignRung)))
620
+
621
+ /**
622
+ * Another accrual policy already sitting on this exact subject, open-ended.
623
+ *
624
+ * Two policies of one kind on one rung is not refused by the server — their effective ranges may
625
+ * well separate them — but it is the ambiguity this whole screen exists to surface, so it is named
626
+ * rather than blocked.
627
+ */
628
+ const rungTaken = $derived(
629
+ assignOpen
630
+ ? (livePolicies.find((policy) =>
631
+ policy.assignments.some(
632
+ (assignment) =>
633
+ assignment.subjectKind === assignRung &&
634
+ (assignment.subjectId ?? '') === (assignRung === 'workspace' ? '' : assignSubjectId) &&
635
+ assignment.effectiveTo === null &&
636
+ policy.id !== assignPolicyId,
637
+ ),
638
+ ) ?? null)
639
+ : null,
640
+ )
641
+
642
+ const assignProblem = $derived.by(() => {
643
+ if (!assignPolicyId) return t('accr_error_pick_policy')
644
+ const blocked = rungBlocked(assignRung)
645
+ if (blocked) return blocked
646
+ if (assignRung !== 'workspace' && !assignSubjectId) return t('accr_error_pick_subject')
647
+ if (!assignFrom) return t('accr_error_from')
648
+ if (assignTo && assignTo < assignFrom) return t('accr_error_to_before_from')
649
+ return null
650
+ })
651
+
652
+ const assign = createMutation(() => ({
653
+ mutationFn: () =>
654
+ api.policies.assign({
655
+ workspaceId,
656
+ policyId: assignPolicyId,
657
+ subjectKind: assignRung,
658
+ subjectId: assignRung === 'workspace' ? null : assignSubjectId,
659
+ effectiveFrom: assignFrom,
660
+ effectiveTo: assignTo || null,
661
+ }),
662
+ onSuccess: () => {
663
+ toast.success(
664
+ t('accr_assigned', {
665
+ name: policies.find((p) => p.id === assignPolicyId)?.name ?? '',
666
+ subject: subjectName(assignRung, assignSubjectId || null),
667
+ }),
668
+ )
669
+ assignOpen = false
670
+ assignError = null
671
+ refresh()
672
+ },
673
+ onError: (error: Error) => {
674
+ assignError = failureText(error, 'accr_assign_error')
675
+ },
676
+ onSettled: settled,
677
+ }))
678
+
679
+ let unassigning = $state<LadderRow | null>(null)
680
+
681
+ const unassign = createMutation(() => ({
682
+ mutationFn: (row: LadderRow) => api.policies.unassign({ workspaceId, assignmentId: row.assignment.id }),
683
+ onSuccess: (_ok, row: LadderRow) => {
684
+ toast.success(
685
+ t('accr_unassigned', {
686
+ name: row.policy.name,
687
+ subject: subjectName(row.assignment.subjectKind, row.assignment.subjectId),
688
+ }),
689
+ )
690
+ unassigning = null
691
+ refresh()
692
+ },
693
+ onError: (error: Error) => toast.error(failureText(error, 'accr_unassign_error')),
694
+ onSettled: settled,
695
+ }))
696
+
697
+ // ---------------------------------------------------------------- preview, then run
698
+
699
+ let runOpen = $state(false)
700
+ /** Last month, because that is the period the job credits when a month turns. */
701
+ const lastMonth = monthRange(new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1))
702
+ let runFrom = $state(lastMonth.from)
703
+ let runTo = $state(lastMonth.to)
704
+
705
+ const runRangeValid = $derived(Boolean(runFrom && runTo && runFrom <= runTo))
706
+
707
+ const previewQuery = createQuery(() => ({
708
+ queryKey: ['hr', 'accrual-preview', workspaceId, runFrom, runTo] as const,
709
+ enabled: Boolean(runOpen && workspaceId && runRangeValid),
710
+ // A period that computes to nothing is an answer, not a network blip: no point retrying it three
711
+ // times behind a spinner while somebody waits to read numbers.
712
+ retry: false,
713
+ queryFn: () => api.accrual.preview({ workspaceId, from: runFrom, to: runTo }),
714
+ }))
715
+ const preview = $derived(previewQuery.data ?? null)
716
+
717
+ /** What a run would actually write. `alreadyAccrued` rows are the idempotence, drawn but not counted. */
718
+ const creditable = $derived(
719
+ preview ? preview.rows.filter((row) => !row.alreadyAccrued && row.minutes > 0) : [],
720
+ )
721
+ const alreadyDone = $derived(preview ? preview.rows.filter((row) => row.alreadyAccrued).length : 0)
722
+
723
+ const runAccrual = createMutation(() => ({
724
+ mutationFn: () => api.accrual.run({ workspaceId, from: runFrom, to: runTo }),
725
+ onSuccess: (result: { credited: number; skipped: number; totalMinutes: number }) => {
726
+ toast.success(t('accr_run_done', { count: result.credited }))
727
+ runOpen = false
728
+ refresh()
729
+ },
730
+ onError: (error: Error) => toast.error(failureText(error, 'accr_run_error')),
731
+ onSettled: settled,
732
+ }))
733
+
734
+ /** Long lists are for reading, not for scrolling past — the tail is counted instead. */
735
+ const HEAD = 10
736
+ </script>
737
+
738
+ <SettingsPage title={t('settings_accrual')} description={t('accr_desc')}>
739
+ {#snippet actions()}
740
+ {#if manage}
741
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
742
+ {/if}
743
+ {/snippet}
744
+
745
+ {#if !manage}
746
+ <!-- Typed straight into the address bar: every write below will be refused, so say it once
747
+ rather than letting somebody find out one button at a time. -->
748
+ <p class="note warn" role="status">{t('accr_readonly')}</p>
749
+ {/if}
750
+
751
+ {#if stale}
752
+ <p class="stale" role="status">
753
+ <span>{t('accr_stale')}</span>
754
+ <Button size="sm" variant="ghost" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
755
+ </p>
756
+ {/if}
757
+
758
+ <SettingsSection title={t('accr_policies')} description={t('accr_policies_desc')}>
759
+ {#snippet action()}
760
+ {#if policies.length > 0 || showArchived}
761
+ <Switch
762
+ size="sm"
763
+ checked={showArchived}
764
+ onCheckedChange={(on) => (showArchived = on)}
765
+ label={t('accr_show_archived')}
766
+ />
767
+ {/if}
768
+ {/snippet}
769
+
770
+ {#if policiesLoading}
771
+ <div class="rows">
772
+ {#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
773
+ </div>
774
+ {:else if policies.length}
775
+ <div class="tiles">
776
+ <StatTile size="md" label={t('accr_stat_policies')} value={formatCount(livePolicies.length, 99)} />
777
+ <StatTile
778
+ size="md"
779
+ label={t('accr_stat_assignments')}
780
+ value={formatCount(liveAssignments.length, 999)}
781
+ />
782
+ <StatTile
783
+ size="md"
784
+ label={t('accr_stat_default')}
785
+ value={workspaceDefault ? workspaceDefault.name : t('accr_stat_default_none')}
786
+ note={workspaceDefault ? null : t('accr_stat_default_none_note')}
787
+ />
788
+ </div>
789
+
790
+ <div class="table" role="table" aria-label={t('accr_policies')}>
791
+ <div class="thead" role="row">
792
+ <span role="columnheader">{t('accr_col_policy')}</span>
793
+ <span role="columnheader">{t('accr_col_earns')}</span>
794
+ <span role="columnheader">{t('accr_col_when')}</span>
795
+ <span role="columnheader">{t('accr_col_applies')}</span>
796
+ <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
797
+ </div>
798
+ {#each policies as policy (policy.id)}
799
+ {@const config = configOf(policy)}
800
+ <div class="trow" role="row">
801
+ <span class="cell what" role="cell">
802
+ <span class="strong">{policy.name}</span>
803
+ <span class="chips">
804
+ {#if policy.archivedAt}
805
+ <Badge tone="grey">{t('accr_badge_archived')}</Badge>
806
+ {/if}
807
+ {#if policy.source === 'pack'}
808
+ <Badge tone="grey">{t('cal_origin_pack')}</Badge>
809
+ {/if}
810
+ </span>
811
+ <span class="sub">{rangeLabel(policy.effectiveFrom, policy.effectiveTo)}</span>
812
+ </span>
813
+
814
+ <span class="cell" role="cell">
815
+ {#if config}
816
+ <span class="num">{t('accr_days_per_year', { count: config.daysPerYear })}</span>
817
+ <span class="sub">
818
+ {leaveTypeName(config.leaveTypeKey)} · {duration(config.minutesPerDay)}
819
+ {#if config.seniorityTiers.length > 0}
820
+ · {t('accr_tiers_count', { count: config.seniorityTiers.length })}
821
+ {/if}
822
+ </span>
823
+ {:else}
824
+ <!-- A row whose config the schema cannot read: say so rather than print undefined. -->
825
+ <span class="sub danger">{t('accr_config_unreadable')}</span>
826
+ {/if}
827
+ </span>
828
+
829
+ <span class="cell" role="cell">
830
+ {#if config}
831
+ <Badge tone="info">{freqLabel(config.frequency)}</Badge>
832
+ <span class="sub">
833
+ {#if config.waitingPeriodMonths > 0}
834
+ {t('accr_waiting_short', { count: config.waitingPeriodMonths })}
835
+ {/if}
836
+ {#if config.roundToMinutes > 0}
837
+ {#if config.waitingPeriodMonths > 0}·{/if}
838
+ {t('accr_round_short', { step: duration(config.roundToMinutes) })}
839
+ {/if}
840
+ </span>
841
+ {/if}
842
+ </span>
843
+
844
+ <span class="cell num" role="cell">
845
+ {#if policy.assignments.length > 0}
846
+ {formatCount(policy.assignments.length, 999)}
847
+ {:else}
848
+ <span class="sub warn-text">{t('accr_nobody')}</span>
849
+ {/if}
850
+ </span>
851
+
852
+ <span class="cell actions" role="cell">
853
+ {#if manage}
854
+ <DropdownMenu items={policyMenu(policy)}>
855
+ {#snippet trigger(props)}
856
+ <IconButton
857
+ icon="ellipsis"
858
+ label={t('accr_actions_for', { name: policy.name })}
859
+ size={28}
860
+ {...props}
861
+ />
862
+ {/snippet}
863
+ </DropdownMenu>
864
+ {/if}
865
+ </span>
866
+ </div>
867
+ {/each}
868
+ </div>
869
+ {:else if policiesQuery.isError}
870
+ <EmptyState icon="triangle-alert" title={t('accr_error')} description={t('accr_error_desc')}>
871
+ {#snippet actions()}
872
+ <Button variant="secondary" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
873
+ {/snippet}
874
+ </EmptyState>
875
+ {:else}
876
+ <EmptyState icon="gauge" title={t('accr_none')} description={t('accr_none_desc')}>
877
+ {#snippet actions()}
878
+ {#if manage}
879
+ <Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
880
+ {/if}
881
+ {/snippet}
882
+ </EmptyState>
883
+ {/if}
884
+ </SettingsSection>
885
+
886
+ <!-- ---------------------------------------------------------------- the ladder -->
887
+ <SettingsSection title={t('accr_ladder')} description={t('accr_ladder_desc')}>
888
+ {#snippet action()}
889
+ {#if manage && livePolicies.length > 0}
890
+ <Button size="sm" variant="secondary" icon="user-plus" onclick={() => openAssign()}>
891
+ {t('accr_assign')}
892
+ </Button>
893
+ {/if}
894
+ {/snippet}
895
+
896
+ {#if policiesLoading}
897
+ <div class="rows">
898
+ {#each [1, 2, 3, 4] as n (n)}<Skeleton height="44px" />{/each}
899
+ </div>
900
+ {:else if liveAssignments.length}
901
+ {#if !workspaceDefault}
902
+ <!-- The bottom rung is the only one that catches everybody, and its absence is silent
903
+ everywhere else: people with nothing nearer simply never accrue. -->
904
+ <p class="note warn">{t('accr_no_default')}</p>
905
+ {/if}
906
+
907
+ <div class="ladder">
908
+ {#each ladder as rung (rung.kind)}
909
+ <div class="rung" class:empty={rung.rows.length === 0}>
910
+ <SectionLabel label={rungLabel(rung.kind)} count={rung.rows.length} sub />
911
+ <p class="hint">{rungHint(rung.kind)}</p>
912
+
913
+ {#if rung.rows.length === 0}
914
+ <p class="sub">{t('accr_rung_empty')}</p>
915
+ {:else}
916
+ <ul class="rlist">
917
+ {#each rung.rows as row (row.assignment.id)}
918
+ <li>
919
+ <span class="rsubject">
920
+ <Badge tone="grey">{rungLabel(rung.kind)}</Badge>
921
+ <span class="strong">{subjectName(rung.kind, row.assignment.subjectId)}</span>
922
+ </span>
923
+ <span class="rpolicy">{row.policy.name}</span>
924
+ <span class="sub rwhen">
925
+ {rangeLabel(row.assignment.effectiveFrom, row.assignment.effectiveTo)}
926
+ </span>
927
+ {#if manage}
928
+ <IconButton
929
+ icon="trash-2"
930
+ label={t('accr_unassign_label', {
931
+ name: row.policy.name,
932
+ subject: subjectName(rung.kind, row.assignment.subjectId),
933
+ })}
934
+ size={28}
935
+ onclick={() => (unassigning = row)}
936
+ />
937
+ {/if}
938
+ </li>
939
+ {/each}
940
+ </ul>
941
+ {/if}
942
+ </div>
943
+ {/each}
944
+ </div>
945
+ {:else if policiesQuery.isError}
946
+ <EmptyState icon="triangle-alert" title={t('accr_ladder_error')}>
947
+ {#snippet actions()}
948
+ <Button variant="secondary" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
949
+ {/snippet}
950
+ </EmptyState>
951
+ {:else}
952
+ <EmptyState
953
+ icon="git-branch"
954
+ title={t('accr_ladder_none')}
955
+ description={livePolicies.length > 0 ? t('accr_ladder_none_desc') : t('accr_ladder_needs_policy')}
956
+ >
957
+ {#snippet actions()}
958
+ {#if manage && livePolicies.length > 0}
959
+ <Button icon="user-plus" onclick={() => openAssign()}>{t('accr_assign')}</Button>
960
+ {:else if manage}
961
+ <Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
962
+ {/if}
963
+ {/snippet}
964
+ </EmptyState>
965
+ {/if}
966
+ </SettingsSection>
967
+
968
+ <!-- ---------------------------------------------------------------- crediting -->
969
+ <SettingsSection title={t('accr_run_section')} description={t('accr_run_section_desc')}>
970
+ <p class="note">{t('accr_job_note')}</p>
971
+ {#if manage && livePolicies.length === 0}
972
+ <!-- Disabled with the reason beside it, rather than a dead button somebody clicks twice. -->
973
+ <p class="hint spaced">{t('accr_run_needs_policy')}</p>
974
+ {/if}
975
+ {#snippet footer()}
976
+ <Button
977
+ variant="secondary"
978
+ icon="play"
979
+ disabled={!manage || livePolicies.length === 0}
980
+ onclick={() => {
981
+ runOpen = true
982
+ }}
983
+ >
984
+ {t('accr_run_open')}
985
+ </Button>
986
+ {/snippet}
987
+ </SettingsSection>
988
+ </SettingsPage>
989
+
990
+ <!-- ---------------------------------------------------------------- the policy form -->
991
+ <Dialog
992
+ open={policyDialog !== null}
993
+ size="lg"
994
+ title={policyDialog === 'edit' ? t('accr_edit_title') : t('accr_create_title')}
995
+ onOpenChange={(open) => {
996
+ if (!open) policyDialog = null
997
+ }}
998
+ >
999
+ <div class="form">
1000
+ {#if policyDialog === 'edit'}
1001
+ <!-- The contract's own rule, said where it matters: an edit rewrites what was true in the
1002
+ past, and everything already derived from it becomes unexplainable. -->
1003
+ <p class="note">{t('accr_edit_retroactive')}</p>
1004
+ {/if}
1005
+
1006
+ <Field label={t('accr_name')} hint={t('accr_name_hint')} required>
1007
+ {#snippet children(id)}
1008
+ <Input {id} bind:value={policyName} maxlength={120} />
1009
+ {/snippet}
1010
+ </Field>
1011
+
1012
+ <Field label={t('accr_frequency')} hint={freqDesc(frequency)}>
1013
+ {#snippet children(id)}
1014
+ <Select
1015
+ {id}
1016
+ value={frequency}
1017
+ onValueChange={(v) => (frequency = v as Frequency)}
1018
+ options={FREQUENCIES.map((f) => ({ value: f, label: freqLabel(f) }))}
1019
+ />
1020
+ {/snippet}
1021
+ </Field>
1022
+
1023
+ {#if frequency === 'per_hour_worked' && !hasAttendance}
1024
+ <!-- The ratio it accrues on is worked minutes over scheduled minutes, and both come from
1025
+ attendance day sheets. Without them the divisor is zero and every run skips everybody. -->
1026
+ <p class="note warn">{t('accr_needs_attendance')}</p>
1027
+ {/if}
1028
+
1029
+ <Field label={t('accr_leave_type')} hint={t('accr_leave_type_hint')} required>
1030
+ {#snippet children(id)}
1031
+ <Select
1032
+ {id}
1033
+ value={leaveTypeKey}
1034
+ onValueChange={(v) => (leaveTypeKey = v)}
1035
+ placeholder={t('accr_leave_type_pick')}
1036
+ ariaLabel={t('accr_leave_type')}
1037
+ options={leaveTypes.map((type) => ({ value: type.key, label: type.name }))}
1038
+ />
1039
+ {/snippet}
1040
+ </Field>
1041
+
1042
+ {#if leaveTypes.length === 0 && !leaveTypesQuery.isLoading}
1043
+ <p class="note warn">
1044
+ {t('accr_no_leave_types')}
1045
+ {#if canHr('leaveManage')}
1046
+ <a class="link" href={`/${workspaceSlug}/settings/hr/leave`}>{t('settings_leave')}</a>
1047
+ {/if}
1048
+ </p>
1049
+ {/if}
1050
+
1051
+ <div class="pair">
1052
+ <Field label={t('accr_days_field')} hint={t('accr_days_field_hint')}>
1053
+ {#snippet children(id)}
1054
+ <Input {id} type="number" min={0} max={365} step={0.5} bind:value={daysPerYear} />
1055
+ {/snippet}
1056
+ </Field>
1057
+ <Field
1058
+ label={t('accr_day_length')}
1059
+ hint={t('accr_day_length_hint', { length: duration(clamped(minutesPerDay, 1, 1440)) })}
1060
+ >
1061
+ {#snippet children(id)}
1062
+ <Input {id} type="number" min={1} max={1440} bind:value={minutesPerDay} />
1063
+ {/snippet}
1064
+ </Field>
1065
+ </div>
1066
+
1067
+ <div class="pair">
1068
+ <Field label={t('accr_waiting')} hint={t('accr_waiting_hint')}>
1069
+ {#snippet children(id)}
1070
+ <Input {id} type="number" min={0} max={24} bind:value={waitingMonths} />
1071
+ {/snippet}
1072
+ </Field>
1073
+ <Field label={t('accr_rounding')} hint={t('accr_rounding_hint')}>
1074
+ {#snippet children(id)}
1075
+ <Select {id} value={roundTo} onValueChange={(v) => (roundTo = v)} options={roundOptions} />
1076
+ {/snippet}
1077
+ </Field>
1078
+ </div>
1079
+
1080
+ <div class="tierbox">
1081
+ <SectionLabel label={t('accr_tiers')} count={tiers.length} sub>
1082
+ {#snippet trailing()}
1083
+ <Button
1084
+ size="xs"
1085
+ variant="ghost"
1086
+ icon="plus"
1087
+ onclick={() => (tiers = [...tiers, { afterYears: '5', daysPerYear: daysPerYear }])}
1088
+ >
1089
+ {t('accr_tier_add')}
1090
+ </Button>
1091
+ {/snippet}
1092
+ </SectionLabel>
1093
+ <p class="hint">{t('accr_tiers_hint')}</p>
1094
+
1095
+ {#if tiers.length === 0}
1096
+ <p class="sub">{t('accr_tiers_none', { count: clampedDays(daysPerYear, 0, 365) })}</p>
1097
+ {:else}
1098
+ <ul class="tiers">
1099
+ {#each tiers as tier, index (index)}
1100
+ <li>
1101
+ <Field label={t('accr_tier_after')}>
1102
+ {#snippet children(id)}
1103
+ <Input {id} size="sm" type="number" min={0} max={60} bind:value={tier.afterYears} />
1104
+ {/snippet}
1105
+ </Field>
1106
+ <Field label={t('accr_tier_days')}>
1107
+ {#snippet children(id)}
1108
+ <Input
1109
+ {id}
1110
+ size="sm"
1111
+ type="number"
1112
+ min={0}
1113
+ max={365}
1114
+ step={0.5}
1115
+ bind:value={tier.daysPerYear}
1116
+ />
1117
+ {/snippet}
1118
+ </Field>
1119
+ <IconButton
1120
+ icon="trash-2"
1121
+ label={t('accr_tier_remove', { years: clamped(tier.afterYears, 0, 60) })}
1122
+ size={28}
1123
+ onclick={() => (tiers = tiers.filter((_, i) => i !== index))}
1124
+ />
1125
+ </li>
1126
+ {/each}
1127
+ </ul>
1128
+ {/if}
1129
+ {#if tiersClash}
1130
+ <p class="hint danger" role="alert">{t('accr_error_tier_clash')}</p>
1131
+ {/if}
1132
+ </div>
1133
+
1134
+ <div class="pair">
1135
+ <Field label={t('accr_effective_from')} hint={t('accr_effective_from_hint')} required>
1136
+ {#snippet children(id)}
1137
+ <Input {id} type="date" bind:value={policyFrom} disabled={policyDialog === 'edit'} />
1138
+ {/snippet}
1139
+ </Field>
1140
+ <Field label={t('accr_effective_to')} hint={t('accr_effective_to_hint')}>
1141
+ {#snippet children(id)}
1142
+ <Input {id} type="date" bind:value={policyTo} />
1143
+ {/snippet}
1144
+ </Field>
1145
+ </div>
1146
+
1147
+ {#if policyError}
1148
+ <p class="note danger-note" role="alert">{policyError}</p>
1149
+ {/if}
1150
+ </div>
1151
+
1152
+ {#snippet footer()}
1153
+ {#if formProblem}
1154
+ <span class="hint problem">{formProblem}</span>
1155
+ {/if}
1156
+ <Button variant="secondary" onclick={() => (policyDialog = null)} disabled={savePolicy.isPending}>
1157
+ {t('cancel')}
1158
+ </Button>
1159
+ <Button
1160
+ loading={savePolicy.isPending}
1161
+ disabled={!manage || formProblem !== null}
1162
+ onclick={() => once(() => savePolicy.mutate())}
1163
+ >
1164
+ {policyDialog === 'edit' ? t('common.save') : t('common.create')}
1165
+ </Button>
1166
+ {/snippet}
1167
+ </Dialog>
1168
+
1169
+ <!-- ---------------------------------------------------------------- archive a policy -->
1170
+ <Dialog
1171
+ open={archiving !== null}
1172
+ size="sm"
1173
+ title={archiving ? t('accr_archive_title', { name: archiving.name }) : ''}
1174
+ description={t('accr_archive_body')}
1175
+ onOpenChange={(open) => {
1176
+ if (!open) archiving = null
1177
+ }}
1178
+ >
1179
+ {#if archiving}
1180
+ <p class="body">{t('accr_archive_keeps')}</p>
1181
+ {#if archiving.assignments.length > 0}
1182
+ <p class="body warn-text">
1183
+ {t('accr_archive_assigned', { count: archiving.assignments.length })}
1184
+ </p>
1185
+ {/if}
1186
+ {/if}
1187
+
1188
+ {#snippet footer()}
1189
+ <Button variant="secondary" onclick={() => (archiving = null)} disabled={archivePolicy.isPending}>
1190
+ {t('cancel')}
1191
+ </Button>
1192
+ <Button
1193
+ variant="danger"
1194
+ loading={archivePolicy.isPending}
1195
+ onclick={() => {
1196
+ if (archiving) once(() => archiving && archivePolicy.mutate(archiving))
1197
+ }}
1198
+ >
1199
+ {t('common.archive')}
1200
+ </Button>
1201
+ {/snippet}
1202
+ </Dialog>
1203
+
1204
+ <!-- ---------------------------------------------------------------- assign a policy -->
1205
+ <Dialog
1206
+ open={assignOpen}
1207
+ title={t('accr_assign_title')}
1208
+ description={t('accr_assign_desc')}
1209
+ onOpenChange={(open) => {
1210
+ if (!open) assignOpen = false
1211
+ }}
1212
+ >
1213
+ <div class="form">
1214
+ <Field label={t('accr_assign_policy')} required>
1215
+ {#snippet children(id)}
1216
+ <Select
1217
+ {id}
1218
+ value={assignPolicyId}
1219
+ onValueChange={(v) => (assignPolicyId = v)}
1220
+ placeholder={t('accr_assign_policy_pick')}
1221
+ ariaLabel={t('accr_assign_policy')}
1222
+ options={livePolicies.map((policy) => ({ value: policy.id, label: policy.name }))}
1223
+ />
1224
+ {/snippet}
1225
+ </Field>
1226
+
1227
+ <Field label={t('accr_assign_rung')} hint={rungHint(assignRung)} required>
1228
+ {#snippet children(id)}
1229
+ <Select
1230
+ {id}
1231
+ value={assignRung}
1232
+ onValueChange={pickRung}
1233
+ options={LADDER.map((kind) => ({
1234
+ value: kind,
1235
+ label: rungLabel(kind),
1236
+ }))}
1237
+ />
1238
+ {/snippet}
1239
+ </Field>
1240
+
1241
+ {#if beatenBy.length > 0}
1242
+ <div class="beaten">
1243
+ <span class="hint">{t('accr_beaten_by')}</span>
1244
+ <span class="chips">
1245
+ {#each beatenBy as kind (kind)}
1246
+ <Badge tone="warning">{rungLabel(kind)}</Badge>
1247
+ {/each}
1248
+ </span>
1249
+ </div>
1250
+ {:else}
1251
+ <p class="hint">{t('accr_beats_everything')}</p>
1252
+ {/if}
1253
+
1254
+ {#if assignRung !== 'workspace'}
1255
+ {@const blocked = rungBlocked(assignRung)}
1256
+ <Field label={t('accr_assign_subject', { rung: rungLabel(assignRung) })} error={blocked} required>
1257
+ {#snippet children(id)}
1258
+ <Select
1259
+ {id}
1260
+ value={assignSubjectId}
1261
+ onValueChange={(v) => (assignSubjectId = v)}
1262
+ disabled={Boolean(blocked) || subjectsLoading || subjectChoices.length === 0}
1263
+ placeholder={subjectsLoading ? t('accr_subject_loading') : t('accr_subject_pick')}
1264
+ ariaLabel={t('accr_assign_subject', { rung: rungLabel(assignRung) })}
1265
+ options={subjectChoices}
1266
+ />
1267
+ {/snippet}
1268
+ </Field>
1269
+ {#if !blocked && !subjectsLoading && subjectChoices.length === 0}
1270
+ <p class="hint">{t('accr_subject_none', { rung: rungLabel(assignRung) })}</p>
1271
+ {/if}
1272
+ {/if}
1273
+
1274
+ {#if rungTaken}
1275
+ <p class="note warn">
1276
+ {t('accr_rung_taken', {
1277
+ name: rungTaken.name,
1278
+ subject: subjectName(assignRung, assignSubjectId || null),
1279
+ })}
1280
+ </p>
1281
+ {/if}
1282
+
1283
+ <div class="pair">
1284
+ <Field label={t('accr_effective_from')} required>
1285
+ {#snippet children(id)}
1286
+ <Input {id} type="date" bind:value={assignFrom} />
1287
+ {/snippet}
1288
+ </Field>
1289
+ <Field label={t('accr_effective_to')} hint={t('accr_assign_to_hint')}>
1290
+ {#snippet children(id)}
1291
+ <Input {id} type="date" bind:value={assignTo} />
1292
+ {/snippet}
1293
+ </Field>
1294
+ </div>
1295
+
1296
+ {#if assignError}
1297
+ <p class="note danger-note" role="alert">{assignError}</p>
1298
+ {/if}
1299
+ </div>
1300
+
1301
+ {#snippet footer()}
1302
+ {#if assignProblem}
1303
+ <span class="hint problem">{assignProblem}</span>
1304
+ {/if}
1305
+ <Button variant="secondary" onclick={() => (assignOpen = false)} disabled={assign.isPending}>
1306
+ {t('cancel')}
1307
+ </Button>
1308
+ <Button
1309
+ loading={assign.isPending}
1310
+ disabled={!manage || assignProblem !== null}
1311
+ onclick={() => once(() => assign.mutate())}
1312
+ >
1313
+ {t('accr_assign')}
1314
+ </Button>
1315
+ {/snippet}
1316
+ </Dialog>
1317
+
1318
+ <!-- ---------------------------------------------------------------- unassign -->
1319
+ <Dialog
1320
+ open={unassigning !== null}
1321
+ size="sm"
1322
+ title={unassigning
1323
+ ? t('accr_unassign_title', {
1324
+ subject: subjectName(unassigning.assignment.subjectKind, unassigning.assignment.subjectId),
1325
+ })
1326
+ : ''}
1327
+ description={unassigning ? t('accr_unassign_body', { name: unassigning.policy.name }) : ''}
1328
+ onOpenChange={(open) => {
1329
+ if (!open) unassigning = null
1330
+ }}
1331
+ >
1332
+ {#if unassigning}
1333
+ <!-- What happens to the people underneath is the whole question, and it has two answers. -->
1334
+ <p class="body">
1335
+ {workspaceDefault && unassigning.assignment.subjectKind !== 'workspace'
1336
+ ? t('accr_unassign_falls_back', { name: workspaceDefault.name })
1337
+ : t('accr_unassign_no_fallback')}
1338
+ </p>
1339
+ <p class="body sub">{t('accr_unassign_keeps')}</p>
1340
+ {/if}
1341
+
1342
+ {#snippet footer()}
1343
+ <Button variant="secondary" onclick={() => (unassigning = null)} disabled={unassign.isPending}>
1344
+ {t('cancel')}
1345
+ </Button>
1346
+ <Button
1347
+ variant="danger"
1348
+ loading={unassign.isPending}
1349
+ onclick={() => {
1350
+ if (unassigning) once(() => unassigning && unassign.mutate(unassigning))
1351
+ }}
1352
+ >
1353
+ {t('accr_unassign')}
1354
+ </Button>
1355
+ {/snippet}
1356
+ </Dialog>
1357
+
1358
+ <!-- ---------------------------------------------------------------- preview, then run -->
1359
+ <Dialog
1360
+ bind:open={runOpen}
1361
+ size="lg"
1362
+ title={t('accr_run_title')}
1363
+ description={t('accr_run_desc')}
1364
+ onOpenChange={(open) => {
1365
+ if (!open) runOpen = false
1366
+ }}
1367
+ >
1368
+ <div class="form">
1369
+ <div class="pair">
1370
+ <Field label={t('accr_run_from')} required>
1371
+ {#snippet children(id)}
1372
+ <Input {id} type="date" bind:value={runFrom} />
1373
+ {/snippet}
1374
+ </Field>
1375
+ <Field label={t('accr_run_to')} required>
1376
+ {#snippet children(id)}
1377
+ <Input {id} type="date" bind:value={runTo} />
1378
+ {/snippet}
1379
+ </Field>
1380
+ </div>
1381
+
1382
+ {#if !runRangeValid}
1383
+ <p class="hint">{t('accr_error_to_before_from')}</p>
1384
+ {:else if previewQuery.isLoading || previewQuery.isFetching}
1385
+ <div class="rows">
1386
+ {#each [1, 2, 3, 4] as n (n)}<Skeleton height="40px" />{/each}
1387
+ </div>
1388
+ {:else if preview}
1389
+ <div class="tiles">
1390
+ <StatTile size="md" label={t('accr_run_people')} value={formatCount(creditable.length, 999)} />
1391
+ <StatTile size="md" label={t('accr_run_total')} value={duration(preview.totalMinutes)} />
1392
+ <StatTile size="md" label={t('accr_run_skipped')} value={formatCount(preview.skipped.length, 999)} />
1393
+ </div>
1394
+
1395
+ {#if preview.rows.length === 0}
1396
+ <EmptyState
1397
+ icon="check-check"
1398
+ compact
1399
+ title={t('accr_run_nothing')}
1400
+ description={t('accr_run_nothing_desc')}
1401
+ />
1402
+ {:else}
1403
+ <SectionLabel label={t('accr_run_rows')} count={preview.rows.length} sub />
1404
+ <div class="scroll">
1405
+ <ul class="plist">
1406
+ {#each preview.rows as row (row.personId + row.leaveTypeId)}
1407
+ <li class:done={row.alreadyAccrued}>
1408
+ <span class="pname">{row.displayName}</span>
1409
+ <span class="pdays num">
1410
+ {days(row.days)}
1411
+ {t('days', { count: row.days })}
1412
+ </span>
1413
+ <span class="sub ptype">{row.leaveTypeName}</span>
1414
+ <!-- The server's own words for why the number is what it is: it is arithmetic, not
1415
+ a sentence this module could translate without recomputing it. -->
1416
+ <span class="sub preason">{row.reason}</span>
1417
+ {#if row.alreadyAccrued}
1418
+ <Badge tone="grey">{t('accr_run_already')}</Badge>
1419
+ {/if}
1420
+ </li>
1421
+ {/each}
1422
+ </ul>
1423
+ </div>
1424
+ {/if}
1425
+
1426
+ {#if preview.skipped.length > 0}
1427
+ <SectionLabel label={t('accr_run_skipped')} count={preview.skipped.length} sub />
1428
+ <ul class="plist">
1429
+ {#each preview.skipped.slice(0, HEAD) as row (row.personId)}
1430
+ <li>
1431
+ <span class="pname">{row.displayName}</span>
1432
+ <span class="sub preason">{row.reason}</span>
1433
+ </li>
1434
+ {/each}
1435
+ {#if preview.skipped.length > HEAD}
1436
+ <li class="sub">{t('accr_run_more', { count: preview.skipped.length - HEAD })}</li>
1437
+ {/if}
1438
+ </ul>
1439
+ {/if}
1440
+
1441
+ {#if alreadyDone > 0}
1442
+ <p class="hint">{t('accr_run_idempotent', { count: alreadyDone })}</p>
1443
+ {/if}
1444
+ <p class="note warn">{t('accr_run_writes')}</p>
1445
+ {:else if previewQuery.isError}
1446
+ <EmptyState icon="triangle-alert" title={t('accr_preview_error')} description={t('accr_error_desc')}>
1447
+ {#snippet actions()}
1448
+ <Button variant="secondary" onclick={() => void previewQuery.refetch()}>{t('retry')}</Button>
1449
+ {/snippet}
1450
+ </EmptyState>
1451
+ {/if}
1452
+ </div>
1453
+
1454
+ {#snippet footer()}
1455
+ <Button variant="secondary" onclick={() => (runOpen = false)} disabled={runAccrual.isPending}>
1456
+ {t('cancel')}
1457
+ </Button>
1458
+ <!--
1459
+ The run cannot be reached without the preview: it is disabled until `accrual.preview` has
1460
+ answered with something to credit, which is the whole reason that procedure exists.
1461
+ -->
1462
+ <Button
1463
+ loading={runAccrual.isPending}
1464
+ disabled={!manage || !preview || creditable.length === 0}
1465
+ onclick={() => once(() => runAccrual.mutate())}
1466
+ >
1467
+ {t('accr_run_credit', { count: creditable.length })}
1468
+ </Button>
1469
+ {/snippet}
1470
+ </Dialog>
1471
+
1472
+ <style>
1473
+ .rows {
1474
+ display: grid;
1475
+ gap: 4px;
1476
+ }
1477
+ .tiles {
1478
+ display: grid;
1479
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1480
+ gap: 10px;
1481
+ margin-block-end: 18px;
1482
+ }
1483
+
1484
+ /* One grid for the header and every row, so the columns line up down the page. */
1485
+ .table {
1486
+ width: 100%;
1487
+ --hr-accr-cols: minmax(150px, 1.4fr) minmax(120px, 1.2fr) minmax(110px, 1fr) 56px 32px;
1488
+ }
1489
+ .thead,
1490
+ .trow {
1491
+ display: grid;
1492
+ grid-template-columns: var(--hr-accr-cols);
1493
+ gap: 10px;
1494
+ align-items: center;
1495
+ padding-inline: 10px;
1496
+ }
1497
+ .thead {
1498
+ height: 32px;
1499
+ border-block-end: 1px solid var(--kern-border);
1500
+ font-size: 11px;
1501
+ font-weight: 600;
1502
+ letter-spacing: 0.06em;
1503
+ text-transform: uppercase;
1504
+ color: var(--kern-ink-500);
1505
+ }
1506
+ .trow {
1507
+ min-height: 52px;
1508
+ border-block-end: 1px solid var(--kern-border-hairline);
1509
+ border-radius: var(--kern-r-md);
1510
+ }
1511
+ .trow:hover {
1512
+ background: var(--kern-surface-raised);
1513
+ }
1514
+ .cell {
1515
+ min-width: 0;
1516
+ }
1517
+ .what {
1518
+ display: grid;
1519
+ gap: 2px;
1520
+ }
1521
+ .strong {
1522
+ min-width: 0;
1523
+ overflow: hidden;
1524
+ text-overflow: ellipsis;
1525
+ white-space: nowrap;
1526
+ font-size: 13.5px;
1527
+ font-weight: 500;
1528
+ color: var(--kern-ink-900);
1529
+ }
1530
+ /* Muted with a colour, never opacity: opacity fades text against the page whatever token it names. */
1531
+ .sub {
1532
+ min-width: 0;
1533
+ overflow: hidden;
1534
+ text-overflow: ellipsis;
1535
+ font-size: 12px;
1536
+ color: var(--kern-ink-500);
1537
+ }
1538
+ .chips {
1539
+ display: inline-flex;
1540
+ align-items: center;
1541
+ gap: 6px;
1542
+ }
1543
+ .num {
1544
+ font-variant-numeric: tabular-nums;
1545
+ }
1546
+ .actions {
1547
+ display: flex;
1548
+ justify-content: flex-end;
1549
+ overflow: visible;
1550
+ }
1551
+ .danger {
1552
+ color: var(--kern-danger);
1553
+ }
1554
+ .warn-text {
1555
+ color: var(--kern-warning);
1556
+ }
1557
+
1558
+ .ladder {
1559
+ display: grid;
1560
+ gap: 16px;
1561
+ }
1562
+ .rung {
1563
+ display: grid;
1564
+ gap: 2px;
1565
+ padding-inline-start: 10px;
1566
+ border-inline-start: 2px solid var(--kern-accent);
1567
+ }
1568
+ /* A rung nobody uses still belongs on the ladder — it is drawn quieter, not dropped, because the
1569
+ gap between two rungs is itself the thing an admin is reading. */
1570
+ .rung.empty {
1571
+ border-inline-start-color: var(--kern-border);
1572
+ }
1573
+ .rlist {
1574
+ display: grid;
1575
+ gap: 2px;
1576
+ margin: 4px 0 0;
1577
+ padding: 0;
1578
+ list-style: none;
1579
+ }
1580
+ .rlist li {
1581
+ display: grid;
1582
+ grid-template-columns: minmax(140px, 1.4fr) minmax(100px, 1fr) minmax(110px, 0.9fr) 32px;
1583
+ gap: 10px;
1584
+ align-items: center;
1585
+ min-height: 36px;
1586
+ min-width: 0;
1587
+ padding-inline: 8px;
1588
+ border-radius: var(--kern-r-md);
1589
+ }
1590
+ .rlist li:hover {
1591
+ background: var(--kern-surface-raised);
1592
+ }
1593
+ .rsubject {
1594
+ display: flex;
1595
+ align-items: center;
1596
+ gap: 8px;
1597
+ min-width: 0;
1598
+ }
1599
+ .rpolicy {
1600
+ min-width: 0;
1601
+ overflow: hidden;
1602
+ text-overflow: ellipsis;
1603
+ white-space: nowrap;
1604
+ font-size: 13px;
1605
+ color: var(--kern-ink-700);
1606
+ }
1607
+ .rwhen {
1608
+ white-space: nowrap;
1609
+ }
1610
+
1611
+ .hint {
1612
+ margin: 0;
1613
+ font-size: 12px;
1614
+ color: var(--kern-ink-500);
1615
+ }
1616
+ .hint.spaced {
1617
+ margin-block-start: 10px;
1618
+ }
1619
+ /* The reason a footer button is disabled sits beside it and takes the slack, so a long sentence
1620
+ pushes nothing off the end of the row. */
1621
+ .problem {
1622
+ flex: 1;
1623
+ min-width: 0;
1624
+ text-wrap: pretty;
1625
+ }
1626
+ .note {
1627
+ margin: 0;
1628
+ padding: 10px 12px;
1629
+ border-radius: var(--kern-r-md2);
1630
+ background: var(--kern-info-tint);
1631
+ color: var(--kern-ink-700);
1632
+ font-size: 12.5px;
1633
+ line-height: 1.5;
1634
+ }
1635
+ .note.warn {
1636
+ background: var(--kern-warning-tint);
1637
+ color: var(--kern-warning);
1638
+ }
1639
+ .danger-note {
1640
+ background: var(--kern-danger-tint);
1641
+ color: var(--kern-danger);
1642
+ }
1643
+ .stale {
1644
+ display: flex;
1645
+ align-items: center;
1646
+ justify-content: space-between;
1647
+ flex-wrap: wrap;
1648
+ gap: 8px;
1649
+ margin: 0;
1650
+ padding-block: 6px;
1651
+ padding-inline: 12px 8px;
1652
+ border-radius: var(--kern-r-md);
1653
+ background: var(--kern-warning-tint);
1654
+ color: var(--kern-warning);
1655
+ font-size: 12.5px;
1656
+ }
1657
+ .body {
1658
+ margin: 0 0 4px;
1659
+ font-size: 13.5px;
1660
+ }
1661
+ .link {
1662
+ color: var(--kern-accent-text);
1663
+ text-decoration: underline;
1664
+ }
1665
+
1666
+ .form {
1667
+ display: grid;
1668
+ gap: 14px;
1669
+ }
1670
+ .pair {
1671
+ display: grid;
1672
+ grid-template-columns: 1fr 1fr;
1673
+ gap: 12px;
1674
+ align-items: start;
1675
+ }
1676
+ .beaten {
1677
+ display: flex;
1678
+ align-items: center;
1679
+ flex-wrap: wrap;
1680
+ gap: 8px;
1681
+ }
1682
+ .tierbox {
1683
+ display: grid;
1684
+ gap: 4px;
1685
+ }
1686
+ .tiers {
1687
+ display: grid;
1688
+ gap: 8px;
1689
+ margin: 4px 0 0;
1690
+ padding: 0;
1691
+ list-style: none;
1692
+ }
1693
+ .tiers li {
1694
+ display: grid;
1695
+ grid-template-columns: 1fr 1fr 32px;
1696
+ gap: 10px;
1697
+ align-items: end;
1698
+ }
1699
+
1700
+ .scroll {
1701
+ max-block-size: 300px;
1702
+ overflow-y: auto;
1703
+ overflow-x: auto;
1704
+ }
1705
+ .plist {
1706
+ display: grid;
1707
+ gap: 2px;
1708
+ margin: 0;
1709
+ padding: 0;
1710
+ list-style: none;
1711
+ }
1712
+ .plist li {
1713
+ display: grid;
1714
+ grid-template-columns: minmax(120px, 1fr) 72px minmax(80px, 0.8fr) minmax(120px, 1.4fr) auto;
1715
+ gap: 10px;
1716
+ align-items: baseline;
1717
+ min-width: 0;
1718
+ padding-block: 4px;
1719
+ font-size: 13px;
1720
+ }
1721
+ /* A row already credited is still shown — that is the idempotence made visible — and it is drawn
1722
+ quieter with a colour rather than faded with opacity. */
1723
+ .plist li.done .pname {
1724
+ color: var(--kern-ink-500);
1725
+ }
1726
+ .pname {
1727
+ min-width: 0;
1728
+ overflow: hidden;
1729
+ text-overflow: ellipsis;
1730
+ white-space: nowrap;
1731
+ }
1732
+ .pdays {
1733
+ white-space: nowrap;
1734
+ }
1735
+ .ptype,
1736
+ .preason {
1737
+ white-space: nowrap;
1738
+ }
1739
+
1740
+ .sr-only {
1741
+ position: absolute;
1742
+ width: 1px;
1743
+ height: 1px;
1744
+ overflow: hidden;
1745
+ clip-path: inset(50%);
1746
+ white-space: nowrap;
1747
+ }
1748
+
1749
+ @media (max-width: 640px) {
1750
+ .table {
1751
+ --hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
1752
+ }
1753
+ /* The frequency and the assignment count go; what a policy earns and who it names cannot. */
1754
+ .thead > :nth-child(3),
1755
+ .trow > :nth-child(3),
1756
+ .thead > :nth-child(4),
1757
+ .trow > :nth-child(4) {
1758
+ display: none;
1759
+ }
1760
+ /* Both lists stop being tables and become wrapped lines: the effective range and the server's
1761
+ arithmetic are the parts a narrow screen can lose without losing the answer. */
1762
+ .rlist li,
1763
+ .plist li {
1764
+ display: flex;
1765
+ flex-wrap: wrap;
1766
+ gap: 4px 10px;
1767
+ align-items: center;
1768
+ }
1769
+ .rwhen,
1770
+ .ptype,
1771
+ .preason {
1772
+ display: none;
1773
+ }
1774
+ .pair,
1775
+ .tiers li {
1776
+ grid-template-columns: 1fr;
1777
+ }
1778
+ }
1779
+ </style>