@kernhq/module-hr 0.10.4 → 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,343 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ EmptyState,
6
+ formatCount,
7
+ formatDate,
8
+ formatDateRange,
9
+ messageLocale,
10
+ SectionLabel,
11
+ Skeleton,
12
+ } from '@kernhq/ui'
13
+ import { createQuery } from '@tanstack/svelte-query'
14
+ import { getHrApi } from '../api-instance.js'
15
+ import { t } from '../i18n.js'
16
+ import type { Employment } from '../index.js'
17
+ import { canHr } from '../permissions.js'
18
+ import { formatDays, hrKeys } from '../query.js'
19
+ import EmploymentChangeDialog from './EmploymentChangeDialog.svelte'
20
+ import PersonInline from './PersonInline.svelte'
21
+
22
+ /**
23
+ * What this person's job is, what it was, and how to change it.
24
+ *
25
+ * `hr.employment.manage` is granted by default and says "Change job, manager, department or hours";
26
+ * until this section existed it granted access to nothing, and `employment.history` — the whole
27
+ * reason the table is effective-dated — was called from nowhere. A record that cannot answer "who
28
+ * did she report to in March" is the thing the March approval needs, so the history is here rather
29
+ * than in a report somebody exports.
30
+ *
31
+ * The history is behind a disclosure: it is one query per person, almost nobody opens a panel to
32
+ * read it, and the current period says most of what somebody came for.
33
+ */
34
+ interface Props {
35
+ personId: string
36
+ workspaceId: string
37
+ personName: string
38
+ }
39
+ const { personId, workspaceId, personName }: Props = $props()
40
+
41
+ const api = getHrApi()
42
+
43
+ const mayView = $derived(canHr('employmentView'))
44
+ const mayManage = $derived(canHr('employmentManage'))
45
+ /** Departments and positions are the org chart's, and a viewer without it sees ids or nothing. */
46
+ const mayReadOrg = $derived(canHr('orgView'))
47
+
48
+ /**
49
+ * The same key `PersonPanel` uses for the current period.
50
+ *
51
+ * Two queries, one cache entry: the panel needs the open row for the reporting line it draws beside
52
+ * the office, and this section needs it for everything below. Sharing the key is what keeps that
53
+ * from being two requests for one answer.
54
+ */
55
+ const currentQuery = createQuery(() => ({
56
+ queryKey: hrKeys.employment(workspaceId, personId),
57
+ enabled: Boolean(workspaceId && personId) && mayView,
58
+ queryFn: () => api.employment.current({ workspaceId, personId }),
59
+ }))
60
+ const current = $derived(currentQuery.data)
61
+
62
+ let showHistory = $state(false)
63
+
64
+ const historyQuery = createQuery(() => ({
65
+ queryKey: ['hr', 'employment-history', workspaceId, personId] as const,
66
+ enabled: showHistory && Boolean(workspaceId && personId) && mayView,
67
+ queryFn: () => api.employment.history({ workspaceId, personId }),
68
+ }))
69
+ const history = $derived(historyQuery.data ?? [])
70
+
71
+ let changing = $state(false)
72
+
73
+ /**
74
+ * The two lists that turn an id into a word, fetched with the section rather than with the history.
75
+ *
76
+ * The open period names a position, so waiting for the disclosure would leave the one fact somebody
77
+ * came for blank. Both are per-workspace lists cached under their own key, so the second person
78
+ * somebody opens costs nothing.
79
+ *
80
+ * Archived rows are included on purpose: a period from two years ago points at a department that may
81
+ * well have been dissolved since, and printing its id — or nothing — is how history stops being
82
+ * readable.
83
+ */
84
+ const unitsQuery = createQuery(() => ({
85
+ queryKey: ['hr', 'org-units', workspaceId, 'with-archived'] as const,
86
+ enabled: Boolean(workspaceId) && mayView && mayReadOrg,
87
+ queryFn: () => api.org.units.tree({ workspaceId, includeArchived: true }),
88
+ }))
89
+ const positionsQuery = createQuery(() => ({
90
+ queryKey: ['hr', 'positions', workspaceId, 'with-archived'] as const,
91
+ enabled: Boolean(workspaceId) && mayView && mayReadOrg,
92
+ queryFn: () => api.org.positions.list({ workspaceId, includeArchived: true }),
93
+ }))
94
+ const units = $derived(unitsQuery.data ?? [])
95
+ const positions = $derived(positionsQuery.data ?? [])
96
+
97
+ const unitName = (id: string | null): string | null =>
98
+ id ? (units.find((u) => u.id === id)?.name ?? null) : null
99
+ const positionTitle = (id: string | null): string | null =>
100
+ id ? (positions.find((p) => p.id === id)?.title ?? null) : null
101
+
102
+ /**
103
+ * The employment types the server can send, as words.
104
+ *
105
+ * A map rather than a chain, and an unknown value falls through to itself — a type added on the
106
+ * server shows up as its raw name rather than as nothing.
107
+ */
108
+ const EMPLOYMENT_KEYS: Record<string, string> = {
109
+ full_time: 'employment_full_time',
110
+ part_time: 'employment_part_time',
111
+ contract: 'employment_contract',
112
+ intern: 'employment_intern',
113
+ temporary: 'employment_temporary',
114
+ freelance: 'employment_freelance',
115
+ }
116
+ const typeLabel = (value: string) => (EMPLOYMENT_KEYS[value] ? t(EMPLOYMENT_KEYS[value]) : value)
117
+
118
+ /**
119
+ * A calendar date, read in the reader's language.
120
+ *
121
+ * The `T00:00:00` is not decoration: `new Date('2026-03-01')` is parsed as *UTC* midnight, so west
122
+ * of Greenwich the panel would print the last day of February for a period starting in March.
123
+ */
124
+ const dateLabel = (iso: string): string => formatDate(`${iso}T00:00:00`)
125
+
126
+ /** A locale-aware number that keeps halves: 0.8 of a full-time week, 37.5 hours of one. */
127
+ const num = (value: number) => formatDays(value, messageLocale())
128
+
129
+ /**
130
+ * A period, as one string.
131
+ *
132
+ * `formatDateRange` rather than two dates and a dash: a hand-built range reads backwards under
133
+ * `dir="rtl"` — the earlier date lands to the right of the later one — and this collapses the parts
134
+ * the two dates share for free. An open period ends at "now" rather than at a date, and saying so
135
+ * beats an empty cell.
136
+ */
137
+ const periodLabel = (row: Employment): string =>
138
+ row.effectiveTo
139
+ ? formatDateRange(`${row.effectiveFrom}T00:00:00`, `${row.effectiveTo}T00:00:00`)
140
+ : t('job_since', { date: dateLabel(row.effectiveFrom) })
141
+ </script>
142
+
143
+ {#if mayView}
144
+ <section class="sec">
145
+ <SectionLabel label={t('employment')}>
146
+ {#snippet trailing()}
147
+ <!-- Hidden rather than disabled: `hr.employment.manage` is a permission, so somebody without
148
+ it may never record a change, and a dead button teaches nothing. -->
149
+ {#if mayManage}
150
+ <Button size="sm" variant="secondary" icon="plus" onclick={() => (changing = true)}>
151
+ {t('job_change')}
152
+ </Button>
153
+ {/if}
154
+ {/snippet}
155
+ </SectionLabel>
156
+
157
+ <!--
158
+ Held data outranks the error. Every punch and every decision invalidates the whole module, so a
159
+ failed background refetch leaves the query in `error` with the job still in hand — and an error
160
+ branch above this one would blank a record that is on screen and correct.
161
+ -->
162
+ {#if currentQuery.isLoading}
163
+ <div class="rows"><Skeleton lines={3} /></div>
164
+ {:else if current}
165
+ <!--
166
+ No department and no manager here: the panel lists both directly above, resolved through the
167
+ office ladder rather than read off this row, and saying it twice in one column reads as two
168
+ answers to the same question. In the history below they are the point — that is where "who
169
+ did she report to in March" is asked.
170
+ -->
171
+ <dl class="facts">
172
+ <dt>{t('job_period')}</dt>
173
+ <dd>{periodLabel(current)}</dd>
174
+ {#if positionTitle(current.positionId)}
175
+ <dt>{t('job_position')}</dt>
176
+ <dd>{positionTitle(current.positionId)}</dd>
177
+ {/if}
178
+ <dt>{t('employment')}</dt>
179
+ <dd>
180
+ <Badge tone="grey">{typeLabel(current.employmentType)}</Badge>
181
+ {#if current.fte < 1}<span class="muted">{t('job_fte_value', { fte: num(current.fte) })}</span>{/if}
182
+ </dd>
183
+ {#if current.contractHoursWeek !== null}
184
+ <dt>{t('job_hours')}</dt>
185
+ <dd>{t('job_hours_value', { hours: num(current.contractHoursWeek) })}</dd>
186
+ {/if}
187
+ {#if current.reason}
188
+ <dt>{t('job_reason')}</dt>
189
+ <dd class="muted">{current.reason}</dd>
190
+ {/if}
191
+ </dl>
192
+ {:else if currentQuery.isError}
193
+ <EmptyState compact icon="triangle-alert" title={t('job_error')}>
194
+ {#snippet actions()}
195
+ <Button size="sm" variant="secondary" onclick={() => void currentQuery.refetch()}>
196
+ {t('retry')}
197
+ </Button>
198
+ {/snippet}
199
+ </EmptyState>
200
+ {:else}
201
+ <EmptyState compact icon="briefcase" title={t('job_none')} description={t('job_none_desc')}>
202
+ {#snippet actions()}
203
+ {#if mayManage}
204
+ <Button size="sm" icon="plus" onclick={() => (changing = true)}>{t('job_change')}</Button>
205
+ {/if}
206
+ {/snippet}
207
+ </EmptyState>
208
+ {/if}
209
+
210
+ <SectionLabel
211
+ sub
212
+ collapsible
213
+ open={showHistory}
214
+ onToggle={() => (showHistory = !showHistory)}
215
+ label={t('job_history')}
216
+ count={showHistory && history.length ? formatCount(history.length, 999) : null}
217
+ />
218
+
219
+ {#if showHistory}
220
+ {#if historyQuery.isLoading}
221
+ <div class="rows">
222
+ {#each [1, 2] as n (n)}<Skeleton height="52px" />{/each}
223
+ </div>
224
+ {:else if history.length}
225
+ <ol class="history">
226
+ {#each history as row (row.id)}
227
+ <li class:open={row.effectiveTo === null}>
228
+ <span class="when">{periodLabel(row)}</span>
229
+ <span class="what">
230
+ <Badge tone={row.effectiveTo === null ? 'accent' : 'grey'}>
231
+ {typeLabel(row.employmentType)}
232
+ </Badge>
233
+ {#if positionTitle(row.positionId)}<span>{positionTitle(row.positionId)}</span>{/if}
234
+ {#if unitName(row.orgUnitId)}<span class="muted">{unitName(row.orgUnitId)}</span>{/if}
235
+ </span>
236
+ {#if row.managerPersonId}
237
+ <span class="muted">
238
+ {t('manager')}: <PersonInline id={row.managerPersonId} {workspaceId} />
239
+ </span>
240
+ {/if}
241
+ {#if row.reason}<span class="muted">{row.reason}</span>{/if}
242
+ </li>
243
+ {/each}
244
+ </ol>
245
+ {:else if historyQuery.isError}
246
+ <EmptyState compact icon="triangle-alert" title={t('job_history_error')}>
247
+ {#snippet actions()}
248
+ <Button size="sm" variant="secondary" onclick={() => void historyQuery.refetch()}>
249
+ {t('retry')}
250
+ </Button>
251
+ {/snippet}
252
+ </EmptyState>
253
+ {:else}
254
+ <p class="hint">{t('job_history_none')}</p>
255
+ {/if}
256
+ {/if}
257
+ </section>
258
+
259
+ {#if mayManage}
260
+ <EmploymentChangeDialog
261
+ open={changing}
262
+ {workspaceId}
263
+ {personId}
264
+ {personName}
265
+ current={current ?? null}
266
+ {units}
267
+ {positions}
268
+ onClose={() => (changing = false)}
269
+ />
270
+ {/if}
271
+ {/if}
272
+
273
+ <style>
274
+ .sec {
275
+ margin-block-start: 20px;
276
+ }
277
+ .rows {
278
+ display: grid;
279
+ gap: 6px;
280
+ padding-block: 8px;
281
+ }
282
+ .facts {
283
+ display: grid;
284
+ grid-template-columns: auto 1fr;
285
+ gap: 8px 16px;
286
+ margin: 10px 0 4px;
287
+ }
288
+ .facts dt {
289
+ color: var(--kern-ink-500);
290
+ font-size: 12px;
291
+ }
292
+ .facts dd {
293
+ margin: 0;
294
+ display: flex;
295
+ align-items: center;
296
+ gap: 8px;
297
+ flex-wrap: wrap;
298
+ min-width: 0;
299
+ }
300
+ .history {
301
+ list-style: none;
302
+ margin: 8px 0 0;
303
+ padding: 0;
304
+ display: grid;
305
+ gap: 4px;
306
+ }
307
+ .history li {
308
+ display: grid;
309
+ gap: 4px;
310
+ padding: 8px 10px;
311
+ border-radius: var(--kern-r-md);
312
+ border-inline-start: 2px solid var(--kern-border);
313
+ background: var(--kern-surface);
314
+ font-size: 13px;
315
+ }
316
+ /* The period in force, marked with a border rather than a tint alone so it survives a theme where
317
+ the tint is nearly the surface it sits on. */
318
+ .history li.open {
319
+ border-inline-start-color: var(--kern-accent);
320
+ background: var(--kern-surface-active);
321
+ }
322
+ .when {
323
+ font-weight: 500;
324
+ font-variant-numeric: tabular-nums;
325
+ }
326
+ .what {
327
+ display: flex;
328
+ align-items: center;
329
+ gap: 8px;
330
+ flex-wrap: wrap;
331
+ min-width: 0;
332
+ }
333
+ /* A colour, not opacity: opacity fades text against the panel whatever token it names. */
334
+ .muted {
335
+ color: var(--kern-ink-500);
336
+ font-size: 12px;
337
+ }
338
+ .hint {
339
+ margin: 8px 0 0;
340
+ font-size: 12px;
341
+ color: var(--kern-ink-500);
342
+ }
343
+ </style>
@@ -18,6 +18,10 @@ import { getHrApi } from '../api-instance.js'
18
18
  import { t } from '../i18n.js'
19
19
  import { canHr } from '../permissions.js'
20
20
  import { hrKeys, isoDate } from '../query.js'
21
+ import PersonDocumentsSection from './PersonDocumentsSection.svelte'
22
+ import PersonJobSection from './PersonJobSection.svelte'
23
+ import PersonSensitiveSection from './PersonSensitiveSection.svelte'
24
+ import { explainRefusal } from './refusal.js'
21
25
 
22
26
  /**
23
27
  * One person, beside the directory rather than instead of it.
@@ -28,6 +32,12 @@ import { hrKeys, isoDate } from '../query.js'
28
32
  *
29
33
  * The record is editable here: name and contact, and ending employment. Those APIs existed before
30
34
  * this panel did; showing three fields and no actions was not a finished screen.
35
+ *
36
+ * Below the identity list the panel is three sections, each of them a permission or a capability
37
+ * this module declared and then reached from nowhere: the **job** and its effective-dated history,
38
+ * the **personal details** `hr.person.view_sensitive` exists for, and the **documents** the
39
+ * `documents` capability switches on. Each owns its own queries, its own four states and its own
40
+ * gate — the panel decides where they sit, not whether they may be seen.
31
41
  */
32
42
  interface Props {
33
43
  personId: string
@@ -53,6 +63,13 @@ const resolutionQuery = createQuery(() => ({
53
63
  }))
54
64
  const resolution = $derived(resolutionQuery.data)
55
65
 
66
+ /**
67
+ * The open period, for the reporting line only.
68
+ *
69
+ * `PersonJobSection` reads the same key for everything it draws, so this is one request rather than
70
+ * two: what the job *is* belongs to that section, and what it implies about who somebody reports to
71
+ * belongs beside the office in the list below.
72
+ */
56
73
  const employmentQuery = createQuery(() => ({
57
74
  queryKey: hrKeys.employment(workspaceId, personId),
58
75
  enabled: Boolean(workspaceId && personId) && canHr('employmentView'),
@@ -70,27 +87,6 @@ const managerQuery = createQuery(() => ({
70
87
  const close = () =>
71
88
  void navigation.go(`/${workspaceSlug}/hr`, { replaceState: true, keepFocus: true, noScroll: true })
72
89
 
73
- /**
74
- * The sentence to put in front of somebody when a write here fails.
75
- *
76
- * A refusal arrives as two pieces: a machine-readable `reason` a module translates, and the
77
- * English sentence the router wrote for a reader. Nothing under `people.*` sends a reason today —
78
- * its refusals are a record that has gone and a field the server will not take — so this uses the
79
- * second, and only for the codes that carry a sentence somebody wrote. Everything else is machine
80
- * text in English: a network drop, a 500, a gateway, and `Forbidden` and `Unauthorized`, which are
81
- * one word each. A toast is the last place to paste any of them, so they fall back to this
82
- * module's own string.
83
- *
84
- * When a `people.*` refusal does grow a reason, it is read the way `ClockControls.svelte` reads a
85
- * punch's — keyed by the code, never by the sentence.
86
- */
87
- const READABLE = new Set(['BAD_REQUEST', 'CONFLICT', 'NOT_FOUND'])
88
- function explain(error: unknown, fallback: string): string {
89
- const failure = error as { code?: unknown; message?: string }
90
- const readable = typeof failure.code === 'string' && READABLE.has(failure.code)
91
- return (readable ? failure.message : '') || fallback
92
- }
93
-
94
90
  let editing = $state(false)
95
91
  let displayName = $state('')
96
92
  let workEmail = $state('')
@@ -129,7 +125,7 @@ const save = createMutation(() => ({
129
125
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
130
126
  editing = false
131
127
  },
132
- onError: (error) => toast.error(explain(error, t('person_save_error'))),
128
+ onError: (error) => toast.error(explainRefusal(error, t('person_save_error'))),
133
129
  onSettled: () => {
134
130
  saving = false
135
131
  },
@@ -158,7 +154,7 @@ const offboard = createMutation(() => ({
158
154
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
159
155
  offboarding = false
160
156
  },
161
- onError: (error) => toast.error(explain(error, t('person_offboard_error'))),
157
+ onError: (error) => toast.error(explainRefusal(error, t('person_offboard_error'))),
162
158
  onSettled: () => {
163
159
  ending = false
164
160
  },
@@ -170,23 +166,6 @@ const submitOffboard = () => {
170
166
  offboard.mutate()
171
167
  }
172
168
 
173
- /**
174
- * The employment types the server can send, as words.
175
- *
176
- * A map rather than a chain: the parameter used to be called `t`, which shadowed the message
177
- * function and turned every branch into a call on a string. An unknown value falls through to
178
- * itself, so a type added on the server shows up as its raw name rather than as nothing.
179
- */
180
- const EMPLOYMENT_KEYS: Record<string, string> = {
181
- full_time: 'employment_full_time',
182
- part_time: 'employment_part_time',
183
- contract: 'employment_contract',
184
- intern: 'employment_intern',
185
- temporary: 'employment_temporary',
186
- freelance: 'employment_freelance',
187
- }
188
- const typeLabel = (value: string) => (EMPLOYMENT_KEYS[value] ? t(EMPLOYMENT_KEYS[value]) : value)
189
-
190
169
  /**
191
170
  * The clock where this person works, in the reader's language.
192
171
  *
@@ -278,10 +257,6 @@ const left = $derived(person?.status === 'terminated')
278
257
  <dt>{t('manager')}</dt>
279
258
  <dd>{managerQuery.data.displayName}</dd>
280
259
  {/if}
281
- {#if employment}
282
- <dt>{t('employment')}</dt>
283
- <dd>{typeLabel(employment.employmentType)}</dd>
284
- {/if}
285
260
  </dl>
286
261
 
287
262
  <Badge tone={person.status === 'active' ? 'active' : person.status === 'on_leave' ? 'upcoming' : 'grey'}
@@ -295,6 +270,10 @@ const left = $derived(person?.status === 'terminated')
295
270
  ? t('status_offboarding')
296
271
  : t('status_terminated')}</Badge
297
272
  >
273
+
274
+ <PersonJobSection {personId} {workspaceId} personName={person.displayName} />
275
+ <PersonSensitiveSection {personId} {workspaceId} personName={person.displayName} />
276
+ <PersonDocumentsSection {personId} {workspaceId} personName={person.displayName} />
298
277
  </div>
299
278
  {:else if personQuery.isError}
300
279
  <!--