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