@kernhq/module-hr 0.10.0 → 0.10.2

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 (90) hide show
  1. package/dist/contract/attendance.d.ts +1 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +9 -0
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +1 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +489 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1888 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +154 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +17 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +81 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +0 -2
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +12 -4
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -8
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -14
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/ledger.d.ts +0 -2
  44. package/dist/server/services/ledger.d.ts.map +1 -1
  45. package/dist/server/services/ledger.js +1 -2
  46. package/dist/server/services/ledger.js.map +1 -1
  47. package/dist/server/services/policies.d.ts +6 -2
  48. package/dist/server/services/policies.d.ts.map +1 -1
  49. package/dist/server/services/policies.js +7 -2
  50. package/dist/server/services/policies.js.map +1 -1
  51. package/migrations/0005_approval_requester.sql +11 -1
  52. package/migrations/0006_schedule_no_overlap.sql +85 -0
  53. package/migrations/0007_hot_path_indexes.sql +38 -0
  54. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  55. package/migrations/meta/0005_snapshot.json +4047 -0
  56. package/migrations/meta/0007_snapshot.json +4225 -0
  57. package/migrations/meta/0009_snapshot.json +4231 -0
  58. package/migrations/meta/_journal.json +21 -0
  59. package/package.json +7 -7
  60. package/src/client/components/ClockControls.svelte +127 -20
  61. package/src/client/components/ClockControls.test.ts +446 -0
  62. package/src/client/components/DelegationDialog.svelte +12 -3
  63. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  64. package/src/client/components/PersonFormDialog.svelte +53 -3
  65. package/src/client/components/PersonPanel.svelte +134 -26
  66. package/src/client/i18n.ts +5 -927
  67. package/src/client/messages.test.ts +154 -0
  68. package/src/client/messages.ts +3388 -0
  69. package/src/client/mock.ts +1576 -161
  70. package/src/client/module.ts +15 -1
  71. package/src/client/pages/ApprovalsPage.svelte +120 -46
  72. package/src/client/pages/AttendancePage.svelte +106 -20
  73. package/src/client/pages/DirectoryPage.svelte +192 -55
  74. package/src/client/pages/LeavePage.svelte +280 -25
  75. package/src/client/pages/OfficesPage.svelte +26 -8
  76. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  77. package/src/client/query.ts +12 -0
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +14 -7
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/attendance.ts +9 -0
  90. package/src/contract/capabilities.ts +17 -0
@@ -1,3 +1,4 @@
1
+ import { formatDate, formatDateRange } from '@kernhq/ui'
1
2
  import type { ApprovalRequest } from '../contract/index.js'
2
3
  import { t } from './i18n.js'
3
4
 
@@ -16,7 +17,9 @@ export function summarise(request: ApprovalRequest): string {
16
17
  if (!p) return request.summary
17
18
  if (request.subjectType === 'leave' && p.from && p.to)
18
19
  return t('approval_summary_leave', {
19
- days: String(p.days ?? ''),
20
+ // A number, not a string: `count` is what selects the plural form, and passing it as text
21
+ // rendered "1 days" and left the digits Latin on a Persian screen. `t()` formats it.
22
+ count: Number(p.days ?? 0),
20
23
  range: dateRange(String(p.from), String(p.to)),
21
24
  })
22
25
  if (request.subjectType === 'regularization' && p.date)
@@ -26,11 +29,15 @@ export function summarise(request: ApprovalRequest): string {
26
29
  return request.summary
27
30
  }
28
31
 
29
- export const day = (iso: string) =>
30
- new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(iso))
32
+ /**
33
+ * `formatDate`, not a bare `Intl.DateTimeFormat(undefined, )`.
34
+ *
35
+ * `undefined` means the browser's locale, which is not the one the reader chose — so the date half
36
+ * of a sentence came out in Latin digits while `t()` rendered the word half in Persian ones, in the
37
+ * same line. The shared helper passes `messageLocale()`, and it is the same call underneath.
38
+ * This matters wherever it is used, and `summarise()` is what all three approvals surfaces render.
39
+ */
40
+ export const day = (iso: string) => formatDate(iso)
31
41
 
32
42
  /** One range, not two dates and a dash — the separator is the locale's, and RTL needs its own. */
