@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,382 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
EmptyState,
|
|
6
|
+
Field,
|
|
7
|
+
formatDate,
|
|
8
|
+
Icon,
|
|
9
|
+
Input,
|
|
10
|
+
SectionLabel,
|
|
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 { explainRefusal } from './refusal.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The fields `hr.person.view_sensitive` exists for, and the one somebody needs in a hurry.
|
|
22
|
+
*
|
|
23
|
+
* Both permissions are `dangerous` with `defaultRoles: []` — deliberately nobody's — and until this
|
|
24
|
+
* section existed neither reached anything: a national identity number, a birth date, an IBAN and
|
|
25
|
+
* an emergency contact were storable, encrypted and unreadable.
|
|
26
|
+
*
|
|
27
|
+
* **Nothing is fetched until it is asked for.** The section is collapsed and the query is disabled,
|
|
28
|
+
* so opening somebody's panel does not pull their bank details onto the screen of whoever is
|
|
29
|
+
* standing behind you, and does not decrypt them server-side either. Holding the permission is not
|
|
30
|
+
* the same as wanting the data on screen.
|
|
31
|
+
*
|
|
32
|
+
* **The emergency contact is not buried.** It is the field that has to be found by somebody who has
|
|
33
|
+
* never opened this panel before, under pressure — so it is the first thing inside the section, the
|
|
34
|
+
* section says so in its own hint, and the number is a `tel:` link rather than a string to copy out
|
|
35
|
+
* by hand.
|
|
36
|
+
*/
|
|
37
|
+
interface Props {
|
|
38
|
+
personId: string
|
|
39
|
+
workspaceId: string
|
|
40
|
+
personName: string
|
|
41
|
+
}
|
|
42
|
+
const { personId, workspaceId, personName }: Props = $props()
|
|
43
|
+
|
|
44
|
+
const api = getHrApi()
|
|
45
|
+
const queryClient = useQueryClient()
|
|
46
|
+
|
|
47
|
+
const mayView = $derived(canHr('personViewSensitive'))
|
|
48
|
+
const mayManage = $derived(canHr('personManageSensitive'))
|
|
49
|
+
|
|
50
|
+
let revealed = $state(false)
|
|
51
|
+
|
|
52
|
+
const sensitiveQuery = createQuery(() => ({
|
|
53
|
+
queryKey: ['hr', 'sensitive', workspaceId, personId] as const,
|
|
54
|
+
// The permission *and* the disclosure: a query fired on render would decrypt and send these
|
|
55
|
+
// fields to a screen nobody has asked to look at.
|
|
56
|
+
enabled: revealed && mayView && Boolean(workspaceId && personId),
|
|
57
|
+
queryFn: () => api.people.sensitive.get({ workspaceId, personId }),
|
|
58
|
+
}))
|
|
59
|
+
const details = $derived(sensitiveQuery.data)
|
|
60
|
+
const empty = $derived(
|
|
61
|
+
Boolean(details) &&
|
|
62
|
+
!details?.nationalId &&
|
|
63
|
+
!details?.birthDate &&
|
|
64
|
+
!details?.iban &&
|
|
65
|
+
!details?.emergencyContact,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A calendar date, read in the reader's language.
|
|
70
|
+
*
|
|
71
|
+
* The `T00:00:00` is not decoration: `new Date('1991-03-01')` is parsed as *UTC* midnight, so west
|
|
72
|
+
* of Greenwich a birthday on the first would be shown as the last day of the month before.
|
|
73
|
+
*/
|
|
74
|
+
const dateLabel = (iso: string): string => formatDate(`${iso}T00:00:00`)
|
|
75
|
+
|
|
76
|
+
/** `tel:` wants the number without the spaces people write it with. */
|
|
77
|
+
const dial = (phone: string) => `tel:${phone.replace(/[^+0-9]/g, '')}`
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- editing
|
|
80
|
+
|
|
81
|
+
let editing = $state(false)
|
|
82
|
+
let nationalId = $state('')
|
|
83
|
+
let birthDate = $state('')
|
|
84
|
+
let iban = $state('')
|
|
85
|
+
let contactName = $state('')
|
|
86
|
+
let relationship = $state('')
|
|
87
|
+
let contactPhone = $state('')
|
|
88
|
+
|
|
89
|
+
function openEdit() {
|
|
90
|
+
nationalId = details?.nationalId ?? ''
|
|
91
|
+
birthDate = details?.birthDate ?? ''
|
|
92
|
+
iban = details?.iban ?? ''
|
|
93
|
+
contactName = details?.emergencyContact?.name ?? ''
|
|
94
|
+
relationship = details?.emergencyContact?.relationship ?? ''
|
|
95
|
+
contactPhone = details?.emergencyContact?.phone ?? ''
|
|
96
|
+
editing = true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A contact is a name *and* a number; either alone is somebody nobody can reach. */
|
|
100
|
+
const contactPartial = $derived(
|
|
101
|
+
(contactName.trim() === '') !== (contactPhone.trim() === '') && (contactName + contactPhone).trim() !== '',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `saving` rather than `save.isPending`: the disabled attribute only reaches the button on the next
|
|
106
|
+
* render, so two quick clicks are one render apart — and both would write.
|
|
107
|
+
*/
|
|
108
|
+
let saving = $state(false)
|
|
109
|
+
|
|
110
|
+
const save = createMutation(() => ({
|
|
111
|
+
// Unlike an employment change, an empty field here really does clear: `sensitive.update` takes
|
|
112
|
+
// null for every one of these, which is what makes "she asked us to delete her bank details" a
|
|
113
|
+
// thing this screen can do.
|
|
114
|
+
mutationFn: () =>
|
|
115
|
+
api.people.sensitive.update({
|
|
116
|
+
workspaceId,
|
|
117
|
+
personId,
|
|
118
|
+
nationalId: nationalId.trim() || null,
|
|
119
|
+
birthDate: birthDate || null,
|
|
120
|
+
iban: iban.trim() || null,
|
|
121
|
+
emergencyContact:
|
|
122
|
+
contactName.trim() && contactPhone.trim()
|
|
123
|
+
? {
|
|
124
|
+
name: contactName.trim(),
|
|
125
|
+
relationship: relationship.trim() || undefined,
|
|
126
|
+
phone: contactPhone.trim(),
|
|
127
|
+
}
|
|
128
|
+
: null,
|
|
129
|
+
}),
|
|
130
|
+
onSuccess: () => {
|
|
131
|
+
toast.success(t('person_updated'))
|
|
132
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'sensitive', workspaceId, personId] })
|
|
133
|
+
editing = false
|
|
134
|
+
},
|
|
135
|
+
onError: (error) => toast.error(explainRefusal(error, t('sensitive_save_error'))),
|
|
136
|
+
onSettled: () => {
|
|
137
|
+
saving = false
|
|
138
|
+
},
|
|
139
|
+
}))
|
|
140
|
+
|
|
141
|
+
const submit = () => {
|
|
142
|
+
if (saving) return
|
|
143
|
+
saving = true
|
|
144
|
+
save.mutate()
|
|
145
|
+
}
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<!-- No permission, no section: this is not a door to rattle. -->
|
|
149
|
+
{#if mayView}
|
|
150
|
+
<section class="sec">
|
|
151
|
+
<SectionLabel
|
|
152
|
+
collapsible
|
|
153
|
+
open={revealed}
|
|
154
|
+
onToggle={() => (revealed = !revealed)}
|
|
155
|
+
label={t('sensitive_title')}
|
|
156
|
+
>
|
|
157
|
+
{#snippet trailing()}
|
|
158
|
+
{#if revealed && mayManage && details}
|
|
159
|
+
<Button size="sm" variant="secondary" icon="pencil" onclick={openEdit}>{t('common.edit')}</Button>
|
|
160
|
+
{/if}
|
|
161
|
+
{/snippet}
|
|
162
|
+
</SectionLabel>
|
|
163
|
+
|
|
164
|
+
{#if !revealed}
|
|
165
|
+
<p class="hint">{t('sensitive_hidden')}</p>
|
|
166
|
+
{:else if sensitiveQuery.isLoading}
|
|
167
|
+
<div class="rows"><Skeleton lines={3} /></div>
|
|
168
|
+
{:else if details && !empty}
|
|
169
|
+
<!--
|
|
170
|
+
The emergency contact first, and as a row somebody can act on. Everything below it is a fact
|
|
171
|
+
to read; this one is a phone call, and it is the reason a colleague opens this section at a
|
|
172
|
+
moment when reading carefully is not on offer.
|
|
173
|
+
-->
|
|
174
|
+
<div class="emergency">
|
|
175
|
+
<span class="ec-head">
|
|
176
|
+
<Icon name="circle-alert" size={14} strokeWidth={1.8} />
|
|
177
|
+
{t('sensitive_emergency')}
|
|
178
|
+
</span>
|
|
179
|
+
{#if details.emergencyContact}
|
|
180
|
+
<span class="ec-name">
|
|
181
|
+
{details.emergencyContact.name}
|
|
182
|
+
{#if details.emergencyContact.relationship}
|
|
183
|
+
<span class="muted">— {details.emergencyContact.relationship}</span>
|
|
184
|
+
{/if}
|
|
185
|
+
</span>
|
|
186
|
+
<a class="ec-phone" href={dial(details.emergencyContact.phone)}>
|
|
187
|
+
{details.emergencyContact.phone}
|
|
188
|
+
</a>
|
|
189
|
+
{:else}
|
|
190
|
+
<span class="muted">{t('sensitive_emergency_none')}</span>
|
|
191
|
+
{#if mayManage}
|
|
192
|
+
<Button size="sm" variant="secondary" onclick={openEdit}>{t('sensitive_add_contact')}</Button>
|
|
193
|
+
{/if}
|
|
194
|
+
{/if}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<dl class="facts">
|
|
198
|
+
{#if details.birthDate}
|
|
199
|
+
<dt>{t('sensitive_birth_date')}</dt>
|
|
200
|
+
<dd>{dateLabel(details.birthDate)}</dd>
|
|
201
|
+
{/if}
|
|
202
|
+
{#if details.nationalId}
|
|
203
|
+
<dt>{t('sensitive_national_id')}</dt>
|
|
204
|
+
<dd class="mono">{details.nationalId}</dd>
|
|
205
|
+
{/if}
|
|
206
|
+
{#if details.iban}
|
|
207
|
+
<dt>{t('sensitive_iban')}</dt>
|
|
208
|
+
<dd class="mono">{details.iban}</dd>
|
|
209
|
+
{/if}
|
|
210
|
+
</dl>
|
|
211
|
+
{:else if sensitiveQuery.isError}
|
|
212
|
+
<EmptyState compact icon="triangle-alert" title={t('sensitive_error')}>
|
|
213
|
+
{#snippet actions()}
|
|
214
|
+
<Button size="sm" variant="secondary" onclick={() => void sensitiveQuery.refetch()}>
|
|
215
|
+
{t('retry')}
|
|
216
|
+
</Button>
|
|
217
|
+
{/snippet}
|
|
218
|
+
</EmptyState>
|
|
219
|
+
{:else}
|
|
220
|
+
<EmptyState
|
|
221
|
+
compact
|
|
222
|
+
icon="lock"
|
|
223
|
+
title={t('sensitive_none')}
|
|
224
|
+
description={t('sensitive_none_desc')}
|
|
225
|
+
>
|
|
226
|
+
{#snippet actions()}
|
|
227
|
+
{#if mayManage}
|
|
228
|
+
<Button size="sm" icon="plus" onclick={openEdit}>{t('sensitive_add_contact')}</Button>
|
|
229
|
+
{/if}
|
|
230
|
+
{/snippet}
|
|
231
|
+
</EmptyState>
|
|
232
|
+
{/if}
|
|
233
|
+
</section>
|
|
234
|
+
|
|
235
|
+
<Dialog
|
|
236
|
+
bind:open={editing}
|
|
237
|
+
title={t('sensitive_edit_title', { name: personName })}
|
|
238
|
+
description={t('sensitive_edit_body')}
|
|
239
|
+
>
|
|
240
|
+
<div class="form">
|
|
241
|
+
<Field
|
|
242
|
+
label={t('sensitive_contact_name')}
|
|
243
|
+
hint={t('sensitive_contact_hint')}
|
|
244
|
+
error={contactPartial ? t('sensitive_contact_partial') : null}
|
|
245
|
+
id="hr-sens-contact"
|
|
246
|
+
>
|
|
247
|
+
{#snippet children(id)}
|
|
248
|
+
<Input {id} bind:value={contactName} maxlength={160} autocomplete="off" />
|
|
249
|
+
{/snippet}
|
|
250
|
+
</Field>
|
|
251
|
+
<div class="pair">
|
|
252
|
+
<Field label={t('sensitive_relationship')} hint={t('common.optional')} id="hr-sens-rel">
|
|
253
|
+
{#snippet children(id)}
|
|
254
|
+
<Input {id} bind:value={relationship} maxlength={64} autocomplete="off" />
|
|
255
|
+
{/snippet}
|
|
256
|
+
</Field>
|
|
257
|
+
<Field label={t('phone')} id="hr-sens-phone">
|
|
258
|
+
{#snippet children(id)}
|
|
259
|
+
<Input {id} type="tel" bind:value={contactPhone} maxlength={32} autocomplete="off" />
|
|
260
|
+
{/snippet}
|
|
261
|
+
</Field>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<Field label={t('sensitive_birth_date')} hint={t('common.optional')} id="hr-sens-birth">
|
|
265
|
+
{#snippet children(id)}
|
|
266
|
+
<Input {id} type="date" value={birthDate} oninput={(e) => (birthDate = e.currentTarget.value)} />
|
|
267
|
+
{/snippet}
|
|
268
|
+
</Field>
|
|
269
|
+
<Field label={t('sensitive_national_id')} hint={t('common.optional')} id="hr-sens-nid">
|
|
270
|
+
{#snippet children(id)}
|
|
271
|
+
<Input {id} mono bind:value={nationalId} maxlength={64} autocomplete="off" />
|
|
272
|
+
{/snippet}
|
|
273
|
+
</Field>
|
|
274
|
+
<Field label={t('sensitive_iban')} hint={t('common.optional')} id="hr-sens-iban">
|
|
275
|
+
{#snippet children(id)}
|
|
276
|
+
<Input {id} mono bind:value={iban} maxlength={48} autocomplete="off" />
|
|
277
|
+
{/snippet}
|
|
278
|
+
</Field>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{#snippet footer()}
|
|
282
|
+
<Button variant="secondary" onclick={() => (editing = false)} disabled={save.isPending}>
|
|
283
|
+
{t('common.cancel')}
|
|
284
|
+
</Button>
|
|
285
|
+
<Button
|
|
286
|
+
loading={save.isPending}
|
|
287
|
+
disabled={contactPartial || !mayManage || saving}
|
|
288
|
+
onclick={submit}
|
|
289
|
+
>
|
|
290
|
+
{t('common.save')}
|
|
291
|
+
</Button>
|
|
292
|
+
{/snippet}
|
|
293
|
+
</Dialog>
|
|
294
|
+
{/if}
|
|
295
|
+
|
|
296
|
+
<style>
|
|
297
|
+
.sec {
|
|
298
|
+
margin-block-start: 20px;
|
|
299
|
+
}
|
|
300
|
+
.rows {
|
|
301
|
+
display: grid;
|
|
302
|
+
gap: 6px;
|
|
303
|
+
padding-block: 8px;
|
|
304
|
+
}
|
|
305
|
+
.hint {
|
|
306
|
+
margin: 8px 0 0;
|
|
307
|
+
font-size: 12px;
|
|
308
|
+
color: var(--kern-ink-500);
|
|
309
|
+
}
|
|
310
|
+
.emergency {
|
|
311
|
+
display: grid;
|
|
312
|
+
gap: 4px;
|
|
313
|
+
margin-block-start: 10px;
|
|
314
|
+
padding: 10px 12px;
|
|
315
|
+
border: 1px solid var(--kern-border);
|
|
316
|
+
border-radius: var(--kern-r-md);
|
|
317
|
+
background: var(--kern-surface);
|
|
318
|
+
}
|
|
319
|
+
.ec-head {
|
|
320
|
+
display: flex;
|
|
321
|
+
align-items: center;
|
|
322
|
+
gap: 6px;
|
|
323
|
+
font-size: 11px;
|
|
324
|
+
font-weight: 600;
|
|
325
|
+
letter-spacing: 0.06em;
|
|
326
|
+
text-transform: uppercase;
|
|
327
|
+
color: var(--kern-ink-500);
|
|
328
|
+
}
|
|
329
|
+
.ec-name {
|
|
330
|
+
font-size: 13.5px;
|
|
331
|
+
font-weight: 500;
|
|
332
|
+
}
|
|
333
|
+
.ec-phone {
|
|
334
|
+
font-size: 14px;
|
|
335
|
+
/* Underlined as well as coloured: colour alone is not what makes a link a link, and this is the
|
|
336
|
+
one control on the panel somebody uses without reading the page first. */
|
|
337
|
+
text-decoration: underline;
|
|
338
|
+
text-underline-offset: 3px;
|
|
339
|
+
font-variant-numeric: tabular-nums;
|
|
340
|
+
color: var(--kern-accent-text);
|
|
341
|
+
/* The number is read left to right whatever the interface direction: a phone number mirrored by
|
|
342
|
+
the paragraph around it is a number somebody dials wrong. */
|
|
343
|
+
direction: ltr;
|
|
344
|
+
unicode-bidi: isolate;
|
|
345
|
+
width: fit-content;
|
|
346
|
+
}
|
|
347
|
+
.facts {
|
|
348
|
+
display: grid;
|
|
349
|
+
grid-template-columns: auto 1fr;
|
|
350
|
+
gap: 8px 16px;
|
|
351
|
+
margin: 12px 0 0;
|
|
352
|
+
}
|
|
353
|
+
.facts dt {
|
|
354
|
+
color: var(--kern-ink-500);
|
|
355
|
+
font-size: 12px;
|
|
356
|
+
}
|
|
357
|
+
.facts dd {
|
|
358
|
+
margin: 0;
|
|
359
|
+
min-width: 0;
|
|
360
|
+
overflow-wrap: anywhere;
|
|
361
|
+
}
|
|
362
|
+
.mono {
|
|
363
|
+
font-family: var(--kern-font-mono);
|
|
364
|
+
font-size: 12.5px;
|
|
365
|
+
direction: ltr;
|
|
366
|
+
unicode-bidi: isolate;
|
|
367
|
+
}
|
|
368
|
+
/* A colour, not opacity: opacity fades text against the panel whatever token it names. */
|
|
369
|
+
.muted {
|
|
370
|
+
color: var(--kern-ink-500);
|
|
371
|
+
font-size: 12px;
|
|
372
|
+
}
|
|
373
|
+
.form {
|
|
374
|
+
display: grid;
|
|
375
|
+
gap: 14px;
|
|
376
|
+
}
|
|
377
|
+
.pair {
|
|
378
|
+
display: grid;
|
|
379
|
+
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
380
|
+
gap: 14px;
|
|
381
|
+
}
|
|
382
|
+
</style>
|