@kernhq/module-hr 0.13.1 → 0.14.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.
Files changed (61) hide show
  1. package/dist/contract/attendance.d.ts +35 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +25 -1
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts.map +1 -1
  6. package/dist/contract/capabilities.js +10 -0
  7. package/dist/contract/capabilities.js.map +1 -1
  8. package/dist/contract/events.d.ts +17 -16
  9. package/dist/contract/events.d.ts.map +1 -1
  10. package/dist/contract/events.js +23 -16
  11. package/dist/contract/events.js.map +1 -1
  12. package/dist/contract/models.d.ts +1 -0
  13. package/dist/contract/models.d.ts.map +1 -1
  14. package/dist/contract/models.js +11 -0
  15. package/dist/contract/models.js.map +1 -1
  16. package/dist/contract/router.d.ts +12 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +11 -3
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/contract/settings.d.ts +0 -1
  21. package/dist/contract/settings.d.ts.map +1 -1
  22. package/dist/contract/settings.js +11 -5
  23. package/dist/contract/settings.js.map +1 -1
  24. package/dist/server/index.d.ts +0 -1
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +30 -0
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/jobs.d.ts.map +1 -1
  29. package/dist/server/jobs.js +13 -5
  30. package/dist/server/jobs.js.map +1 -1
  31. package/dist/server/router.d.ts +12 -0
  32. package/dist/server/router.d.ts.map +1 -1
  33. package/dist/server/router.js +374 -76
  34. package/dist/server/router.js.map +1 -1
  35. package/dist/server/services/access.d.ts +176 -0
  36. package/dist/server/services/access.d.ts.map +1 -0
  37. package/dist/server/services/access.js +260 -0
  38. package/dist/server/services/access.js.map +1 -0
  39. package/dist/server/services/people.d.ts +1 -0
  40. package/dist/server/services/people.d.ts.map +1 -1
  41. package/dist/server/services/people.js +3 -0
  42. package/dist/server/services/people.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/client/components/DayDetail.svelte +32 -14
  45. package/src/client/components/PersonPanel.svelte +66 -5
  46. package/src/client/components/redaction.ts +51 -0
  47. package/src/client/messages.ts +346 -77
  48. package/src/client/pages/AttendancePage.svelte +24 -1
  49. package/src/client/pages/DirectoryPage.svelte +285 -11
  50. package/src/client/pages/OfficesPage.svelte +6 -0
  51. package/src/client/pages/OrgPage.svelte +7 -0
  52. package/src/client/permissions.ts +12 -4
  53. package/src/client/settings/AccrualSettings.svelte +715 -95
  54. package/src/client/settings/GeneralSettings.svelte +12 -9
  55. package/src/client/settings/SchedulesSettings.svelte +31 -78
  56. package/src/contract/attendance.ts +27 -1
  57. package/src/contract/capabilities.ts +10 -0
  58. package/src/contract/events.ts +23 -19
  59. package/src/contract/models.ts +11 -0
  60. package/src/contract/router.ts +11 -3
  61. package/src/contract/settings.ts +11 -5
@@ -30,8 +30,10 @@ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-qu
30
30
  // `AccrualConfig` is imported as a value on purpose — see `configOf`.
31
31
  import {
32
32
  AccrualConfig,
33
+ CarryForwardConfig,
33
34
  type Policy,
34
35
  type PolicyAssignment,
36
+ type PolicyKind,
35
37
  type PolicySubjectKind,
36
38
  SUBJECT_PRIORITY,
37
39
  } from '../../contract/policies.js'
@@ -46,7 +48,7 @@ import { formatDays, formatDuration, hrKeys, isoDate, monthRange } from '../quer
46
48
  *
47
49
  * Switching `leave_accrual` on used to start an hourly job and give nobody a way to look at it: the
48
50
  * 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.
51
+ * four things justify this screen, and everything on it serves one of them.
50
52
  *
51
53
  * **A policy is data, and the data has to say when leave appears.** All four frequencies are
52
54
  * implemented and each answers a different question — a twelfth every month, the whole year on one
@@ -65,6 +67,14 @@ import { formatDays, formatDuration, hrKeys, isoDate, monthRange } from '../quer
65
67
  * the run button lives inside the preview dialog and is disabled until there are numbers on screen.
66
68
  * A credit cannot be undone from here — it is corrected with a leave adjustment — which is exactly
67
69
  * why an admin gets to read it first.
70
+ *
71
+ * **What is earned has to survive the turn of the year, and that is a second policy.** The engine
72
+ * has always carried a capped balance forward and expired the rest — `carryForward`, the
73
+ * `carry-forward` job, and `carry_in` / `carry_out` / `expiry` in the ledger — but a
74
+ * `carry_forward` policy could not be written from anywhere, so the capability card promised
75
+ * carry-forward and expiry and an admin could reach neither. Both kinds are `policies` rows on one
76
+ * ladder, resolved independently of each other, so they share this screen's assign dialog and are
77
+ * told apart by a badge rather than by two ladders that would each hide half the answer.
68
78
  */
69
79
  const api = getHrApi()
70
80
  const queryClient = useQueryClient()
@@ -105,6 +115,16 @@ const freqLabel = (frequency: Frequency): string =>
105
115
  ? t('accr_freq_anniversary')
106
116
  : t('accr_freq_per_hour')
107
117
 
118
+ /**
119
+ * Which of the two kinds a row is.
120
+ *
121
+ * Only `accrual` and `carry_forward` ever reach this screen: both queries below name their kind, so
122
+ * nothing else is ever in the arrays this labels. A third kind shown here would read as "Accrual"
123
+ * and would have to be given its own branch on the way in.
124
+ */
125
+ const kindLabel = (kind: PolicyKind): string =>
126
+ kind === 'carry_forward' ? t('policy_kind_carry_forward') : t('policy_kind_accrual')
127
+
108
128
  /** What the frequency actually does. Copied from `grantForPeriod`, which is what performs it. */
109
129
  const freqDesc = (frequency: Frequency): string =>
110
130
  frequency === 'monthly'
@@ -175,12 +195,6 @@ const policies = $derived((policiesQuery.data ?? []) as PolicyRow[])
175
195
  * the first frame offers "no policies yet" to a workspace that has six.
176
196
  */
177
197
  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
198
 
