@kernhq/module-hr 0.7.0 → 0.9.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 (74) hide show
  1. package/dist/contract/capabilities.d.ts.map +1 -1
  2. package/dist/contract/capabilities.js +31 -0
  3. package/dist/contract/capabilities.js.map +1 -1
  4. package/dist/contract/index.d.ts +1 -0
  5. package/dist/contract/index.d.ts.map +1 -1
  6. package/dist/contract/index.js +1 -0
  7. package/dist/contract/index.js.map +1 -1
  8. package/dist/contract/permissions.d.ts +23 -0
  9. package/dist/contract/permissions.d.ts.map +1 -1
  10. package/dist/contract/permissions.js +27 -0
  11. package/dist/contract/permissions.js.map +1 -1
  12. package/dist/contract/policies.d.ts +256 -0
  13. package/dist/contract/policies.d.ts.map +1 -0
  14. package/dist/contract/policies.js +175 -0
  15. package/dist/contract/policies.js.map +1 -0
  16. package/dist/contract/router.d.ts +707 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +127 -0
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/policy/accrual.d.ts +127 -0
  21. package/dist/policy/accrual.d.ts.map +1 -0
  22. package/dist/policy/accrual.js +134 -0
  23. package/dist/policy/accrual.js.map +1 -0
  24. package/dist/server/jobs.d.ts.map +1 -1
  25. package/dist/server/jobs.js +186 -1
  26. package/dist/server/jobs.js.map +1 -1
  27. package/dist/server/router.d.ts +772 -0
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +462 -1
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +612 -1
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +76 -0
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/policies.d.ts +53 -0
  36. package/dist/server/services/policies.d.ts.map +1 -0
  37. package/dist/server/services/policies.js +158 -0
  38. package/dist/server/services/policies.js.map +1 -0
  39. package/migrations/meta/_journal.json +7 -0
  40. package/package.json +22 -2
  41. package/src/client/api-instance.ts +29 -0
  42. package/src/client/components/ClockControls.svelte +127 -0
  43. package/src/client/components/HrSidebar.svelte +73 -0
  44. package/src/client/components/LeaveRequestDialog.svelte +169 -0
  45. package/src/client/components/PersonFormDialog.svelte +184 -0
  46. package/src/client/components/PersonInline.svelte +49 -0
  47. package/src/client/components/PersonPanel.svelte +328 -0
  48. package/src/client/core-api.ts +34 -0
  49. package/src/client/i18n.ts +640 -0
  50. package/src/client/index.ts +3 -0
  51. package/src/client/mock.ts +447 -0
  52. package/src/client/module.ts +295 -0
  53. package/src/client/pages/ApprovalsPage.svelte +129 -0
  54. package/src/client/pages/AttendancePage.svelte +147 -0
  55. package/src/client/pages/DirectoryPage.svelte +428 -0
  56. package/src/client/pages/LeavePage.svelte +180 -0
  57. package/src/client/pages/OfficesPage.svelte +119 -0
  58. package/src/client/permissions.ts +31 -0
  59. package/src/client/query.test.ts +70 -0
  60. package/src/client/query.ts +69 -0
  61. package/src/client/settings/CalendarsSettings.svelte +21 -0
  62. package/src/client/settings/CapabilitiesSettings.svelte +122 -0
  63. package/src/client/settings/LeaveSettings.svelte +21 -0
  64. package/src/client/settings/OfficesSettings.svelte +21 -0
  65. package/src/client/settings/SchedulesSettings.svelte +21 -0
  66. package/src/client/widgets/ApprovalsWidget.svelte +86 -0
  67. package/src/client/widgets/ClockWidget.svelte +9 -0
  68. package/src/client/widgets/HeadcountWidget.svelte +29 -0
  69. package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
  70. package/src/client/widgets/WhosOutWidget.svelte +75 -0
  71. package/src/contract/capabilities.ts +31 -0
  72. package/src/contract/index.ts +1 -0
  73. package/src/contract/permissions.ts +28 -0
  74. package/src/contract/router.ts +148 -0
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getHrApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { formatDays, hrKeys } from '../query.js'
7
+
8
+ /**
9
+ * What is left of each kind of leave.
10
+ *
11
+ * Shows `available`, not `balance`: pending requests are already spoken for, and a card promising
12
+ * twenty days when five are awaiting approval is how somebody books a week they do not have.
13
+ */
14
+ const { workspaceId }: WidgetProps = $props()
15
+ const api = getHrApi()
16
+
17
+ const balanceQuery = createQuery(() => ({
18
+ queryKey: hrKeys.leaveBalance(workspaceId, undefined),
19
+ enabled: Boolean(workspaceId),
20
+ queryFn: () => api.leave.balance.get({ workspaceId }),
21
+ }))
22
+ const balances = $derived(balanceQuery.data ?? [])
23
+ </script>
24
+
25
+ {#if balanceQuery.isLoading}
26
+ <Skeleton height="72px" />
27
+ {:else if balances.length === 0}
28
+ <EmptyState bare compact icon="tree-palm" title={t('leave_none')} />
29
+ {:else}
30
+ <ul>
31
+ {#each balances as b (b.leaveTypeId)}
32
+ <li>
33
+ <span class="name">{b.leaveTypeName}</span>
34
+ <span class="value">{formatDays(b.available)} <span class="unit">{t('days')}</span></span>
35
+ </li>
36
+ {/each}
37
+ </ul>
38
+ {/if}
39
+
40
+ <style>
41
+ ul {
42
+ display: grid;
43
+ gap: 8px;
44
+ list-style: none;
45
+ margin: 0;
46
+ padding: 0;
47
+ }
48
+ li {
49
+ display: flex;
50
+ align-items: baseline;
51
+ justify-content: space-between;
52
+ gap: 8px;
53
+ }
54
+ .name {
55
+ color: var(--kern-ink-500);
56
+ font-size: 12px;
57
+ min-width: 0;
58
+ }
59
+ .value {
60
+ font-size: 15px;
61
+ font-variant-numeric: tabular-nums;
62
+ }
63
+ .unit {
64
+ font-size: 12px;
65
+ color: var(--kern-ink-500);
66
+ }
67
+ </style>
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ import { Avatar, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getHrApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { hrKeys, isoDate } from '../query.js'
7
+
8
+ /**
9
+ * Who is away over the next fortnight.
10
+ *
11
+ * The leave *type* is shown only when the server sends it — most companies want the team to know
12
+ * somebody is out without knowing it is sick leave, so the decision is made server-side and this
13
+ * renders whatever it was given.
14
+ */
15
+ const { workspaceId }: WidgetProps = $props()
16
+ const api = getHrApi()
17
+
18
+ const from = isoDate()
19
+ const to = isoDate(new Date(Date.now() + 14 * 86_400_000))
20
+
21
+ const outQuery = createQuery(() => ({
22
+ queryKey: hrKeys.leaveCalendar(workspaceId, from, to),
23
+ enabled: Boolean(workspaceId),
24
+ queryFn: () => api.leave.team.calendar({ workspaceId, from, to }),
25
+ }))
26
+ const away = $derived(outQuery.data ?? [])
27
+
28
+ const range = (a: string, b: string) =>
29
+ new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).formatRange(
30
+ new Date(`${a}T00:00:00`),
31
+ new Date(`${b}T00:00:00`),
32
+ )
33
+ </script>
34
+
35
+ {#if outQuery.isLoading}
36
+ <Skeleton height="96px" />
37
+ {:else if away.length === 0}
38
+ <EmptyState bare compact icon="calendar-days" title={t('leave_none')} />
39
+ {:else}
40
+ <ul>
41
+ {#each away as person (person.requestId)}
42
+ <li>
43
+ <Avatar name={person.displayName} id={person.personId} size={24} />
44
+ <span class="name">{person.displayName}</span>
45
+ <span class="meta">{range(person.startsOn, person.endsOn)}</span>
46
+ </li>
47
+ {/each}
48
+ </ul>
49
+ {/if}
50
+
51
+ <style>
52
+ ul {
53
+ display: grid;
54
+ gap: 8px;
55
+ list-style: none;
56
+ margin: 0;
57
+ padding: 0;
58
+ }
59
+ li {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 8px;
63
+ }
64
+ .name {
65
+ flex: 1;
66
+ min-width: 0;
67
+ overflow: hidden;
68
+ text-overflow: ellipsis;
69
+ white-space: nowrap;
70
+ }
71
+ .meta {
72
+ color: var(--kern-ink-500);
73
+ font-size: 12px;
74
+ }
75
+ </style>
@@ -74,6 +74,24 @@ export const hrCapabilities = defineCapabilities([
74
74
  defaultEnabled: true,
75
75
  level: 1,
76
76
  },
77
+ {
78
+ id: 'leave_accrual',
79
+ label: 'Accrual',
80
+ description: 'Earn leave over time, with proration, carry-forward and expiry',
81
+ dependsOn: ['leave'],
82
+ // Off by default. Plenty of companies grant a fixed allowance on 1 January and never accrue —
83
+ // and for them an accrual engine is a screen full of settings that change nothing.
84
+ defaultEnabled: false,
85
+ level: 2,
86
+ },
87
+ {
88
+ id: 'periods',
89
+ label: 'Payroll periods',
90
+ description: 'Close a month so a filed payroll cannot move underneath it',
91
+ dependsOn: ['core'],
92
+ defaultEnabled: false,
93
+ level: 2,
94
+ },
77
95
  {
78
96
  id: 'approvals',
79
97
  label: 'Approval chains',
@@ -185,6 +203,19 @@ export const hrCapabilityProcedures: Record<string, readonly string[]> = {
185
203
  'attendance.regularizations.list',
186
204
  'attendance.regularizations.request',
187
205
  ],
206
+ leave_accrual: [
207
+ 'policies.list',
208
+ 'policies.get',
209
+ 'policies.create',
210
+ 'policies.update',
211
+ 'policies.archive',
212
+ 'policies.assign',
213
+ 'policies.unassign',
214
+ 'policies.resolveFor',
215
+ 'accrual.preview',
216
+ 'accrual.run',
217
+ ],
218
+ periods: ['periods.list', 'periods.create', 'periods.lock', 'periods.unlock'],
188
219
  approvals: [
189
220
  'approvals.chains.list',
190
221
  'approvals.chains.create',
@@ -13,5 +13,6 @@ export * from './events.js'
13
13
  export * from './leave.js'
14
14
  export * from './models.js'
15
15
  export * from './permissions.js'
16
+ export * from './policies.js'
16
17
  export * from './router.js'
17
18
  export * from './settings.js'
@@ -273,6 +273,31 @@ export const hrPermissions = definePermissions([
273
273
  dangerous: false,
274
274
  },
275
275
 
276
+ // ---------------------------------------------------------------- policies and periods
277
+ {
278
+ key: 'hr.policy.view',
279
+ label: 'View accrual, overtime and rounding policies',
280
+ scope: 'workspace',
281
+ defaultRoles: ['owner', 'admin'],
282
+ dangerous: false,
283
+ },
284
+ {
285
+ key: 'hr.policy.manage',
286
+ label: 'Change policies and who they apply to',
287
+ description: 'Changes what everybody accrues. A retroactive change is an adjustment, not a rewrite.',
288
+ scope: 'workspace',
289
+ defaultRoles: ['owner'],
290
+ dangerous: true,
291
+ },
292
+ {
293
+ key: 'hr.period.manage',
294
+ label: 'Open, lock and unlock payroll periods',
295
+ description: 'Locking freezes a month against recomputation. Unlocking lets it move again.',
296
+ scope: 'workspace',
297
+ defaultRoles: ['owner'],
298
+ dangerous: true,
299
+ },
300
+
276
301
  // ---------------------------------------------------------------- approvals
277
302
  {
278
303
  key: 'hr.approval.manage',
@@ -334,6 +359,9 @@ export const HR_PERMISSIONS = {
334
359
  attendanceManage: 'hr.attendance.manage',
335
360
  overtimeView: 'hr.overtime.view',
336
361
  overtimeManage: 'hr.overtime.manage',
362
+ policyView: 'hr.policy.view',
363
+ policyManage: 'hr.policy.manage',
364
+ periodManage: 'hr.period.manage',
337
365
  approvalManage: 'hr.approval.manage',
338
366
  approvalDelegate: 'hr.approval.delegate',
339
367
  } as const
@@ -53,6 +53,15 @@ import {
53
53
  TimeZone,
54
54
  WorkingWeek,
55
55
  } from './models.js'
56
+ import {
57
+ AccrualPreview,
58
+ Period,
59
+ Policy,
60
+ PolicyAssignment,
61
+ PolicyKind,
62
+ PolicySubjectKind,
63
+ ResolvedPolicy,
64
+ } from './policies.js'
56
65
 
57
66
  const ws = z.object({ workspaceId: WorkspaceId })
58
67
  const t = ['hr'] as const
@@ -602,6 +611,145 @@ export const hrContract = {
602
611
  .output(ok),
603
612
  },
604
613
 
614
+ // ---------------------------------------------------------------- policies
615
+ policies: {
616
+ list: baseContract
617
+ .route({ method: 'GET', path: '/policies', tags: t })
618
+ .input(ws.extend({ kind: PolicyKind.optional(), includeArchived: z.boolean().default(false) }))
619
+ .output(z.array(Policy.extend({ assignments: z.array(PolicyAssignment) }))),
620
+ get: baseContract
621
+ .route({ method: 'GET', path: '/policies/{policyId}', tags: t })
622
+ .input(ws.extend({ policyId: z.uuid() }))
623
+ .output(Policy.extend({ assignments: z.array(PolicyAssignment) })),
624
+ create: baseContract
625
+ .route({ method: 'POST', path: '/policies', tags: t })
626
+ .input(
627
+ ws.extend({
628
+ kind: PolicyKind,
629
+ name: z.string().min(1).max(120),
630
+ /** Validated against the schema for `kind` — a config for the wrong kind is refused. */
631
+ config: z.record(z.string(), z.unknown()),
632
+ effectiveFrom: IsoDate,
633
+ effectiveTo: IsoDate.nullish(),
634
+ }),
635
+ )
636
+ .output(Policy),
637
+ /**
638
+ * Edits a policy in place.
639
+ *
640
+ * A change that should apply from a date rather than retroactively is a **new** policy with a
641
+ * later `effectiveFrom`, not an edit — editing rewrites what was true in the past, and anything
642
+ * already derived from it becomes unexplainable.
643
+ */
644
+ update: baseContract
645
+ .route({ method: 'PATCH', path: '/policies/{policyId}', tags: t })
646
+ .input(
647
+ ws.extend({
648
+ policyId: z.uuid(),
649
+ name: z.string().min(1).max(120).optional(),
650
+ config: z.record(z.string(), z.unknown()).optional(),
651
+ effectiveTo: IsoDate.nullish(),
652
+ }),
653
+ )
654
+ .output(Policy),
655
+ archive: baseContract
656
+ .route({ method: 'DELETE', path: '/policies/{policyId}', tags: t })
657
+ .input(ws.extend({ policyId: z.uuid() }))
658
+ .output(ok),
659
+ assign: baseContract
660
+ .route({ method: 'POST', path: '/policies/{policyId}/assign', tags: t })
661
+ .input(
662
+ ws.extend({
663
+ policyId: z.uuid(),
664
+ subjectKind: PolicySubjectKind,
665
+ /** Null for `workspace`, which needs no id. */
666
+ subjectId: z.uuid().nullish(),
667
+ effectiveFrom: IsoDate,
668
+ effectiveTo: IsoDate.nullish(),
669
+ }),
670
+ )
671
+ .output(PolicyAssignment),
672
+ unassign: baseContract
673
+ .route({ method: 'DELETE', path: '/policies/assignments/{assignmentId}', tags: t })
674
+ .input(ws.extend({ assignmentId: z.uuid() }))
675
+ .output(ok),
676
+ /**
677
+ * Which policy of each kind applies to this person on this date, and which rung answered.
678
+ *
679
+ * "Why does she accrue differently from her team" is the question this module gets asked, and
680
+ * this is what answers it without a database session.
681
+ */
682
+ resolveFor: baseContract
683
+ .route({ method: 'GET', path: '/people/{personId}/policies', tags: t })
684
+ .input(ws.extend({ personId: z.uuid(), on: IsoDate.optional() }))
685
+ .output(z.array(ResolvedPolicy)),
686
+ },
687
+
688
+ // ---------------------------------------------------------------- accrual
689
+ accrual: {
690
+ /**
691
+ * What a run would credit, per person, before it writes anything.
692
+ *
693
+ * Runs the same code the run does. A preview computed differently from the thing it previews is
694
+ * a preview that eventually lies.
695
+ */
696
+ preview: baseContract
697
+ .route({ method: 'POST', path: '/accrual/preview', tags: t })
698
+ .input(ws.extend({ from: IsoDate, to: IsoDate, personId: z.uuid().optional() }))
699
+ .output(AccrualPreview),
700
+ /**
701
+ * Credits the ledger.
702
+ *
703
+ * Idempotent per person, per leave type, per period: a second run for the same window credits
704
+ * nothing, because an accrual job that double-credits when somebody clicks twice is worse than
705
+ * one that never ran.
706
+ */
707
+ run: baseContract
708
+ .route({ method: 'POST', path: '/accrual/run', tags: t })
709
+ .input(ws.extend({ from: IsoDate, to: IsoDate, personId: z.uuid().optional() }))
710
+ .output(
711
+ z.object({
712
+ credited: z.number().int(),
713
+ skipped: z.number().int(),
714
+ totalMinutes: z.number().int(),
715
+ }),
716
+ ),
717
+ },
718
+
719
+ // ---------------------------------------------------------------- periods
720
+ periods: {
721
+ list: baseContract
722
+ .route({ method: 'GET', path: '/periods', tags: t })
723
+ .input(ws.extend({ kind: Period.shape.kind.optional(), ...PageInput.shape }))
724
+ .output(page(Period)),
725
+ create: baseContract
726
+ .route({ method: 'POST', path: '/periods', tags: t })
727
+ .input(
728
+ ws.extend({
729
+ kind: Period.shape.kind.default('payroll'),
730
+ legalEntityId: z.uuid().nullish(),
731
+ startsOn: IsoDate,
732
+ endsOn: IsoDate,
733
+ }),
734
+ )
735
+ .output(Period),
736
+ /** Freezes the month. Every derived day inside it stops being recomputable. */
737
+ lock: baseContract
738
+ .route({ method: 'POST', path: '/periods/{periodId}/lock', tags: t })
739
+ .input(ws.extend({ periodId: z.uuid(), note: z.string().max(500).nullish() }))
740
+ .output(Period.extend({ lockedDays: z.number().int() })),
741
+ /**
742
+ * Reopens it.
743
+ *
744
+ * Dangerous on purpose: a payroll has usually been filed against a locked month, and reopening
745
+ * lets figures move underneath it. The response says how many days became recomputable again.
746
+ */
747
+ unlock: baseContract
748
+ .route({ method: 'POST', path: '/periods/{periodId}/unlock', tags: t })
749
+ .input(ws.extend({ periodId: z.uuid(), reason: z.string().min(1).max(500) }))
750
+ .output(Period.extend({ unlockedDays: z.number().int() })),
751
+ },
752
+
605
753
  // ---------------------------------------------------------------- attendance
606
754
  attendance: {
607
755
  /** Am I clocked in? The one call a clock widget makes. */