@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,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>