185
199
  /**
186
200
  * The config, parsed rather than cast.
@@ -195,6 +209,40 @@ function configOf(policy: PolicyRow): AccrualConfig | null {
195
209
  return parsed.success ? parsed.data : null
196
210
  }
197
211
 
212
+ // ---------------------------------------------------------------- carry-forward
213
+
214
+ /**
215
+ * The other kind of policy on this screen, asked for separately.
216
+ *
217
+ * One list per kind rather than one call filtered here: `policies.list` takes the kind, the two
218
+ * lists are drawn in two sections, and a single query would make the accrual tiles count
219
+ * carry-forward rows. `showArchived` is shared, because it is one control at the top of the page.
220
+ */
221
+ const carryQuery = createQuery(() => ({
222
+ queryKey: ['hr', 'policies', workspaceId, 'carry_forward', showArchived] as const,
223
+ enabled: Boolean(workspaceId),
224
+ queryFn: () => api.policies.list({ workspaceId, kind: 'carry_forward', includeArchived: showArchived }),
225
+ }))
226
+ const carryPolicies = $derived((carryQuery.data ?? []) as PolicyRow[])
227
+ const carryLoading = $derived(!workspaceId || carryQuery.isLoading)
228
+
229
+ /** Same reason as `configOf`: the stored jsonb is only a carry-forward config if it parses as one. */
230
+ function carryConfigOf(policy: PolicyRow): CarryForwardConfig | null {
231
+ const parsed = CarryForwardConfig.safeParse(policy.config)
232
+ return parsed.success ? parsed.data : null
233
+ }
234
+
235
+ /**
236
+ * A failed refetch that still has an answer, on either list.
237
+ *
238
+ * Every write here invalidates the whole module, so a refetch failing while the last good lists are
239
+ * on screen is the ordinary case — an error branch above the data would blank a working page. One
240
+ * banner for both, because a reader is being told the page is behind, not which query said so.
241
+ */
242
+ const stale = $derived(
243
+ (policiesQuery.isError && policies.length > 0) || (carryQuery.isError && carryPolicies.length > 0),
244
+ )
245
+
198
246
  const leaveTypesQuery = createQuery(() => ({
199
247
  queryKey: hrKeys.leaveTypes(workspaceId),
200
248
  enabled: Boolean(workspaceId) && canHr('leaveView'),
@@ -204,10 +252,30 @@ const leaveTypes = $derived(leaveTypesQuery.data ?? [])
204
252
  const leaveTypeName = (key: string): string => leaveTypes.find((type) => type.key === key)?.name ?? key
205
253
 
206
254
  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
- )
255
+
256
+ /**
257
+ * The rung everybody with nothing nearer falls to, per kind. Its absence is the interesting case,
258
+ * and it is a different question for each kind: a workspace can accrue without carrying anything
259
+ * over, and the two gaps are said in two different places rather than in one sentence about
260
+ * "policies" that would be half wrong whichever kind was missing.
261
+ */
262
+ const defaultOn = (rows: PolicyRow[]) =>
263
+ rows.find((p) => !p.archivedAt && p.assignments.some((a) => a.subjectKind === 'workspace')) ?? null
264
+ const workspaceDefault = $derived(defaultOn(policies))
265
+ const carryDefault = $derived(defaultOn(carryPolicies))
266
+
267
+ const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
268
+ const liveCarry = $derived(carryPolicies.filter((policy) => !policy.archivedAt))
269
+ /**
270
+ * Both kinds on one ladder.
271
+ *
272
+ * They are resolved independently — a person can be answered by their office's accrual policy and
273
+ * the workspace's carry-forward policy at once — so this is not a list of rivals. It is drawn as
274
+ * one ladder because the *rungs* are the same six and the precedence is the same rule, and two
275
+ * ladders side by side would each hide half of what applies to somebody.
276
+ */
277
+ const ladderPolicies = $derived([...livePolicies, ...liveCarry])
278
+ const ladderAssignments = $derived(ladderPolicies.flatMap((policy) => policy.assignments))
211
279
 
212
280
  /**
213
281
  * A policy change moves leave balances, the accrual preview and every resolution beneath it, so the
@@ -270,6 +338,19 @@ function failureText(error: unknown, fallbackKey: string): string {
270
338
 
271
339
  // ---------------------------------------------------------------- the policy form
272
340
 
341
+ /**
342
+ * What an empty form starts a day at, and the only eight hours on this screen.
343
+ *
344
+ * A seed, never a conversion. `accrueForPeriod` multiplies entitlement days by the policy's own
345
+ * `minutesPerDay`, so anything here that turns time into days — or days into time — reads that
346
+ * figure instead of assuming one: five days of a seven-and-a-half-hour day is 2250 minutes, and at
347
+ * a hardcoded 8 × 60 the same five days read as 2400, which is a cap that never bites and a
348
+ * balance nobody can reconcile against the ledger.
349
+ */
350
+ const DEFAULT_DAY_MINUTES = 8 * 60
351
+ /** The contract's own ceiling on `roundToMinutes`. A whole day exceeds it on any day past eight hours. */
352
+ const ROUND_CAP_MINUTES = 480
353
+
273
354
  let policyDialog = $state<'create' | 'edit' | null>(null)
274
355
  let policyId = $state('')
275
356
  let policyName = $state('')
@@ -278,11 +359,30 @@ let policyTo = $state('')
278
359
  let frequency = $state<Frequency>('monthly')
279
360
  let leaveTypeKey = $state('')
280
361
  let daysPerYear = $state('20')
281
- let minutesPerDay = $state('480')
362
+ let minutesPerDay = $state(String(DEFAULT_DAY_MINUTES))
282
363
  let waitingMonths = $state('0')
283
- let roundTo = $state('0')
364
+ /** A token rather than a minute count — see `roundMinutes`. */
365
+ let roundChoice = $state('0')
284
366
  let tiers = $state<Tier[]>([])
367
+ /**
368
+ * Carried through the form rather than shown in it.
369
+ *
370
+ * `AccrualConfig.calendar` decides where a period boundary falls — Iran accrues on Jalali months —
371
+ * and nothing on this screen sets it yet. Omitting it from the draft is not neutral: the schema
372
+ * defaults it, so saving a name change on a Persian-calendar policy would silently move it to
373
+ * Gregorian. It is read back and written out untouched until there is a control for it.
374
+ */
375
+ let policyCalendar = $state<AccrualConfig['calendar']>('gregorian')
285
376
  let policyError = $state<string | null>(null)
377
+ /**
378
+ * Whether the form was seeded from a config the schema could not read.
379
+ *
380
+ * `configOf` answering null means the stored jsonb is not an accrual config — an older release, or
381
+ * a row edited by hand. Every field below then holds a default, and saving writes those defaults
382
+ * over whatever the engine is actually using, so the dialog says so rather than presenting eight
383
+ * hours and twenty days as though they were the policy.
384
+ */
385
+ let seededFromUnreadable = $state(false)
286
386
 
287
387
  function openCreate() {
288
388
  policyDialog = 'create'
@@ -293,27 +393,36 @@ function openCreate() {
293
393
  frequency = 'monthly'
294
394
  leaveTypeKey = leaveTypes[0]?.key ?? ''
295
395
  daysPerYear = '20'
296
- minutesPerDay = '480'
396
+ minutesPerDay = String(DEFAULT_DAY_MINUTES)
297
397
  waitingMonths = '0'
298
- roundTo = '0'
398
+ roundChoice = '0'
299
399
  tiers = []
400
+ policyCalendar = 'gregorian'
300
401
  policyError = null
402
+ seededFromUnreadable = false
301
403
  }
302
404
 
303
405
  function fillFrom(policy: PolicyRow) {
304
406
  const config = configOf(policy)
407
+ seededFromUnreadable = config === null
305
408
  policyName = policy.name
306
409
  policyTo = policy.effectiveTo ?? ''
307
410
  frequency = config?.frequency ?? 'monthly'
308
411
  leaveTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
309
412
  daysPerYear = String(config?.daysPerYear ?? 20)
310
- minutesPerDay = String(config?.minutesPerDay ?? 480)
413
+ const day = config?.minutesPerDay ?? DEFAULT_DAY_MINUTES
414
+ minutesPerDay = String(day)
311
415
  waitingMonths = String(config?.waitingPeriodMonths ?? 0)
312
- roundTo = String(config?.roundToMinutes ?? 0)
416
+ // Back to what the step *meant* under the day it was saved with. Read as a bare number, a policy
417
+ // rounding to a whole 450-minute day would reopen as "450 minutes" and stop following the day the
418
+ // moment somebody lengthened it.
419
+ const step = config?.roundToMinutes ?? 0
420
+ roundChoice = step === 0 ? '0' : step === day ? 'day' : step === Math.round(day / 2) ? 'half' : String(step)
313
421
  tiers = (config?.seniorityTiers ?? []).map((tier) => ({
314
422
  afterYears: String(tier.afterYears),
315
423
  daysPerYear: String(tier.daysPerYear),
316
424
  }))
425
+ policyCalendar = config?.calendar ?? 'gregorian'
317
426
  policyError = null
318
427
  }
319
428
 
@@ -346,33 +455,56 @@ const clampedDays = (value: string, min: number, max: number) => {
346
455
  }
347
456
 
348
457
  /**
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.
458
+ * The day this policy is being given, and the figure everything below converts against.
459
+ *
460
+ * The same number the server stores as `minutesPerDay` and `accrueForPeriod` multiplies by, so a
461
+ * length shown here is the length leave is actually earned in.
462
+ */
463
+ const dayMinutes = $derived(clamped(minutesPerDay, 1, 1440))
464
+
465
+ /**
466
+ * Rounding held as what the admin meant, resolved against the day beside it.
467
+ *
468
+ * "A whole day" is 450 minutes on a seven-and-a-half-hour day and 480 on an eight-hour one. Held as
469
+ * a bare number the step stops meaning a day the moment the day length changes — silently, because
470
+ * the select goes on reading "A whole day" while the engine rounds to something else. So the choice
471
+ * is a token and the minutes follow from it.
472
+ */
473
+ const roundMinutes = $derived(
474
+ roundChoice === 'day'
475
+ ? dayMinutes
476
+ : roundChoice === 'half'
477
+ ? Math.round(dayMinutes / 2)
478
+ : Math.max(0, Math.round(Number(roundChoice) || 0)),
479
+ )
480
+
481
+ /**
482
+ * Rounding offered as the steps somebody actually means, half and whole days included.
483
+ *
484
+ * The two day steps are kept beside the fixed ones even where they land on the same number, because
485
+ * they are a different choice: one follows the day length, the other stays where it was typed. A
486
+ * whole day past the contract's cap is offered and refused by `formProblem` with the reason next to
487
+ * the button, rather than dropped from the list with nothing to explain the gap.
353
488
  */
354
489
  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
- }
490
+ const half = Math.round(dayMinutes / 2)
491
+ const options = [
492
+ { value: '0', minutes: 0, label: t('accr_round_exact') },
493
+ { value: '15', minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
494
+ { value: '30', minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
495
+ { value: '60', minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
496
+ { value: 'half', minutes: half, label: t('accr_round_half_day', { length: duration(half) }) },
497
+ { value: 'day', minutes: dayMinutes, label: t('accr_round_day', { length: duration(dayMinutes) }) },
498
+ ]
371
499
  // A stored step none of these produce would otherwise vanish the moment somebody opened the
372
500
  // 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))
501
+ if (!options.some((option) => option.value === roundChoice))
502
+ options.push({
503
+ value: roundChoice,
504
+ minutes: roundMinutes,
505
+ label: t('accr_round_minutes', { count: roundMinutes }),
506
+ })
507
+ return options.sort((a, b) => a.minutes - b.minutes).map(({ value, label }) => ({ value, label }))
376
508
  })
377
509
 
378
510
  const tierYears = $derived(tiers.map((tier) => clamped(tier.afterYears, 0, 60)))
@@ -384,19 +516,24 @@ const formProblem = $derived.by(() => {
384
516
  if (!policyFrom) return t('accr_error_from')
385
517
  if (policyTo && policyTo < policyFrom) return t('accr_error_to_before_from')
386
518
  if (tiersClash) return t('accr_error_tier_clash')
519
+ // Said rather than clamped: a step quietly cut back to eight hours is a policy rounding to
520
+ // something other than the day it names.
521
+ if (roundMinutes > ROUND_CAP_MINUTES) return t('accr_error_round_cap', { max: duration(ROUND_CAP_MINUTES) })
387
522
  return null
388
523
  })
389
524
 
390
525
  const configDraft = $derived({
391
526
  frequency,
392
527
  daysPerYear: clampedDays(daysPerYear, 0, 365),
393
- minutesPerDay: clamped(minutesPerDay, 1, 1440),
528
+ minutesPerDay: dayMinutes,
394
529
  seniorityTiers: tiers.map((tier) => ({
395
530
  afterYears: clamped(tier.afterYears, 0, 60),
396
531
  daysPerYear: clampedDays(tier.daysPerYear, 0, 365),
397
532
  })),
398
533
  waitingPeriodMonths: clamped(waitingMonths, 0, 24),
399
- roundToMinutes: clamped(roundTo, 0, 480),
534
+ // Save is blocked above the cap, so this never silently shortens a step somebody chose.
535
+ roundToMinutes: Math.min(roundMinutes, ROUND_CAP_MINUTES),
536
+ calendar: policyCalendar,
400
537
  leaveTypeKey,
401
538
  })
402
539
 
@@ -468,11 +605,150 @@ function policyMenu(policy: PolicyRow): MenuItem[] {
468
605
  ]
469
606
  }
470
607
 
608
+ // ---------------------------------------------------------------- the carry-forward form
609
+
610
+ /**
611
+ * What survives the turn of the year, and how long there is to use it.
612
+ *
613
+ * Three fields, and each of them is something the engine already reads: `maxDays` is the cap
614
+ * `carryForward` clips the closing balance to, `expiresAfterMonths` is what `carryExpiryDate`
615
+ * counts from 1 January, and `leaveTypeKey` is the balance both apply to. Nothing here is invented
616
+ * — a fourth field would be a setting nothing obeys, which is the defect this screen exists to
617
+ * repair rather than repeat.
618
+ *
619
+ * The deadline is held as a switch and a number rather than as a nullable number, because "never"
620
+ * and "one month" are the two answers an admin actually has and a blank box does not say which of
621
+ * them it means.
622
+ */
623
+ let carryDialog = $state<'create' | 'edit' | null>(null)
624
+ let carryId = $state('')
625
+ let carryName = $state('')
626
+ let carryTypeKey = $state('')
627
+ let carryMaxDays = $state('5')
628
+ let carryExpires = $state(false)
629
+ let carryMonths = $state('3')
630
+ let carryFrom = $state(isoDate())
631
+ let carryTo = $state('')
632
+ let carryError = $state<string | null>(null)
633
+ /** Same trap as `seededFromUnreadable`, for the other schema. */
634
+ let carrySeededFromUnreadable = $state(false)
635
+
636
+ function openCarryCreate() {
637
+ carryDialog = 'create'
638
+ carryId = ''
639
+ carryName = ''
640
+ carryTypeKey = leaveTypes[0]?.key ?? ''
641
+ carryMaxDays = '5'
642
+ carryExpires = false
643
+ carryMonths = '3'
644
+ carryFrom = `${new Date().getFullYear()}-01-01`
645
+ carryTo = ''
646
+ carryError = null
647
+ carrySeededFromUnreadable = false
648
+ }
649
+
650
+ function fillCarryFrom(policy: PolicyRow) {
651
+ const config = carryConfigOf(policy)
652
+ carrySeededFromUnreadable = config === null
653
+ carryName = policy.name
654
+ carryTo = policy.effectiveTo ?? ''
655
+ carryTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
656
+ carryMaxDays = String(config?.maxDays ?? 5)
657
+ carryExpires = (config?.expiresAfterMonths ?? null) !== null
658
+ // The number keeps its last value while the switch is off, so turning it back on offers what was
659
+ // there rather than an empty box.
660
+ carryMonths = String(config?.expiresAfterMonths ?? 3)
661
+ carryError = null
662
+ }
663
+
664
+ function openCarryEdit(policy: PolicyRow) {
665
+ carryDialog = 'edit'
666
+ carryId = policy.id
667
+ carryFrom = policy.effectiveFrom
668
+ fillCarryFrom(policy)
669
+ }
670
+
671
+ /** The same affordance as `openDuplicate`: a rule that changes from a date is a new policy. */
672
+ function openCarryDuplicate(policy: PolicyRow) {
673
+ carryDialog = 'create'
674
+ carryId = ''
675
+ fillCarryFrom(policy)
676
+ carryName = t('accr_copy_of', { name: policy.name })
677
+ carryFrom = isoDate()
678
+ carryTo = ''
679
+ }
680
+
681
+ const carryCap = $derived(clampedDays(carryMaxDays, 0, 365))
682
+ const carryExpiryMonths = $derived(clamped(carryMonths, 1, 24))
683
+
684
+ const carryProblem = $derived.by(() => {
685
+ if (!carryName.trim()) return t('accr_error_name')
686
+ if (!carryTypeKey) return t('cf_error_leave_type')
687
+ if (!carryFrom) return t('accr_error_from')
688
+ if (carryTo && carryTo < carryFrom) return t('accr_error_to_before_from')
689
+ return null
690
+ })
691
+
692
+ const carryConfigDraft = $derived({
693
+ leaveTypeKey: carryTypeKey,
694
+ maxDays: carryCap,
695
+ expiresAfterMonths: carryExpires ? carryExpiryMonths : null,
696
+ })
697
+
698
+ const saveCarry = createMutation(() => ({
699
+ mutationFn: () =>
700
+ carryDialog === 'edit'
701
+ ? api.policies.update({
702
+ workspaceId,
703
+ policyId: carryId,
704
+ name: carryName.trim(),
705
+ config: $state.snapshot(carryConfigDraft),
706
+ effectiveTo: carryTo || null,
707
+ })
708
+ : api.policies.create({
709
+ workspaceId,
710
+ kind: 'carry_forward',
711
+ name: carryName.trim(),
712
+ config: $state.snapshot(carryConfigDraft),
713
+ effectiveFrom: carryFrom,
714
+ effectiveTo: carryTo || null,
715
+ }),
716
+ onSuccess: (policy: Policy) => {
717
+ toast.success(carryDialog === 'edit' ? t('accr_saved') : t('accr_created', { name: policy.name }))
718
+ carryDialog = null
719
+ carryError = null
720
+ refresh()
721
+ },
722
+ onError: (error: Error) => {
723
+ carryError = failureText(error, 'accr_save_error')
724
+ },
725
+ onSettled: settled,
726
+ }))
727
+
728
+ function carryMenu(policy: PolicyRow): MenuItem[] {
729
+ return [
730
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openCarryEdit(policy) },
731
+ { label: t('accr_duplicate'), icon: 'copy', onSelect: () => openCarryDuplicate(policy) },
732
+ { label: t('accr_assign'), icon: 'user-plus', onSelect: () => openAssign(policy.id) },
733
+ { type: 'separator' },
734
+ {
735
+ label: t('common.archive'),
736
+ icon: 'archive',
737
+ danger: true,
738
+ disabled: Boolean(policy.archivedAt),
739
+ hint: policy.archivedAt ? t('accr_already_archived') : undefined,
740
+ onSelect: () => {
741
+ archiving = policy
742
+ },
743
+ },
744
+ ]
745
+ }
746
+
471
747
  // ---------------------------------------------------------------- the ladder
472
748
 
473
749
  /** Every rung needs a name for its subjects, and each name costs a request — so each is asked for
474
750
  * 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)
751
+ const rungInUse = (kind: PolicySubjectKind) => ladderAssignments.some((a) => a.subjectKind === kind)
476
752
 
477
753
  let assignOpen = $state(false)
478
754
  let assignRung = $state<PolicySubjectKind>('workspace')
@@ -550,18 +826,20 @@ type LadderRow = { assignment: PolicyAssignment; policy: PolicyRow }
550
826
  const ladder = $derived(
551
827
  LADDER.map((kind) => ({
552
828
  kind,
553
- rows: policies
554
- .filter((policy) => !policy.archivedAt)
829
+ rows: ladderPolicies
555
830
  .flatMap((policy) =>
556
831
  policy.assignments
557
832
  .filter((assignment) => assignment.subjectKind === kind)
558
833
  .map((assignment): LadderRow => ({ assignment, policy })),
559
834
  )
560
- .sort((a, b) =>
561
- subjectName(kind, a.assignment.subjectId).localeCompare(
562
- subjectName(kind, b.assignment.subjectId),
563
- messageLocale(),
564
- ),
835
+ // Subject first, then kind: one person's two policies belong next to each other, and reading
836
+ // down a rung is reading down a list of people rather than a list of policy kinds.
837
+ .sort(
838
+ (a, b) =>
839
+ subjectName(kind, a.assignment.subjectId).localeCompare(
840
+ subjectName(kind, b.assignment.subjectId),
841
+ messageLocale(),
842
+ ) || kindLabel(a.policy.kind).localeCompare(kindLabel(b.policy.kind), messageLocale()),
565
843
  ),
566
844
  })),
567
845
  )
@@ -577,11 +855,9 @@ let assignFrom = $state(isoDate())
577
855
  let assignTo = $state('')
578
856
  let assignError = $state<string | null>(null)
579
857
 
580
- const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
581
-
582
858
  function openAssign(preferredId?: string) {
583
859
  assignOpen = true
584
- assignPolicyId = preferredId ?? livePolicies[0]?.id ?? ''
860
+ assignPolicyId = preferredId ?? ladderPolicies[0]?.id ?? ''
585
861
  assignRung = 'workspace'
586
862
  assignSubjectId = ''
587
863
  assignFrom = isoDate()
@@ -618,23 +894,30 @@ const subjectsLoading = $derived(
618
894
  * sentence, so the order reads down the ladder in every writing direction. */
619
895
  const beatenBy = $derived(LADDER.slice(0, LADDER.indexOf(assignRung)))
620
896
 
897
+ /** The policy being assigned, and therefore which kind the rung is being read for. */
898
+ const assignKind = $derived(ladderPolicies.find((p) => p.id === assignPolicyId)?.kind ?? 'accrual')
899
+
621
900
  /**
622
- * Another accrual policy already sitting on this exact subject, open-ended.
901
+ * Another policy **of the same kind** already sitting on this exact subject, open-ended.
623
902
  *
624
903
  * Two policies of one kind on one rung is not refused by the server — their effective ranges may
625
904
  * well separate them — but it is the ambiguity this whole screen exists to surface, so it is named
626
- * rather than blocked.
905
+ * rather than blocked. Across kinds there is no ambiguity at all: accrual and carry-forward are
906
+ * resolved separately, so warning about an accrual policy while a carry-forward one is being
907
+ * assigned would invent a conflict that does not exist.
627
908
  */
628
909
  const rungTaken = $derived(
629
910
  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
- ),
911
+ ? (ladderPolicies.find(
912
+ (policy) =>
913
+ policy.kind === assignKind &&
914
+ policy.assignments.some(
915
+ (assignment) =>
916
+ assignment.subjectKind === assignRung &&
917
+ (assignment.subjectId ?? '') === (assignRung === 'workspace' ? '' : assignSubjectId) &&
918
+ assignment.effectiveTo === null &&
919
+ policy.id !== assignPolicyId,
920
+ ),
638
921
  ) ?? null)
639
922
  : null,
640
923
  )
