@kernhq/module-hr 0.8.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.
- package/package.json +22 -2
- package/src/client/api-instance.ts +29 -0
- package/src/client/components/ClockControls.svelte +127 -0
- package/src/client/components/HrSidebar.svelte +73 -0
- package/src/client/components/LeaveRequestDialog.svelte +169 -0
- package/src/client/components/PersonFormDialog.svelte +184 -0
- package/src/client/components/PersonInline.svelte +49 -0
- package/src/client/components/PersonPanel.svelte +328 -0
- package/src/client/core-api.ts +34 -0
- package/src/client/i18n.ts +640 -0
- package/src/client/index.ts +3 -0
- package/src/client/mock.ts +447 -0
- package/src/client/module.ts +295 -0
- package/src/client/pages/ApprovalsPage.svelte +129 -0
- package/src/client/pages/AttendancePage.svelte +147 -0
- package/src/client/pages/DirectoryPage.svelte +428 -0
- package/src/client/pages/LeavePage.svelte +180 -0
- package/src/client/pages/OfficesPage.svelte +119 -0
- package/src/client/permissions.ts +31 -0
- package/src/client/query.test.ts +70 -0
- package/src/client/query.ts +69 -0
- package/src/client/settings/CalendarsSettings.svelte +21 -0
- package/src/client/settings/CapabilitiesSettings.svelte +122 -0
- package/src/client/settings/LeaveSettings.svelte +21 -0
- package/src/client/settings/OfficesSettings.svelte +21 -0
- package/src/client/settings/SchedulesSettings.svelte +21 -0
- package/src/client/widgets/ApprovalsWidget.svelte +86 -0
- package/src/client/widgets/ClockWidget.svelte +9 -0
- package/src/client/widgets/HeadcountWidget.svelte +29 -0
- package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
- package/src/client/widgets/WhosOutWidget.svelte +75 -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>
|