@kernhq/module-hr 0.10.5 → 0.12.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 +3 -3
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1895 -102
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Field, formatDate, Input, Select, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
// The client barrel re-exports the models the screens have needed so far, and the employment *type*
|
|
5
|
+
// was not one of them. Straight from the contract rather than widening a barrel another lane shares.
|
|
6
|
+
import type { EmploymentType } from '../../contract/models.js'
|
|
7
|
+
import { getHrApi } from '../api-instance.js'
|
|
8
|
+
import { t } from '../i18n.js'
|
|
9
|
+
import type { Employment, OrgUnit, Position } from '../index.js'
|
|
10
|
+
import { canHr } from '../permissions.js'
|
|
11
|
+
import { hrKeys, isoDate } from '../query.js'
|
|
12
|
+
import { explainRefusal } from './refusal.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Recording that somebody's job changed.
|
|
16
|
+
*
|
|
17
|
+
* `employment.change` never updates a row: it closes the open period and opens a new one from a
|
|
18
|
+
* date. So this is not an edit form, and the difference matters twice over.
|
|
19
|
+
*
|
|
20
|
+
* **The date is the field.** `effectiveFrom` may be in the past on purpose — a promotion agreed in
|
|
21
|
+
* March and entered in May has to say March, or every approval and balance computed against it
|
|
22
|
+
* reads the wrong job. So the date leads the form, back-dating is offered rather than fought, and
|
|
23
|
+
* the summary above the button says whose record moves and from when.
|
|
24
|
+
*
|
|
25
|
+
* **Nothing here can clear a field.** The server carries the open period's value forward wherever a
|
|
26
|
+
* field arrives empty, which is what makes "change the manager and leave everything else" one
|
|
27
|
+
* click — and it also means a "None" option would be a lie. So a control shows the current value
|
|
28
|
+
* and offers others; emptying it is not on the menu.
|
|
29
|
+
*/
|
|
30
|
+
interface Props {
|
|
31
|
+
open: boolean
|
|
32
|
+
workspaceId: string
|
|
33
|
+
personId: string
|
|
34
|
+
personName: string
|
|
35
|
+
/** The open period, or null when this person has no employment row yet. */
|
|
36
|
+
current: Employment | null
|
|
37
|
+
units: OrgUnit[]
|
|
38
|
+
positions: Position[]
|
|
39
|
+
onClose: () => void
|
|
40
|
+
}
|
|
41
|
+
const { open, workspaceId, personId, personName, current, units, positions, onClose }: Props = $props()
|
|
42
|
+
|
|
43
|
+
const api = getHrApi()
|
|
44
|
+
const queryClient = useQueryClient()
|
|
45
|
+
|
|
46
|
+
let effectiveFrom = $state(isoDate())
|
|
47
|
+
let orgUnitId = $state('')
|
|
48
|
+
let positionId = $state('')
|
|
49
|
+
let managerPersonId = $state('')
|
|
50
|
+
let employmentType = $state<EmploymentType>('full_time')
|
|
51
|
+
let fte = $state('')
|
|
52
|
+
let hours = $state('')
|
|
53
|
+
let reason = $state('')
|
|
54
|
+
|
|
55
|
+
/** Yesterday's half-filled change must not ride along on today's, so every open starts from the record. */
|
|
56
|
+
$effect(() => {
|
|
57
|
+
if (!open) return
|
|
58
|
+
effectiveFrom = isoDate()
|
|
59
|
+
orgUnitId = current?.orgUnitId ?? ''
|
|
60
|
+
positionId = current?.positionId ?? ''
|
|
61
|
+
managerPersonId = current?.managerPersonId ?? ''
|
|
62
|
+
employmentType = current?.employmentType ?? 'full_time'
|
|
63
|
+
fte = current ? String(current.fte) : ''
|
|
64
|
+
hours = current?.contractHoursWeek == null ? '' : String(current.contractHoursWeek)
|
|
65
|
+
reason = ''
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Everybody who could be the manager, asked for only once the dialog is open.
|
|
70
|
+
*
|
|
71
|
+
* A panel that fetched the whole directory to draw one read-only line would pay for this list on
|
|
72
|
+
* every person somebody clicked; here it is the price of a picker that is actually open.
|
|
73
|
+
*/
|
|
74
|
+
const peopleQuery = createQuery(() => ({
|
|
75
|
+
queryKey: hrKeys.people(workspaceId, { limit: 200 }),
|
|
76
|
+
enabled: open && Boolean(workspaceId),
|
|
77
|
+
queryFn: () => api.people.list({ workspaceId, limit: 200 }),
|
|
78
|
+
}))
|
|
79
|
+
const people = $derived(peopleQuery.data?.items ?? [])
|
|
80
|
+
|
|
81
|
+
const typeOptions = [
|
|
82
|
+
{ value: 'full_time', label: t('employment_full_time') },
|
|
83
|
+
{ value: 'part_time', label: t('employment_part_time') },
|
|
84
|
+
{ value: 'contract', label: t('employment_contract') },
|
|
85
|
+
{ value: 'intern', label: t('employment_intern') },
|
|
86
|
+
{ value: 'temporary', label: t('employment_temporary') },
|
|
87
|
+
{ value: 'freelance', label: t('employment_freelance') },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
const unitOptions = $derived(units.map((u) => ({ value: u.id, label: u.name })))
|
|
91
|
+
const positionOptions = $derived(positions.map((p) => ({ value: p.id, label: p.title })))
|
|
92
|
+
/** Nobody manages themselves, and offering it only earns a refusal from the database. */
|
|
93
|
+
const managerOptions = $derived(
|
|
94
|
+
people.filter((p) => p.id !== personId).map((p) => ({ value: p.id, label: p.displayName })),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A calendar date, read in the reader's language.
|
|
99
|
+
*
|
|
100
|
+
* The `T00:00:00` is not decoration: `new Date('2026-03-01')` is parsed as *UTC* midnight, so west
|
|
101
|
+
* of Greenwich the panel would print the last day of February for a change dated the first of March.
|
|
102
|
+
*/
|
|
103
|
+
const dateLabel = (iso: string): string => formatDate(`${iso}T00:00:00`)
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The server refuses a date before the open period starts, and says so in a sentence.
|
|
107
|
+
*
|
|
108
|
+
* Saying it here as well is not belt and braces: it is the difference between a field that explains
|
|
109
|
+
* itself while somebody types and a toast after they press the button.
|
|
110
|
+
*/
|
|
111
|
+
const tooEarly = $derived(Boolean(current && effectiveFrom && effectiveFrom < current.effectiveFrom))
|
|
112
|
+
const backdated = $derived(Boolean(effectiveFrom) && effectiveFrom < isoDate() && !tooEarly)
|
|
113
|
+
|
|
114
|
+
const dateError = $derived(
|
|
115
|
+
tooEarly && current ? t('job_change_too_early', { date: dateLabel(current.effectiveFrom) }) : null,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const number = (value: string): number | undefined => {
|
|
119
|
+
const parsed = Number(value)
|
|
120
|
+
return value.trim() === '' || Number.isNaN(parsed) ? undefined : parsed
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fteValid = $derived(
|
|
124
|
+
fte.trim() === '' || (number(fte) !== undefined && Number(fte) > 0 && Number(fte) <= 1),
|
|
125
|
+
)
|
|
126
|
+
const hoursValid = $derived(
|
|
127
|
+
hours.trim() === '' || (number(hours) !== undefined && Number(hours) >= 0 && Number(hours) <= 168),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `saving` rather than `change.isPending`: the disabled attribute only reaches the button on the
|
|
132
|
+
* next render, so two quick clicks are one render apart — and here that is two periods opened on
|
|
133
|
+
* the same date, which the database then has to refuse.
|
|
134
|
+
*/
|
|
135
|
+
let saving = $state(false)
|
|
136
|
+
|
|
137
|
+
const change = createMutation(() => ({
|
|
138
|
+
mutationFn: () =>
|
|
139
|
+
api.employment.change({
|
|
140
|
+
workspaceId,
|
|
141
|
+
personId,
|
|
142
|
+
effectiveFrom,
|
|
143
|
+
orgUnitId: orgUnitId || undefined,
|
|
144
|
+
positionId: positionId || undefined,
|
|
145
|
+
managerPersonId: managerPersonId || undefined,
|
|
146
|
+
employmentType,
|
|
147
|
+
fte: number(fte),
|
|
148
|
+
contractHoursWeek: number(hours),
|
|
149
|
+
reason: reason.trim() || null,
|
|
150
|
+
}),
|
|
151
|
+
onSuccess: () => {
|
|
152
|
+
toast.success(t('job_changed', { name: personName }))
|
|
153
|
+
// A job change moves the reporting line, the department and everything resolved from them —
|
|
154
|
+
// the panel's own resolution row included — so the module's cache is dropped whole rather than
|
|
155
|
+
// guessing which keys a new period touched.
|
|
156
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
157
|
+
onClose()
|
|
158
|
+
},
|
|
159
|
+
onError: (error) => toast.error(explainRefusal(error, t('job_change_error'))),
|
|
160
|
+
onSettled: () => {
|
|
161
|
+
saving = false
|
|
162
|
+
},
|
|
163
|
+
}))
|
|
164
|
+
|
|
165
|
+
const submit = () => {
|
|
166
|
+
if (saving) return
|
|
167
|
+
saving = true
|
|
168
|
+
change.mutate()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const canSubmit = $derived(
|
|
172
|
+
Boolean(effectiveFrom) && !tooEarly && fteValid && hoursValid && canHr('employmentManage') && !saving,
|
|
173
|
+
)
|
|
174
|
+
</script>
|
|
175
|
+
|
|
176
|
+
<Dialog
|
|
177
|
+
{open}
|
|
178
|
+
title={t('job_change_title', { name: personName })}
|
|
179
|
+
description={t('job_change_body')}
|
|
180
|
+
onOpenChange={(next) => {
|
|
181
|
+
if (!next) onClose()
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<div class="form">
|
|
185
|
+
<Field
|
|
186
|
+
label={t('job_effective_from')}
|
|
187
|
+
hint={t('job_effective_hint')}
|
|
188
|
+
error={dateError}
|
|
189
|
+
id="hr-job-from"
|
|
190
|
+
required
|
|
191
|
+
>
|
|
192
|
+
{#snippet children(id)}
|
|
193
|
+
<Input {id} type="date" value={effectiveFrom} oninput={(e) => (effectiveFrom = e.currentTarget.value)} />
|
|
194
|
+
{/snippet}
|
|
195
|
+
</Field>
|
|
196
|
+
|
|
197
|
+
<Field label={t('department')} id="hr-job-unit">
|
|
198
|
+
{#snippet children(id)}
|
|
199
|
+
<Select
|
|
200
|
+
{id}
|
|
201
|
+
value={orgUnitId}
|
|
202
|
+
onValueChange={(v) => (orgUnitId = v)}
|
|
203
|
+
options={unitOptions}
|
|
204
|
+
placeholder={t('job_not_set')}
|
|
205
|
+
ariaLabel={t('department')}
|
|
206
|
+
/>
|
|
207
|
+
{/snippet}
|
|
208
|
+
</Field>
|
|
209
|
+
|
|
210
|
+
<Field label={t('job_position')} id="hr-job-position">
|
|
211
|
+
{#snippet children(id)}
|
|
212
|
+
<Select
|
|
213
|
+
{id}
|
|
214
|
+
value={positionId}
|
|
215
|
+
onValueChange={(v) => (positionId = v)}
|
|
216
|
+
options={positionOptions}
|
|
217
|
+
placeholder={t('job_not_set')}
|
|
218
|
+
ariaLabel={t('job_position')}
|
|
219
|
+
/>
|
|
220
|
+
{/snippet}
|
|
221
|
+
</Field>
|
|
222
|
+
|
|
223
|
+
<Field label={t('manager')} id="hr-job-manager">
|
|
224
|
+
{#snippet children(id)}
|
|
225
|
+
<Select
|
|
226
|
+
{id}
|
|
227
|
+
value={managerPersonId}
|
|
228
|
+
onValueChange={(v) => (managerPersonId = v)}
|
|
229
|
+
options={managerOptions}
|
|
230
|
+
placeholder={t('job_not_set')}
|
|
231
|
+
ariaLabel={t('manager')}
|
|
232
|
+
/>
|
|
233
|
+
{/snippet}
|
|
234
|
+
</Field>
|
|
235
|
+
|
|
236
|
+
<Field label={t('employment')} id="hr-job-type">
|
|
237
|
+
{#snippet children(id)}
|
|
238
|
+
<Select
|
|
239
|
+
{id}
|
|
240
|
+
value={employmentType}
|
|
241
|
+
onValueChange={(v) => (employmentType = v as EmploymentType)}
|
|
242
|
+
options={typeOptions}
|
|
243
|
+
ariaLabel={t('employment')}
|
|
244
|
+
/>
|
|
245
|
+
{/snippet}
|
|
246
|
+
</Field>
|
|
247
|
+
|
|
248
|
+
<div class="pair">
|
|
249
|
+
<Field
|
|
250
|
+
label={t('job_fte')}
|
|
251
|
+
hint={t('job_fte_hint')}
|
|
252
|
+
error={fteValid ? null : t('job_fte_invalid')}
|
|
253
|
+
id="hr-job-fte"
|
|
254
|
+
>
|
|
255
|
+
{#snippet children(id)}
|
|
256
|
+
<Input
|
|
257
|
+
{id}
|
|
258
|
+
type="number"
|
|
259
|
+
min="0.05"
|
|
260
|
+
max="1"
|
|
261
|
+
step="0.05"
|
|
262
|
+
value={fte}
|
|
263
|
+
oninput={(e) => (fte = e.currentTarget.value)}
|
|
264
|
+
/>
|
|
265
|
+
{/snippet}
|
|
266
|
+
</Field>
|
|
267
|
+
<Field
|
|
268
|
+
label={t('job_hours')}
|
|
269
|
+
error={hoursValid ? null : t('job_hours_invalid')}
|
|
270
|
+
id="hr-job-hours"
|
|
271
|
+
>
|
|
272
|
+
{#snippet children(id)}
|
|
273
|
+
<Input
|
|
274
|
+
{id}
|
|
275
|
+
type="number"
|
|
276
|
+
min="0"
|
|
277
|
+
max="168"
|
|
278
|
+
step="0.5"
|
|
279
|
+
value={hours}
|
|
280
|
+
oninput={(e) => (hours = e.currentTarget.value)}
|
|
281
|
+
/>
|
|
282
|
+
{/snippet}
|
|
283
|
+
</Field>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<Field label={t('job_reason')} hint={t('job_reason_hint')} id="hr-job-reason">
|
|
287
|
+
{#snippet children(id)}
|
|
288
|
+
<Input {id} bind:value={reason} maxlength={200} />
|
|
289
|
+
{/snippet}
|
|
290
|
+
</Field>
|
|
291
|
+
|
|
292
|
+
<!--
|
|
293
|
+
The sentence somebody needs before they press the button: whose record moves, from when, and
|
|
294
|
+
what happens to the period that is open now. "Are you sure?" says none of that, and this is an
|
|
295
|
+
append-only record — the new period is undone by recording another change, never by an undo.
|
|
296
|
+
-->
|
|
297
|
+
<p class="summary">
|
|
298
|
+
{t('job_change_summary', { name: personName, date: effectiveFrom ? dateLabel(effectiveFrom) : '—' })}
|
|
299
|
+
{#if current}
|
|
300
|
+
<span class="muted">
|
|
301
|
+
{t('job_change_closes', { date: dateLabel(current.effectiveFrom) })}
|
|
302
|
+
</span>
|
|
303
|
+
{/if}
|
|
304
|
+
{#if backdated}
|
|
305
|
+
<span class="muted">{t('job_change_backdated')}</span>
|
|
306
|
+
{/if}
|
|
307
|
+
</p>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{#snippet footer()}
|
|
311
|
+
<Button variant="secondary" onclick={onClose} disabled={change.isPending}>{t('common.cancel')}</Button>
|
|
312
|
+
<Button loading={change.isPending} disabled={!canSubmit} onclick={submit}>{t('job_change')}</Button>
|
|
313
|
+
{/snippet}
|
|
314
|
+
</Dialog>
|
|
315
|
+
|
|
316
|
+
<style>
|
|
317
|
+
.form {
|
|
318
|
+
display: grid;
|
|
319
|
+
gap: 14px;
|
|
320
|
+
}
|
|
321
|
+
.pair {
|
|
322
|
+
display: grid;
|
|
323
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
324
|
+
gap: 14px;
|
|
325
|
+
}
|
|
326
|
+
.summary {
|
|
327
|
+
margin: 0;
|
|
328
|
+
padding: 10px 12px;
|
|
329
|
+
border: 1px solid var(--kern-border);
|
|
330
|
+
border-radius: var(--kern-r-md);
|
|
331
|
+
background: var(--kern-surface-raised);
|
|
332
|
+
font-size: 13px;
|
|
333
|
+
line-height: 1.5;
|
|
334
|
+
}
|
|
335
|
+
/* A colour, not opacity: opacity fades text against the dialog whatever token it names. */
|
|
336
|
+
.muted {
|
|
337
|
+
display: block;
|
|
338
|
+
color: var(--kern-ink-500);
|
|
339
|
+
font-size: 12px;
|
|
340
|
+
}
|
|
341
|
+
</style>
|
|
@@ -70,4 +70,11 @@ const active = (path: string) => pathname === `/${workspaceSlug}${path}`
|
|
|
70
70
|
label={t('offices_title')}
|
|
71
71
|
/>
|
|
72
72
|
{/if}
|
|
73
|
+
<!--
|
|
74
|
+
No capability: departments and positions belong to the module's always-on core, and
|
|
75
|
+
`hr.org.view` is a default member permission — the chart is the HR screen most of a company
|
|
76
|
+
opens. A route with no entry here is reachable only by typing its URL, which is the same as not
|
|
77
|
+
shipping it.
|
|
78
|
+
-->
|
|
79
|
+
<SidebarItem href={href('/hr/org')} icon="git-branch" active={active('/hr/org')} label={t('org_title')} />
|
|
73
80
|
</SidebarGroup>
|