@kernhq/module-hr 0.13.2 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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/jobs.d.ts.map +1 -1
  27. package/dist/server/jobs.js +13 -5
  28. package/dist/server/jobs.js.map +1 -1
  29. package/dist/server/router.d.ts +12 -0
  30. package/dist/server/router.d.ts.map +1 -1
  31. package/dist/server/router.js +99 -16
  32. package/dist/server/router.js.map +1 -1
  33. package/dist/server/services/access.d.ts +176 -0
  34. package/dist/server/services/access.d.ts.map +1 -0
  35. package/dist/server/services/access.js +260 -0
  36. package/dist/server/services/access.js.map +1 -0
  37. package/dist/server/services/people.d.ts +1 -0
  38. package/dist/server/services/people.d.ts.map +1 -1
  39. package/dist/server/services/people.js +3 -0
  40. package/dist/server/services/people.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client/components/DayDetail.svelte +32 -14
  43. package/src/client/components/PersonPanel.svelte +66 -5
  44. package/src/client/components/redaction.ts +39 -0
  45. package/src/client/messages.ts +241 -22
  46. package/src/client/pages/DirectoryPage.svelte +48 -1
  47. package/src/client/permissions.ts +6 -8
  48. package/src/client/settings/AccrualSettings.svelte +596 -57
  49. package/src/client/settings/GeneralSettings.svelte +12 -9
  50. package/src/contract/attendance.ts +27 -1
  51. package/src/contract/capabilities.ts +10 -0
  52. package/src/contract/events.ts +23 -19
  53. package/src/contract/models.ts +11 -0
  54. package/src/contract/router.ts +11 -3
  55. 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
@@ -296,6 +364,15 @@ let waitingMonths = $state('0')
296
364
  /** A token rather than a minute count — see `roundMinutes`. */
297
365
  let roundChoice = $state('0')
298
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')
299
376
  let policyError = $state<string | null>(null)
300
377
  /**
301
378
  * Whether the form was seeded from a config the schema could not read.
@@ -320,6 +397,7 @@ function openCreate() {
320
397
  waitingMonths = '0'
321
398
  roundChoice = '0'
322
399
  tiers = []
400
+ policyCalendar = 'gregorian'
323
401
  policyError = null
324
402
  seededFromUnreadable = false
325
403
  }
@@ -344,6 +422,7 @@ function fillFrom(policy: PolicyRow) {
344
422
  afterYears: String(tier.afterYears),
345
423
  daysPerYear: String(tier.daysPerYear),
346
424
  }))
425
+ policyCalendar = config?.calendar ?? 'gregorian'
347
426
  policyError = null
348
427
  }
349
428
 
@@ -454,6 +533,7 @@ const configDraft = $derived({
454
533
  waitingPeriodMonths: clamped(waitingMonths, 0, 24),
455
534
  // Save is blocked above the cap, so this never silently shortens a step somebody chose.
456
535
  roundToMinutes: Math.min(roundMinutes, ROUND_CAP_MINUTES),
536
+ calendar: policyCalendar,
457
537
  leaveTypeKey,
458
538
  })
459
539
 
@@ -525,11 +605,150 @@ function policyMenu(policy: PolicyRow): MenuItem[] {
525
605
  ]
526
606
  }
527
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
+
528
747
  // ---------------------------------------------------------------- the ladder
529
748
 
530
749
  /** Every rung needs a name for its subjects, and each name costs a request — so each is asked for
531
750
  * only when a rung carries an assignment or the assign dialog is open on it. */
532
- const rungInUse = (kind: PolicySubjectKind) => liveAssignments.some((a) => a.subjectKind === kind)
751
+ const rungInUse = (kind: PolicySubjectKind) => ladderAssignments.some((a) => a.subjectKind === kind)
533
752
 
534
753
  let assignOpen = $state(false)
535
754
  let assignRung = $state<PolicySubjectKind>('workspace')
@@ -607,18 +826,20 @@ type LadderRow = { assignment: PolicyAssignment; policy: PolicyRow }
607
826
  const ladder = $derived(
608
827
  LADDER.map((kind) => ({
609
828
  kind,
610
- rows: policies
611
- .filter((policy) => !policy.archivedAt)
829
+ rows: ladderPolicies
612
830
  .flatMap((policy) =>
613
831
  policy.assignments
614
832
  .filter((assignment) => assignment.subjectKind === kind)
615
833
  .map((assignment): LadderRow => ({ assignment, policy })),
616
834
  )
617
- .sort((a, b) =>
618
- subjectName(kind, a.assignment.subjectId).localeCompare(
619
- subjectName(kind, b.assignment.subjectId),
620
- messageLocale(),
621
- ),
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()),
622
843
  ),
623
844
  })),
624
845
  )
