@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.
- package/package.json +5 -5
- 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 +1417 -89
- 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,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(
|
|
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(
|
|
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
|
<!--
|