@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.
Files changed (31) hide show
  1. package/package.json +22 -2
  2. package/src/client/api-instance.ts +29 -0
  3. package/src/client/components/ClockControls.svelte +127 -0
  4. package/src/client/components/HrSidebar.svelte +73 -0
  5. package/src/client/components/LeaveRequestDialog.svelte +169 -0
  6. package/src/client/components/PersonFormDialog.svelte +184 -0
  7. package/src/client/components/PersonInline.svelte +49 -0
  8. package/src/client/components/PersonPanel.svelte +328 -0
  9. package/src/client/core-api.ts +34 -0
  10. package/src/client/i18n.ts +640 -0
  11. package/src/client/index.ts +3 -0
  12. package/src/client/mock.ts +447 -0
  13. package/src/client/module.ts +295 -0
  14. package/src/client/pages/ApprovalsPage.svelte +129 -0
  15. package/src/client/pages/AttendancePage.svelte +147 -0
  16. package/src/client/pages/DirectoryPage.svelte +428 -0
  17. package/src/client/pages/LeavePage.svelte +180 -0
  18. package/src/client/pages/OfficesPage.svelte +119 -0
  19. package/src/client/permissions.ts +31 -0
  20. package/src/client/query.test.ts +70 -0
  21. package/src/client/query.ts +69 -0
  22. package/src/client/settings/CalendarsSettings.svelte +21 -0
  23. package/src/client/settings/CapabilitiesSettings.svelte +122 -0
  24. package/src/client/settings/LeaveSettings.svelte +21 -0
  25. package/src/client/settings/OfficesSettings.svelte +21 -0
  26. package/src/client/settings/SchedulesSettings.svelte +21 -0
  27. package/src/client/widgets/ApprovalsWidget.svelte +86 -0
  28. package/src/client/widgets/ClockWidget.svelte +9 -0
  29. package/src/client/widgets/HeadcountWidget.svelte +29 -0
  30. package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
  31. 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
+ }