@kernhq/module-hr 0.10.5 → 0.11.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.
@@ -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>