@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,428 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Badge,
5
+ type BadgeTone,
6
+ Button,
7
+ Card,
8
+ coreApi,
9
+ EmptyState,
10
+ Input,
11
+ keys,
12
+ navigation,
13
+ Page,
14
+ PageHeader,
15
+ SectionLabel,
16
+ Skeleton,
17
+ StatTile,
18
+ session,
19
+ Tabs,
20
+ } from '@kernhq/ui'
21
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
22
+ import { getHrApi } from '../api-instance.js'
23
+ import PersonFormDialog from '../components/PersonFormDialog.svelte'
24
+ import PersonPanel from '../components/PersonPanel.svelte'
25
+ import type { CoreApi } from '../core-api.js'
26
+ import { t } from '../i18n.js'
27
+ import { canHr, HR_CAPABILITIES } from '../permissions.js'
28
+ import { formatDays, hrKeys } from '../query.js'
29
+
30
+ /**
31
+ * The people view, laid out to DESIGN.md §3.12.
32
+ *
33
+ * Four stat tiles, then `minmax(0,1fr) 320px`: a real table on the left — name, role, office,
34
+ * started, status on one grid so the columns line up down the page — and the things that need a
35
+ * decision on the right. A flat list of names would be a directory; this is the screen somebody
36
+ * actually opens in the morning, which is why what is waiting on them sits beside it.
37
+ *
38
+ * Each row carries the person's **local time**, because the directory of a company with more than
39
+ * one office is also the answer to "can I call them now".
40
+ */
41
+ const api = getHrApi()
42
+ const core = coreApi<CoreApi>()
43
+ const queryClient = useQueryClient()
44
+
45
+ const workspaceSlug = $derived(navigation.workspaceSlug)
46
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
47
+ const workspaceId = $derived(workspace?.id ?? '')
48
+
49
+ let search = $state('')
50
+ let officeTab = $state('all')
51
+ const selected = $derived(navigation.search.person)
52
+ const creating = $derived(navigation.search.new === '1')
53
+
54
+ const modulesQuery = createQuery(() => ({
55
+ queryKey: keys.modules(workspaceId),
56
+ enabled: Boolean(workspaceId),
57
+ queryFn: () => core.workspaces.modules.list({ workspaceId }),
58
+ }))
59
+ const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
60
+
61
+ /** Debounced: every keystroke would otherwise be a request, and the term is part of the cache key. */
62
+ let debounced = $state('')
63
+ $effect(() => {
64
+ const term = search
65
+ const handle = setTimeout(() => {
66
+ debounced = term
67
+ }, 250)
68
+ return () => clearTimeout(handle)
69
+ })
70
+
71
+ const officesQuery = createQuery(() => ({
72
+ queryKey: hrKeys.offices(workspaceId),
73
+ enabled: Boolean(workspaceId) && showOffices && canHr('officeView'),
74
+ queryFn: () => api.offices.list({ workspaceId, includeArchived: false }),
75
+ }))
76
+ const offices = $derived(officesQuery.data ?? [])
77
+
78
+ const peopleQuery = createQuery(() => ({
79
+ queryKey: hrKeys.people(workspaceId, { q: debounced, officeId: officeTab }),
80
+ enabled: Boolean(workspaceId),
81
+ queryFn: () =>
82
+ api.people.list({
83
+ workspaceId,
84
+ limit: 100,
85
+ ...(debounced ? { q: debounced } : {}),
86
+ ...(officeTab !== 'all' ? { officeId: officeTab } : {}),
87
+ }),
88
+ }))
89
+ const people = $derived(peopleQuery.data?.items ?? [])
90
+
91
+ /** Office tabs only once there is more than one place of work — otherwise they say nothing. */
92
+ const tabs = $derived([
93
+ { value: 'all', label: t('title') },
94
+ ...offices.map((o) => ({ value: o.id, label: o.name })),
95
+ ])
96
+
97
+ const balancesQuery = createQuery(() => ({
98
+ queryKey: hrKeys.leaveBalance(workspaceId, undefined),
99
+ enabled: Boolean(workspaceId),
100
+ queryFn: () => api.leave.balance.get({ workspaceId }),
101
+ }))
102
+
103
+ const inboxQuery = createQuery(() => ({
104
+ queryKey: hrKeys.approvalInbox(workspaceId),
105
+ enabled: Boolean(workspaceId),
106
+ queryFn: () => api.approvals.inbox({ workspaceId, limit: 6, includeDecided: false }),
107
+ }))
108
+ const waiting = $derived(inboxQuery.data?.items ?? [])
109
+
110
+ const decide = createMutation(() => ({
111
+ mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
112
+ api.approvals.decide({ workspaceId, ...vars }),
113
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['hr'] }),
114
+ }))
115
+
116
+ const stats = $derived({
117
+ headcount: peopleQuery.data?.total ?? people.length,
118
+ offices: offices.length,
119
+ away: people.filter((p) => p.status === 'on_leave').length,
120
+ balance: balancesQuery.data?.[0]?.available ?? 0,
121
+ })
122
+
123
+ const statusLabel = (s: string) =>
124
+ s === 'active'
125
+ ? t('status_active')
126
+ : s === 'onboarding'
127
+ ? t('status_onboarding')
128
+ : s === 'on_leave'
129
+ ? t('status_on_leave')
130
+ : s === 'offboarding'
131
+ ? t('status_offboarding')
132
+ : t('status_terminated')
133
+
134
+ /** The design system already has tones for these exact states — §1.1 semantic chips. */
135
+ const statusTone = (s: string): BadgeTone =>
136
+ s === 'active'
137
+ ? 'active'
138
+ : s === 'on_leave'
139
+ ? 'on-leave'
140
+ : s === 'onboarding'
141
+ ? 'onboarding'
142
+ : s === 'terminated'
143
+ ? 'grey'
144
+ : 'upcoming'
145
+
146
+ /** Re-renders the clocks once a minute; a directory showing a stale time is worse than none. */
147
+ let tick = $state(0)
148
+ $effect(() => {
149
+ const handle = setInterval(() => {
150
+ tick++
151
+ }, 60_000)
152
+ return () => clearInterval(handle)
153
+ })
154
+
155
+ function localTime(timezone: string | null, _tick: number): string | null {
156
+ void _tick
157
+ if (!timezone) return null
158
+ try {
159
+ return new Intl.DateTimeFormat(undefined, {
160
+ timeZone: timezone,
161
+ hour: 'numeric',
162
+ minute: '2-digit',
163
+ }).format(new Date())
164
+ } catch {
165
+ // An unknown zone must not take the directory down with it.
166
+ return null
167
+ }
168
+ }
169
+
170
+ const started = (iso: string | null) =>
171
+ iso
172
+ ? new Intl.DateTimeFormat(undefined, { month: 'short', year: 'numeric' }).format(
173
+ new Date(`${iso}T00:00:00`),
174
+ )
175
+ : '—'
176
+ </script>
177
+
178
+ <PageHeader
179
+ crumbs={[{ label: workspace?.name ?? '' }, { label: t('title') }]}
180
+ title={t('title')}
181
+ subtitle={t('subtitle')}
182
+ >
183
+ {#snippet actions()}
184
+ {#if canHr('personManage')}
185
+ <Button size="sm" href={`/${workspaceSlug}/hr?new=1`}>{t('add_person')}</Button>
186
+ {/if}
187
+ {/snippet}
188
+ </PageHeader>
189
+
190
+ <Page>
191
+ <div class="tiles">
192
+ <StatTile size="md" label={t('widget_headcount_title')} value={new Intl.NumberFormat().format(stats.headcount)} />
193
+ <StatTile size="md" label={t('offices_title')} value={new Intl.NumberFormat().format(stats.offices)} />
194
+ <StatTile size="md" label={t('status_on_leave')} value={new Intl.NumberFormat().format(stats.away)} />
195
+ <StatTile size="md" label={t('available')} value={formatDays(stats.balance)} note={t('days')} />
196
+ </div>
197
+
198
+ <div class="split">
199
+ <section>
200
+ <SectionLabel label={t('title')} count={people.length} />
201
+
202
+ <div class="filters">
203
+ {#if tabs.length > 1}
204
+ <Tabs items={tabs} value={officeTab} variant="pill" onValueChange={(v) => (officeTab = v)} />
205
+ {/if}
206
+ <div class="search">
207
+ <Input bind:value={search} placeholder={t('search_people')} type="search" size="sm" />
208
+ </div>
209
+ </div>
210
+
211
+ {#if peopleQuery.isLoading}
212
+ <div class="rows">
213
+ {#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="48px" />{/each}
214
+ </div>
215
+ {:else if peopleQuery.isError}
216
+ <EmptyState icon="triangle-alert" title={t('people_error')}>
217
+ {#snippet actions()}
218
+ <Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('common.retry')}</Button>
219
+ {/snippet}
220
+ </EmptyState>
221
+ {:else if people.length === 0}
222
+ <EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
223
+ {:else}
224
+ <div class="table" role="table" aria-label={t('title')}>
225
+ <div class="thead" role="row">
226
+ <span role="columnheader">{t('title')}</span>
227
+ <span role="columnheader">{t('employee_no')}</span>
228
+ <span role="columnheader">{t('office')}</span>
229
+ <span role="columnheader">{t('started')}</span>
230
+ <span role="columnheader">{t('local_time')}</span>
231
+ <span role="columnheader">{t('status')}</span>
232
+ </div>
233
+ {#each people as person (person.id)}
234
+ {@const time = localTime(person.timezone, tick)}
235
+ <a class="trow" role="row" href={`/${workspaceSlug}/hr?person=${person.id}`}>
236
+ <span class="cell who" role="cell">
237
+ <Avatar name={person.displayName} id={person.id} size={28} />
238
+ <span class="stack">
239
+ <span class="name">{person.displayName}</span>
240
+ {#if person.workEmail}<span class="sub">{person.workEmail}</span>{/if}
241
+ </span>
242
+ </span>
243
+ <span class="cell role" role="cell">{person.employeeNo ?? '—'}</span>
244
+ <span class="cell muted" role="cell">{person.officeName ?? '—'}</span>
245
+ <span class="cell muted" role="cell">{started(person.hiredOn)}</span>
246
+ <span class="cell num" role="cell" title={person.timezone ?? ''}>{time ?? '—'}</span>
247
+ <span class="cell" role="cell">
248
+ <Badge tone={statusTone(person.status)}>{statusLabel(person.status)}</Badge>
249
+ </span>
250
+ </a>
251
+ {/each}
252
+ </div>
253
+ {/if}
254
+ </section>
255
+
256
+ <aside>
257
+ <SectionLabel label={t('approvals_title')} count={waiting.length} />
258
+ {#if inboxQuery.isLoading}
259
+ <Skeleton height="120px" />
260
+ {:else if waiting.length === 0}
261
+ <EmptyState bare compact icon="check-check" title={t('approvals_none')} />
262
+ {:else}
263
+ <div class="cards">
264
+ {#each waiting as item (item.id)}
265
+ <Card>
266
+ <div class="cardhead">
267
+ <Badge tone="upcoming">{t('leave_title')}</Badge>
268
+ </div>
269
+ <p class="summary">{item.summary}</p>
270
+ <div class="cardactions">
271
+ <Button
272
+ size="sm"
273
+ disabled={decide.isPending}
274
+ onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
275
+ >{t('approve')}</Button
276
+ >
277
+ <Button
278
+ size="sm"
279
+ variant="secondary"
280
+ disabled={decide.isPending}
281
+ onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
282
+ >{t('reject')}</Button
283
+ >
284
+ </div>
285
+ </Card>
286
+ {/each}
287
+ </div>
288
+ {/if}
289
+ </aside>
290
+ </div>
291
+ </Page>
292
+
293
+ {#if selected}
294
+ <PersonPanel personId={selected} {workspaceId} {workspaceSlug} />
295
+ {/if}
296
+
297
+ <PersonFormDialog
298
+ open={creating}
299
+ {workspaceId}
300
+ {workspaceSlug}
301
+ {offices}
302
+ showOffice={showOffices}
303
+ />
304
+
305
+ <style>
306
+ /* §3.12: four stat tiles, then a 1fr / 320px split, gap 20. */
307
+ .tiles {
308
+ display: grid;
309
+ grid-template-columns: repeat(4, minmax(0, 1fr));
310
+ gap: 12px;
311
+ margin-block-end: 20px;
312
+ }
313
+ .split {
314
+ display: grid;
315
+ grid-template-columns: minmax(0, 1fr) 320px;
316
+ gap: 20px;
317
+ align-items: start;
318
+ }
319
+ /* Tabs on the start edge, search on the end — logical properties so it flips under dir="rtl". */
320
+ .filters {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 12px;
324
+ margin-block: 4px 8px;
325
+ }
326
+ .search {
327
+ margin-inline-start: auto;
328
+ width: min(260px, 40%);
329
+ }
330
+ .rows {
331
+ display: grid;
332
+ gap: 4px;
333
+ }
334
+
335
+ /* One grid for the header and every row, so the columns line up down the page. */
336
+ .table {
337
+ --hr-cols: minmax(180px, 1.1fr) minmax(80px, 0.5fr) minmax(90px, 0.6fr) 110px 96px 104px;
338
+ width: 100%;
339
+ }
340
+ .thead,
341
+ .trow {
342
+ display: grid;
343
+ grid-template-columns: var(--hr-cols);
344
+ gap: 12px;
345
+ align-items: center;
346
+ padding-inline: 12px;
347
+ }
348
+ .thead {
349
+ height: 34px;
350
+ border-block-end: 1px solid var(--kern-border);
351
+ font-size: 11px;
352
+ font-weight: 600;
353
+ letter-spacing: 0.06em;
354
+ text-transform: uppercase;
355
+ color: var(--kern-ink-400);
356
+ }
357
+ .trow {
358
+ height: 48px;
359
+ border-block-end: 1px solid var(--kern-border-hairline);
360
+ text-decoration: none;
361
+ color: inherit;
362
+ border-radius: 6px;
363
+ }
364
+ .trow:hover {
365
+ background: var(--kern-surface-raised, #fff);
366
+ }
367
+ .cell {
368
+ min-width: 0;
369
+ overflow: hidden;
370
+ text-overflow: ellipsis;
371
+ white-space: nowrap;
372
+ }
373
+ .who {
374
+ display: flex;
375
+ align-items: center;
376
+ gap: 10px;
377
+ }
378
+ .stack {
379
+ display: flex;
380
+ flex-direction: column;
381
+ min-width: 0;
382
+ }
383
+ .name {
384
+ font-size: 13.5px;
385
+ font-weight: 500;
386
+ }
387
+ .sub,
388
+ .muted {
389
+ font-size: 12px;
390
+ color: var(--kern-ink-500);
391
+ }
392
+ .role {
393
+ font-size: 13px;
394
+ }
395
+ .num {
396
+ font-size: 13px;
397
+ color: var(--kern-ink-500);
398
+ font-variant-numeric: tabular-nums;
399
+ }
400
+ .cards {
401
+ display: grid;
402
+ gap: 8px;
403
+ }
404
+ .cardhead {
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 8px;
408
+ }
409
+ .summary {
410
+ font-size: 13.5px;
411
+ margin: 7px 0 0;
412
+ }
413
+ .cardactions {
414
+ display: flex;
415
+ gap: 6px;
416
+ margin-block-start: 11px;
417
+ }
418
+
419
+ /* Below 1024 the right column stacks under the table rather than squeezing it. */
420
+ @media (max-width: 1024px) {
421
+ .split {
422
+ grid-template-columns: minmax(0, 1fr);
423
+ }
424
+ .tiles {
425
+ grid-template-columns: repeat(2, minmax(0, 1fr));
426
+ }
427
+ }
428
+ </style>
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Card,
6
+ EmptyState,
7
+ navigation,
8
+ Page,
9
+ PageHeader,
10
+ Skeleton,
11
+ StatTile,
12
+ session,
13
+ toast,
14
+ } from '@kernhq/ui'
15
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
16
+ import { getHrApi } from '../api-instance.js'
17
+ import LeaveRequestDialog from '../components/LeaveRequestDialog.svelte'
18
+ import { t } from '../i18n.js'
19
+ import { canHr } from '../permissions.js'
20
+ import { formatDays, hrKeys } from '../query.js'
21
+
22
+ /**
23
+ * My time off: what is left, and what is booked.
24
+ *
25
+ * Balance first, then the requests, because "how much do I have" is the question somebody opens
26
+ * this page with — and the number they need before deciding anything is `available`, not `balance`.
27
+ * Pending requests are already spoken for; showing the raw balance is how somebody books a week
28
+ * they do not have and finds out at approval.
29
+ */
30
+ const api = getHrApi()
31
+ const queryClient = useQueryClient()
32
+
33
+ const workspaceSlug = $derived(navigation.workspaceSlug)
34
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
35
+ const workspaceId = $derived(workspace?.id ?? '')
36
+ const requesting = $derived(navigation.search.new === '1')
37
+
38
+ const balanceQuery = createQuery(() => ({
39
+ queryKey: hrKeys.leaveBalance(workspaceId, undefined),
40
+ enabled: Boolean(workspaceId),
41
+ queryFn: () => api.leave.balance.get({ workspaceId }),
42
+ }))
43
+ const balances = $derived(balanceQuery.data ?? [])
44
+
45
+ const requestsQuery = createQuery(() => ({
46
+ queryKey: hrKeys.leaveRequests(workspaceId, undefined),
47
+ enabled: Boolean(workspaceId),
48
+ queryFn: () => api.leave.requests.list({ workspaceId, limit: 50 }),
49
+ }))
50
+ const requests = $derived(requestsQuery.data?.items ?? [])
51
+
52
+ const cancel = createMutation(() => ({
53
+ mutationFn: (requestId: string) => api.leave.requests.cancel({ workspaceId, requestId }),
54
+ onSuccess: () => {
55
+ toast.success(t('leave_cancelled_toast'))
56
+ void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-balance'] })
57
+ void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-requests'] })
58
+ void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-calendar'] })
59
+ },
60
+ onError: (error: Error) => toast.error(error.message),
61
+ }))
62
+
63
+ const statusLabel = (s: string) =>
64
+ s === 'pending'
65
+ ? t('leave_pending')
66
+ : s === 'approved'
67
+ ? t('leave_approved')
68
+ : s === 'rejected'
69
+ ? t('leave_rejected')
70
+ : s === 'withdrawn'
71
+ ? t('leave_withdrawn')
72
+ : t('leave_cancelled')
73
+
74
+ const statusTone = (s: string) =>
75
+ s === 'approved' ? 'done' : s === 'pending' ? 'upcoming' : s === 'rejected' ? 'declined' : 'grey'
76
+
77
+ /**
78
+ * A date range, through `Intl.formatRange`.
79
+ *
80
+ * Not two dates and a dash: a hand-built range reads backwards under `dir="rtl"` — the earlier date
81
+ * ends up on the right — and `formatRange` collapses the parts the two dates share for free.
82
+ */
83
+ function range(from: string, to: string): string {
84
+ const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })
85
+ return fmt.formatRange(new Date(`${from}T00:00:00`), new Date(`${to}T00:00:00`))
86
+ }
87
+
88
+ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pending' || status === 'approved')
89
+ </script>
90
+
91
+ <PageHeader
92
+ crumbs={[{ label: workspace?.name ?? '' }, { label: t('leave_title') }]}
93
+ title={t('leave_title')}
94
+ >
95
+ {#snippet actions()}
96
+ {#if canHr('leaveRequest')}
97
+ <Button size="sm" href={`/${workspaceSlug}/hr/leave?new=1`}>{t('request_leave')}</Button>
98
+ {/if}
99
+ {/snippet}
100
+ </PageHeader>
101
+
102
+ <Page>
103
+ {#if balanceQuery.isLoading}
104
+ <Skeleton height="88px" />
105
+ {:else if balances.length}
106
+ <div class="tiles">
107
+ {#each balances as balance (balance.leaveTypeId)}
108
+ <StatTile
109
+ label={balance.leaveTypeName}
110
+ value={formatDays(balance.available)}
111
+ note={`${t('available')} · ${t('days')}`}
112
+ />
113
+ {/each}
114
+ </div>
115
+ {/if}
116
+
117
+ <h2>{t('leave_title')}</h2>
118
+ {#if requestsQuery.isLoading}
119
+ <Skeleton height="120px" />
120
+ {:else if requests.length === 0}
121
+ <EmptyState icon="tree-palm" title={t('leave_none')} description={t('leave_none_desc')} />
122
+ {:else}
123
+ <ul>
124
+ {#each requests as request (request.id)}
125
+ <li>
126
+ <Card>
127
+ <div class="row">
128
+ <span class="dates">{range(request.startsOn, request.endsOn)}</span>
129
+ <span class="meta">{formatDays(request.workingDays)} {t('days')}</span>
130
+ <Badge tone={statusTone(request.status)}>{statusLabel(request.status)}</Badge>
131
+ {#if canCancel(request.status)}
132
+ <Button
133
+ size="sm"
134
+ variant="ghost"
135
+ disabled={cancel.isPending}
136
+ onclick={() => cancel.mutate(request.id)}>{t('cancel_request')}</Button
137
+ >
138
+ {/if}
139
+ </div>
140
+ </Card>
141
+ </li>
142
+ {/each}
143
+ </ul>
144
+ {/if}
145
+ </Page>
146
+
147
+ <LeaveRequestDialog open={requesting} {workspaceId} {workspaceSlug} />
148
+
149
+ <style>
150
+ .tiles {
151
+ display: grid;
152
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
153
+ gap: 12px;
154
+ margin-block-end: 20px;
155
+ }
156
+ h2 {
157
+ font-size: 13.5px;
158
+ margin: 0 0 12px;
159
+ }
160
+ ul {
161
+ display: grid;
162
+ gap: 8px;
163
+ list-style: none;
164
+ margin: 0;
165
+ padding: 0;
166
+ }
167
+ .row {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 12px;
171
+ }
172
+ .dates {
173
+ flex: 1;
174
+ font-weight: 500;
175
+ }
176
+ .meta {
177
+ color: var(--kern-ink-500);
178
+ font-size: 12px;
179
+ }
180
+ </style>