@@ -678,6 +961,22 @@ const assign = createMutation(() => ({
678
961
 
679
962
  let unassigning = $state<LadderRow | null>(null)
680
963
 
964
+ const unassignIsCarry = $derived(unassigning?.policy.kind === 'carry_forward')
965
+ /**
966
+ * What the people under this assignment fall through to, in the kind being removed.
967
+ *
968
+ * The bottom rung is only a fallback for its own kind: naming the accrual policy while a
969
+ * carry-forward assignment is being removed would promise that leave still carries when nothing
970
+ * carries it any more.
971
+ */
972
+ const unassignFallback = $derived(
973
+ unassigning === null || unassigning.assignment.subjectKind === 'workspace'
974
+ ? null
975
+ : unassignIsCarry
976
+ ? carryDefault
977
+ : workspaceDefault,
978
+ )
979
+
681
980
  const unassign = createMutation(() => ({
682
981
  mutationFn: (row: LadderRow) => api.policies.unassign({ workspaceId, assignmentId: row.assignment.id }),
683
982
  onSuccess: (_ok, row: LadderRow) => {
@@ -737,8 +1036,15 @@ const HEAD = 10
737
1036
 
738
1037
  <SettingsPage title={t('settings_accrual')} description={t('accr_desc')}>
739
1038
  {#snippet actions()}
740
- {#if manage}
741
- <Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
1039
+ <!-- One switch for both lists below, so it sits above both rather than inside one of them and
1040
+ silently filtering the other. Each section keeps its own "new" button. -->
1041
+ {#if policies.length > 0 || carryPolicies.length > 0 || showArchived}
1042
+ <Switch
1043
+ size="sm"
1044
+ checked={showArchived}
1045
+ onCheckedChange={(on) => (showArchived = on)}
1046
+ label={t('accr_show_archived')}
1047
+ />
742
1048
  {/if}
743
1049
  {/snippet}
744
1050
 
@@ -751,19 +1057,23 @@ const HEAD = 10
751
1057
  {#if stale}
752
1058
  <p class="stale" role="status">
753
1059
  <span>{t('accr_stale')}</span>
754
- <Button size="sm" variant="ghost" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
1060
+ <Button
1061
+ size="sm"
1062
+ variant="ghost"
1063
+ onclick={() => {
1064
+ void policiesQuery.refetch()
1065
+ void carryQuery.refetch()
1066
+ }}
1067
+ >
1068
+ {t('retry')}
1069
+ </Button>
755
1070
  </p>
756
1071
  {/if}
757
1072
 
758
1073
  <SettingsSection title={t('accr_policies')} description={t('accr_policies_desc')}>
759
1074
  {#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
- />
1075
+ {#if manage}
1076
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
767
1077
  {/if}
768
1078
  {/snippet}
769
1079
 
@@ -815,7 +1125,11 @@ const HEAD = 10
815
1125
  {#if config}
816
1126
  <span class="num">{t('accr_days_per_year', { count: config.daysPerYear })}</span>
817
1127
  <span class="sub">
818
- {leaveTypeName(config.leaveTypeKey)} · {duration(config.minutesPerDay)}
1128
+ <!-- The day length is named rather than left as a bare duration beside a leave
1129
+ type, because it is the number those days per year are earned in. -->
1130
+ {leaveTypeName(config.leaveTypeKey)} · {t('accr_day_is', {
1131
+ length: duration(config.minutesPerDay),
1132
+ })}
819
1133
  {#if config.seniorityTiers.length > 0}
820
1134
  · {t('accr_tiers_count', { count: config.seniorityTiers.length })}
821
1135
  {/if}
@@ -883,21 +1197,147 @@ const HEAD = 10
883
1197
  {/if}
884
1198
  </SettingsSection>
885
1199
 
1200
+ <!-- ---------------------------------------------------------------- carry-forward -->
1201
+ <SettingsSection title={t('cf_section')} description={t('cf_section_desc')}>
1202
+ {#snippet action()}
1203
+ {#if manage}
1204
+ <Button size="sm" icon="plus" onclick={openCarryCreate}>{t('accr_new')}</Button>
1205
+ {/if}
1206
+ {/snippet}
1207
+
1208
+ {#if carryLoading}
1209
+ <div class="rows">
1210
+ {#each [1, 2] as n (n)}<Skeleton height="52px" />{/each}
1211
+ </div>
1212
+ {:else if carryPolicies.length}
1213
+ <div class="stack">
1214
+ {#if !carryDefault}
1215
+ <!-- Same silence as the accrual ladder's missing bottom rung, and a different sentence:
1216
+ nothing is refused, people simply keep nothing at the turn of the year. -->
1217
+ <p class="note warn">{t('cf_no_default')}</p>
1218
+ {/if}
1219
+ {#if !policiesLoading && livePolicies.length === 0}
1220
+ <!-- The job converts a cap in days with the day length from that person's *accrual*
1221
+ policy, and skips them when there is none. A cap with no accrual policy anywhere is
1222
+ therefore a rule that cannot run at all — said only once the accrual list has
1223
+ actually answered, so the warning cannot flash while it loads. -->
1224
+ <p class="note warn">{t('cf_needs_accrual')}</p>
1225
+ {/if}
1226
+
1227
+ <div class="table cf" role="table" aria-label={t('cf_section')}>
1228
+ <div class="thead" role="row">
1229
+ <span role="columnheader">{t('accr_col_policy')}</span>
1230
+ <span role="columnheader">{t('cf_col_carries')}</span>
1231
+ <span role="columnheader">{t('cf_col_deadline')}</span>
1232
+ <span role="columnheader">{t('accr_col_applies')}</span>
1233
+ <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
1234
+ </div>
1235
+ {#each carryPolicies as policy (policy.id)}
1236
+ {@const config = carryConfigOf(policy)}
1237
+ <div class="trow" role="row">
1238
+ <span class="cell what" role="cell">
1239
+ <span class="strong">{policy.name}</span>
1240
+ <span class="chips">
1241
+ {#if policy.archivedAt}
1242
+ <Badge tone="grey">{t('accr_badge_archived')}</Badge>
1243
+ {/if}
1244
+ {#if policy.source === 'pack'}
1245
+ <Badge tone="grey">{t('cal_origin_pack')}</Badge>
1246
+ {/if}
1247
+ </span>
1248
+ <span class="sub">{rangeLabel(policy.effectiveFrom, policy.effectiveTo)}</span>
1249
+ </span>
1250
+
1251
+ <span class="cell" role="cell">
1252
+ {#if config}
1253
+ {#if config.maxDays > 0}
1254
+ <span class="num">
1255
+ {days(config.maxDays)}
1256
+ {t('days', { count: config.maxDays })}
1257
+ </span>
1258
+ {:else}
1259
+ <!-- A cap of zero is a real answer and a drastic one: the whole remaining
1260
+ balance is written off as an expiry entry. It is named, not left as "0". -->
1261
+ <span class="warn-text">{t('cf_carries_nothing')}</span>
1262
+ {/if}
1263
+ <span class="sub">{leaveTypeName(config.leaveTypeKey)}</span>
1264
+ {:else}
1265
+ <span class="sub danger">{t('accr_config_unreadable')}</span>
1266
+ {/if}
1267
+ </span>
1268
+
1269
+ <span class="cell" role="cell">
1270
+ {#if config}
1271
+ {#if config.expiresAfterMonths === null}
1272
+ <Badge tone="grey">{t('cf_no_deadline')}</Badge>
1273
+ {:else}
1274
+ <Badge tone="warning">
1275
+ {t('cf_deadline_short', { count: config.expiresAfterMonths })}
1276
+ </Badge>
1277
+ {/if}
1278
+ {/if}
1279
+ </span>
1280
+
1281
+ <span class="cell num" role="cell">
1282
+ {#if policy.assignments.length > 0}
1283
+ {formatCount(policy.assignments.length, 999)}
1284
+ {:else}
1285
+ <span class="sub warn-text">{t('accr_nobody')}</span>
1286
+ {/if}
1287
+ </span>
1288
+
1289
+ <span class="cell actions" role="cell">
1290
+ {#if manage}
1291
+ <DropdownMenu items={carryMenu(policy)}>
1292
+ {#snippet trigger(props)}
1293
+ <IconButton
1294
+ icon="ellipsis"
1295
+ label={t('accr_actions_for', { name: policy.name })}
1296
+ size={28}
1297
+ {...props}
1298
+ />
1299
+ {/snippet}
1300
+ </DropdownMenu>
1301
+ {/if}
1302
+ </span>
1303
+ </div>
1304
+ {/each}
1305
+ </div>
1306
+
1307
+ <p class="note">{t('cf_job_note')}</p>
1308
+ </div>
1309
+ {:else if carryQuery.isError}
1310
+ <EmptyState icon="triangle-alert" title={t('cf_error')} description={t('accr_error_desc')}>
1311
+ {#snippet actions()}
1312
+ <Button variant="secondary" onclick={() => void carryQuery.refetch()}>{t('retry')}</Button>
1313
+ {/snippet}
1314
+ </EmptyState>
1315
+ {:else}
1316
+ <EmptyState icon="calendar-days" title={t('cf_none')} description={t('cf_none_desc')}>
1317
+ {#snippet actions()}
1318
+ {#if manage}
1319
+ <Button icon="plus" onclick={openCarryCreate}>{t('accr_new')}</Button>
1320
+ {/if}
1321
+ {/snippet}
1322
+ </EmptyState>
1323
+ {/if}
1324
+ </SettingsSection>
1325
+
886
1326
  <!-- ---------------------------------------------------------------- the ladder -->
887
1327
  <SettingsSection title={t('accr_ladder')} description={t('accr_ladder_desc')}>
888
1328
  {#snippet action()}
889
- {#if manage && livePolicies.length > 0}
1329
+ {#if manage && ladderPolicies.length > 0}
890
1330
  <Button size="sm" variant="secondary" icon="user-plus" onclick={() => openAssign()}>
891
1331
  {t('accr_assign')}
892
1332
  </Button>
893
1333
  {/if}
894
1334
  {/snippet}
895
1335
 
896
- {#if policiesLoading}
1336
+ {#if policiesLoading || carryLoading}
897
1337
  <div class="rows">
898
1338
  {#each [1, 2, 3, 4] as n (n)}<Skeleton height="44px" />{/each}
899
1339
  </div>
900
- {:else if liveAssignments.length}
1340
+ {:else if ladderAssignments.length}
901
1341
  {#if !workspaceDefault}
902
1342
  <!-- The bottom rung is the only one that catches everybody, and its absence is silent
903
1343
  everywhere else: people with nothing nearer simply never accrue. -->
@@ -920,7 +1360,15 @@ const HEAD = 10
920
1360
  <Badge tone="grey">{rungLabel(rung.kind)}</Badge>
921
1361
  <span class="strong">{subjectName(rung.kind, row.assignment.subjectId)}</span>
922
1362
  </span>
923
- <span class="rpolicy">{row.policy.name}</span>
1363
+ <span class="rpolicy">
1364
+ <!-- Which kind is on this rung. Two policies on one subject is ordinary here
1365
+ — accrual and carry-forward are resolved separately — and without the
1366
+ badge the pair reads as a conflict rather than as two answers. -->
1367
+ <Badge tone={row.policy.kind === 'carry_forward' ? 'info' : 'accent'}>
1368
+ {kindLabel(row.policy.kind)}
1369
+ </Badge>
1370
+ <span class="rname">{row.policy.name}</span>
1371
+ </span>
924
1372
  <span class="sub rwhen">
925
1373
  {rangeLabel(row.assignment.effectiveFrom, row.assignment.effectiveTo)}
926
1374
  </span>
@@ -942,20 +1390,30 @@ const HEAD = 10
942
1390
  </div>
943
1391
  {/each}
944
1392
  </div>
945
- {:else if policiesQuery.isError}
1393
+ {:else if policiesQuery.isError || carryQuery.isError}
946
1394
  <EmptyState icon="triangle-alert" title={t('accr_ladder_error')}>
947
1395
  {#snippet actions()}
948
- <Button variant="secondary" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
1396
+ <Button
1397
+ variant="secondary"
1398
+ onclick={() => {
1399
+ void policiesQuery.refetch()
1400
+ void carryQuery.refetch()
1401
+ }}
1402
+ >
1403
+ {t('retry')}
1404
+ </Button>
949
1405
  {/snippet}
950
1406
  </EmptyState>
951
1407
  {:else}
952
1408
  <EmptyState
953
1409
  icon="git-branch"
954
1410
  title={t('accr_ladder_none')}
955
- description={livePolicies.length > 0 ? t('accr_ladder_none_desc') : t('accr_ladder_needs_policy')}
1411
+ description={ladderPolicies.length > 0
1412
+ ? t('accr_ladder_none_desc')
1413
+ : t('accr_ladder_needs_policy')}
956
1414
  >
957
1415
  {#snippet actions()}
958
- {#if manage && livePolicies.length > 0}
1416
+ {#if manage && ladderPolicies.length > 0}
959
1417
  <Button icon="user-plus" onclick={() => openAssign()}>{t('accr_assign')}</Button>
960
1418
  {:else if manage}
961
1419
  <Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
@@ -1003,6 +1461,13 @@ const HEAD = 10
1003
1461
  <p class="note">{t('accr_edit_retroactive')}</p>
1004
1462
  {/if}
1005
1463
 
1464
+ {#if seededFromUnreadable}
1465
+ <!-- Every field below is a default, the day length included — and the day length is what the
1466
+ engine multiplies entitlement days by. Saying so is the difference between repairing a
1467
+ broken row and overwriting a working one with eight hours and twenty days. -->
1468
+ <p class="note warn">{t('accr_config_unreadable_form')}</p>
1469
+ {/if}
1470
+
1006
1471
  <Field label={t('accr_name')} hint={t('accr_name_hint')} required>
1007
1472
  {#snippet children(id)}
1008
1473
  <Input {id} bind:value={policyName} maxlength={120} />
@@ -1054,10 +1519,7 @@ const HEAD = 10
1054
1519
  <Input {id} type="number" min={0} max={365} step={0.5} bind:value={daysPerYear} />
1055
1520
  {/snippet}
1056
1521
  </Field>
1057
- <Field
1058
- label={t('accr_day_length')}
1059
- hint={t('accr_day_length_hint', { length: duration(clamped(minutesPerDay, 1, 1440)) })}
1060
- >
1522
+ <Field label={t('accr_day_length')} hint={t('accr_day_length_hint', { length: duration(dayMinutes) })}>
1061
1523
  {#snippet children(id)}
1062
1524
  <Input {id} type="number" min={1} max={1440} bind:value={minutesPerDay} />
1063
1525
  {/snippet}
@@ -1072,7 +1534,12 @@ const HEAD = 10
1072
1534
  </Field>
1073
1535
  <Field label={t('accr_rounding')} hint={t('accr_rounding_hint')}>
1074
1536
  {#snippet children(id)}
1075
- <Select {id} value={roundTo} onValueChange={(v) => (roundTo = v)} options={roundOptions} />
1537
+ <Select
1538
+ {id}
1539
+ value={roundChoice}
1540
+ onValueChange={(v) => (roundChoice = v)}
1541
+ options={roundOptions}
1542
+ />
1076
1543
  {/snippet}
1077
1544
  </Field>
1078
1545
  </div>
@@ -1166,6 +1633,118 @@ const HEAD = 10
1166
1633
  {/snippet}
1167
1634
  </Dialog>
1168
1635
 
1636
+ <!-- ---------------------------------------------------------------- the carry-forward form -->
1637
+ <Dialog
1638
+ open={carryDialog !== null}
1639
+ title={carryDialog === 'edit' ? t('cf_edit_title') : t('cf_create_title')}
1640
+ onOpenChange={(open) => {
1641
+ if (!open) carryDialog = null
1642
+ }}
1643
+ >
1644
+ <div class="form">
1645
+ {#if carryDialog === 'edit'}
1646
+ <p class="note">{t('accr_edit_retroactive')}</p>
1647
+ {/if}
1648
+
1649
+ {#if carrySeededFromUnreadable}
1650
+ <p class="note warn">{t('accr_config_unreadable_form')}</p>
1651
+ {/if}
1652
+
1653
+ <Field label={t('accr_name')} hint={t('accr_name_hint')} required>
1654
+ {#snippet children(id)}
1655
+ <Input {id} bind:value={carryName} maxlength={120} />
1656
+ {/snippet}
1657
+ </Field>
1658
+
1659
+ <Field label={t('accr_leave_type')} hint={t('cf_leave_type_hint')} required>
1660
+ {#snippet children(id)}
1661
+ <Select
1662
+ {id}
1663
+ value={carryTypeKey}
1664
+ onValueChange={(v) => (carryTypeKey = v)}
1665
+ placeholder={t('accr_leave_type_pick')}
1666
+ ariaLabel={t('accr_leave_type')}
1667
+ options={leaveTypes.map((type) => ({ value: type.key, label: type.name }))}
1668
+ />
1669
+ {/snippet}
1670
+ </Field>
1671
+
1672
+ {#if leaveTypes.length === 0 && !leaveTypesQuery.isLoading}
1673
+ <p class="note warn">
1674
+ {t('accr_no_leave_types')}
1675
+ {#if canHr('leaveManage')}
1676
+ <a class="link" href={`/${workspaceSlug}/settings/hr/leave`}>{t('settings_leave')}</a>
1677
+ {/if}
1678
+ </p>
1679
+ {/if}
1680
+
1681
+ <Field label={t('cf_cap')} hint={t('cf_cap_hint')}>
1682
+ {#snippet children(id)}
1683
+ <Input {id} type="number" min={0} max={365} step={0.5} bind:value={carryMaxDays} />
1684
+ {/snippet}
1685
+ </Field>
1686
+
1687
+ {#if carryCap === 0}
1688
+ <!-- Not a disabled rule: a zero cap writes the whole closing balance off as an expiry entry,
1689
+ which is the most drastic thing this form can save. -->
1690
+ <p class="note warn">{t('cf_cap_zero')}</p>
1691
+ {/if}
1692
+
1693
+ <!-- A switch and a number rather than an empty box meaning "never": both are real answers, and
1694
+ a blank field does not say which of them it is. Nothing is disabled — the months field is
1695
+ simply not there while there is no deadline to describe. -->
1696
+ <Switch
1697
+ checked={carryExpires}
1698
+ onCheckedChange={(on) => (carryExpires = on)}
1699
+ label={t('cf_expires')}
1700
+ description={t('cf_expires_hint')}
1701
+ />
1702
+
1703
+ {#if carryExpires}
1704
+ <Field label={t('cf_months')} hint={t('cf_months_hint')}>
1705
+ {#snippet children(id)}
1706
+ <Input {id} type="number" min={1} max={24} bind:value={carryMonths} />
1707
+ {/snippet}
1708
+ </Field>
1709
+ {/if}
1710
+
1711
+ <div class="pair">
1712
+ <Field label={t('accr_effective_from')} hint={t('accr_effective_from_hint')} required>
1713
+ {#snippet children(id)}
1714
+ <Input {id} type="date" bind:value={carryFrom} disabled={carryDialog === 'edit'} />
1715
+ {/snippet}
1716
+ </Field>
1717
+ <Field label={t('accr_effective_to')} hint={t('accr_effective_to_hint')}>
1718
+ {#snippet children(id)}
1719
+ <Input {id} type="date" bind:value={carryTo} />
1720
+ {/snippet}
1721
+ </Field>
1722
+ </div>
1723
+
1724
+ <p class="note">{t('cf_needs_accrual_note')}</p>
1725
+
1726
+ {#if carryError}
1727
+ <p class="note danger-note" role="alert">{carryError}</p>
1728
+ {/if}
1729
+ </div>
1730
+
1731
+ {#snippet footer()}
1732
+ {#if carryProblem}
1733
+ <span class="hint problem">{carryProblem}</span>
1734
+ {/if}
1735
+ <Button variant="secondary" onclick={() => (carryDialog = null)} disabled={saveCarry.isPending}>
1736
+ {t('cancel')}
1737
+ </Button>
1738
+ <Button
1739
+ loading={saveCarry.isPending}
1740
+ disabled={!manage || carryProblem !== null}
1741
+ onclick={() => once(() => saveCarry.mutate())}
1742
+ >
1743
+ {carryDialog === 'edit' ? t('common.save') : t('common.create')}
1744
+ </Button>
1745
+ {/snippet}
1746
+ </Dialog>
1747
+
1169
1748
  <!-- ---------------------------------------------------------------- archive a policy -->
1170
1749
  <Dialog
1171
1750
  open={archiving !== null}
@@ -1219,7 +1798,13 @@ const HEAD = 10
1219
1798
  onValueChange={(v) => (assignPolicyId = v)}
1220
1799
  placeholder={t('accr_assign_policy_pick')}
1221
1800
  ariaLabel={t('accr_assign_policy')}
1222
- options={livePolicies.map((policy) => ({ value: policy.id, label: policy.name }))}
1801
+ options={ladderPolicies.map((policy) => ({
1802
+ value: policy.id,
1803
+ label: policy.name,
1804
+ // Grouped by kind rather than labelled with it: which rung a policy may sit on is the
1805
+ // same question for both, but "5 days" means nothing until you know which it is.
1806
+ group: kindLabel(policy.kind),
1807
+ }))}
1223
1808
  />
1224
1809
  {/snippet}
1225
1810
  </Field>
@@ -1320,7 +1905,7 @@ const HEAD = 10
1320
1905
  open={unassigning !== null}
1321
1906
  size="sm"
1322
1907
  title={unassigning
1323
- ? t('accr_unassign_title', {
1908
+ ? t(unassignIsCarry ? 'cf_unassign_title' : 'accr_unassign_title', {
1324
1909
  subject: subjectName(unassigning.assignment.subjectKind, unassigning.assignment.subjectId),
1325
1910
  })
1326
1911
  : ''}
@@ -1332,9 +1917,9 @@ const HEAD = 10
1332
1917
  {#if unassigning}
1333
1918
  <!-- What happens to the people underneath is the whole question, and it has two answers. -->
1334
1919
  <p class="body">
1335
- {workspaceDefault && unassigning.assignment.subjectKind !== 'workspace'
1336
- ? t('accr_unassign_falls_back', { name: workspaceDefault.name })
1337
- : t('accr_unassign_no_fallback')}
1920
+ {unassignFallback
1921
+ ? t('accr_unassign_falls_back', { name: unassignFallback.name })
1922
+ : t(unassignIsCarry ? 'cf_unassign_no_fallback' : 'accr_unassign_no_fallback')}
1338
1923
  </p>
1339
1924
  <p class="body sub">{t('accr_unassign_keeps')}</p>
1340
1925
  {/if}
@@ -1388,7 +1973,18 @@ const HEAD = 10
1388
1973
  {:else if preview}
1389
1974
  <div class="tiles">
1390
1975
  <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)} />
1976
+ <!--
1977
+ In time, not days, and deliberately so: one run covers everybody, and the people in it may
1978
+ sit on policies whose days are different lengths, so there is no single day this sum could
1979
+ honestly be divided by. Each row below *is* in days, because the server divided it by that
1980
+ person's own policy. The unit is on the tile rather than left to be inferred.
1981
+ -->
1982
+ <StatTile
1983
+ size="md"
1984
+ label={t('accr_run_total')}
1985
+ value={duration(preview.totalMinutes)}
1986
+ note={t('accr_run_total_note')}
1987
+ />
1392
1988
  <StatTile size="md" label={t('accr_run_skipped')} value={formatCount(preview.skipped.length, 999)} />
1393
1989
  </div>
1394
1990
 
@@ -1481,11 +2077,22 @@ const HEAD = 10
1481
2077
  margin-block-end: 18px;
1482
2078
  }
1483
2079
 
2080
+ /* Notes, a table and a footnote in one section: one gap rather than a margin per element. */
2081
+ .stack {
2082
+ display: grid;
2083
+ gap: 12px;
2084
+ }
2085
+
1484
2086
  /* One grid for the header and every row, so the columns line up down the page. */
1485
2087
  .table {
1486
2088
  width: 100%;
1487
2089
  --hr-accr-cols: minmax(150px, 1.4fr) minmax(120px, 1.2fr) minmax(110px, 1fr) 56px 32px;
1488
2090
  }
2091
+ /* The carry-forward table is the same anatomy with a narrower deadline column: it holds one chip,
2092
+ where the accrual table's third column holds a badge and a line of settings under it. */
2093
+ .table.cf {
2094
+ --hr-accr-cols: minmax(150px, 1.4fr) minmax(110px, 1fr) minmax(100px, 0.9fr) 56px 32px;
2095
+ }
1489
2096
  .thead,
1490
2097
  .trow {
1491
2098
  display: grid;
@@ -1579,7 +2186,9 @@ const HEAD = 10
1579
2186
  }
1580
2187
  .rlist li {
1581
2188
  display: grid;
1582
- grid-template-columns: minmax(140px, 1.4fr) minmax(100px, 1fr) minmax(110px, 0.9fr) 32px;
2189
+ /* The policy column carries a kind chip as well as the name now, so it is the wider of the two
2190
+ text columns rather than the narrower. */
2191
+ grid-template-columns: minmax(140px, 1.2fr) minmax(150px, 1.3fr) minmax(110px, 0.8fr) 32px;
1583
2192
  gap: 10px;
1584
2193
  align-items: center;
1585
2194
  min-height: 36px;
@@ -1597,6 +2206,12 @@ const HEAD = 10
1597
2206
  min-width: 0;
1598
2207
  }
1599
2208
  .rpolicy {
2209
+ display: flex;
2210
+ align-items: center;
2211
+ gap: 8px;
2212
+ min-width: 0;
2213
+ }
2214
+ .rname {
1600
2215
  min-width: 0;
1601
2216
  overflow: hidden;
1602
2217
  text-overflow: ellipsis;
@@ -1750,7 +2365,12 @@ const HEAD = 10
1750
2365
  .table {
1751
2366
  --hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
1752
2367
  }
1753
- /* The frequency and the assignment count go; what a policy earns and who it names cannot. */
2368
+ /* `.table.cf` outranks `.table` at any width, so the narrow layout has to be restated for it. */
2369
+ .table.cf {
2370
+ --hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
2371
+ }
2372
+ /* The third and fourth columns of both tables go — the frequency or the deadline, and the
2373
+ assignment count. What a policy is called and what it is worth cannot. */
1754
2374
  .thead > :nth-child(3),
1755
2375
  .trow > :nth-child(3),
1756
2376
  .thead > :nth-child(4),