@@ -634,11 +855,9 @@ let assignFrom = $state(isoDate())
634
855
  let assignTo = $state('')
635
856
  let assignError = $state<string | null>(null)
636
857
 
637
- const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
638
-
639
858
  function openAssign(preferredId?: string) {
640
859
  assignOpen = true
641
- assignPolicyId = preferredId ?? livePolicies[0]?.id ?? ''
860
+ assignPolicyId = preferredId ?? ladderPolicies[0]?.id ?? ''
642
861
  assignRung = 'workspace'
643
862
  assignSubjectId = ''
644
863
  assignFrom = isoDate()
@@ -675,23 +894,30 @@ const subjectsLoading = $derived(
675
894
  * sentence, so the order reads down the ladder in every writing direction. */
676
895
  const beatenBy = $derived(LADDER.slice(0, LADDER.indexOf(assignRung)))
677
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
+
678
900
  /**
679
- * 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.
680
902
  *
681
903
  * Two policies of one kind on one rung is not refused by the server — their effective ranges may
682
904
  * well separate them — but it is the ambiguity this whole screen exists to surface, so it is named
683
- * 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.
684
908
  */
685
909
  const rungTaken = $derived(
686
910
  assignOpen
687
- ? (livePolicies.find((policy) =>
688
- policy.assignments.some(
689
- (assignment) =>
690
- assignment.subjectKind === assignRung &&
691
- (assignment.subjectId ?? '') === (assignRung === 'workspace' ? '' : assignSubjectId) &&
692
- assignment.effectiveTo === null &&
693
- policy.id !== assignPolicyId,
694
- ),
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
+ ),
695
921
  ) ?? null)
696
922
  : null,
697
923
  )
@@ -735,6 +961,22 @@ const assign = createMutation(() => ({
735
961
 
736
962
  let unassigning = $state<LadderRow | null>(null)
737
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
+
738
980
  const unassign = createMutation(() => ({
739
981
  mutationFn: (row: LadderRow) => api.policies.unassign({ workspaceId, assignmentId: row.assignment.id }),
740
982
  onSuccess: (_ok, row: LadderRow) => {
@@ -794,8 +1036,15 @@ const HEAD = 10
794
1036
 
795
1037
  <SettingsPage title={t('settings_accrual')} description={t('accr_desc')}>
796
1038
  {#snippet actions()}
797
- {#if manage}
798
- <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
+ />
799
1048
  {/if}
800
1049
  {/snippet}
801
1050
 
@@ -808,19 +1057,23 @@ const HEAD = 10
808
1057
  {#if stale}
809
1058
  <p class="stale" role="status">
810
1059
  <span>{t('accr_stale')}</span>
811
- <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>
812
1070
  </p>
813
1071
  {/if}
814
1072
 
815
1073
  <SettingsSection title={t('accr_policies')} description={t('accr_policies_desc')}>
816
1074
  {#snippet action()}
817
- {#if policies.length > 0 || showArchived}
818
- <Switch
819
- size="sm"
820
- checked={showArchived}
821
- onCheckedChange={(on) => (showArchived = on)}
822
- label={t('accr_show_archived')}
823
- />
1075
+ {#if manage}
1076
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
824
1077
  {/if}
825
1078
  {/snippet}
826
1079
 
@@ -944,21 +1197,147 @@ const HEAD = 10
944
1197
  {/if}
945
1198
  </SettingsSection>
946
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
+
947
1326
  <!-- ---------------------------------------------------------------- the ladder -->
948
1327
  <SettingsSection title={t('accr_ladder')} description={t('accr_ladder_desc')}>
949
1328
  {#snippet action()}
950
- {#if manage && livePolicies.length > 0}
1329
+ {#if manage && ladderPolicies.length > 0}
951
1330
  <Button size="sm" variant="secondary" icon="user-plus" onclick={() => openAssign()}>
952
1331
  {t('accr_assign')}
953
1332
  </Button>
954
1333
  {/if}
955
1334
  {/snippet}
956
1335
 
957
- {#if policiesLoading}
1336
+ {#if policiesLoading || carryLoading}
958
1337
  <div class="rows">
959
1338
  {#each [1, 2, 3, 4] as n (n)}<Skeleton height="44px" />{/each}
960
1339
  </div>
961
- {:else if liveAssignments.length}
1340
+ {:else if ladderAssignments.length}
962
1341
  {#if !workspaceDefault}
963
1342
  <!-- The bottom rung is the only one that catches everybody, and its absence is silent
964
1343
  everywhere else: people with nothing nearer simply never accrue. -->
@@ -981,7 +1360,15 @@ const HEAD = 10
981
1360
  <Badge tone="grey">{rungLabel(rung.kind)}</Badge>
982
1361
  <span class="strong">{subjectName(rung.kind, row.assignment.subjectId)}</span>
983
1362
  </span>
984
- <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>
985
1372
  <span class="sub rwhen">
986
1373
  {rangeLabel(row.assignment.effectiveFrom, row.assignment.effectiveTo)}
987
1374
  </span>
@@ -1003,20 +1390,30 @@ const HEAD = 10
1003
1390
  </div>
1004
1391
  {/each}
1005
1392
  </div>
1006
- {:else if policiesQuery.isError}
1393
+ {:else if policiesQuery.isError || carryQuery.isError}
1007
1394
  <EmptyState icon="triangle-alert" title={t('accr_ladder_error')}>
1008
1395
  {#snippet actions()}
1009
- <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>
1010
1405
  {/snippet}
1011
1406
  </EmptyState>
1012
1407
  {:else}
1013
1408
  <EmptyState
1014
1409
  icon="git-branch"
1015
1410
  title={t('accr_ladder_none')}
1016
- 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')}
1017
1414
  >
1018
1415
  {#snippet actions()}
1019
- {#if manage && livePolicies.length > 0}
1416
+ {#if manage && ladderPolicies.length > 0}
1020
1417
  <Button icon="user-plus" onclick={() => openAssign()}>{t('accr_assign')}</Button>
1021
1418
  {:else if manage}
1022
1419
  <Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
@@ -1236,6 +1633,118 @@ const HEAD = 10
1236
1633
  {/snippet}
1237
1634
  </Dialog>
1238
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
+
1239
1748
  <!-- ---------------------------------------------------------------- archive a policy -->
1240
1749
  <Dialog
1241
1750
  open={archiving !== null}
@@ -1289,7 +1798,13 @@ const HEAD = 10
1289
1798
  onValueChange={(v) => (assignPolicyId = v)}
1290
1799
  placeholder={t('accr_assign_policy_pick')}
1291
1800
  ariaLabel={t('accr_assign_policy')}
1292
- 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
+ }))}
1293
1808
  />
1294
1809
  {/snippet}
1295
1810
  </Field>
@@ -1390,7 +1905,7 @@ const HEAD = 10
1390
1905
  open={unassigning !== null}
1391
1906
  size="sm"
1392
1907
  title={unassigning
1393
- ? t('accr_unassign_title', {
1908
+ ? t(unassignIsCarry ? 'cf_unassign_title' : 'accr_unassign_title', {
1394
1909
  subject: subjectName(unassigning.assignment.subjectKind, unassigning.assignment.subjectId),
1395
1910
  })
1396
1911
  : ''}
@@ -1402,9 +1917,9 @@ const HEAD = 10
1402
1917
  {#if unassigning}
1403
1918
  <!-- What happens to the people underneath is the whole question, and it has two answers. -->
1404
1919
  <p class="body">
1405
- {workspaceDefault && unassigning.assignment.subjectKind !== 'workspace'
1406
- ? t('accr_unassign_falls_back', { name: workspaceDefault.name })
1407
- : 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')}
1408
1923
  </p>
1409
1924
  <p class="body sub">{t('accr_unassign_keeps')}</p>
1410
1925
  {/if}
@@ -1562,11 +2077,22 @@ const HEAD = 10
1562
2077
  margin-block-end: 18px;
1563
2078
  }
1564
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
+
1565
2086
  /* One grid for the header and every row, so the columns line up down the page. */
1566
2087
  .table {
1567
2088
  width: 100%;
1568
2089
  --hr-accr-cols: minmax(150px, 1.4fr) minmax(120px, 1.2fr) minmax(110px, 1fr) 56px 32px;
1569
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
+ }
1570
2096
  .thead,
1571
2097
  .trow {
1572
2098
  display: grid;
@@ -1660,7 +2186,9 @@ const HEAD = 10
1660
2186
  }
1661
2187
  .rlist li {
1662
2188
  display: grid;
1663
- 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;
1664
2192
  gap: 10px;
1665
2193
  align-items: center;
1666
2194
  min-height: 36px;
@@ -1678,6 +2206,12 @@ const HEAD = 10
1678
2206
  min-width: 0;
1679
2207
  }
1680
2208
  .rpolicy {
2209
+ display: flex;
2210
+ align-items: center;
2211
+ gap: 8px;
2212
+ min-width: 0;
2213
+ }
2214
+ .rname {
1681
2215
  min-width: 0;
1682
2216
  overflow: hidden;
1683
2217
  text-overflow: ellipsis;
@@ -1831,7 +2365,12 @@ const HEAD = 10
1831
2365
  .table {
1832
2366
  --hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
1833
2367
  }
1834
- /* 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. */
1835
2374
  .thead > :nth-child(3),
1836
2375
  .trow > :nth-child(3),
1837
2376
  .thead > :nth-child(4),