33
- export const dateRange = (from: string, to: string) =>
34
- from === to
35
- ? day(from)
36
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).formatRange(new Date(from), new Date(to))
43
+ export const dateRange = (from: string, to: string) => (from === to ? day(from) : formatDateRange(from, to))
@@ -2,8 +2,11 @@
2
2
  import { Badge, Button, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
3
3
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
4
  import { getHrApi } from '../api-instance.js'
5
+ import DecisionDialog from '../components/DecisionDialog.svelte'
5
6
  import { t } from '../i18n.js'
7
+ import type { ApprovalRequest } from '../index.js'
6
8
  import { hrKeys } from '../query.js'
9
+ import { summarise } from '../summary.js'
7
10
 
8
11
  /**
9
12
  * Requests waiting on me, decidable from the card.
@@ -23,45 +26,141 @@ const inboxQuery = createQuery(() => ({
23
26
  }))
24
27
  const items = $derived(inboxQuery.data?.items ?? [])
25
28
 
29
+ let asked = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
30
+ let decideError = $state<string | null>(null)
31
+
32
+ /**
33
+ * `deciding` rather than `decide.isPending`: the disabled attribute only reaches the button on the
34
+ * next render, so two quick clicks both fire and the same request is decided twice. This is set in
35
+ * the same tick as the first click.
36
+ */
37
+ let deciding = $state(false)
38
+
26
39
  const decide = createMutation(() => ({
27
- mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
28
- api.approvals.decide({ workspaceId, ...vars }),
29
- onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['hr'] }),
40
+ mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
41
+ api.approvals.decide({
42
+ workspaceId,
43
+ requestId: vars.requestId,
44
+ decision: vars.decision,
45
+ comment: vars.comment.trim() || null,
46
+ }),
47
+ onSuccess: () => {
48
+ asked = null
49
+ decideError = null
50
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
51
+ },
52
+ onError: (error) => {
53
+ decideError = decideFailure(error)
54
+ // A refusal means the server's inbox is not the one on screen, so the row that was just clicked
55
+ // is stale as well as the decision. Re-read all of HR exactly as a decision that landed does —
56
+ // without this, a request somebody else already approved sits on the card for ever and every
57
+ // retry earns the same sentence.
58
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
59
+ },
60
+ onSettled: () => {
61
+ deciding = false
62
+ },
30
63
  }))
64
+
65
+ /**
66
+ * The decision refusals this module has its own sentence for, keyed by the `reason` the router
67
+ * sends beside the refusal. Empty because `approvals.decide` refuses through `KernError.conflict`,
68
+ * whose reason argument stays on the server — see `ApprovalsPage.svelte`, which carries the whole
69
+ * note and the same shape.
70
+ */
71
+ const decideRefusalMessages: Record<string, string> = {}
72
+
73
+ /**
74
+ * What a refused decision says to the person who made it.
75
+ *
76
+ * The commonest failure here is that the request is no longer yours to decide — somebody else
77
+ * approved it, the person cancelled it, or a delegation moved the step — and the router refuses
78
+ * that with a sentence it wrote for a reader. That sentence is the only thing that says which of
79
+ * those happened, so it is repeated verbatim. Everything else that can fail carries machine text in
80
+ * English, so it falls back to this module's own string. The test is the transport's `code`, never
81
+ * the sentence.
82
+ */
83
+ function decideFailure(error: unknown): string {
84
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
85
+ if (failure.code !== 'CONFLICT') return t('decide_error')
86
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
87
+ const key = reason ? decideRefusalMessages[reason] : undefined
88
+ const translated = key ? t(key) : undefined
89
+ return (translated && translated !== key ? translated : failure.message) || t('decide_error')
90
+ }
91
+
92
+ const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
93
+ decideError = null
94
+ asked = { request, decision }
95
+ }
96
+
97
+ const confirmDecision = (comment: string) => {
98
+ if (!asked || deciding) return
99
+ deciding = true
100
+ decide.mutate({ requestId: asked.request.id, decision: asked.decision, comment })
101
+ }
31
102
  </script>
32
103
 
