@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.
@@ -0,0 +1,492 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ EmptyState,
7
+ Field,
8
+ formatCount,
9
+ formatDate,
10
+ Icon,
11
+ IconButton,
12
+ Input,
13
+ ProgressBar,
14
+ SectionLabel,
15
+ Select,
16
+ Skeleton,
17
+ session,
18
+ toast,
19
+ uploadFile,
20
+ } from '@kernhq/ui'
21
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
22
+ import { getHrApi } from '../api-instance.js'
23
+ import { HR_CAPABILITIES } from '../capabilities.js'
24
+ import { t } from '../i18n.js'
25
+ import type { PersonDocument } from '../index.js'
26
+ import { canHr } from '../permissions.js'
27
+ import { isoDate } from '../query.js'
28
+ import { explainRefusal } from './refusal.js'
29
+
30
+ /**
31
+ * The contracts, identity documents and certificates kept against one person.
32
+ *
33
+ * `documents` is a capability and `hr.document.view` / `hr.document.manage` are two permissions
34
+ * nobody holds by default, and all three led nowhere until this section existed —
35
+ * `src/contract/capabilities.ts` says out loud that a switch which changes nothing is worse than a
36
+ * missing switch, and this is the switch.
37
+ *
38
+ * Off means **absent**, not refused: the API answers 404 for a workspace that never enabled
39
+ * documents, so a section saying "you may not see this" would contradict both the server and a
40
+ * shell that already hid the feature.
41
+ *
42
+ * The bytes never pass through HR. `uploadFile` presigns against core, PUTs to object storage and
43
+ * marks the file ready; `documents.attach` then records the file id against the person. A file
44
+ * uploaded but never attached is a stray file, so the attach follows the upload in one action.
45
+ */
46
+ interface Props {
47
+ personId: string
48
+ workspaceId: string
49
+ personName: string
50
+ }
51
+ const { personId, workspaceId, personName }: Props = $props()
52
+
53
+ const api = getHrApi()
54
+ const queryClient = useQueryClient()
55
+
56
+ const enabled = $derived(session.hasCapability('hr', HR_CAPABILITIES.documents))
57
+ const mayView = $derived(canHr('documentView'))
58
+ const mayManage = $derived(canHr('documentManage'))
59
+
60
+ const documentsQuery = createQuery(() => ({
61
+ queryKey: ['hr', 'documents', workspaceId, personId] as const,
62
+ enabled: enabled && mayView && Boolean(workspaceId && personId),
63
+ queryFn: () => api.documents.list({ workspaceId, personId }),
64
+ }))
65
+ const documents = $derived(documentsQuery.data ?? [])
66
+
67
+ /**
68
+ * The kinds offered, and the words for them.
69
+ *
70
+ * The contract takes any string up to 48 characters, so a kind written by an import or by a later
71
+ * version of this screen falls through to itself rather than to nothing.
72
+ */
73
+ const KIND_KEYS: Record<string, string> = {
74
+ contract: 'doc_kind_contract',
75
+ id: 'doc_kind_id',
76
+ certificate: 'doc_kind_certificate',
77
+ payslip: 'doc_kind_payslip',
78
+ other: 'doc_kind_other',
79
+ }
80
+ const kindLabel = (kind: string) => (KIND_KEYS[kind] ? t(KIND_KEYS[kind]) : kind)
81
+ const kindOptions = Object.keys(KIND_KEYS).map((kind) => ({ value: kind, label: kindLabel(kind) }))
82
+
83
+ /**
84
+ * A calendar date, read in the reader's language.
85
+ *
86
+ * The `T00:00:00` is not decoration: `new Date('2026-03-01')` is parsed as *UTC* midnight, so west
87
+ * of Greenwich a document issued on the first of March would be shown as expiring in February.
88
+ */
89
+ const dateLabel = (iso: string): string => formatDate(`${iso}T00:00:00`)
90
+
91
+ /**
92
+ * Whether a document has run out, or is about to.
93
+ *
94
+ * The reason a work permit or a right-to-work check is worth keeping here at all is that somebody
95
+ * has to notice before it lapses, and a list of names and dates does not make anybody notice.
96
+ */
97
+ const SOON_DAYS = 60
98
+ function expiry(row: PersonDocument): 'expired' | 'soon' | null {
99
+ if (!row.expiresOn) return null
100
+ const today = isoDate()
101
+ if (row.expiresOn < today) return 'expired'
102
+ const limit = new Date(`${today}T00:00:00`)
103
+ limit.setDate(limit.getDate() + SOON_DAYS)
104
+ return row.expiresOn <= isoDate(limit) ? 'soon' : null
105
+ }
106
+
107
+ // ---------------------------------------------------------------- attaching
108
+
109
+ let picker = $state<HTMLInputElement | null>(null)
110
+ let chosen = $state<File | null>(null)
111
+ let docName = $state('')
112
+ let docKind = $state('other')
113
+ let issuedOn = $state('')
114
+ let expiresOn = $state('')
115
+ /** Null while the browser is not reporting progress, which is what an unknown-length body does. */
116
+ let progress = $state<number | null>(null)
117
+ let attachError = $state<string | null>(null)
118
+
119
+ function pick(files: FileList | null) {
120
+ const file = files?.[0]
121
+ if (!file) return
122
+ chosen = file
123
+ // The file's own name is what somebody would type, so it is the default rather than an empty box.
124
+ docName = file.name.replace(/\.[^.]+$/, '').slice(0, 200)
125
+ docKind = 'other'
126
+ issuedOn = ''
127
+ expiresOn = ''
128
+ progress = null
129
+ attachError = null
130
+ }
131
+
132
+ /**
133
+ * `attaching` rather than `attach.isPending`: the disabled attribute only reaches the button on the
134
+ * next render, so two quick clicks are one render apart — and here that is the same file uploaded
135
+ * twice and attached twice.
136
+ */
137
+ let attaching = $state(false)
138
+
139
+ const attach = createMutation(() => ({
140
+ mutationFn: async () => {
141
+ const file = chosen
142
+ if (!file) throw new Error('no file')
143
+ progress = 0
144
+ const uploaded = await uploadFile({
145
+ workspaceId,
146
+ file,
147
+ name: file.name,
148
+ mimeType: file.type || undefined,
149
+ // What the file belongs to, recorded with the file itself: core can then answer "what is this
150
+ // file" without HR, which is what makes an orphan findable.
151
+ attachedTo: { module: 'hr', type: 'person', id: personId },
152
+ onProgress: ({ ratio }) => {
153
+ progress = ratio
154
+ },
155
+ })
156
+ return api.documents.attach({
157
+ workspaceId,
158
+ personId,
159
+ fileId: uploaded.id,
160
+ name: docName.trim(),
161
+ kind: docKind,
162
+ issuedOn: issuedOn || null,
163
+ expiresOn: expiresOn || null,
164
+ })
165
+ },
166
+ onSuccess: (row) => {
167
+ toast.success(t('doc_attached', { name: row.name }))
168
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
169
+ chosen = null
170
+ },
171
+ onError: (error) => {
172
+ // The dialog stays open with the file still chosen: the failure is almost always the network or
173
+ // a size limit, and making somebody find the file again to retry is a punishment for both.
174
+ attachError = explainRefusal(error, t('doc_attach_error'))
175
+ progress = null
176
+ },
177
+ onSettled: () => {
178
+ attaching = false
179
+ },
180
+ }))
181
+
182
+ const submitAttach = () => {
183
+ if (attaching) return
184
+ attaching = true
185
+ attachError = null
186
+ attach.mutate()
187
+ }
188
+
189
+ // ---------------------------------------------------------------- removing
190
+
191
+ let removing = $state<PersonDocument | null>(null)
192
+ let deleting = $state(false)
193
+
194
+ const remove = createMutation(() => ({
195
+ mutationFn: (row: PersonDocument) => api.documents.remove({ workspaceId, personId, documentId: row.id }),
196
+ onSuccess: (_ok, row: PersonDocument) => {
197
+ toast.success(t('doc_removed', { name: row.name }))
198
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
199
+ removing = null
200
+ },
201
+ onError: (error) => toast.error(explainRefusal(error, t('doc_remove_error'))),
202
+ onSettled: () => {
203
+ deleting = false
204
+ },
205
+ }))
206
+
207
+ const submitRemove = () => {
208
+ if (deleting || !removing) return
209
+ deleting = true
210
+ remove.mutate(removing)
211
+ }
212
+
213
+ const canAttach = $derived(Boolean(chosen) && docName.trim().length > 0 && mayManage && !attaching)
214
+ </script>
215
+
216
+ <!-- A capability that is off is not a locked door: nothing is drawn, and the API answers 404. -->
217
+ {#if enabled && mayView}
218
+ <section class="sec">
219
+ <SectionLabel label={t('docs_title')} count={documents.length ? formatCount(documents.length, 999) : null}>
220
+ {#snippet trailing()}
221
+ <!-- Hidden rather than disabled: `hr.document.manage` is a permission somebody either has
222
+ or will never have here, and a dead button explains nothing. -->
223
+ {#if mayManage}
224
+ <input
225
+ bind:this={picker}
226
+ type="file"
227
+ hidden
228
+ onchange={(e) => {
229
+ pick(e.currentTarget.files)
230
+ e.currentTarget.value = ''
231
+ }}
232
+ />
233
+ <Button size="sm" variant="secondary" icon="paperclip" onclick={() => picker?.click()}>
234
+ {t('doc_attach')}
235
+ </Button>
236
+ {/if}
237
+ {/snippet}
238
+ </SectionLabel>
239
+
240
+ <!--
241
+ Held data outranks the error: every write in this module drops the whole HR cache, so a failed
242
+ background refetch leaves the query in `error` with a good list still in hand — and an error
243
+ branch above this one would blank it.
244
+ -->
245
+ {#if documentsQuery.isLoading}
246
+ <div class="rows">
247
+ {#each [1, 2] as n (n)}<Skeleton height="44px" />{/each}
248
+ </div>
249
+ {:else if documents.length}
250
+ <ul class="docs">
251
+ {#each documents as row (row.id)}
252
+ {@const lapse = expiry(row)}
253
+ <li>
254
+ <span class="ic"><Icon name="file-text" size={14} strokeWidth={1.7} /></span>
255
+ <span class="name" title={row.name}>{row.name}</span>
256
+ <span class="chips">
257
+ <Badge tone="grey">{kindLabel(row.kind)}</Badge>
258
+ {#if lapse === 'expired'}
259
+ <Badge tone="danger">{t('doc_expired')}</Badge>
260
+ {:else if lapse === 'soon'}
261
+ <Badge tone="warning">{t('doc_expiring')}</Badge>
262
+ {/if}
263
+ </span>
264
+ <span class="dates">
265
+ {#if row.expiresOn}
266
+ {t('doc_expires_on', { date: dateLabel(row.expiresOn) })}
267
+ {:else if row.issuedOn}
268
+ {t('doc_issued_on', { date: dateLabel(row.issuedOn) })}
269
+ {:else}
270
+ {t('doc_added_on', { date: dateLabel(row.createdAt) })}
271
+ {/if}
272
+ </span>
273
+ {#if mayManage}
274
+ <IconButton
275
+ icon="trash-2"
276
+ size={26}
277
+ label={t('doc_remove_label', { name: row.name })}
278
+ onclick={() => (removing = row)}
279
+ />
280
+ {/if}
281
+ </li>
282
+ {/each}
283
+ </ul>
284
+ {:else if documentsQuery.isError}
285
+ <EmptyState compact icon="triangle-alert" title={t('docs_error')}>
286
+ {#snippet actions()}
287
+ <Button size="sm" variant="secondary" onclick={() => void documentsQuery.refetch()}>
288
+ {t('retry')}
289
+ </Button>
290
+ {/snippet}
291
+ </EmptyState>
292
+ {:else}
293
+ <EmptyState compact icon="file-text" title={t('docs_none')} description={t('docs_none_desc')}>
294
+ {#snippet actions()}
295
+ {#if mayManage}
296
+ <Button size="sm" icon="paperclip" onclick={() => picker?.click()}>{t('doc_attach')}</Button>
297
+ {/if}
298
+ {/snippet}
299
+ </EmptyState>
300
+ {/if}
301
+ </section>
302
+
303
+ <Dialog
304
+ open={chosen !== null}
305
+ size="sm"
306
+ title={t('doc_attach_title', { name: personName })}
307
+ description={t('doc_attach_body')}
308
+ onOpenChange={(open) => {
309
+ if (!open && !attaching) chosen = null
310
+ }}
311
+ >
312
+ <div class="form">
313
+ {#if chosen}
314
+ <p class="file">
315
+ <Icon name="paperclip" size={13} strokeWidth={1.8} />
316
+ <span class="name" title={chosen.name}>{chosen.name}</span>
317
+ </p>
318
+ {/if}
319
+
320
+ <Field label={t('doc_name')} id="hr-doc-name" required>
321
+ {#snippet children(id)}
322
+ <Input {id} bind:value={docName} maxlength={200} />
323
+ {/snippet}
324
+ </Field>
325
+
326
+ <Field label={t('doc_kind')} id="hr-doc-kind">
327
+ {#snippet children(id)}
328
+ <Select
329
+ {id}
330
+ value={docKind}
331
+ onValueChange={(v) => (docKind = v)}
332
+ options={kindOptions}
333
+ ariaLabel={t('doc_kind')}
334
+ />
335
+ {/snippet}
336
+ </Field>
337
+
338
+ <div class="pair">
339
+ <Field label={t('doc_issued')} hint={t('common.optional')} id="hr-doc-issued">
340
+ {#snippet children(id)}
341
+ <Input {id} type="date" value={issuedOn} oninput={(e) => (issuedOn = e.currentTarget.value)} />
342
+ {/snippet}
343
+ </Field>
344
+ <Field label={t('doc_expires')} hint={t('doc_expires_hint')} id="hr-doc-expires">
345
+ {#snippet children(id)}
346
+ <Input {id} type="date" value={expiresOn} oninput={(e) => (expiresOn = e.currentTarget.value)} />
347
+ {/snippet}
348
+ </Field>
349
+ </div>
350
+
351
+ {#if attaching}
352
+ <div class="progress">
353
+ <ProgressBar
354
+ value={progress === null ? 0 : progress * 100}
355
+ label={t('doc_uploading', { name: chosen?.name ?? '' })}
356
+ />
357
+ <span class="muted">{t('doc_uploading', { name: chosen?.name ?? '' })}</span>
358
+ </div>
359
+ {/if}
360
+ {#if attachError}
361
+ <p class="err" role="alert">{attachError}</p>
362
+ {/if}
363
+ </div>
364
+
365
+ {#snippet footer()}
366
+ <Button variant="secondary" onclick={() => (chosen = null)} disabled={attaching}>
367
+ {t('common.cancel')}
368
+ </Button>
369
+ <Button loading={attach.isPending} disabled={!canAttach} onclick={submitAttach}>
370
+ {t('doc_attach')}
371
+ </Button>
372
+ {/snippet}
373
+ </Dialog>
374
+
375
+ <Dialog
376
+ open={removing !== null}
377
+ size="sm"
378
+ title={removing ? t('doc_remove_title', { name: removing.name }) : ''}
379
+ description={t('doc_remove_body', { person: personName })}
380
+ onOpenChange={(open) => {
381
+ if (!open) removing = null
382
+ }}
383
+ >
384
+ {#if removing}
385
+ <p class="body">
386
+ <span class="strong">{removing.name}</span>
387
+ <span class="muted">&nbsp;— {kindLabel(removing.kind)}</span>
388
+ </p>
389
+ {/if}
390
+
391
+ {#snippet footer()}
392
+ <Button variant="secondary" onclick={() => (removing = null)} disabled={remove.isPending}>
393
+ {t('common.cancel')}
394
+ </Button>
395
+ <Button variant="danger" loading={remove.isPending} disabled={deleting} onclick={submitRemove}>
396
+ {t('common.remove')}
397
+ </Button>
398
+ {/snippet}
399
+ </Dialog>
400
+ {/if}
401
+
402
+ <style>
403
+ .sec {
404
+ margin-block-start: 20px;
405
+ }
406
+ .rows {
407
+ display: grid;
408
+ gap: 6px;
409
+ padding-block: 8px;
410
+ }
411
+ .docs {
412
+ list-style: none;
413
+ margin: 8px 0 0;
414
+ padding: 0;
415
+ display: grid;
416
+ gap: 2px;
417
+ }
418
+ .docs li {
419
+ display: grid;
420
+ grid-template-columns: 16px minmax(0, 1fr) auto;
421
+ align-items: center;
422
+ gap: 4px 8px;
423
+ padding: 8px 6px 8px 8px;
424
+ border-radius: var(--kern-r-md);
425
+ font-size: 13px;
426
+ }
427
+ /* `surface-hover`, not `surface-raised`: the panel this list sits in is already raised, so a
428
+ raised hover is white on white and the row gives no feedback at all. */
429
+ .docs li:hover {
430
+ background: var(--kern-surface-hover);
431
+ }
432
+ .ic {
433
+ color: var(--kern-ink-500);
434
+ display: flex;
435
+ }
436
+ .name {
437
+ min-width: 0;
438
+ overflow: hidden;
439
+ text-overflow: ellipsis;
440
+ white-space: nowrap;
441
+ font-weight: 500;
442
+ }
443
+ .chips,
444
+ .dates {
445
+ grid-column: 2;
446
+ display: flex;
447
+ align-items: center;
448
+ gap: 6px;
449
+ flex-wrap: wrap;
450
+ min-width: 0;
451
+ }
452
+ /* A colour, not opacity: opacity fades text against the panel whatever token it names. */
453
+ .dates,
454
+ .muted {
455
+ color: var(--kern-ink-500);
456
+ font-size: 12px;
457
+ }
458
+ .form {
459
+ display: grid;
460
+ gap: 14px;
461
+ }
462
+ .pair {
463
+ display: grid;
464
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
465
+ gap: 14px;
466
+ }
467
+ .file {
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 6px;
471
+ margin: 0;
472
+ min-width: 0;
473
+ color: var(--kern-ink-500);
474
+ font-size: 12.5px;
475
+ }
476
+ .progress {
477
+ display: grid;
478
+ gap: 6px;
479
+ }
480
+ .err {
481
+ margin: 0;
482
+ font-size: 12.5px;
483
+ color: var(--kern-danger);
484
+ }
485
+ .body {
486
+ margin: 0 0 4px;
487
+ font-size: 13.5px;
488
+ }
489
+ .strong {
490
+ font-weight: 500;
491
+ }
492
+ </style>