@kernhq/module-hr 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -5
- 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,328 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
Field,
|
|
8
|
+
Input,
|
|
9
|
+
navigation,
|
|
10
|
+
RightPanel,
|
|
11
|
+
Skeleton,
|
|
12
|
+
toast,
|
|
13
|
+
} from '@kernhq/ui'
|
|
14
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
15
|
+
import { getHrApi } from '../api-instance.js'
|
|
16
|
+
import { t } from '../i18n.js'
|
|
17
|
+
import { canHr } from '../permissions.js'
|
|
18
|
+
import { hrKeys, isoDate } from '../query.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One person, beside the directory rather than instead of it.
|
|
22
|
+
*
|
|
23
|
+
* A panel rather than a route because picking somebody out of a list and going back to it is the
|
|
24
|
+
* whole interaction — a full page navigation loses the list's scroll position and the search term,
|
|
25
|
+
* and both are how the person got here.
|
|
26
|
+
*
|
|
27
|
+
* The record is editable here: name and contact, and ending employment. Those APIs existed before
|
|
28
|
+
* this panel did; showing three fields and no actions was not a finished screen.
|
|
29
|
+
*/
|
|
30
|
+
interface Props {
|
|
31
|
+
personId: string
|
|
32
|
+
workspaceId: string
|
|
33
|
+
workspaceSlug: string
|
|
34
|
+
}
|
|
35
|
+
const { personId, workspaceId, workspaceSlug }: Props = $props()
|
|
36
|
+
|
|
37
|
+
const api = getHrApi()
|
|
38
|
+
const queryClient = useQueryClient()
|
|
39
|
+
|
|
40
|
+
const personQuery = createQuery(() => ({
|
|
41
|
+
queryKey: hrKeys.person(workspaceId, personId),
|
|
42
|
+
enabled: Boolean(workspaceId && personId),
|
|
43
|
+
queryFn: () => api.people.get({ workspaceId, personId }),
|
|
44
|
+
}))
|
|
45
|
+
const person = $derived(personQuery.data)
|
|
46
|
+
|
|
47
|
+
const resolutionQuery = createQuery(() => ({
|
|
48
|
+
queryKey: hrKeys.resolution(workspaceId, personId),
|
|
49
|
+
enabled: Boolean(workspaceId && personId),
|
|
50
|
+
queryFn: () => api.offices.resolveFor({ workspaceId, personId }),
|
|
51
|
+
}))
|
|
52
|
+
const resolution = $derived(resolutionQuery.data)
|
|
53
|
+
|
|
54
|
+
const employmentQuery = createQuery(() => ({
|
|
55
|
+
queryKey: hrKeys.employment(workspaceId, personId),
|
|
56
|
+
enabled: Boolean(workspaceId && personId) && canHr('employmentView'),
|
|
57
|
+
queryFn: () => api.employment.current({ workspaceId, personId }),
|
|
58
|
+
}))
|
|
59
|
+
const employment = $derived(employmentQuery.data)
|
|
60
|
+
|
|
61
|
+
const managerId = $derived(resolution?.managerPersonId ?? employment?.managerPersonId ?? null)
|
|
62
|
+
const managerQuery = createQuery(() => ({
|
|
63
|
+
queryKey: hrKeys.person(workspaceId, managerId ?? ''),
|
|
64
|
+
enabled: Boolean(workspaceId && managerId),
|
|
65
|
+
queryFn: () => api.people.get({ workspaceId, personId: managerId! }),
|
|
66
|
+
}))
|
|
67
|
+
|
|
68
|
+
const close = () =>
|
|
69
|
+
void navigation.go(`/${workspaceSlug}/hr`, { replaceState: true, keepFocus: true, noScroll: true })
|
|
70
|
+
|
|
71
|
+
let editing = $state(false)
|
|
72
|
+
let displayName = $state('')
|
|
73
|
+
let workEmail = $state('')
|
|
74
|
+
let personalEmail = $state('')
|
|
75
|
+
let phone = $state('')
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
if (editing && person) {
|
|
79
|
+
displayName = person.displayName
|
|
80
|
+
workEmail = person.workEmail ?? ''
|
|
81
|
+
personalEmail = person.personalEmail ?? ''
|
|
82
|
+
phone = person.phone ?? ''
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const save = createMutation(() => ({
|
|
87
|
+
mutationFn: () =>
|
|
88
|
+
api.people.update({
|
|
89
|
+
workspaceId,
|
|
90
|
+
personId,
|
|
91
|
+
displayName: displayName.trim(),
|
|
92
|
+
workEmail: workEmail.trim() || null,
|
|
93
|
+
personalEmail: personalEmail.trim() || null,
|
|
94
|
+
phone: phone.trim() || null,
|
|
95
|
+
}),
|
|
96
|
+
onSuccess: () => {
|
|
97
|
+
toast.success(t('person_updated'))
|
|
98
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
99
|
+
editing = false
|
|
100
|
+
},
|
|
101
|
+
onError: (error: Error) => toast.error(error.message),
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
let offboarding = $state(false)
|
|
105
|
+
let lastDay = $state(isoDate())
|
|
106
|
+
let offboardReason = $state('')
|
|
107
|
+
|
|
108
|
+
const offboard = createMutation(() => ({
|
|
109
|
+
mutationFn: () =>
|
|
110
|
+
api.people.offboard({
|
|
111
|
+
workspaceId,
|
|
112
|
+
personId,
|
|
113
|
+
on: lastDay,
|
|
114
|
+
reason: offboardReason.trim() || undefined,
|
|
115
|
+
}),
|
|
116
|
+
onSuccess: (updated) => {
|
|
117
|
+
toast.success(t('person_offboarded', { name: updated.displayName }))
|
|
118
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
119
|
+
offboarding = false
|
|
120
|
+
},
|
|
121
|
+
onError: (error: Error) => toast.error(error.message),
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The employment types the server can send, as words.
|
|
126
|
+
*
|
|
127
|
+
* A map rather than a chain: the parameter used to be called `t`, which shadowed the message
|
|
128
|
+
* function and turned every branch into a call on a string. An unknown value falls through to
|
|
129
|
+
* itself, so a type added on the server shows up as its raw name rather than as nothing.
|
|
130
|
+
*/
|
|
131
|
+
const EMPLOYMENT_KEYS: Record<string, string> = {
|
|
132
|
+
full_time: 'employment_full_time',
|
|
133
|
+
part_time: 'employment_part_time',
|
|
134
|
+
contract: 'employment_contract',
|
|
135
|
+
intern: 'employment_intern',
|
|
136
|
+
temporary: 'employment_temporary',
|
|
137
|
+
freelance: 'employment_freelance',
|
|
138
|
+
}
|
|
139
|
+
const typeLabel = (value: string) => (EMPLOYMENT_KEYS[value] ? t(EMPLOYMENT_KEYS[value]) : value)
|
|
140
|
+
|
|
141
|
+
const canManage = $derived(canHr('personManage'))
|
|
142
|
+
const left = $derived(person?.status === 'terminated')
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<RightPanel onClose={close} title={person?.displayName ?? ''}>
|
|
146
|
+
{#if personQuery.isLoading}
|
|
147
|
+
<div class="pad"><Skeleton height="120px" /></div>
|
|
148
|
+
{:else if person}
|
|
149
|
+
<div class="pad">
|
|
150
|
+
<div class="head">
|
|
151
|
+
<Avatar name={person.displayName} id={person.id} size={56} />
|
|
152
|
+
<div>
|
|
153
|
+
<h2>{person.displayName}</h2>
|
|
154
|
+
{#if person.workEmail}<p class="meta">{person.workEmail}</p>{/if}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<dl>
|
|
159
|
+
{#if resolution?.primaryOfficeName}
|
|
160
|
+
<dt>{t('office')}</dt>
|
|
161
|
+
<dd>{resolution.primaryOfficeName}</dd>
|
|
162
|
+
{/if}
|
|
163
|
+
{#if resolution?.timezone}
|
|
164
|
+
<dt>{t('local_time')}</dt>
|
|
165
|
+
<dd>
|
|
166
|
+
{new Intl.DateTimeFormat(undefined, {
|
|
167
|
+
timeZone: resolution.timezone,
|
|
168
|
+
hour: 'numeric',
|
|
169
|
+
minute: '2-digit',
|
|
170
|
+
}).format(new Date())}
|
|
171
|
+
<span class="meta">{resolution.timezone}</span>
|
|
172
|
+
</dd>
|
|
173
|
+
{/if}
|
|
174
|
+
{#if person.employeeNo}
|
|
175
|
+
<dt>{t('employee_no')}</dt>
|
|
176
|
+
<dd>{person.employeeNo}</dd>
|
|
177
|
+
{/if}
|
|
178
|
+
{#if person.hiredOn}
|
|
179
|
+
<dt>{t('started')}</dt>
|
|
180
|
+
<dd>
|
|
181
|
+
{new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(
|
|
182
|
+
new Date(`${person.hiredOn}T00:00:00`),
|
|
183
|
+
)}
|
|
184
|
+
</dd>
|
|
185
|
+
{/if}
|
|
186
|
+
{#if person.phone}
|
|
187
|
+
<dt>{t('phone')}</dt>
|
|
188
|
+
<dd>{person.phone}</dd>
|
|
189
|
+
{/if}
|
|
190
|
+
{#if resolution?.orgUnitPath}
|
|
191
|
+
<dt>{t('department')}</dt>
|
|
192
|
+
<dd>{resolution.orgUnitPath}</dd>
|
|
193
|
+
{/if}
|
|
194
|
+
{#if managerQuery.data}
|
|
195
|
+
<dt>{t('manager')}</dt>
|
|
196
|
+
<dd>{managerQuery.data.displayName}</dd>
|
|
197
|
+
{/if}
|
|
198
|
+
{#if employment}
|
|
199
|
+
<dt>{t('employment')}</dt>
|
|
200
|
+
<dd>{typeLabel(employment.employmentType)}</dd>
|
|
201
|
+
{/if}
|
|
202
|
+
</dl>
|
|
203
|
+
|
|
204
|
+
<Badge tone={person.status === 'active' ? 'active' : person.status === 'on_leave' ? 'upcoming' : 'grey'}
|
|
205
|
+
>{person.status === 'active'
|
|
206
|
+
? t('status_active')
|
|
207
|
+
: person.status === 'on_leave'
|
|
208
|
+
? t('status_on_leave')
|
|
209
|
+
: person.status === 'onboarding'
|
|
210
|
+
? t('status_onboarding')
|
|
211
|
+
: person.status === 'offboarding'
|
|
212
|
+
? t('status_offboarding')
|
|
213
|
+
: t('status_terminated')}</Badge
|
|
214
|
+
>
|
|
215
|
+
</div>
|
|
216
|
+
{/if}
|
|
217
|
+
|
|
218
|
+
{#snippet footer()}
|
|
219
|
+
{#if canManage && person && !left}
|
|
220
|
+
<div class="actions">
|
|
221
|
+
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>{t('edit_person')}</Button>
|
|
222
|
+
<Button size="sm" variant="danger" onclick={() => (offboarding = true)}>{t('offboard')}</Button>
|
|
223
|
+
</div>
|
|
224
|
+
{/if}
|
|
225
|
+
{/snippet}
|
|
226
|
+
</RightPanel>
|
|
227
|
+
|
|
228
|
+
<Dialog bind:open={editing} title={t('edit_person')}>
|
|
229
|
+
<div class="form">
|
|
230
|
+
<Field label={t('display_name')} id="hr-edit-name" required>
|
|
231
|
+
{#snippet children(id)}
|
|
232
|
+
<Input {id} bind:value={displayName} autocomplete="name" />
|
|
233
|
+
{/snippet}
|
|
234
|
+
</Field>
|
|
235
|
+
<Field label={t('work_email')} id="hr-edit-work" hint={t('common.optional')}>
|
|
236
|
+
{#snippet children(id)}
|
|
237
|
+
<Input {id} type="email" bind:value={workEmail} autocomplete="email" />
|
|
238
|
+
{/snippet}
|
|
239
|
+
</Field>
|
|
240
|
+
<Field label={t('personal_email')} id="hr-edit-personal" hint={t('common.optional')}>
|
|
241
|
+
{#snippet children(id)}
|
|
242
|
+
<Input {id} type="email" bind:value={personalEmail} />
|
|
243
|
+
{/snippet}
|
|
244
|
+
</Field>
|
|
245
|
+
<Field label={t('phone')} id="hr-edit-phone" hint={t('common.optional')}>
|
|
246
|
+
{#snippet children(id)}
|
|
247
|
+
<Input {id} type="tel" bind:value={phone} autocomplete="tel" />
|
|
248
|
+
{/snippet}
|
|
249
|
+
</Field>
|
|
250
|
+
</div>
|
|
251
|
+
{#snippet footer()}
|
|
252
|
+
<Button variant="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</Button>
|
|
253
|
+
<Button
|
|
254
|
+
onclick={() => save.mutate()}
|
|
255
|
+
disabled={displayName.trim().length === 0}
|
|
256
|
+
loading={save.isPending}>{t('common.save')}</Button
|
|
257
|
+
>
|
|
258
|
+
{/snippet}
|
|
259
|
+
</Dialog>
|
|
260
|
+
|
|
261
|
+
<Dialog
|
|
262
|
+
bind:open={offboarding}
|
|
263
|
+
title={t('offboard_title', { name: person?.displayName ?? '' })}
|
|
264
|
+
description={t('offboard_body')}
|
|
265
|
+
size="sm"
|
|
266
|
+
>
|
|
267
|
+
<div class="form">
|
|
268
|
+
<Field label={t('offboard_date')} id="hr-offboard-on" required>
|
|
269
|
+
{#snippet children(id)}
|
|
270
|
+
<Input {id} type="date" bind:value={lastDay} />
|
|
271
|
+
{/snippet}
|
|
272
|
+
</Field>
|
|
273
|
+
<Field label={t('offboard_reason')} id="hr-offboard-reason" hint={t('common.optional')}>
|
|
274
|
+
{#snippet children(id)}
|
|
275
|
+
<Input {id} bind:value={offboardReason} />
|
|
276
|
+
{/snippet}
|
|
277
|
+
</Field>
|
|
278
|
+
</div>
|
|
279
|
+
{#snippet footer()}
|
|
280
|
+
<Button variant="ghost" onclick={() => (offboarding = false)}>{t('common.cancel')}</Button>
|
|
281
|
+
<Button variant="danger" onclick={() => offboard.mutate()} loading={offboard.isPending}
|
|
282
|
+
>{t('offboard')}</Button
|
|
283
|
+
>
|
|
284
|
+
{/snippet}
|
|
285
|
+
</Dialog>
|
|
286
|
+
|
|
287
|
+
<style>
|
|
288
|
+
.pad {
|
|
289
|
+
padding: 18px 20px;
|
|
290
|
+
}
|
|
291
|
+
.head {
|
|
292
|
+
display: flex;
|
|
293
|
+
gap: 12px;
|
|
294
|
+
align-items: center;
|
|
295
|
+
margin-block-end: 16px;
|
|
296
|
+
}
|
|
297
|
+
h2 {
|
|
298
|
+
margin: 0;
|
|
299
|
+
font-size: 15px;
|
|
300
|
+
}
|
|
301
|
+
.meta {
|
|
302
|
+
color: var(--kern-ink-500);
|
|
303
|
+
font-size: 12px;
|
|
304
|
+
margin: 0;
|
|
305
|
+
}
|
|
306
|
+
dl {
|
|
307
|
+
display: grid;
|
|
308
|
+
grid-template-columns: auto 1fr;
|
|
309
|
+
gap: 8px 16px;
|
|
310
|
+
margin: 0 0 16px;
|
|
311
|
+
}
|
|
312
|
+
dt {
|
|
313
|
+
color: var(--kern-ink-500);
|
|
314
|
+
font-size: 12px;
|
|
315
|
+
}
|
|
316
|
+
dd {
|
|
317
|
+
margin: 0;
|
|
318
|
+
}
|
|
319
|
+
.actions {
|
|
320
|
+
display: flex;
|
|
321
|
+
gap: 8px;
|
|
322
|
+
justify-content: end;
|
|
323
|
+
}
|
|
324
|
+
.form {
|
|
325
|
+
display: grid;
|
|
326
|
+
gap: 14px;
|
|
327
|
+
}
|
|
328
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CapabilityDef } from '@kernhq/contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The slice of core's API this module reaches for, named by shape rather than imported.
|
|
5
|
+
*
|
|
6
|
+
* A module talks to another module through `kernel.call()` on the server; on the client the shell
|
|
7
|
+
* hands over its own configured core client, and typing the seam structurally keeps the dependency
|
|
8
|
+
* pointing one way — hr does not import core's router type, and core does not know hr exists.
|
|
9
|
+
*
|
|
10
|
+
* Keep it to what is actually called. A wide type here is a promise about core's surface that this
|
|
11
|
+
* module has no standing to make.
|
|
12
|
+
*/
|
|
13
|
+
export interface CoreApi {
|
|
14
|
+
workspaces: {
|
|
15
|
+
modules: {
|
|
16
|
+
list(input: { workspaceId: string }): Promise<
|
|
17
|
+
Array<{
|
|
18
|
+
manifest: { id: string; capabilities?: CapabilityDef[] }
|
|
19
|
+
state: {
|
|
20
|
+
enabled: boolean
|
|
21
|
+
/** capability ids the server resolved as on — defaults applied, dependencies pruned */
|
|
22
|
+
capabilities?: string[]
|
|
23
|
+
settings?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
}>
|
|
26
|
+
>
|
|
27
|
+
updateSettings(input: {
|
|
28
|
+
workspaceId: string
|
|
29
|
+
moduleId: string
|
|
30
|
+
settings: Record<string, unknown>
|
|
31
|
+
}): Promise<unknown>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|