104
+ <!--
105
+ Held rows outrank the error. `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and
106
+ every decision anywhere in the module, so a failed background refetch leaves TanStack in `error`
107
+ while `data` is still the last good inbox — an error branch above this one would blank a working
108
+ card, and take its approve buttons with it, on a transient failure. The error is only the whole
109
+ card when there is nothing else to draw.
110
+ -->
33
111
  {#if inboxQuery.isLoading}
34
112
  <Skeleton height="96px" />
35
- {:else if items.length === 0}
36
- <EmptyState bare compact icon="check-check" title={t('approvals_none')} />
37
- {:else}
113
+ {:else if items.length > 0}
38
114
  <ul>
39
115
  {#each items as item (item.id)}
40
116
  <li>
41
- <span class="summary">{item.summary}</span>
117
+ <span class="summary">{summarise(item)}</span>
42
118
  <!-- Row actions go while the grid is being rearranged: the data stays, the buttons do not. -->
43
119
  {#if editing}
44
120
  <Badge tone="upcoming">{t('leave_pending')}</Badge>
45
121
  {:else}
46
- <Button
47
- size="sm"
48
- variant="ghost"
49
- disabled={decide.isPending}
50
- onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
51
- >{t('reject')}</Button
52
- >
53
- <Button
54
- size="sm"
55
- disabled={decide.isPending}
56
- onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
57
- >{t('approve')}</Button
58
- >
122
+ <!--
123
+ Never straight to `decide.mutate`: rejecting somebody's leave is irreversible from the
124
+ interface and notifies them, and a dashboard card is the easiest place in the product to
125
+ hit the wrong button. The dialog says what the decision does and to whom.
126
+ -->
127
+ <Button size="sm" variant="ghost" onclick={() => ask(item, 'reject')}>{t('reject')}</Button>
128
+ <Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
59
129
  {/if}
60
130
  </li>
61
131
  {/each}
62
132
  </ul>
133
+ {:else if inboxQuery.isError}
134
+ <!--
135
+ One row, not an `EmptyState`. This card's smallest declared size is `s`, whose body is 43px —
136
+ one grid row of 84px, less the frame's 41px header — and a compact `EmptyState` is 82px before
137
+ it is given an action, so its retry button sat below a fold nobody scrolls in a card this size.
138
+ Here the row lands where the first request would have been.
139
+
140
+ Without this branch the empty state below claimed "Nothing waiting on you" to a manager whose
141
+ inbox had simply failed to load, which is the one sentence on this card nobody would check.
142
+ -->
143
+ <div class="failed" role="alert">
144
+ <span class="msg">{t('approvals_error')}</span>
145
+ <Button size="xs" variant="ghost" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
146
+ </div>
147
+ {:else}
148
+ <EmptyState bare compact icon="check-check" title={t('approvals_none')} />
63
149
  {/if}
64
150
 
151
+ <!-- Portalled, so it is a dialog over the dashboard rather than something inside an 84px card. -->
152
+ <DecisionDialog
153
+ request={asked?.request ?? null}
154
+ decision={asked?.decision ?? 'approve'}
155
+ pending={deciding}
156
+ error={decideError}
157
+ onConfirm={confirmDecision}
158
+ onCancel={() => {
159
+ asked = null
160
+ decideError = null
161
+ }}
162
+ />
163
+
65
164
  <style>
66
165
  ul {
67
166
  display: grid;
@@ -83,4 +182,18 @@ li {
83
182
  white-space: nowrap;
84
183
  font-size: 12px;
85
184
  }
185
+ .failed {
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: space-between;
189
+ gap: 8px;
190
+ padding-block: 8px;
191
+ padding-inline: 14px;
192
+ }
193
+ /* Muted with a colour, never opacity: 9.86:1 on the card in light, 8.96:1 in dark. */
194
+ .msg {
195
+ min-width: 0;
196
+ font-size: 12.5px;
197
+ color: var(--kern-ink-600);
198
+ }
86
199
  </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Skeleton, StatTile, type WidgetProps } from '@kernhq/ui'
2
+ import { Button, formatCount, Skeleton, StatTile, type WidgetProps } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getHrApi } from '../api-instance.js'
5
5
  import { t } from '../i18n.js'
@@ -14,16 +14,82 @@ const peopleQuery = createQuery(() => ({
14
14
  enabled: Boolean(workspaceId),
15
15
  queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['active'] }),
16
16
  }))
17
- // `total` rather than `items.length`: the request asks for one row, because drawing a number does
18
- // not need the list behind it.
19
- const count = $derived(peopleQuery.data?.total ?? 0)
17
+ /**
18
+ * `total` rather than `items.length`: the request asks for one row, because drawing a number does
19
+ * not need the list behind it.
20
+ *
21
+ * No `?? 0`. A tile reading "0 people" is a claim about the company, and it was the claim this card
22
+ * made every time the fetch failed — indistinguishable from a workspace nobody has been added to.
23
+ * `undefined` is what says "not known", and the branches below are ordered on it.
24
+ */
25
+ const total = $derived(peopleQuery.data?.total)
26
+ /**
27
+ * `formatCount`'s default caps at "99+", which is right for a badge on a nav row and wrong for a
28
+ * headcount — a four-hundred-person company would read "99+". The cap is lifted rather than the
29
+ * locale dropped: a bare `Intl.NumberFormat()` follows the browser's language, so a Persian
30
+ * interface would be the one screen printing Latin digits.
31
+ */
32
+ const people = $derived(total === undefined ? '' : formatCount(total, Number.MAX_SAFE_INTEGER))
20
33
  </script>
21
34
 
35
+ <!--
36
+ The held number outranks the error, as everywhere else in this module:
37
+ `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and every approval decision, so a
38
+ failed background refetch leaves TanStack in `error` while `data` is still the last good count.
39
+ Blanking a card that has a number, because the poll behind it missed once, is the louder bug.
40
+ -->
22
41
  {#if peopleQuery.isLoading}
23
42
  <Skeleton height="72px" />
43
+ {:else if total !== undefined}
44
+ <StatTile label={t('widget_headcount_title')} value={people} />
45
+ {:else if peopleQuery.isError}
46
+ <!--
47
+ The label stays and the number does not. This widget is `compact`, so the frame draws no header
48
+ and the tile's own label is the only thing naming the card — drop it and the board grows an
49
+ anonymous box. The reason takes the number's place, in the same footprint the tile occupied, so
50
+ nothing around it moves.
51
+ -->
52
+ <div class="failed" role="alert">
53
+ <div class="text">
54
+ <span class="label">{t('widget_headcount_title')}</span>
55
+ <span class="msg">{t('people_error')}</span>
56
+ </div>
57
+ <Button size="xs" variant="ghost" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
58
+ </div>
24
59
  {:else}
25
- <StatTile
26
- label={t('widget_headcount_title')}
27
- value={new Intl.NumberFormat().format(count)}
28
- />
60
+ <!--
61
+ No workspace yet. The query is disabled until one arrives, and a disabled query is not
62
+ "loading" — so without this branch the card is simply absent for that first frame.
63
+ -->
64
+ <Skeleton height="72px" />
29
65
  {/if}
66
+
67
+ <style>
68
+ /* The tile's own frame, so a card that fails keeps the shape it had a moment ago. */
69
+ .failed {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ gap: 8px;
74
+ padding-block: 14px;
75
+ padding-inline: 16px;
76
+ border: 1px solid var(--kern-border);
77
+ border-radius: var(--kern-r-2xl);
78
+ background: var(--kern-surface-raised);
79
+ }
80
+ .text {
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: 2px;
84
+ min-width: 0;
85
+ }
86
+ .label {
87
+ font-size: 12px;
88
+ color: var(--kern-ink-450);
89
+ }
90
+ /* Muted with a colour, never opacity: 9.86:1 on the tile in light, 8.96:1 in dark. */
91
+ .msg {
92
+ font-size: 12.5px;
93
+ color: var(--kern-ink-600);
94
+ }
95
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
2
+ import { Button, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getHrApi } from '../api-instance.js'
5
5
  import { t } from '../i18n.js'
@@ -22,19 +22,40 @@ const balanceQuery = createQuery(() => ({
22
22
  const balances = $derived(balanceQuery.data ?? [])
23
23
  </script>
24
24
 
25
+ <!--
26
+ Held balances outrank the error. `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and
27
+ every approval decision anywhere in the module, so a failed background refetch leaves TanStack in
28
+ `error` while `data` is still the last good set of balances — an error branch above this one would
29
+ blank a working card on a transient failure. The error is only the whole card when there is
30
+ nothing else to draw.
31
+ -->
25
32
  {#if balanceQuery.isLoading}
26
33
  <Skeleton height="72px" />
27
- {:else if balances.length === 0}
28
- <EmptyState bare compact icon="tree-palm" title={t('leave_none')} />
29
- {:else}
34
+ {:else if balances.length > 0}
30
35
  <ul>
31
36
  {#each balances as b (b.leaveTypeId)}
32
37
  <li>
33
38
  <span class="name">{b.leaveTypeName}</span>
34
- <span class="value">{formatDays(b.available)} <span class="unit">{t('days')}</span></span>
39
+ <span class="value">{formatDays(b.available)} <span class="unit">{t('days', { count: b.available })}</span></span>
35
40
  </li>
36
41
  {/each}
37
42
  </ul>
43
+ {:else if balanceQuery.isError}
44
+ <!--
45
+ One row, not an `EmptyState`. This card's smallest declared size is `s`, whose body is 43px —
46
+ one grid row of 84px, less the frame's 41px header — and a compact `EmptyState` is 82px before
47
+ it is given an action, so its retry button sat below a fold nobody scrolls in a card this size.
48
+
49
+ Without this branch the empty state below said "No time off booked", which on a card headed "My
50
+ time off" reads as a balance of nothing — the answer somebody plans a year around, given for a
51
+ request that never arrived.
52
+ -->
53
+ <div class="failed" role="alert">
54
+ <span class="msg">{t('balance_error')}</span>
55
+ <Button size="xs" variant="ghost" onclick={() => void balanceQuery.refetch()}>{t('retry')}</Button>
56
+ </div>
57
+ {:else}
58
+ <EmptyState bare compact icon="tree-palm" title={t('leave_none')} />
38
59
  {/if}
39
60
 
40
61
  <style>
@@ -64,4 +85,18 @@ li {
64
85
  font-size: 12px;
65
86
  color: var(--kern-ink-500);
66
87
  }
88
+ .failed {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ gap: 8px;
93
+ padding-block: 8px;
94
+ padding-inline: 14px;
95
+ }
96
+ /* Muted with a colour, never opacity: 9.86:1 on the card in light, 8.96:1 in dark. */
97
+ .msg {
98
+ min-width: 0;
99
+ font-size: 12.5px;
100
+ color: var(--kern-ink-600);
101
+ }
67
102
  </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Avatar, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
2
+ import { Avatar, Button, EmptyState, formatDateRange, Skeleton, type WidgetProps } from '@kernhq/ui'
3
3
  import { createQuery } from '@tanstack/svelte-query'
4
4
  import { getHrApi } from '../api-instance.js'
5
5
  import { t } from '../i18n.js'
@@ -25,18 +25,26 @@ const outQuery = createQuery(() => ({
25
25
  }))
26
26
  const away = $derived(outQuery.data ?? [])
27
27
 
28
+ /**
29
+ * `formatDateRange` rather than `Intl` here: it formats in the reader's *interface* language, which
30
+ * `undefined` does not — that is the browser's language, so a Persian interface printed "6 Aug".
31
+ * `T00:00:00` stays on both ends: a bare `YYYY-MM-DD` is parsed as UTC midnight, which prints the
32
+ * day before west of Greenwich.
33
+ */
28
34
  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
- )
35
+ formatDateRange(`${a}T00:00:00`, `${b}T00:00:00`, { day: 'numeric', month: 'short' })
33
36
  </script>
34
37
 
38
+ <!--
39
+ Held rows outrank the error. `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and
40
+ every approval decision anywhere in the module, so a failed background refetch leaves TanStack in
41
+ `error` while `data` is still the last good calendar — an error branch above this one would blank
42
+ a working card on a transient failure. The error is only the whole card when there is nothing
43
+ else to draw.
44
+ -->
35
45
  {#if outQuery.isLoading}
36
46
  <Skeleton height="96px" />
37
- {:else if away.length === 0}
38
- <EmptyState bare compact icon="calendar-days" title={t('leave_none')} />
39
- {:else}
47
+ {:else if away.length > 0}
40
48
  <ul>
41
49
  {#each away as person (person.requestId)}
42
50
  <li>
@@ -46,6 +54,23 @@ const range = (a: string, b: string) =>
46
54
  </li>
47
55
  {/each}
48
56
  </ul>
57
+ {:else if outQuery.isError}
58
+ <!--
59
+ One row, not an `EmptyState`, for the same reason as the rest of this module's cards: a compact
60
+ `EmptyState` is 82px before it is given an action, which is twice the 43px body a widget has at
61
+ size `s`. This card starts at `m` and has the room, but a person reading two failed HR cards
62
+ side by side should not be shown two different shapes of failure.
63
+
64
+ Without this branch the empty state below said "No time off booked" — read on a card headed
65
+ "Who's out" as *nobody is away*, which is the precise opposite of what is actually unknown, and
66
+ the one answer somebody schedules a week of work around.
67
+ -->
68
+ <div class="failed" role="alert">
69
+ <span class="msg">{t('whos_out_error')}</span>
70
+ <Button size="xs" variant="ghost" onclick={() => void outQuery.refetch()}>{t('retry')}</Button>
71
+ </div>
72
+ {:else}
73
+ <EmptyState bare compact icon="calendar-days" title={t('leave_none')} />
49
74
  {/if}
50
75
 
51
76
  <style>
@@ -72,4 +97,18 @@ li {
72
97
  color: var(--kern-ink-500);
73
98
  font-size: 12px;
74
99
  }
100
+ .failed {
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: space-between;
104
+ gap: 8px;
105
+ padding-block: 8px;
106
+ padding-inline: 14px;
107
+ }
108
+ /* Muted with a colour, never opacity: 9.86:1 on the card in light, 8.96:1 in dark. */
109
+ .msg {
110
+ min-width: 0;
111
+ font-size: 12.5px;
112
+ color: var(--kern-ink-600);
113
+ }
75
114
  </style>
@@ -89,6 +89,15 @@ export const AttendanceDay = z.object({
89
89
  workedMinutes: z.number().int(),
90
90
  breakMinutes: z.number().int(),
91
91
  overtimeMinutes: z.number().int(),
92
+ /**
93
+ * Overtime the policy's annual cap will not take — null where no cap was in force.
94
+ *
95
+ * Declared here or it does not exist: oRPC validates a procedure's output against this schema and
96
+ * zod strips what the schema does not name, so the column was written, stored, and then dropped
97
+ * on the way out. Null and zero mean different things and a compliance report has to tell them
98
+ * apart — null is "no ceiling applied to this day", zero is "one applied and nothing exceeded it".
99
+ */
100
+ beyondCapMinutes: z.number().int().nullable(),
92
101
  lateMinutes: z.number().int(),
93
102
  earlyLeaveMinutes: z.number().int(),
94
103
  status: AttendanceStatus,
@@ -132,6 +132,23 @@ export const hrCapabilities = defineCapabilities([
132
132
 
133
133
  export type HrCapabilityId = (typeof hrCapabilities)[number]['id']
134
134
 
135
+ /**
136
+ * The message ids a screen reads a capability's `label` and `description` through.
137
+ *
138
+ * `CapabilityDef.label` is documented as "an i18n message id or an English fallback", and putting
139
+ * the id *in* the field would leave the fallback nowhere: core's module admin and the shell's mock
140
+ * read this manifest raw, with no HR bundle merged, so they would print `hr.cap_leave` at a person.
141
+ * So the literals above stay the fallback and the id is derived from the capability's own id — a
142
+ * capability added to the list becomes translatable the moment somebody writes its two strings, and
143
+ * nothing that parses this list sees a new field.
144
+ *
145
+ * The `hr.` prefix is written out rather than left to `scopedT`, because a caller has to be able to
146
+ * tell a resolved string from a key that resolved to itself, and `t()` answers a miss with the
147
+ * *namespaced* key.
148
+ */
149
+ export const capabilityLabelKey = (id: string) => `hr.cap_${id}`
150
+ export const capabilityDescriptionKey = (id: string) => `hr.cap_${id}_desc`
151
+
135
152
  /**
136
153
  * Which procedures sit behind which capability.
137
154
  *