@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,1206 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Field,
9
+ formatCount,
10
+ IconButton,
11
+ Input,
12
+ type MenuItem,
13
+ navigation,
14
+ SectionLabel,
15
+ Select,
16
+ type SelectOption,
17
+ SettingsPage,
18
+ SettingsSection,
19
+ Skeleton,
20
+ Switch,
21
+ session,
22
+ toast,
23
+ } from '@kernhq/ui'
24
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
25
+ import { tick } from 'svelte'
26
+ // Straight from the contract rather than widening the client barrel: the barrel re-exports
27
+ // `ApprovalChain` because a screen needed it, and the subject, step and approver shapes are only
28
+ // ever assembled here.
29
+ import type {
30
+ ApprovalChainSpec,
31
+ ApprovalStepSpec,
32
+ ApprovalSubjectType,
33
+ ApproverSubject,
34
+ } from '../../contract/approvals.js'
35
+ import { getHrApi } from '../api-instance.js'
36
+ import { t } from '../i18n.js'
37
+ import type { ApprovalChain } from '../index.js'
38
+ import { canHr } from '../permissions.js'
39
+ import { hrKeys } from '../query.js'
40
+
41
+ /**
42
+ * Who signs what — the screen `approvals_chains_hint` has been pointing at.
43
+ *
44
+ * Three facts about the engine decide everything on this page, and getting any of them wrong here
45
+ * would make the screen lie about the server:
46
+ *
47
+ * **A chain is snapshotted onto a request when it is raised.** Editing a chain, making another one
48
+ * the default, or archiving this one changes nothing that is already in flight — each request keeps
49
+ * the copy it was raised with and the approvers that copy resolved to. That is stated in the edit
50
+ * dialog and again, at length, in the archive confirmation, because an admin who suspects archiving
51
+ * might strand a half-signed request simply will not archive.
52
+ *
53
+ * **Only the default is used.** `chainFor` looks up the default for the subject type and nothing
54
+ * else — a chain that is not the default is a draft, not an alternative route. So the list says so
55
+ * plainly rather than implying five chains all do something.
56
+ *
57
+ * **No default means one implicit step: the requester's manager.** That is what makes a small
58
+ * company work without configuring anything, and it is the sentence somebody needs before they
59
+ * archive the only chain they have.
60
+ *
61
+ * The permission is `hr.approval.manage`, which is what the server gates every procedure here on —
62
+ * including `list`. There is no read-only audience for this page: somebody without the permission
63
+ * never sees it, because the settings entry declares it.
64
+ */
65
+ const api = getHrApi()
66
+ const queryClient = useQueryClient()
67
+
68
+ const workspaceSlug = $derived(navigation.workspaceSlug)
69
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
70
+ const workspaceId = $derived(workspace?.id ?? '')
71
+
72
+ /**
73
+ * The server gates `chains.list` on this too, so somebody without it gets no rows either. Checked
74
+ * here as well because a button that is going to be refused should not be offered.
75
+ */
76
+ const manage = $derived(canHr('approvalManage'))
77
+
78
+ // ---------------------------------------------------------------- vocabulary
79
+
80
+ const SUBJECT_TYPES: ApprovalSubjectType[] = [
81
+ 'leave',
82
+ 'regularization',
83
+ 'overtime',
84
+ 'timesheet',
85
+ 'shift_swap',
86
+ ]
87
+
88
+ /** The same names the inbox uses. An approval called one thing here and another there is two things. */
89
+ const subjectLabel = (subject: ApprovalSubjectType): string =>
90
+ subject === 'leave'
91
+ ? t('leave_title')
92
+ : subject === 'regularization'
93
+ ? t('attendance_title')
94
+ : subject === 'overtime'
95
+ ? t('att_overtime')
96
+ : subject === 'timesheet'
97
+ ? t('approval_subject_timesheet')
98
+ : t('approval_subject_shift_swap')
99
+
100
+ type ApproverKind = ApproverSubject['kind']
101
+
102
+ /**
103
+ * The kinds this screen offers.
104
+ *
105
+ * `group` is missing on purpose: groups live in core and this module's client has no way to list
106
+ * them, so the only picker it could offer is a box to paste a UUID into. A chain that already names
107
+ * one is preserved rather than dropped — see `kindOptions` and the group row below.
108
+ */
109
+ const OFFERED_KINDS: ApproverKind[] = [
110
+ 'manager',
111
+ 'manager_of_manager',
112
+ 'org_unit_head',
113
+ 'office_head',
114
+ 'person',
115
+ 'permission',
116
+ ]
117
+
118
+ const kindLabel = (kind: ApproverKind): string =>
119
+ kind === 'person'
120
+ ? t('chain_who_person')
121
+ : kind === 'manager'
122
+ ? t('chain_who_manager')
123
+ : kind === 'manager_of_manager'
124
+ ? t('chain_who_manager_of_manager')
125
+ : kind === 'org_unit_head'
126
+ ? t('chain_who_org_unit_head')
127
+ : kind === 'office_head'
128
+ ? t('chain_who_office_head')
129
+ : kind === 'permission'
130
+ ? t('chain_who_permission')
131
+ : t('chain_who_group')
132
+
133
+ /** Whether the kind needs a second answer — which person, which permission, which group. */
134
+ const needsId = (kind: ApproverKind) => kind === 'person' || kind === 'permission' || kind === 'group'
135
+
136
+ /**
137
+ * The permissions worth standing in for a group of approvers.
138
+ *
139
+ * Not every key this module declares: "anybody who may view the directory" is most of the company
140
+ * and would make a step meaningless. These are the six that name somebody who does a job. A chain
141
+ * carrying a key that is not on the list keeps it — the option is added back below rather than
142
+ * silently rewritten to the first entry.
143
+ */
144
+ const APPROVER_PERMISSIONS = [
145
+ 'hr.person.manage',
146
+ 'hr.leave.manage',
147
+ 'hr.attendance.manage',
148
+ 'hr.overtime.manage',
149
+ 'hr.office.manage',
150
+ 'hr.approval.manage',
151
+ ] as const
152
+
153
+ const permissionLabel = (key: string): string =>
154
+ key === 'hr.person.manage'
155
+ ? t('chain_perm_person')
156
+ : key === 'hr.leave.manage'
157
+ ? t('chain_perm_leave')
158
+ : key === 'hr.attendance.manage'
159
+ ? t('chain_perm_attendance')
160
+ : key === 'hr.overtime.manage'
161
+ ? t('chain_perm_overtime')
162
+ : key === 'hr.office.manage'
163
+ ? t('chain_perm_office')
164
+ : key === 'hr.approval.manage'
165
+ ? t('chain_perm_approval')
166
+ : key
167
+
168
+ const modeLabel = (mode: ApprovalStepSpec['mode']): string =>
169
+ mode === 'all' ? t('chain_mode_all') : mode === 'any' ? t('chain_mode_any') : t('chain_mode_quorum')
170
+
171
+ const timeoutLabel = (on: ApprovalStepSpec['onTimeout']): string =>
172
+ on === 'remind'
173
+ ? t('chain_timeout_remind')
174
+ : on === 'escalate'
175
+ ? t('chain_timeout_escalate')
176
+ : t('chain_timeout_auto_approve')
177
+
178
+ // ---------------------------------------------------------------- what is on screen
179
+
180
+ let subject = $state<ApprovalSubjectType>('leave')
181
+
182
+ /**
183
+ * The draft is declared above the queries on purpose.
184
+ *
185
+ * `createQuery` reads its options function as it is created, and the directory query's `enabled`
186
+ * asks whether the editor is open — so a `let draft` below it is still in its temporal dead zone
187
+ * when that first read happens, and the whole screen dies on "Cannot access 'draft' before
188
+ * initialization". At runtime only, on first render, which nothing here type-checks.
189
+ */
190
+
191
+ /** `key` is for `{#each}` alone: two identical steps must not share an identity while being edited. */
192
+ type ApproverDraft = { key: string; kind: ApproverKind; id: string }
193
+ type StepDraft = {
194
+ key: string
195
+ name: string
196
+ approvers: ApproverDraft[]
197
+ mode: ApprovalStepSpec['mode']
198
+ /** People, not rows — one row can stand for several. Only read when the mode is `quorum`. */
199
+ minApprovals: number
200
+ /** Empty means it waits for ever, which is what the contract's `null` says. */
201
+ slaHours: string
202
+ onTimeout: ApprovalStepSpec['onTimeout']
203
+ }
204
+ interface Draft {
205
+ /** `null` while creating. The two procedures take different fields, so this decides which. */
206
+ id: string | null
207
+ /** Fixed after creation: `chains.update` has no `subjectType`, and moving one would strand it. */
208
+ subjectType: ApprovalSubjectType
209
+ name: string
210
+ isDefault: boolean
211
+ steps: StepDraft[]
212
+ }
213
+
214
+ let draft = $state<Draft | null>(null)
215
+ let formError = $state<string | null>(null)
216
+
217
+ /**
218
+ * `[module, entity, …scope]`, the shape `hrKeys` uses. Spelled here rather than in `query.ts`
219
+ * because this is the only screen that asks — the same reason the office roster key is spelled in
220
+ * `OfficesSettings`.
221
+ */
222
+ const chainsKey = (ws: string) => ['hr', 'approval-chains', ws] as const
223
+
224
+ /**
225
+ * Every subject type at once, not the filtered call.
226
+ *
227
+ * The filter above is a view of one list, so switching between the five kinds is instant and the
228
+ * counts beside them are real rather than "the one I last fetched".
229
+ */
230
+ const chainsQuery = createQuery(() => ({
231
+ queryKey: chainsKey(workspaceId),
232
+ enabled: Boolean(workspaceId),
233
+ queryFn: () => api.approvals.chains.list({ workspaceId }),
234
+ }))
235
+ const allChains = $derived((chainsQuery.data ?? []) as ApprovalChain[])
236
+ const chains = $derived(allChains.filter((c) => c.subjectType === subject))
237
+ const defaultChain = $derived(chains.find((c) => c.isDefault) ?? null)
238
+
239
+ const subjectOptions = $derived(
240
+ SUBJECT_TYPES.map((s) => ({
241
+ value: s,
242
+ label: subjectLabel(s),
243
+ description: t('chain_subject_count', {
244
+ count: allChains.filter((c) => c.subjectType === s).length,
245
+ }),
246
+ })),
247
+ )
248
+
249
+ /**
250
+ * The directory, for the "a named person" approver.
251
+ *
252
+ * Only while the editor is open: a settings page nobody has opened a dialog on has no reason to
253
+ * pull two hundred people.
254
+ */
255
+ const directoryQuery = createQuery(() => ({
256
+ queryKey: hrKeys.people(workspaceId, { forChains: true }),
257
+ enabled: Boolean(workspaceId) && draft !== null,
258
+ queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
259
+ }))
260
+ const directory = $derived(directoryQuery.data?.items ?? [])
261
+
262
+ /**
263
+ * One click, one write.
264
+ *
265
+ * `disabled={mutation.isPending}` reaches the button on the next render, and two quick clicks are
266
+ * one render apart — which here means two chains, or a chain archived twice. The flag is set in the
267
+ * same tick as the click and cleared when the call settles.
268
+ */
269
+ let firing = $state(false)
270
+ function once(run: () => void) {
271
+ if (firing) return
272
+ firing = true
273
+ run()
274
+ }
275
+ const settled = () => {
276
+ firing = false
277
+ }
278
+
279
+ /**
280
+ * A chain change moves the inbox and every screen that raises a request, so the module's cache is
281
+ * dropped whole rather than guessing which keys a new default touched.
282
+ */
283
+ const refresh = () => {
284
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
285
+ }
286
+
287
+ /**
288
+ * What a refused write says to the person who made it.
289
+ *
290
+ * The transport's `code` is what is tested, never the sentence — a list of sentences is a list
291
+ * somebody has to keep in sync, and the day it drifts the reader is told nothing. `NOT_FOUND` is
292
+ * the one that actually happens: two administrators in the same settings screen, one archives, the
293
+ * other saves. Anything else falls back to the server's own machine text. The same shape as
294
+ * `ClockControls` and `ApprovalsPage`; do not invent a third.
295
+ */
296
+ const refusalMessages: Record<string, string> = {}
297
+
298
+ function chainFailure(error: unknown): string {
299
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
300
+ if (failure.code === 'NOT_FOUND') return t('chain_gone')
301
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
302
+ const key = reason ? refusalMessages[reason] : undefined
303
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
304
+ // a reason no key covers, and a key whose string is not merged yet — land on the server's
305
+ // sentence rather than putting `hr.chain_refused_…` in front of somebody.
306
+ const translated = key ? t(key) : undefined
307
+ return (translated && translated !== key ? translated : failure.message) || t('chain_save_error')
308
+ }
309
+
310
+ // ---------------------------------------------------------------- the draft
311
+
312
+ let nextKey = 0
313
+ const freshKey = () => `k${nextKey++}`
314
+
315
+ const newApprover = (kind: ApproverKind = 'manager'): ApproverDraft => ({
316
+ key: freshKey(),
317
+ kind,
318
+ id: '',
319
+ })
320
+
321
+ const newStep = (name: string): StepDraft => ({
322
+ key: freshKey(),
323
+ name,
324
+ approvers: [newApprover()],
325
+ mode: 'any',
326
+ minApprovals: 2,
327
+ slaHours: '',
328
+ onTimeout: 'remind',
329
+ })
330
+
331
+ function openCreate() {
332
+ formError = null
333
+ draft = {
334
+ id: null,
335
+ subjectType: subject,
336
+ name: '',
337
+ // A workspace with no chain for this kind almost certainly wants the one it is building to be
338
+ // the one that runs; a second chain does nothing until somebody makes it the default, so it
339
+ // does not steal the flag from the chain already in use.
340
+ isDefault: defaultChain === null,
341
+ steps: [newStep(t('chain_step_first'))],
342
+ }
343
+ }
344
+
345
+ function openEdit(chain: ApprovalChain) {
346
+ formError = null
347
+ draft = {
348
+ id: chain.id,
349
+ subjectType: chain.subjectType,
350
+ name: chain.name,
351
+ isDefault: chain.isDefault,
352
+ steps: chain.spec.steps.map((step) => ({
353
+ key: freshKey(),
354
+ name: step.name,
355
+ approvers: step.approvers.map((a) => ({ key: freshKey(), kind: a.kind, id: a.id ?? '' })),
356
+ mode: step.mode,
357
+ minApprovals: step.minApprovals,
358
+ slaHours: step.slaHours === null ? '' : String(step.slaHours),
359
+ onTimeout: step.onTimeout,
360
+ })),
361
+ }
362
+ }
363
+
364
+ // ---------------------------------------------------------------- editing the steps
365
+
366
+ function addStep() {
367
+ if (!draft) return
368
+ draft.steps = [...draft.steps, newStep(t('chain_step_n', { n: formatCount(draft.steps.length + 1) }))]
369
+ }
370
+
371
+ function removeStep(index: number) {
372
+ if (!draft || draft.steps.length <= 1) return
373
+ draft.steps = draft.steps.filter((_, i) => i !== index)
374
+ }
375
+
376
+ function moveStep(index: number, by: number) {
377
+ if (!draft) return
378
+ const to = index + by
379
+ if (to < 0 || to >= draft.steps.length) return
380
+ const steps = [...draft.steps]
381
+ const [moved] = steps.splice(index, 1)
382
+ if (moved) steps.splice(to, 0, moved)
383
+ draft.steps = steps
384
+ }
385
+
386
+ /**
387
+ * Reorder, and keep the keyboard somewhere.
388
+ *
389
+ * The `{#each}` is keyed, so the pressed button moves with its step and holds focus — right up
390
+ * until the step reaches an end and the button disables itself. The browser blurs a focused element
391
+ * the moment it becomes disabled and hands focus nowhere, so somebody reordering steps by keyboard
392
+ * would be dropped back to the top of the page on the last press. Focus goes to the arrow pointing
393
+ * the other way, which is the one they can still use.
394
+ */
395
+ async function moveFrom(event: Event, index: number, by: number) {
396
+ const pressed = event.currentTarget
397
+ moveStep(index, by)
398
+ await tick()
399
+ if (!(pressed instanceof HTMLButtonElement) || !pressed.disabled) return
400
+ const other = by < 0 ? pressed.nextElementSibling : pressed.previousElementSibling
401
+ if (other instanceof HTMLButtonElement && !other.disabled) other.focus()
402
+ }
403
+
404
+ function addApprover(step: StepDraft) {
405
+ step.approvers = [...step.approvers, newApprover()]
406
+ }
407
+
408
+ function removeApprover(step: StepDraft, index: number) {
409
+ if (step.approvers.length <= 1) return
410
+ step.approvers = step.approvers.filter((_, i) => i !== index)
411
+ }
412
+
413
+ /** Changing the kind drops the id with it: a person id is not a permission key. */
414
+ function setKind(approver: ApproverDraft, kind: string) {
415
+ approver.kind = kind as ApproverKind
416
+ approver.id = ''
417
+ }
418
+
419
+ const personOptions = $derived<SelectOption[]>(directory.map((p) => ({ value: p.id, label: p.displayName })))
420
+
421
+ /**
422
+ * The permission choices, with whatever the chain already holds forced in.
423
+ *
424
+ * Without that a key chosen outside this list would render as an empty select, and saving would
425
+ * quietly move the step to a different group of people.
426
+ */
427
+ function permissionOptions(current: string): SelectOption[] {
428
+ const keys = [...APPROVER_PERMISSIONS] as string[]
429
+ if (current && !keys.includes(current)) keys.push(current)
430
+ return keys.map((key) => ({ value: key, label: permissionLabel(key) }))
431
+ }
432
+
433
+ /** `group` is only ever an option where the chain already uses it — see `OFFERED_KINDS`. */
434
+ function kindOptions(current: ApproverKind): SelectOption[] {
435
+ const kinds = current === 'group' ? [...OFFERED_KINDS, 'group' as ApproverKind] : OFFERED_KINDS
436
+ return kinds.map((kind) => ({ value: kind, label: kindLabel(kind) }))
437
+ }
438
+
439
+ // ---------------------------------------------------------------- validating and saving
440
+
441
+ /**
442
+ * The first thing that stops this being saved, as a sentence.
443
+ *
444
+ * One message rather than a per-field error set: the Save button states why it is disabled, and a
445
+ * disabled control with no explanation is a defect.
446
+ */
447
+ const blocked = $derived.by<string | null>(() => {
448
+ if (!draft) return null
449
+ if (!draft.name.trim()) return t('chain_needs_name')
450
+ for (const [index, step] of draft.steps.entries()) {
451
+ const n = formatCount(index + 1)
452
+ if (!step.name.trim()) return t('chain_needs_step_name', { n })
453
+ if (step.approvers.length === 0) return t('chain_needs_approver', { n })
454
+ for (const approver of step.approvers)
455
+ if (needsId(approver.kind) && !approver.id) return t('chain_needs_who', { n })
456
+ if (step.mode === 'quorum' && (!Number.isFinite(step.minApprovals) || step.minApprovals < 1))
457
+ return t('chain_needs_min', { n })
458
+ }
459
+ return null
460
+ })
461
+
462
+ /**
463
+ * The draft as the contract wants it.
464
+ *
465
+ * `minApprovals` is only read by the server for `quorum` — `all` needs every resolved approver and
466
+ * `any` needs one — but the contract requires at least 1 on every step, so the other two modes send
467
+ * 1 rather than a number that would look meaningful and never be used.
468
+ */
469
+ function toSpec(steps: StepDraft[]): ApprovalChainSpec {
470
+ return {
471
+ steps: steps.map((step) => ({
472
+ name: step.name.trim(),
473
+ approvers: step.approvers.map((a) => (needsId(a.kind) ? { kind: a.kind, id: a.id } : { kind: a.kind })),
474
+ mode: step.mode,
475
+ minApprovals: step.mode === 'quorum' ? Math.max(1, Math.trunc(step.minApprovals)) : 1,
476
+ slaHours: step.slaHours.trim() === '' ? null : Math.max(1, Math.trunc(Number(step.slaHours))),
477
+ onTimeout: step.onTimeout,
478
+ })),
479
+ }
480
+ }
481
+
482
+ const save = createMutation(() => ({
483
+ // `$state.snapshot` because the draft is a state proxy, and a proxy cannot be cloned on its way
484
+ // into the request — the call throws instead of saving.
485
+ mutationFn: (input: Draft) =>
486
+ input.id === null
487
+ ? api.approvals.chains.create({
488
+ workspaceId,
489
+ name: input.name.trim(),
490
+ subjectType: input.subjectType,
491
+ spec: toSpec(input.steps),
492
+ isDefault: input.isDefault,
493
+ })
494
+ : api.approvals.chains.update({
495
+ workspaceId,
496
+ chainId: input.id,
497
+ name: input.name.trim(),
498
+ spec: toSpec(input.steps),
499
+ isDefault: input.isDefault,
500
+ }),
501
+ onSuccess: (chain, input) => {
502
+ toast.success(input.id === null ? t('chain_created', { name: chain.name }) : t('chain_saved'))
503
+ // A chain created for another kind than the one on screen would otherwise vanish on save.
504
+ subject = chain.subjectType
505
+ draft = null
506
+ formError = null
507
+ refresh()
508
+ },
509
+ onError: (error: unknown) => {
510
+ formError = chainFailure(error)
511
+ },
512
+ onSettled: settled,
513
+ }))
514
+
515
+ function submit() {
516
+ if (!draft || blocked || firing) return
517
+ formError = null
518
+ const input = $state.snapshot(draft) as Draft
519
+ once(() => save.mutate(input))
520
+ }
521
+
522
+ // ---------------------------------------------------------------- default, and archive
523
+
524
+ let makingDefaultId = $state<string | null>(null)
525
+ let archivingId = $state<string | null>(null)
526
+ let actionError = $state<string | null>(null)
527
+
528
+ /** The live rows, not snapshots: a name edited in another tab must not be confirmed under the old one. */
529
+ const makingDefault = $derived(allChains.find((c) => c.id === makingDefaultId) ?? null)
530
+ const archiving = $derived(allChains.find((c) => c.id === archivingId) ?? null)
531
+ const replacedDefault = $derived(
532
+ makingDefault
533
+ ? (allChains.find((c) => c.subjectType === makingDefault.subjectType && c.isDefault) ?? null)
534
+ : null,
535
+ )
536
+
537
+ const setDefault = createMutation(() => ({
538
+ mutationFn: (chain: ApprovalChain) =>
539
+ api.approvals.chains.update({ workspaceId, chainId: chain.id, isDefault: true }),
540
+ onSuccess: (_chain, input) => {
541
+ toast.success(t('chain_default_toast', { name: input.name }))
542
+ makingDefaultId = null
543
+ actionError = null
544
+ refresh()
545
+ },
546
+ onError: (error: unknown) => {
547
+ actionError = chainFailure(error)
548
+ },
549
+ onSettled: settled,
550
+ }))
551
+
552
+ const archive = createMutation(() => ({
553
+ mutationFn: (chain: ApprovalChain) => api.approvals.chains.archive({ workspaceId, chainId: chain.id }),
554
+ onSuccess: (_ok, input) => {
555
+ toast.success(t('chain_archived_toast', { name: input.name }))
556
+ archivingId = null
557
+ actionError = null
558
+ refresh()
559
+ },
560
+ onError: (error: unknown) => {
561
+ actionError = chainFailure(error)
562
+ },
563
+ onSettled: settled,
564
+ }))
565
+
566
+ function chainMenu(chain: ApprovalChain): MenuItem[] {
567
+ return [
568
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(chain) },
569
+ {
570
+ label: t('chain_make_default'),
571
+ icon: 'star',
572
+ disabled: chain.isDefault,
573
+ // Disabled with the reason beside it: it is already the one every new request uses.
574
+ hint: chain.isDefault ? t('chain_already_default') : undefined,
575
+ onSelect: () => {
576
+ actionError = null
577
+ makingDefaultId = chain.id
578
+ },
579
+ },
580
+ { type: 'separator' },
581
+ {
582
+ label: t('common.archive'),
583
+ icon: 'archive',
584
+ danger: true,
585
+ onSelect: () => {
586
+ actionError = null
587
+ archivingId = chain.id
588
+ },
589
+ },
590
+ ]
591
+ }
592
+
593
+ /** "Manager · Local HR · Finance" — the step names, in the order they run. */
594
+ const stepNames = (chain: ApprovalChain) => chain.spec.steps.map((s) => s.name).join(' · ')
595
+ </script>
596
+
597
+ <SettingsPage title={t('settings_approvals')} description={t('chain_desc')}>
598
+ {#snippet actions()}
599
+ {#if manage}
600
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('chain_new')}</Button>
601
+ {/if}
602
+ {/snippet}
603
+
604
+ <SettingsSection title={subjectLabel(subject)} description={t('chain_section_desc')}>
605
+ {#snippet action()}
606
+ <Select
607
+ size="sm"
608
+ width="190px"
609
+ value={subject}
610
+ ariaLabel={t('chain_subject_filter')}
611
+ onValueChange={(v) => (subject = v as ApprovalSubjectType)}
612
+ options={subjectOptions}
613
+ />
614
+ {/snippet}
615
+
616
+ <!--
617
+ Held rows outrank the error. Every write here invalidates all of `['hr']`, so a failed
618
+ background refetch leaves TanStack in `error` with the last good list still in `data` — an
619
+ error branch above this one would blank a working table and take its menus with it.
620
+ -->
621
+ {#if chainsQuery.isLoading}
622
+ <div class="rows">
623
+ {#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
624
+ </div>
625
+ {:else if chains.length > 0}
626
+ <div class="table" role="table" aria-label={t('settings_approvals')}>
627
+ <div class="thead" role="row">
628
+ <span role="columnheader">{t('chain_name')}</span>
629
+ <span class="num" role="columnheader">{t('chain_steps')}</span>
630
+ <span role="columnheader">{t('chain_who')}</span>
631
+ <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
632
+ </div>
633
+ {#each chains as chain (chain.id)}
634
+ <div class="trow" class:on={chain.isDefault} role="row">
635
+ <span class="cell what" role="cell">
636
+ <span class="strong">{chain.name}</span>
637
+ {#if chain.isDefault}
638
+ <Badge tone="accent">{t('chain_in_use')}</Badge>
639
+ {/if}
640
+ </span>
641
+ <span class="cell muted num" role="cell">{formatCount(chain.spec.steps.length, 99)}</span>
642
+ <span class="cell muted" role="cell">{stepNames(chain)}</span>
643
+ <span class="cell actions" role="cell">
644
+ {#if manage}
645
+ <DropdownMenu items={chainMenu(chain)}>
646
+ {#snippet trigger(props)}
647
+ <IconButton
648
+ icon="ellipsis"
649
+ label={t('chain_actions_for', { name: chain.name })}
650
+ size={28}
651
+ {...props}
652
+ />
653
+ {/snippet}
654
+ </DropdownMenu>
655
+ {/if}
656
+ </span>
657
+ </div>
658
+ {/each}
659
+ </div>
660
+
661
+ {#if defaultChain === null}
662
+ <!-- The truth from `chainFor`: with no default, the engine falls back to one implicit step. -->
663
+ <p class="note warn">{t('chain_no_default_note', { subject: subjectLabel(subject) })}</p>
664
+ {/if}
665
+ {:else if chainsQuery.isError}
666
+ <EmptyState icon="triangle-alert" title={t('chain_error')}>
667
+ {#snippet actions()}
668
+ <Button variant="secondary" onclick={() => void chainsQuery.refetch()}>{t('retry')}</Button>
669
+ {/snippet}
670
+ </EmptyState>
671
+ {:else}
672
+ <EmptyState
673
+ icon="list-checks"
674
+ title={t('chain_none', { subject: subjectLabel(subject) })}
675
+ description={t('chain_none_desc')}
676
+ >
677
+ {#snippet actions()}
678
+ {#if manage}<Button icon="plus" onclick={openCreate}>{t('chain_new')}</Button>{/if}
679
+ {/snippet}
680
+ </EmptyState>
681
+ {/if}
682
+ </SettingsSection>
683
+ </SettingsPage>
684
+
685
+ <!-- ---------------------------------------------------------------- the chain editor -->
686
+ <Dialog
687
+ open={draft !== null}
688
+ size="lg"
689
+ title={draft?.id ? t('chain_edit_title') : t('chain_create_title')}
690
+ onOpenChange={(o) => {
691
+ if (!o) draft = null
692
+ }}
693
+ >
694
+ {#if draft}
695
+ <div class="form">
696
+ <!-- The whole point of the engine, and the thing nobody expects: editing this cannot reach a
697
+ request that has already been raised. -->
698
+ <p class="note">{t('chain_snapshot_note')}</p>
699
+
700
+ <div class="pair">
701
+ <Field label={t('chain_name')} hint={t('chain_name_hint')} required>
702
+ {#snippet children(id)}
703
+ <Input {id} bind:value={draft!.name} maxlength={120} />
704
+ {/snippet}
705
+ </Field>
706
+ <Field
707
+ label={t('chain_subject_filter')}
708
+ hint={draft.id ? t('chain_subject_locked') : t('chain_subject_hint')}
709
+ >
710
+ {#snippet children(id)}
711
+ <Select
712
+ {id}
713
+ value={draft!.subjectType}
714
+ disabled={draft!.id !== null}
715
+ onValueChange={(v) => draft && (draft.subjectType = v as ApprovalSubjectType)}
716
+ options={SUBJECT_TYPES.map((s) => ({ value: s, label: subjectLabel(s) }))}
717
+ />
718
+ {/snippet}
719
+ </Field>
720
+ </div>
721
+
722
+ <Switch
723
+ checked={draft.isDefault}
724
+ onCheckedChange={(v) => draft && (draft.isDefault = v)}
725
+ label={t('chain_default_switch')}
726
+ description={t('chain_default_switch_hint')}
727
+ />
728
+
729
+ <SectionLabel label={t('chain_steps')} count={draft.steps.length} sub>
730
+ {#snippet trailing()}
731
+ <Button size="sm" variant="secondary" icon="plus" onclick={addStep}>
732
+ {t('chain_add_step')}
733
+ </Button>
734
+ {/snippet}
735
+ </SectionLabel>
736
+
737
+ <div class="steps">
738
+ {#each draft.steps as step, index (step.key)}
739
+ <div class="step">
740
+ <div class="step-head">
741
+ <span class="step-n">{t('chain_step_n', { n: formatCount(index + 1) })}</span>
742
+ <span class="grow"></span>
743
+ <IconButton
744
+ icon="chevron-up"
745
+ size={26}
746
+ label={t('chain_move_up', { n: formatCount(index + 1) })}
747
+ disabled={index === 0}
748
+ onclick={(event) => void moveFrom(event, index, -1)}
749
+ />
750
+ <IconButton
751
+ icon="chevron-down"
752
+ size={26}
753
+ label={t('chain_move_down', { n: formatCount(index + 1) })}
754
+ disabled={index === draft.steps.length - 1}
755
+ onclick={(event) => void moveFrom(event, index, 1)}
756
+ />
757
+ <!-- The label carries the reason as well as the tooltip: a screen reader gets the
758
+ same sentence a pointer does. The contract requires at least one step. -->
759
+ <IconButton
760
+ icon="trash-2"
761
+ size={26}
762
+ disabled={draft.steps.length <= 1}
763
+ label={draft.steps.length <= 1 ? t('chain_last_step') : t('chain_remove_step')}
764
+ title={draft.steps.length <= 1 ? t('chain_last_step') : undefined}
765
+ onclick={() => removeStep(index)}
766
+ />
767
+ </div>
768
+
769
+ <Field label={t('chain_step_name')} required>
770
+ {#snippet children(id)}
771
+ <Input {id} size="sm" bind:value={step.name} maxlength={80} />
772
+ {/snippet}
773
+ </Field>
774
+
775
+ <div class="who">
776
+ <span class="who-label">{t('chain_approvers')}</span>
777
+ {#each step.approvers as approver, ai (approver.key)}
778
+ <div class="approver">
779
+ <Select
780
+ size="sm"
781
+ value={approver.kind}
782
+ ariaLabel={t('chain_who')}
783
+ onValueChange={(v) => setKind(approver, v)}
784
+ options={kindOptions(approver.kind)}
785
+ />
786
+ {#if approver.kind === 'person'}
787
+ <Select
788
+ size="sm"
789
+ value={approver.id}
790
+ ariaLabel={t('chain_who_person')}
791
+ placeholder={directoryQuery.isLoading
792
+ ? t('common.loading')
793
+ : personOptions.length === 0
794
+ ? t('no_people')
795
+ : t('choose')}
796
+ onValueChange={(v) => (approver.id = v)}
797
+ options={personOptions}
798
+ />
799
+ {:else if approver.kind === 'permission'}
800
+ <Select
801
+ size="sm"
802
+ value={approver.id}
803
+ ariaLabel={t('chain_who_permission')}
804
+ placeholder={t('choose')}
805
+ onValueChange={(v) => (approver.id = v)}
806
+ options={permissionOptions(approver.id)}
807
+ />
808
+ {:else if approver.kind === 'group'}
809
+ <!-- Kept, not editable: this module's client cannot list core's groups, so the
810
+ only control it could offer is a box to paste a UUID into. -->
811
+ <Input size="sm" mono value={approver.id} disabled aria-label={t('chain_who_group')} />
812
+ {:else}
813
+ <span class="resolved">{t('chain_resolved_hint')}</span>
814
+ {/if}
815
+ <IconButton
816
+ icon="x"
817
+ size={26}
818
+ disabled={step.approvers.length <= 1}
819
+ label={step.approvers.length <= 1
820
+ ? t('chain_last_approver')
821
+ : t('chain_remove_approver')}
822
+ title={step.approvers.length <= 1 ? t('chain_last_approver') : undefined}
823
+ onclick={() => removeApprover(step, ai)}
824
+ />
825
+ </div>
826
+ {/each}
827
+ <div>
828
+ <Button size="sm" variant="secondary" icon="plus" onclick={() => addApprover(step)}>
829
+ {t('chain_add_approver')}
830
+ </Button>
831
+ </div>
832
+ </div>
833
+
834
+ <div class="pair">
835
+ <Field label={t('chain_mode')} hint={t('chain_mode_hint')}>
836
+ {#snippet children(id)}
837
+ <Select
838
+ {id}
839
+ size="sm"
840
+ value={step.mode}
841
+ onValueChange={(v) => (step.mode = v as ApprovalStepSpec['mode'])}
842
+ options={[
843
+ { value: 'any', label: modeLabel('any') },
844
+ { value: 'all', label: modeLabel('all') },
845
+ { value: 'quorum', label: modeLabel('quorum') },
846
+ ]}
847
+ />
848
+ {/snippet}
849
+ </Field>
850
+ {#if step.mode === 'quorum'}
851
+ <Field label={t('chain_min')} hint={t('chain_min_hint')} required>
852
+ {#snippet children(id)}
853
+ <Input
854
+ {id}
855
+ size="sm"
856
+ type="number"
857
+ min={1}
858
+ max={99}
859
+ value={String(step.minApprovals)}
860
+ oninput={(e) => (step.minApprovals = Number(e.currentTarget.value))}
861
+ />
862
+ {/snippet}
863
+ </Field>
864
+ {/if}
865
+ </div>
866
+
867
+ <div class="pair">
868
+ <Field label={t('chain_sla')} hint={t('chain_sla_hint')}>
869
+ {#snippet children(id)}
870
+ <Input
871
+ {id}
872
+ size="sm"
873
+ type="number"
874
+ min={1}
875
+ max={2000}
876
+ placeholder={t('chain_sla_none')}
877
+ bind:value={step.slaHours}
878
+ />
879
+ {/snippet}
880
+ </Field>
881
+ {#if step.slaHours.trim() !== ''}
882
+ <Field label={t('chain_on_timeout')}>
883
+ {#snippet children(id)}
884
+ <Select
885
+ {id}
886
+ size="sm"
887
+ value={step.onTimeout}
888
+ onValueChange={(v) => (step.onTimeout = v as ApprovalStepSpec['onTimeout'])}
889
+ options={[
890
+ { value: 'remind', label: timeoutLabel('remind') },
891
+ { value: 'escalate', label: timeoutLabel('escalate') },
892
+ { value: 'auto_approve', label: timeoutLabel('auto_approve') },
893
+ ]}
894
+ />
895
+ {/snippet}
896
+ </Field>
897
+ {/if}
898
+ </div>
899
+
900
+ {#if step.slaHours.trim() !== '' && step.onTimeout === 'auto_approve'}
901
+ <!-- Said out loud, because it is the one setting on this screen that grants an
902
+ approval nobody read. -->
903
+ <p class="note warn">{t('chain_auto_approve_warning')}</p>
904
+ {/if}
905
+ </div>
906
+ {/each}
907
+ </div>
908
+
909
+ {#if formError}
910
+ <p class="err" role="alert">{formError}</p>
911
+ {:else if blocked}
912
+ <p class="hint">{blocked}</p>
913
+ {/if}
914
+ </div>
915
+ {/if}
916
+
917
+ {#snippet footer()}
918
+ <Button variant="secondary" onclick={() => (draft = null)} disabled={save.isPending}>
919
+ {t('cancel')}
920
+ </Button>
921
+ <Button loading={save.isPending} disabled={!manage || blocked !== null} onclick={submit}>
922
+ {draft?.id ? t('common.save') : t('common.create')}
923
+ </Button>
924
+ {/snippet}
925
+ </Dialog>
926
+
927
+ <!-- ---------------------------------------------------------------- make this the one in use -->
928
+ <Dialog
929
+ open={makingDefault !== null}
930
+ size="sm"
931
+ title={makingDefault ? t('chain_default_title', { name: makingDefault.name }) : ''}
932
+ onOpenChange={(o) => {
933
+ if (!o) makingDefaultId = null
934
+ }}
935
+ >
936
+ {#if makingDefault}
937
+ <p class="body">
938
+ {t('chain_default_body', { subject: subjectLabel(makingDefault.subjectType) })}
939
+ </p>
940
+ {#if replacedDefault && replacedDefault.id !== makingDefault.id}
941
+ <p class="body muted">{t('chain_default_replaces', { name: replacedDefault.name })}</p>
942
+ {/if}
943
+ <p class="note">{t('chain_default_inflight')}</p>
944
+ {#if actionError}
945
+ <p class="err" role="alert">{actionError}</p>
946
+ {/if}
947
+ {/if}
948
+
949
+ {#snippet footer()}
950
+ <Button
951
+ variant="secondary"
952
+ onclick={() => (makingDefaultId = null)}
953
+ disabled={setDefault.isPending}
954
+ >
955
+ {t('cancel')}
956
+ </Button>
957
+ <Button
958
+ loading={setDefault.isPending}
959
+ onclick={() => {
960
+ if (makingDefault) once(() => makingDefault && setDefault.mutate(makingDefault))
961
+ }}
962
+ >
963
+ {t('chain_make_default')}
964
+ </Button>
965
+ {/snippet}
966
+ </Dialog>
967
+
968
+ <!-- ---------------------------------------------------------------- archive -->
969
+ <Dialog
970
+ open={archiving !== null}
971
+ size="sm"
972
+ title={archiving ? t('chain_archive_title', { name: archiving.name }) : ''}
973
+ onOpenChange={(o) => {
974
+ if (!o) archivingId = null
975
+ }}
976
+ >
977
+ {#if archiving}
978
+ <!--
979
+ The sentence this dialog exists for. A request snapshots its chain when it is raised, so
980
+ archiving cannot add a signature to something half-signed or take one away — and an admin who
981
+ suspects it might will leave a chain nobody uses in the list for ever.
982
+ -->
983
+ <p class="note">{t('chain_archive_inflight')}</p>
984
+ <p class="body">
985
+ {archiving.isDefault
986
+ ? t('chain_archive_was_default', { subject: subjectLabel(archiving.subjectType) })
987
+ : t('chain_archive_unused')}
988
+ </p>
989
+ {#if archiving.isDefault}
990
+ <p class="note warn">{t('chain_archive_default_warning')}</p>
991
+ {/if}
992
+ {#if actionError}
993
+ <p class="err" role="alert">{actionError}</p>
994
+ {/if}
995
+ {/if}
996
+
997
+ {#snippet footer()}
998
+ <Button variant="secondary" onclick={() => (archivingId = null)} disabled={archive.isPending}>
999
+ {t('cancel')}
1000
+ </Button>
1001
+ <Button
1002
+ variant="danger"
1003
+ loading={archive.isPending}
1004
+ onclick={() => {
1005
+ if (archiving) once(() => archiving && archive.mutate(archiving))
1006
+ }}
1007
+ >
1008
+ {t('common.archive')}
1009
+ </Button>
1010
+ {/snippet}
1011
+ </Dialog>
1012
+
1013
+ <style>
1014
+ .rows {
1015
+ display: grid;
1016
+ gap: 4px;
1017
+ }
1018
+
1019
+ /* One grid for the header and every row, so the columns line up down the page. */
1020
+ .table {
1021
+ --hr-chain-cols: minmax(150px, 1.2fr) 62px minmax(150px, 1.4fr) 32px;
1022
+ width: 100%;
1023
+ }
1024
+ .thead,
1025
+ .trow {
1026
+ display: grid;
1027
+ grid-template-columns: var(--hr-chain-cols);
1028
+ gap: 10px;
1029
+ align-items: center;
1030
+ padding-inline: 10px;
1031
+ border-inline-start: 2px solid transparent;
1032
+ }
1033
+ .thead {
1034
+ height: 32px;
1035
+ border-block-end: 1px solid var(--kern-border);
1036
+ font-size: 11px;
1037
+ font-weight: 600;
1038
+ letter-spacing: 0.06em;
1039
+ text-transform: uppercase;
1040
+ color: var(--kern-ink-500);
1041
+ }
1042
+ .trow {
1043
+ min-height: 48px;
1044
+ border-block-end: 1px solid var(--kern-border-hairline);
1045
+ border-radius: var(--kern-r-md);
1046
+ }
1047
+ .trow:hover {
1048
+ background: var(--kern-surface-raised);
1049
+ }
1050
+ /* The one chain that actually runs. A border as well as a tint, so it survives a theme where the
1051
+ tint is nearly the surface it sits on. */
1052
+ .trow.on {
1053
+ border-inline-start-color: var(--kern-accent);
1054
+ background: var(--kern-surface-active);
1055
+ }
1056
+ .cell {
1057
+ min-width: 0;
1058
+ overflow: hidden;
1059
+ text-overflow: ellipsis;
1060
+ white-space: nowrap;
1061
+ }
1062
+ .what {
1063
+ display: flex;
1064
+ align-items: center;
1065
+ gap: 8px;
1066
+ }
1067
+ .strong {
1068
+ min-width: 0;
1069
+ overflow: hidden;
1070
+ text-overflow: ellipsis;
1071
+ font-size: 13.5px;
1072
+ font-weight: 500;
1073
+ }
1074
+ .muted {
1075
+ font-size: 13px;
1076
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
1077
+ color: var(--kern-ink-500);
1078
+ }
1079
+ .num {
1080
+ font-variant-numeric: tabular-nums;
1081
+ }
1082
+ .actions {
1083
+ display: flex;
1084
+ justify-content: flex-end;
1085
+ overflow: visible;
1086
+ }
1087
+
1088
+ .form {
1089
+ display: grid;
1090
+ gap: 14px;
1091
+ }
1092
+ .pair {
1093
+ display: grid;
1094
+ grid-template-columns: 1fr 1fr;
1095
+ gap: 12px;
1096
+ align-items: start;
1097
+ }
1098
+
1099
+ .steps {
1100
+ display: grid;
1101
+ gap: 12px;
1102
+ }
1103
+ .step {
1104
+ display: grid;
1105
+ gap: 10px;
1106
+ padding: 12px;
1107
+ border: 1px solid var(--kern-border);
1108
+ border-radius: var(--kern-r-md2);
1109
+ background: var(--kern-surface);
1110
+ }
1111
+ .step-head {
1112
+ display: flex;
1113
+ align-items: center;
1114
+ gap: 4px;
1115
+ }
1116
+ .step-n {
1117
+ font-family: var(--kern-font-mono);
1118
+ font-size: 11.5px;
1119
+ letter-spacing: 0.06em;
1120
+ text-transform: uppercase;
1121
+ color: var(--kern-ink-500);
1122
+ }
1123
+ .grow {
1124
+ flex: 1;
1125
+ }
1126
+
1127
+ .who {
1128
+ display: grid;
1129
+ gap: 6px;
1130
+ }
1131
+ .who-label {
1132
+ font-size: 13px;
1133
+ font-weight: 500;
1134
+ color: var(--kern-ink-800);
1135
+ }
1136
+ .approver {
1137
+ display: grid;
1138
+ grid-template-columns: minmax(140px, 1fr) minmax(140px, 1.2fr) 26px;
1139
+ gap: 8px;
1140
+ align-items: center;
1141
+ }
1142
+ .resolved {
1143
+ font-size: 12.5px;
1144
+ color: var(--kern-ink-500);
1145
+ }
1146
+
1147
+ .note {
1148
+ margin: 0;
1149
+ padding: 10px 12px;
1150
+ border-radius: var(--kern-r-md2);
1151
+ background: var(--kern-info-tint);
1152
+ color: var(--kern-ink-700);
1153
+ font-size: 12.5px;
1154
+ line-height: 1.5;
1155
+ }
1156
+ .note.warn {
1157
+ background: var(--kern-warning-tint);
1158
+ }
1159
+ .body {
1160
+ margin: 0 0 8px;
1161
+ font-size: 13.5px;
1162
+ line-height: 1.5;
1163
+ }
1164
+ .hint {
1165
+ margin: 0;
1166
+ font-size: 12px;
1167
+ color: var(--kern-ink-500);
1168
+ }
1169
+ .err {
1170
+ margin: 0;
1171
+ font-size: 12.5px;
1172
+ color: var(--kern-danger);
1173
+ }
1174
+
1175
+ .sr-only {
1176
+ position: absolute;
1177
+ width: 1px;
1178
+ height: 1px;
1179
+ overflow: hidden;
1180
+ clip-path: inset(50%);
1181
+ white-space: nowrap;
1182
+ }
1183
+
1184
+ @media (max-width: 640px) {
1185
+ .table {
1186
+ --hr-chain-cols: minmax(130px, 1fr) 52px 32px;
1187
+ }
1188
+ /* The step names go; the count and the "in use" badge are what cannot. */
1189
+ .thead > :nth-child(3),
1190
+ .trow > :nth-child(3) {
1191
+ display: none;
1192
+ }
1193
+ .pair {
1194
+ grid-template-columns: 1fr;
1195
+ }
1196
+ .approver {
1197
+ grid-template-columns: minmax(0, 1fr) 26px;
1198
+ }
1199
+ /* The kind and its remove button keep the first line; whichever control answers "which one"
1200
+ takes the whole second line rather than being squeezed to nothing. */
1201
+ .approver > :nth-child(2) {
1202
+ grid-column: 1 / -1;
1203
+ grid-row: 2;
1204
+ }
1205
+ }
1206
+ </style>