@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,1623 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ Field,
9
+ formatCount,
10
+ Icon,
11
+ IconButton,
12
+ Input,
13
+ type MenuItem,
14
+ messageLocale,
15
+ navigation,
16
+ Page,
17
+ PageHeader,
18
+ SearchBox,
19
+ SectionLabel,
20
+ Select,
21
+ type SelectOption,
22
+ Skeleton,
23
+ StatTile,
24
+ session,
25
+ Tabs,
26
+ toast,
27
+ } from '@kernhq/ui'
28
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
29
+ import { getHrApi } from '../api-instance.js'
30
+ import { t } from '../i18n.js'
31
+ import type { OrgUnit, Position } from '../index.js'
32
+ import { canHr } from '../permissions.js'
33
+ import { hrKeys } from '../query.js'
34
+
35
+ /**
36
+ * The shape of the company: which departments exist, what sits under what, who heads each one, and
37
+ * the job titles people are given.
38
+ *
39
+ * `hr.org.view` is a **default member permission** labelled "View the org chart, departments and
40
+ * positions", and until this screen existed there was no org chart — every `org.*` procedure was
41
+ * implemented, `hrKeys.orgUnits` was declared, and nothing called any of it. So this is not a
42
+ * viewer bolted onto a working feature; it is the feature.
43
+ *
44
+ * **Why a drawn tree rather than an indented list.** Units are stored as `ltree`, so the hierarchy
45
+ * is real and can be deep. Indentation alone stops being readable at about three levels — you
46
+ * cannot tell which of the rows above a node is its parent — so every row carries the rails of its
47
+ * ancestors and an elbow into its parent's column. That is also what makes a *subtree* legible,
48
+ * which is the question this screen is actually asked: what is under Engineering.
49
+ *
50
+ * **No capability gate.** Org units and positions live under `core`, which is `required: true` —
51
+ * there is no workspace where this is switched off, and a check against a capability that is always
52
+ * on reads as if there were one. Every *action* is gated on `hr.org.manage`, which is the real
53
+ * question here.
54
+ *
55
+ * **Positions share the screen rather than hiding in settings.** One permission covers the chart,
56
+ * the departments and the positions; splitting the last of the three into a settings page would put
57
+ * two thirds of a permission on one screen and leave a member who may read positions with no route
58
+ * to them.
59
+ */
60
+ const api = getHrApi()
61
+ const queryClient = useQueryClient()
62
+
63
+ const workspaceSlug = $derived(navigation.workspaceSlug)
64
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
65
+ const workspaceId = $derived(workspace?.id ?? '')
66
+
67
+ const canManage = $derived(canHr('orgManage'))
68
+ /**
69
+ * The head of a department is a person, and reading a person is `hr.person.view`. Somebody granted
70
+ * `hr.org.view` alone gets the shape of the company without the names in it — so the head is not
71
+ * drawn as "—", which would read as "nobody heads this", but left out entirely.
72
+ */
73
+ const canReadPeople = $derived(canHr('personView'))
74
+
75
+ type UnitRow = OrgUnit & { headcount: number }
76
+
77
+ /** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
78
+ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
79
+
80
+ // ---------------------------------------------------------------- what is on screen
81
+ //
82
+ // Declared before the first `createQuery`, and that ordering is load-bearing rather than tidy:
83
+ // `createQuery` evaluates its options function immediately to build the observer, so an `enabled`
84
+ // reading a `$state` declared further down throws "Cannot access … before initialization" — at
85
+ // runtime, on the first render, which nothing type-checks.
86
+
87
+ interface UnitDraft {
88
+ /** `null` while creating. The two procedures take different fields, so this decides which. */
89
+ id: string | null
90
+ /** Only sent on create: an existing unit is reparented through `move`, never through `update`. */
91
+ parentId: string | null
92
+ name: string
93
+ code: string
94
+ headPersonId: string
95
+ }
96
+
97
+ interface PositionDraft {
98
+ id: string | null
99
+ title: string
100
+ code: string
101
+ jobFamily: string
102
+ level: string
103
+ }
104
+
105
+ let tab = $state('units')
106
+ let search = $state('')
107
+ let selectedId = $state<string | null>(null)
108
+ let focusedId = $state<string | null>(null)
109
+ /** Expanded is the default, so only what somebody has folded away is recorded. */
110
+ let collapsed = $state<Record<string, true>>({})
111
+
112
+ let unitDraft = $state<UnitDraft | null>(null)
113
+ let positionDraft = $state<PositionDraft | null>(null)
114
+ let movingId = $state<string | null>(null)
115
+ let moveTarget = $state('')
116
+ let archivingUnitId = $state<string | null>(null)
117
+ let archivingPositionId = $state<string | null>(null)
118
+
119
+ let formError = $state<string | null>(null)
120
+ let actionError = $state<string | null>(null)
121
+ /**
122
+ * `disabled={save.isPending}` reaches the button one render late, and two quick clicks are one
123
+ * render apart — which on create is two departments. Both guards are set in the same tick as the
124
+ * click.
125
+ */
126
+ let saving = $state(false)
127
+ let acting = $state(false)
128
+
129
+ // ---------------------------------------------------------------- queries
130
+
131
+ const unitsQuery = createQuery(() => ({
132
+ queryKey: hrKeys.orgUnits(workspaceId),
133
+ enabled: Boolean(workspaceId),
134
+ queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
135
+ }))
136
+ const units = $derived((unitsQuery.data ?? []) as UnitRow[])
137
+
138
+ /**
139
+ * `[module, entity, …scope]`, the shape `hrKeys` uses. Spelled here rather than in `query.ts`
140
+ * because this screen is the only one that asks for positions, the same way the offices settings
141
+ * page spells its own two.
142
+ */
143
+ const positionsKey = (ws: string) => ['hr', 'positions', ws] as const
144
+
145
+ const positionsQuery = createQuery(() => ({
146
+ queryKey: positionsKey(workspaceId),
147
+ enabled: Boolean(workspaceId),
148
+ queryFn: () => api.org.positions.list({ workspaceId, includeArchived: false }),
149
+ }))
150
+ const positions = $derived((positionsQuery.data ?? []) as Position[])
151
+
152
+ /**
153
+ * The directory, for the head-of-department name and the head picker.
154
+ *
155
+ * Fetched whenever the chart is on screen, because the head is part of reading a department rather
156
+ * than part of editing one — a chart that only names its heads once you open a dialog is not
157
+ * answering "who runs this".
158
+ */
159
+ const directoryQuery = createQuery(() => ({
160
+ queryKey: hrKeys.people(workspaceId, { forOrg: true }),
161
+ enabled: Boolean(workspaceId) && canReadPeople,
162
+ queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
163
+ }))
164
+ const directory = $derived(directoryQuery.data?.items ?? [])
165
+ const peopleById = $derived(new Map(directory.map((p) => [p.id, p.displayName])))
166
+
167
+ // ---------------------------------------------------------------- the tree
168
+
169
+ interface TreeNode {
170
+ unit: UnitRow
171
+ children: TreeNode[]
172
+ /** Its own people plus everybody in every department beneath it. */
173
+ total: number
174
+ /** How many departments sit below it, itself excluded. */
175
+ descendants: number
176
+ }
177
+
178
+ const byId = $derived(new Map(units.map((u) => [u.id, u])))
179
+
180
+ const forest = $derived.by((): TreeNode[] => {
181
+ const locale = messageLocale()
182
+ const childrenOf = new Map<string | null, UnitRow[]>()
183
+ for (const unit of units) {
184
+ // A unit whose parent has been archived would vanish with it, taking its own subtree off a
185
+ // chart somebody is most likely opening in order to repair exactly that. It becomes a root
186
+ // instead: visibly detached, but reachable and movable.
187
+ const key = unit.parentId && byId.has(unit.parentId) ? unit.parentId : null
188
+ const siblings = childrenOf.get(key)
189
+ if (siblings) siblings.push(unit)
190
+ else childrenOf.set(key, [unit])
191
+ }
192
+ // `path` sorts by an ltree label built from the id, so the server's order is creation order.
193
+ // People read a department list alphabetically, and the reader's alphabet is not ours.
194
+ const build = (parentId: string | null, ancestors: Set<string>): TreeNode[] =>
195
+ (childrenOf.get(parentId) ?? [])
196
+ .filter((unit) => !ancestors.has(unit.id))
197
+ .sort((a, b) => a.name.localeCompare(b.name, locale))
198
+ .map((unit) => {
199
+ const children = build(unit.id, new Set(ancestors).add(unit.id))
200
+ return {
201
+ unit,
202
+ children,
203
+ total: unit.headcount + children.reduce((sum, c) => sum + c.total, 0),
204
+ descendants: children.reduce((sum, c) => sum + c.descendants + 1, 0),
205
+ }
206
+ })
207
+ return build(null, new Set())
208
+ })
209
+
210
+ const nodeById = $derived.by(() => {
211
+ const map = new Map<string, TreeNode>()
212
+ const walk = (nodes: TreeNode[]) => {
213
+ for (const node of nodes) {
214
+ map.set(node.unit.id, node)
215
+ walk(node.children)
216
+ }
217
+ }
218
+ walk(forest)
219
+ return map
220
+ })
221
+
222
+ const term = $derived(search.trim().toLocaleLowerCase(messageLocale()))
223
+
224
+ /**
225
+ * The units a search leaves on screen: the ones that match, and every ancestor of a match.
226
+ *
227
+ * Dropping the ancestors would leave the matches floating at whatever depth they happen to be,
228
+ * which is the one thing a tree is for. `null` means no search is running, which is not the same as
229
+ * "nothing matched" — an empty set is.
230
+ */
231
+ const visible = $derived.by((): Set<string> | null => {
232
+ if (!term) return null
233
+ const locale = messageLocale()
234
+ const kept = new Set<string>()
235
+ const walk = (node: TreeNode): boolean => {
236
+ const hit =
237
+ node.unit.name.toLocaleLowerCase(locale).includes(term) ||
238
+ (node.unit.code ?? '').toLocaleLowerCase(locale).includes(term)
239
+ let any = hit
240
+ for (const child of node.children) if (walk(child)) any = true
241
+ if (any) kept.add(node.unit.id)
242
+ return any
243
+ }
244
+ for (const root of forest) walk(root)
245
+ return kept
246
+ })
247
+
248
+ interface TreeRow {
249
+ node: TreeNode
250
+ depth: number
251
+ /** One flag per ancestor column: does that ancestor have a following sibling to draw a rail for. */
252
+ rails: boolean[]
253
+ /** Whether this node is the last of its visible siblings, which is what ends its parent's rail. */
254
+ last: boolean
255
+ childCount: number
256
+ expanded: boolean
257
+ }
258
+
259
+ const rows = $derived.by((): TreeRow[] => {
260
+ const filter = visible
261
+ const out: TreeRow[] = []
262
+ const push = (nodes: TreeNode[], depth: number, rails: boolean[]) => {
263
+ const shown = filter ? nodes.filter((n) => filter.has(n.unit.id)) : nodes
264
+ shown.forEach((node, index) => {
265
+ const last = index === shown.length - 1
266
+ const kids = filter ? node.children.filter((c) => filter.has(c.unit.id)) : node.children
267
+ // A search opens every branch it kept: hiding a match behind a fold somebody did not make is
268
+ // the search reporting a hit it will not show.
269
+ const expanded = kids.length > 0 && (filter !== null || !collapsed[node.unit.id])
270
+ out.push({ node, depth, rails, last, childCount: kids.length, expanded })
271
+ if (expanded) push(kids, depth + 1, [...rails, !last])
272
+ })
273
+ }
274
+ push(forest, 0, [])
275
+ return out
276
+ })
277
+
278
+ const selected = $derived(selectedId ? (nodeById.get(selectedId) ?? null) : null)
279
+
280
+ /**
281
+ * Something is always selected once there is anything to select.
282
+ *
283
+ * An empty detail panel beside a full tree is a screen asking the reader to guess that the rows are
284
+ * clickable, and the first root is as good an answer as any. This also re-points the panel when the
285
+ * unit it was showing is archived or moved out from under a filter.
286
+ */
287
+ $effect(() => {
288
+ const known = nodeById
289
+ if (selectedId && known.has(selectedId)) return
290
+ selectedId = forest[0]?.unit.id ?? null
291
+ focusedId = selectedId
292
+ })
293
+
294
+ /** The chain from the root down to `id`, the unit itself last. */
295
+ function ancestryOf(id: string): UnitRow[] {
296
+ const chain: UnitRow[] = []
297
+ const seen = new Set<string>()
298
+ let cursor: UnitRow | undefined = byId.get(id)
299
+ while (cursor && !seen.has(cursor.id)) {
300
+ seen.add(cursor.id)
301
+ chain.unshift(cursor)
302
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined
303
+ }
304
+ return chain
305
+ }
306
+
307
+ /** `Engineering / Platform / Storage` — unambiguous where a bare name repeats under two parents. */
308
+ const pathLabel = (id: string): string =>
309
+ ancestryOf(id)
310
+ .map((u) => u.name)
311
+ .join(' / ')
312
+
313
+ const stats = $derived({
314
+ units: units.length,
315
+ positions: positions.length,
316
+ people: units.reduce((sum, u) => sum + u.headcount, 0),
317
+ levels: units.length === 0 ? 0 : Math.max(...units.map((u) => ancestryOf(u.id).length)),
318
+ })
319
+
320
+ // ---------------------------------------------------------------- moving around the tree
321
+
322
+ function toggle(id: string) {
323
+ if (collapsed[id]) delete collapsed[id]
324
+ else collapsed[id] = true
325
+ }
326
+
327
+ function activate(row: TreeRow) {
328
+ const id = row.node.unit.id
329
+ focusedId = id
330
+ if (row.childCount > 0 && !row.expanded) toggle(id)
331
+ // Folding is only offered when a search is not forcing every kept branch open: writing a fold
332
+ // nothing can show would take effect later, on a screen the person had stopped looking at.
333
+ else if (row.childCount > 0 && visible === null && selectedId === id) toggle(id)
334
+ selectedId = id
335
+ }
336
+
337
+ const rowDomId = (id: string) => `hr-org-${id}`
338
+
339
+ function focusRow(id: string | undefined) {
340
+ if (!id) return
341
+ focusedId = id
342
+ document.getElementById(rowDomId(id))?.focus()
343
+ }
344
+
345
+ /**
346
+ * Arrow keys, as a tree is expected to answer them.
347
+ *
348
+ * Right opens a closed branch and steps into an open one; left closes an open branch and steps out
349
+ * of a leaf. That is what makes a deep chart navigable without a pointer, and it is the difference
350
+ * between `role="tree"` being a label and being true.
351
+ */
352
+ function onTreeKey(event: KeyboardEvent, row: TreeRow, index: number) {
353
+ const id = row.node.unit.id
354
+ switch (event.key) {
355
+ case 'ArrowDown':
356
+ focusRow(rows[index + 1]?.node.unit.id)
357
+ break
358
+ case 'ArrowUp':
359
+ focusRow(rows[index - 1]?.node.unit.id)
360
+ break
361
+ case 'ArrowRight':
362
+ if (row.childCount > 0 && !row.expanded) toggle(id)
363
+ else if (row.expanded) focusRow(rows[index + 1]?.node.unit.id)
364
+ break
365
+ case 'ArrowLeft':
366
+ if (row.expanded) toggle(id)
367
+ else focusRow(rows.findLast((r, i) => i < index && r.depth < row.depth)?.node.unit.id)
368
+ break
369
+ case 'Home':
370
+ focusRow(rows[0]?.node.unit.id)
371
+ break
372
+ case 'End':
373
+ focusRow(rows[rows.length - 1]?.node.unit.id)
374
+ break
375
+ case 'Enter':
376
+ case ' ':
377
+ activate(row)
378
+ break
379
+ default:
380
+ return
381
+ }
382
+ event.preventDefault()
383
+ }
384
+
385
+ // ---------------------------------------------------------------- refusals
386
+
387
+ /**
388
+ * The org refusals this module has its own sentence for, keyed by the `reason` the router sends
389
+ * beside the refusal — never by the sentence, because a list of sentences is a list somebody has to
390
+ * keep in sync and the day it drifts the reader is told nothing.
391
+ *
392
+ * Empty today, and that is the fallback working rather than a gap: `org.units.move` refuses a cycle
393
+ * through `KernError.badRequest` and `org.units.archive` refuses a populated department through
394
+ * `KernError.conflict`, and neither passes a reason — so both arrive as the sentence the router
395
+ * wrote. A reason added to either reaches the reader the moment a key lands here.
396
+ */
397
+ const orgRefusalMessages: Record<string, string> = {}
398
+
399
+ /**
400
+ * What a refused change says to the person who asked for it.
401
+ *
402
+ * The test is the transport's `code`, never the sentence. Everything else that can fail here — a
403
+ * dropped connection, a 500, a gateway — carries machine text in English, and a confirmation dialog
404
+ * is the last place to paste one, so only a deliberate refusal is quoted.
405
+ *
406
+ * The same shape as `ClockControls.svelte` and `LeavePage.svelte`; there is deliberately not a third.
407
+ */
408
+ function refusal(error: unknown, fallbackKey: string): string {
409
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
410
+ if (failure.code !== 'CONFLICT' && failure.code !== 'BAD_REQUEST') return t(fallbackKey)
411
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
412
+ const key = reason ? orgRefusalMessages[reason] : undefined
413
+ // `t()` answers a key it has no string for with the key itself, so both ways of not having one —
414
+ // a reason no key covers, and a key whose string has not been merged — land on the router's
415
+ // sentence rather than putting `hr.org_…` in front of somebody.
416
+ const translated = key ? t(key) : undefined
417
+ return (translated && translated !== key ? translated : failure.message) || t(fallbackKey)
418
+ }
419
+
420
+ // ---------------------------------------------------------------- departments: create and edit
421
+
422
+ function openCreateUnit(parentId: string | null) {
423
+ formError = null
424
+ unitDraft = { id: null, parentId, name: '', code: '', headPersonId: '' }
425
+ }
426
+
427
+ function openEditUnit(unit: UnitRow) {
428
+ formError = null
429
+ unitDraft = {
430
+ id: unit.id,
431
+ parentId: unit.parentId,
432
+ name: unit.name,
433
+ code: unit.code ?? '',
434
+ headPersonId: unit.headPersonId ?? '',
435
+ }
436
+ }
437
+
438
+ const unitDraftValid = $derived(unitDraft !== null && unitDraft.name.trim().length > 0)
439
+
440
+ const headChoices = $derived<SelectOption[]>([
441
+ { value: '', label: t('org_head_none') },
442
+ ...directory.map((p) => ({ value: p.id, label: p.displayName })),
443
+ ])
444
+
445
+ const parentChoices = $derived<SelectOption[]>([
446
+ { value: '', label: t('org_root') },
447
+ ...units
448
+ .map((u) => ({ value: u.id, label: pathLabel(u.id) }))
449
+ .sort((a, b) => a.label.localeCompare(b.label, messageLocale())),
450
+ ])
451
+
452
+ const saveUnit = createMutation(() => ({
453
+ mutationFn: (input: UnitDraft) =>
454
+ input.id === null
455
+ ? api.org.units.create({
456
+ workspaceId,
457
+ name: input.name.trim(),
458
+ parentId: input.parentId,
459
+ code: input.code.trim() || null,
460
+ headPersonId: input.headPersonId || null,
461
+ })
462
+ : api.org.units.update({
463
+ workspaceId,
464
+ unitId: input.id,
465
+ name: input.name.trim(),
466
+ code: input.code.trim() || null,
467
+ headPersonId: input.headPersonId || null,
468
+ }),
469
+ onSuccess: (unit, input) => {
470
+ toast.success(input.id === null ? t('org_unit_created', { name: unit.name }) : t('org_unit_saved'))
471
+ unitDraft = null
472
+ formError = null
473
+ selectedId = unit.id
474
+ // A department decides who is in a subtree, which the directory, the approvals ladder and every
475
+ // team view read. Invalidating the module is cheaper than guessing which.
476
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
477
+ },
478
+ onError: (error: Error) => {
479
+ formError = refusal(error, 'org_unit_save_error')
480
+ },
481
+ onSettled: () => {
482
+ saving = false
483
+ },
484
+ }))
485
+
486
+ function submitUnit() {
487
+ if (!unitDraft || !unitDraftValid || saving) return
488
+ saving = true
489
+ formError = null
490
+ saveUnit.mutate($state.snapshot(unitDraft) as UnitDraft)
491
+ }
492
+
493
+ // ---------------------------------------------------------------- departments: move
494
+
495
+ const moving = $derived(movingId ? (nodeById.get(movingId) ?? null) : null)
496
+
497
+ /**
498
+ * Everywhere this unit could go: any department that is not itself and not underneath it, plus the
499
+ * top level.
500
+ *
501
+ * The server refuses a move into a descendant — it would detach that whole branch from the root,
502
+ * which is the one way an ltree hierarchy can be corrupted beyond repair by an ordinary drag — and
503
+ * an option that always errors is worse than no option, so the impossible ones are not offered.
504
+ */
505
+ const moveChoices = $derived.by((): SelectOption[] => {
506
+ if (!moving) return []
507
+ const forbidden = new Set<string>([moving.unit.id])
508
+ const walk = (nodes: TreeNode[]) => {
509
+ for (const node of nodes) {
510
+ forbidden.add(node.unit.id)
511
+ walk(node.children)
512
+ }
513
+ }
514
+ walk(moving.children)
515
+ return [
516
+ { value: '', label: t('org_root') },
517
+ ...units
518
+ .filter((u) => !forbidden.has(u.id))
519
+ .map((u) => ({ value: u.id, label: pathLabel(u.id) }))
520
+ .sort((a, b) => a.label.localeCompare(b.label, messageLocale())),
521
+ ]
522
+ })
523
+
524
+ const moveUnchanged = $derived(moving !== null && moveTarget === (moving.unit.parentId ?? ''))
525
+
526
+ const moveUnit = createMutation(() => ({
527
+ mutationFn: (vars: { unitId: string; parentId: string | null }) =>
528
+ api.org.units.move({ workspaceId, unitId: vars.unitId, parentId: vars.parentId }),
529
+ onSuccess: (_units, vars) => {
530
+ toast.success(t('org_moved', { name: byId.get(vars.unitId)?.name ?? '' }))
531
+ movingId = null
532
+ actionError = null
533
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
534
+ },
535
+ onError: (error: Error) => {
536
+ actionError = refusal(error, 'org_move_error')
537
+ },
538
+ onSettled: () => {
539
+ acting = false
540
+ },
541
+ }))
542
+
543
+ function confirmMove() {
544
+ if (!moving || acting || moveUnchanged) return
545
+ acting = true
546
+ actionError = null
547
+ moveUnit.mutate({ unitId: moving.unit.id, parentId: moveTarget || null })
548
+ }
549
+
550
+ // ---------------------------------------------------------------- departments: archive
551
+
552
+ const archivingUnit = $derived(archivingUnitId ? (nodeById.get(archivingUnitId) ?? null) : null)
553
+
554
+ const archiveUnit = createMutation(() => ({
555
+ mutationFn: (vars: { unitId: string; name: string }) =>
556
+ api.org.units.archive({ workspaceId, unitId: vars.unitId }),
557
+ onSuccess: (_ok, vars) => {
558
+ toast.success(t('org_archived', { name: vars.name }))
559
+ archivingUnitId = null
560
+ actionError = null
561
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
562
+ },
563
+ onError: (error: Error) => {
564
+ actionError = refusal(error, 'org_archive_error')
565
+ },
566
+ onSettled: () => {
567
+ acting = false
568
+ },
569
+ }))
570
+
571
+ function confirmArchiveUnit() {
572
+ const target = archivingUnit
573
+ if (!target || acting) return
574
+ acting = true
575
+ actionError = null
576
+ archiveUnit.mutate({ unitId: target.unit.id, name: target.unit.name })
577
+ }
578
+
579
+ /**
580
+ * Why archiving the selected department is not offered right now, or `null` when it is.
581
+ *
582
+ * Two different refusals. The server checks the first — it counts current employments and refuses —
583
+ * and saying so before the confirmation beats an error somebody only meets after deciding. The
584
+ * second it does not check: archiving a parent leaves its children pointing at a unit that is no
585
+ * longer in the chart, and they would surface as detached roots. That is a hole this screen closes
586
+ * rather than one it discovers.
587
+ */
588
+ const archiveBlocked = $derived.by((): string | null => {
589
+ if (!selected) return null
590
+ if (selected.children.length > 0)
591
+ return t('org_archive_blocked_children', { count: selected.children.length })
592
+ if (selected.unit.headcount > 0) return t('org_archive_blocked_people', { count: selected.unit.headcount })
593
+ return null
594
+ })
595
+
596
+ // ---------------------------------------------------------------- positions
597
+
598
+ function openCreatePosition() {
599
+ formError = null
600
+ positionDraft = { id: null, title: '', code: '', jobFamily: '', level: '' }
601
+ }
602
+
603
+ function openEditPosition(position: Position) {
604
+ formError = null
605
+ positionDraft = {
606
+ id: position.id,
607
+ title: position.title,
608
+ code: position.code ?? '',
609
+ jobFamily: position.jobFamily ?? '',
610
+ level: position.level ?? '',
611
+ }
612
+ }
613
+
614
+ const positionDraftValid = $derived(positionDraft !== null && positionDraft.title.trim().length > 0)
615
+
616
+ const savePosition = createMutation(() => ({
617
+ mutationFn: (input: PositionDraft) => {
618
+ const shared = {
619
+ workspaceId,
620
+ title: input.title.trim(),
621
+ code: input.code.trim() || null,
622
+ jobFamily: input.jobFamily.trim() || null,
623
+ level: input.level.trim() || null,
624
+ }
625
+ return input.id === null
626
+ ? api.org.positions.create(shared)
627
+ : api.org.positions.update({ ...shared, positionId: input.id })
628
+ },
629
+ onSuccess: (position, input) => {
630
+ toast.success(
631
+ input.id === null ? t('org_position_created', { title: position.title }) : t('org_position_saved'),
632
+ )
633
+ positionDraft = null
634
+ formError = null
635
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
636
+ },
637
+ onError: (error: Error) => {
638
+ formError = refusal(error, 'org_position_save_error')
639
+ },
640
+ onSettled: () => {
641
+ saving = false
642
+ },
643
+ }))
644
+
645
+ function submitPosition() {
646
+ if (!positionDraft || !positionDraftValid || saving) return
647
+ saving = true
648
+ formError = null
649
+ savePosition.mutate($state.snapshot(positionDraft) as PositionDraft)
650
+ }
651
+
652
+ const archivingPosition = $derived(positions.find((p) => p.id === archivingPositionId) ?? null)
653
+
654
+ const archivePosition = createMutation(() => ({
655
+ mutationFn: (vars: { positionId: string; title: string }) =>
656
+ api.org.positions.archive({ workspaceId, positionId: vars.positionId }),
657
+ onSuccess: (_ok, vars) => {
658
+ toast.success(t('org_position_archived', { title: vars.title }))
659
+ archivingPositionId = null
660
+ actionError = null
661
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
662
+ },
663
+ onError: (error: Error) => {
664
+ actionError = refusal(error, 'org_position_archive_error')
665
+ },
666
+ onSettled: () => {
667
+ acting = false
668
+ },
669
+ }))
670
+
671
+ function confirmArchivePosition() {
672
+ const target = archivingPosition
673
+ if (!target || acting) return
674
+ acting = true
675
+ actionError = null
676
+ archivePosition.mutate({ positionId: target.id, title: target.title })
677
+ }
678
+
679
+ function positionActions(position: Position): MenuItem[] {
680
+ return [
681
+ { label: t('common.edit'), icon: 'pencil', onSelect: () => openEditPosition(position) },
682
+ { type: 'separator' },
683
+ {
684
+ label: t('common.archive'),
685
+ icon: 'archive',
686
+ danger: true,
687
+ onSelect: () => {
688
+ actionError = null
689
+ archivingPositionId = position.id
690
+ },
691
+ },
692
+ ]
693
+ }
694
+
695
+ const tabs = $derived([
696
+ { value: 'units', label: t('org_tab_chart'), icon: 'git-branch', count: count(stats.units) },
697
+ { value: 'positions', label: t('org_tab_positions'), icon: 'briefcase', count: count(stats.positions) },
698
+ ])
699
+ </script>
700
+
701
+ <PageHeader crumbs={[{ label: workspace?.name ?? '' }, { label: t('org_title') }]} title={t('org_title')}>
702
+ {#snippet actions()}
703
+ {#if canManage}
704
+ {#if tab === 'positions'}
705
+ <Button size="sm" icon="plus" onclick={openCreatePosition}>{t('org_position_add')}</Button>
706
+ {:else}
707
+ <Button size="sm" icon="plus" onclick={() => openCreateUnit(null)}>{t('org_unit_add')}</Button>
708
+ {/if}
709
+ {/if}
710
+ {/snippet}
711
+ </PageHeader>
712
+
713
+ <Page>
714
+ <!--
715
+ Tiles say what the list beneath cannot: how deep the company is, and how many people are placed
716
+ in it at all. "Departments" repeats the tab's own count on purpose — it is the one number a
717
+ reader looks for first, and the tab count is small enough to miss.
718
+ -->
719
+ {#if unitsQuery.isLoading}
720
+ <div class="tiles">
721
+ {#each [1, 2, 3, 4] as n (n)}<Skeleton height="86px" />{/each}
722
+ </div>
723
+ {:else if !unitsQuery.isError && units.length > 0}
724
+ <div class="tiles">
725
+ <StatTile size="md" label={t('org_stat_units')} value={count(stats.units)} />
726
+ <StatTile size="md" label={t('org_stat_levels')} value={count(stats.levels)} />
727
+ <StatTile size="md" label={t('org_stat_people')} value={count(stats.people)} />
728
+ <StatTile size="md" label={t('org_stat_positions')} value={count(stats.positions)} />
729
+ </div>
730
+ {/if}
731
+
732
+ <div class="tabbar">
733
+ <Tabs items={tabs} value={tab} variant="pill" label={t('org_title')} onValueChange={(v) => (tab = v)} />
734
+ {#if tab === 'units' && units.length > 0}
735
+ <SearchBox
736
+ height={32}
737
+ width="240px"
738
+ bind:value={search}
739
+ label={t('org_search_label')}
740
+ placeholder={t('org_search_placeholder')}
741
+ />
742
+ {/if}
743
+ </div>
744
+
745
+ {#if tab === 'units'}
746
+ <!--
747
+ Held data outranks the error. Every mutation on this screen invalidates the whole module, so a
748
+ failed background refetch leaves the query in `error` with a perfectly good chart still in
749
+ `data` — and an error branch placed first blanks the tree somebody is working in.
750
+ -->
751
+ {#if unitsQuery.isLoading}
752
+ <div class="rows">
753
+ {#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="40px" />{/each}
754
+ </div>
755
+ {:else if units.length === 0 && unitsQuery.isError}
756
+ <EmptyState icon="triangle-alert" title={t('org_error')} description={t('org_error_desc')}>
757
+ {#snippet actions()}
758
+ <Button variant="secondary" onclick={() => void unitsQuery.refetch()}>{t('retry')}</Button>
759
+ {/snippet}
760
+ </EmptyState>
761
+ {:else if units.length === 0}
762
+ <EmptyState icon="git-branch" title={t('org_none')} description={t('org_none_desc')}>
763
+ {#snippet actions()}
764
+ {#if canManage}
765
+ <Button icon="plus" onclick={() => openCreateUnit(null)}>{t('org_unit_add')}</Button>
766
+ {/if}
767
+ {/snippet}
768
+ </EmptyState>
769
+ {:else}
770
+ <div class="split">
771
+ <section class="treepane" aria-label={t('org_tab_chart')}>
772
+ {#if rows.length === 0}
773
+ <EmptyState compact icon="search" title={t('org_no_match')} description={t('org_no_match_desc')} />
774
+ {:else}
775
+ <div class="tree" role="tree" aria-label={t('org_tab_chart')}>
776
+ {#each rows as row, index (row.node.unit.id)}
777
+ {@const unit = row.node.unit}
778
+ {@const head = unit.headPersonId ? peopleById.get(unit.headPersonId) : undefined}
779
+ <div
780
+ id={rowDomId(unit.id)}
781
+ class="node"
782
+ class:sel={selectedId === unit.id}
783
+ role="treeitem"
784
+ aria-level={row.depth + 1}
785
+ aria-selected={selectedId === unit.id}
786
+ aria-expanded={row.childCount > 0 ? row.expanded : undefined}
787
+ tabindex={focusedId === unit.id ? 0 : -1}
788
+ onclick={() => activate(row)}
789
+ onkeydown={(event) => onTreeKey(event, row, index)}
790
+ >
791
+ {#each row.rails as rail, column (column)}
792
+ {#if column < row.depth - 1}
793
+ <span class="rail" class:on={rail} aria-hidden="true"></span>
794
+ {:else}
795
+ <span class="elbow" class:through={!row.last} aria-hidden="true"></span>
796
+ {/if}
797
+ {/each}
798
+ <span class="chev" class:folded={row.childCount > 0 && !row.expanded} aria-hidden="true">
799
+ {#if row.childCount > 0}
800
+ <Icon name="chevron-down" size={13} strokeWidth={1.9} />
801
+ {/if}
802
+ </span>
803
+ <span class="nname">{unit.name}</span>
804
+ {#if unit.code}<span class="code">{unit.code}</span>{/if}
805
+ {#if canReadPeople && head}
806
+ <span class="head"><Icon name="star" size={11} strokeWidth={1.8} />{head}</span>
807
+ {/if}
808
+ <span class="spacer"></span>
809
+ {#if row.node.total > 0}
810
+ <span class="pill" title={t('org_with_below')}>
811
+ <Icon name="users" size={11} strokeWidth={1.8} />{count(row.node.total)}
812
+ </span>
813
+ {/if}
814
+ </div>
815
+ {/each}
816
+ </div>
817
+ {/if}
818
+ </section>
819
+
820
+ <aside class="detail" aria-label={t('org_detail_label')}>
821
+ {#if selected}
822
+ {@const unit = selected.unit}
823
+ {@const chain = ancestryOf(unit.id)}
824
+ {@const head = unit.headPersonId ? peopleById.get(unit.headPersonId) : undefined}
825
+ <nav class="crumbs" aria-label={t('org_path')}>
826
+ {#each chain.slice(0, -1) as step (step.id)}
827
+ <button type="button" class="crumb" onclick={() => (selectedId = step.id)}>{step.name}</button>
828
+ <span class="sep" aria-hidden="true">/</span>
829
+ {/each}
830
+ <span class="crumb here">{unit.name}</span>
831
+ </nav>
832
+
833
+ <h2 class="dtitle">
834
+ {unit.name}
835
+ {#if unit.code}<Badge tone="grey">{unit.code}</Badge>{/if}
836
+ </h2>
837
+
838
+ {#if canReadPeople}
839
+ <div class="headrow">
840
+ <span class="dlabel">{t('org_head')}</span>
841
+ {#if head}
842
+ <span class="hname"><Icon name="star" size={12} strokeWidth={1.8} />{head}</span>
843
+ {:else if unit.headPersonId}
844
+ <!-- Named on the record but not in the active directory: offboarded, or beyond
845
+ the page this screen reads. Saying "not set" would be a lie. -->
846
+ <span class="hname muted">{t('org_head_unknown')}</span>
847
+ {:else}
848
+ <span class="hname muted">{t('org_head_none')}</span>
849
+ {/if}
850
+ {#if canManage}
851
+ <Button size="xs" variant="ghost" onclick={() => openEditUnit(unit)}>
852
+ {unit.headPersonId ? t('common.edit') : t('org_head_set')}
853
+ </Button>
854
+ {/if}
855
+ </div>
856
+ {/if}
857
+
858
+ <div class="metrics">
859
+ <div class="metric">
860
+ <span class="mv">{count(unit.headcount)}</span>
861
+ <span class="ml">{t('org_here')}</span>
862
+ </div>
863
+ <div class="metric">
864
+ <span class="mv">{count(selected.total)}</span>
865
+ <span class="ml">{t('org_with_below')}</span>
866
+ </div>
867
+ <div class="metric">
868
+ <span class="mv">{count(selected.descendants)}</span>
869
+ <span class="ml">{t('org_subunits')}</span>
870
+ </div>
871
+ </div>
872
+
873
+ {#if selected.total > 0}
874
+ <Button
875
+ size="sm"
876
+ variant="secondary"
877
+ icon="users"
878
+ href={`/${workspaceSlug}/hr?orgUnit=${unit.id}`}
879
+ >
880
+ {t('org_see_people', { count: selected.total })}
881
+ </Button>
882
+ {/if}
883
+
884
+ <SectionLabel sub label={t('org_subunits')} count={count(selected.children.length)} />
885
+ {#if selected.children.length === 0}
886
+ <p class="hint">{t('org_subunits_none')}</p>
887
+ {:else}
888
+ <ul class="kids">
889
+ {#each selected.children as child (child.unit.id)}
890
+ <li>
891
+ <button type="button" class="kid" onclick={() => (selectedId = child.unit.id)}>
892
+ <span class="kname">{child.unit.name}</span>
893
+ <span class="pill">
894
+ <Icon name="users" size={11} strokeWidth={1.8} />{count(child.total)}
895
+ </span>
896
+ </button>
897
+ </li>
898
+ {/each}
899
+ </ul>
900
+ {/if}
901
+
902
+ {#if canManage}
903
+ <div class="acts">
904
+ <Button size="sm" variant="secondary" icon="plus" onclick={() => openCreateUnit(unit.id)}>
905
+ {t('org_unit_add_child')}
906
+ </Button>
907
+ <Button size="sm" variant="secondary" icon="pencil" onclick={() => openEditUnit(unit)}>
908
+ {t('common.edit')}
909
+ </Button>
910
+ <Button
911
+ size="sm"
912
+ variant="secondary"
913
+ icon="move"
914
+ onclick={() => {
915
+ actionError = null
916
+ moveTarget = unit.parentId ?? ''
917
+ movingId = unit.id
918
+ }}
919
+ >
920
+ {t('org_move')}
921
+ </Button>
922
+ <Button
923
+ size="sm"
924
+ variant="ghost"
925
+ icon="archive"
926
+ disabled={archiveBlocked !== null}
927
+ onclick={() => {
928
+ actionError = null
929
+ archivingUnitId = unit.id
930
+ }}
931
+ >
932
+ {t('common.archive')}
933
+ </Button>
934
+ </div>
935
+ <!-- A disabled control with no explanation is a bug: this is the explanation. -->
936
+ {#if archiveBlocked}<p class="hint">{archiveBlocked}</p>{/if}
937
+ {/if}
938
+ {:else}
939
+ <EmptyState compact icon="git-branch" title={t('org_detail_none')} description={t('org_detail_none_desc')} />
940
+ {/if}
941
+ </aside>
942
+ </div>
943
+ {/if}
944
+ {:else if positionsQuery.isLoading}
945
+ <div class="rows">
946
+ {#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
947
+ </div>
948
+ {:else if positions.length === 0 && positionsQuery.isError}
949
+ <EmptyState icon="triangle-alert" title={t('org_positions_error')} description={t('org_error_desc')}>
950
+ {#snippet actions()}
951
+ <Button variant="secondary" onclick={() => void positionsQuery.refetch()}>{t('retry')}</Button>
952
+ {/snippet}
953
+ </EmptyState>
954
+ {:else if positions.length === 0}
955
+ <EmptyState icon="briefcase" title={t('org_positions_none')} description={t('org_positions_none_desc')}>
956
+ {#snippet actions()}
957
+ {#if canManage}
958
+ <Button icon="plus" onclick={openCreatePosition}>{t('org_position_add')}</Button>
959
+ {/if}
960
+ {/snippet}
961
+ </EmptyState>
962
+ {:else}
963
+ <div class="table" role="table" aria-label={t('org_tab_positions')}>
964
+ <div class="thead" role="row">
965
+ <span role="columnheader">{t('org_position_title')}</span>
966
+ <span role="columnheader">{t('org_code')}</span>
967
+ <span role="columnheader">{t('org_position_family')}</span>
968
+ <span role="columnheader">{t('org_position_level')}</span>
969
+ <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
970
+ </div>
971
+ {#each positions as position (position.id)}
972
+ <div class="trow" role="row">
973
+ <span class="cell name" role="cell">{position.title}</span>
974
+ <span class="cell mono muted" role="cell">{position.code ?? '—'}</span>
975
+ <span class="cell muted" role="cell">{position.jobFamily ?? '—'}</span>
976
+ <span class="cell muted" role="cell">{position.level ?? '—'}</span>
977
+ <span class="cell end" role="cell">
978
+ {#if canManage}
979
+ <DropdownMenu items={positionActions(position)} align="end">
980
+ {#snippet trigger(props)}
981
+ <IconButton
982
+ {...props}
983
+ icon="ellipsis"
984
+ size={28}
985
+ label={t('org_position_actions', { title: position.title })}
986
+ />
987
+ {/snippet}
988
+ </DropdownMenu>
989
+ {/if}
990
+ </span>
991
+ </div>
992
+ {/each}
993
+ </div>
994
+ {/if}
995
+ </Page>
996
+
997
+ <!-- ------------------------------------------------------------------ department form -->
998
+ <Dialog
999
+ open={unitDraft !== null}
1000
+ size="sm"
1001
+ title={unitDraft?.id
1002
+ ? t('org_unit_edit_title', { name: unitDraft.name })
1003
+ : unitDraft?.parentId
1004
+ ? t('org_unit_new_under', { name: byId.get(unitDraft.parentId)?.name ?? '' })
1005
+ : t('org_unit_new')}
1006
+ onOpenChange={(open) => {
1007
+ if (!open && !saving) unitDraft = null
1008
+ }}
1009
+ >
1010
+ {#if unitDraft}
1011
+ <form
1012
+ class="form"
1013
+ onsubmit={(event) => {
1014
+ event.preventDefault()
1015
+ submitUnit()
1016
+ }}
1017
+ >
1018
+ <Field label={t('display_name')} required>
1019
+ {#snippet children(id)}
1020
+ <Input {id} bind:value={unitDraft!.name} autocomplete="off" />
1021
+ {/snippet}
1022
+ </Field>
1023
+ <Field label={t('org_code')} hint={t('org_code_hint')}>
1024
+ {#snippet children(id)}
1025
+ <Input {id} mono bind:value={unitDraft!.code} autocomplete="off" />
1026
+ {/snippet}
1027
+ </Field>
1028
+ {#if unitDraft.id === null}
1029
+ <Field label={t('org_parent')}>
1030
+ {#snippet children(id)}
1031
+ <Select
1032
+ {id}
1033
+ value={unitDraft?.parentId ?? ''}
1034
+ options={parentChoices}
1035
+ placeholder={t('org_root')}
1036
+ onValueChange={(v: string) => {
1037
+ if (unitDraft) unitDraft.parentId = v || null
1038
+ }}
1039
+ />
1040
+ {/snippet}
1041
+ </Field>
1042
+ {:else}
1043
+ <!-- `org.units.update` takes no parent: reparenting rewrites the ltree path of everything
1044
+ beneath, which is `move`'s job and needs its own confirmation. -->
1045
+ <Field label={t('org_parent')} hint={t('org_parent_fixed_hint')}>
1046
+ {#snippet children(id)}
1047
+ <Input {id} readonly value={unitDraft?.parentId ? pathLabel(unitDraft.parentId) : t('org_root')} />
1048
+ {/snippet}
1049
+ </Field>
1050
+ {/if}
1051
+ {#if canReadPeople}
1052
+ <Field label={t('org_head')} hint={t('org_head_hint')}>
1053
+ {#snippet children(id)}
1054
+ <Select
1055
+ {id}
1056
+ bind:value={unitDraft!.headPersonId}
1057
+ options={headChoices}
1058
+ disabled={directoryQuery.isLoading}
1059
+ placeholder={directory.length === 0 ? t('no_people') : t('org_head_none')}
1060
+ />
1061
+ {/snippet}
1062
+ </Field>
1063
+ {/if}
1064
+ {#if formError}<p class="err" role="alert">{formError}</p>{/if}
1065
+ </form>
1066
+ {/if}
1067
+
1068
+ {#snippet footer()}
1069
+ <Button variant="ghost" onclick={() => (unitDraft = null)}>{t('common.cancel')}</Button>
1070
+ <Button onclick={submitUnit} disabled={!unitDraftValid || !canManage} loading={saving}>
1071
+ {unitDraft?.id ? t('common.save') : t('org_unit_add')}
1072
+ </Button>
1073
+ {/snippet}
1074
+ </Dialog>
1075
+
1076
+ <!-- ------------------------------------------------------------------ move -->
1077
+ <Dialog
1078
+ open={moving !== null}
1079
+ size="sm"
1080
+ title={t('org_move_title', { name: moving?.unit.name ?? '' })}
1081
+ onOpenChange={(open) => {
1082
+ if (!open && !acting) movingId = null
1083
+ }}
1084
+ >
1085
+ {#if moving}
1086
+ <p class="body">{t('org_move_body', { name: moving.unit.name })}</p>
1087
+ <!--
1088
+ "Everything beneath it" is an abstraction, and the whole point of confirming a move is to
1089
+ turn it into the two numbers somebody can weigh: how many departments and how many people.
1090
+ The department count includes the one being moved, so it is never zero; the people count can
1091
+ be, and zero people is its own sentence rather than a plural form reading "0 people".
1092
+ -->
1093
+ <ul class="facts">
1094
+ <li>{t('org_move_units', { count: moving.descendants + 1 })}</li>
1095
+ <li>
1096
+ {moving.total > 0 ? t('org_move_people', { count: moving.total }) : t('org_move_people_none')}
1097
+ </li>
1098
+ </ul>
1099
+ <Field label={t('org_move_to')} required>
1100
+ {#snippet children(id)}
1101
+ <Select {id} bind:value={moveTarget} options={moveChoices} placeholder={t('org_root')} />
1102
+ {/snippet}
1103
+ </Field>
1104
+ {#if moveUnchanged}<p class="hint">{t('org_move_same')}</p>{/if}
1105
+ {#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
1106
+ {/if}
1107
+
1108
+ {#snippet footer()}
1109
+ <Button variant="ghost" onclick={() => (movingId = null)}>{t('common.cancel')}</Button>
1110
+ <Button onclick={confirmMove} disabled={moveUnchanged || !canManage} loading={acting}>
1111
+ {t('org_move')}
1112
+ </Button>
1113
+ {/snippet}
1114
+ </Dialog>
1115
+
1116
+ <!-- ------------------------------------------------------------------ archive a department -->
1117
+ <Dialog
1118
+ open={archivingUnit !== null}
1119
+ size="sm"
1120
+ title={t('org_archive_title', { name: archivingUnit?.unit.name ?? '' })}
1121
+ onOpenChange={(open) => {
1122
+ if (!open && !acting) archivingUnitId = null
1123
+ }}
1124
+ >
1125
+ <p class="body">{t('org_archive_body')}</p>
1126
+ <p class="body muted">{t('org_archive_note')}</p>
1127
+ {#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
1128
+
1129
+ {#snippet footer()}
1130
+ <Button variant="ghost" onclick={() => (archivingUnitId = null)}>{t('common.cancel')}</Button>
1131
+ <Button variant="danger" loading={acting} onclick={confirmArchiveUnit}>{t('common.archive')}</Button>
1132
+ {/snippet}
1133
+ </Dialog>
1134
+
1135
+ <!-- ------------------------------------------------------------------ position form -->
1136
+ <Dialog
1137
+ open={positionDraft !== null}
1138
+ size="sm"
1139
+ title={positionDraft?.id
1140
+ ? t('org_position_edit_title', { title: positionDraft.title })
1141
+ : t('org_position_new')}
1142
+ onOpenChange={(open) => {
1143
+ if (!open && !saving) positionDraft = null
1144
+ }}
1145
+ >
1146
+ {#if positionDraft}
1147
+ <form
1148
+ class="form"
1149
+ onsubmit={(event) => {
1150
+ event.preventDefault()
1151
+ submitPosition()
1152
+ }}
1153
+ >
1154
+ <Field label={t('org_position_title')} required>
1155
+ {#snippet children(id)}
1156
+ <Input {id} bind:value={positionDraft!.title} autocomplete="off" />
1157
+ {/snippet}
1158
+ </Field>
1159
+ <Field label={t('org_code')} hint={t('org_code_hint')}>
1160
+ {#snippet children(id)}
1161
+ <Input {id} mono bind:value={positionDraft!.code} autocomplete="off" />
1162
+ {/snippet}
1163
+ </Field>
1164
+ <Field label={t('org_position_family')} hint={t('org_position_family_hint')}>
1165
+ {#snippet children(id)}
1166
+ <Input {id} bind:value={positionDraft!.jobFamily} autocomplete="off" />
1167
+ {/snippet}
1168
+ </Field>
1169
+ <Field label={t('org_position_level')} hint={t('org_position_level_hint')}>
1170
+ {#snippet children(id)}
1171
+ <Input {id} mono bind:value={positionDraft!.level} autocomplete="off" />
1172
+ {/snippet}
1173
+ </Field>
1174
+ {#if formError}<p class="err" role="alert">{formError}</p>{/if}
1175
+ </form>
1176
+ {/if}
1177
+
1178
+ {#snippet footer()}
1179
+ <Button variant="ghost" onclick={() => (positionDraft = null)}>{t('common.cancel')}</Button>
1180
+ <Button onclick={submitPosition} disabled={!positionDraftValid || !canManage} loading={saving}>
1181
+ {positionDraft?.id ? t('common.save') : t('org_position_add')}
1182
+ </Button>
1183
+ {/snippet}
1184
+ </Dialog>
1185
+
1186
+ <!-- ------------------------------------------------------------------ archive a position -->
1187
+ <Dialog
1188
+ open={archivingPosition !== null}
1189
+ size="sm"
1190
+ title={t('org_position_archive_title', { title: archivingPosition?.title ?? '' })}
1191
+ onOpenChange={(open) => {
1192
+ if (!open && !acting) archivingPositionId = null
1193
+ }}
1194
+ >
1195
+ <p class="body">{t('org_position_archive_body')}</p>
1196
+ {#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
1197
+
1198
+ {#snippet footer()}
1199
+ <Button variant="ghost" onclick={() => (archivingPositionId = null)}>{t('common.cancel')}</Button>
1200
+ <Button variant="danger" loading={acting} onclick={confirmArchivePosition}>{t('common.archive')}</Button>
1201
+ {/snippet}
1202
+ </Dialog>
1203
+
1204
+ <style>
1205
+ .tiles {
1206
+ display: grid;
1207
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1208
+ gap: 12px;
1209
+ margin-block-end: 18px;
1210
+ }
1211
+ .rows {
1212
+ display: grid;
1213
+ gap: 4px;
1214
+ }
1215
+ .tabbar {
1216
+ display: flex;
1217
+ align-items: center;
1218
+ justify-content: space-between;
1219
+ gap: 12px;
1220
+ flex-wrap: wrap;
1221
+ margin-block-end: 14px;
1222
+ }
1223
+
1224
+ /* The chart and its detail. One column below 900px: a 280px panel beside a tree is two cramped
1225
+ columns rather than two useful ones. */
1226
+ .split {
1227
+ display: grid;
1228
+ grid-template-columns: minmax(0, 1fr) 320px;
1229
+ gap: 20px;
1230
+ align-items: start;
1231
+ }
1232
+ @media (max-width: 900px) {
1233
+ .split {
1234
+ grid-template-columns: minmax(0, 1fr);
1235
+ }
1236
+ }
1237
+
1238
+ .treepane {
1239
+ min-width: 0;
1240
+ overflow-x: auto;
1241
+ /* The focus ring is drawn outside a row's box, and a scroll container clips it. */
1242
+ padding: 3px;
1243
+ }
1244
+ .tree {
1245
+ min-width: max-content;
1246
+ }
1247
+
1248
+ .node {
1249
+ display: flex;
1250
+ align-items: stretch;
1251
+ gap: 0;
1252
+ min-height: 40px;
1253
+ padding-inline-end: 10px;
1254
+ border-radius: var(--kern-r-md);
1255
+ cursor: pointer;
1256
+ color: var(--kern-ink-800);
1257
+ }
1258
+ .node:hover {
1259
+ background: var(--kern-surface-hover);
1260
+ }
1261
+ .node.sel {
1262
+ background: var(--kern-accent-tint);
1263
+ }
1264
+ .node:focus-visible {
1265
+ outline: none;
1266
+ box-shadow: 0 0 0 3px var(--kern-ring);
1267
+ }
1268
+
1269
+ /*
1270
+ The rails. Each ancestor column is 18px wide and carries a vertical hairline when that ancestor
1271
+ still has a sibling below it; the last column is the elbow into this node's parent. Everything is
1272
+ positioned with `inset-inline-start`, so the whole drawing mirrors under dir="rtl" without a
1273
+ second rule.
1274
+ */
1275
+ .rail,
1276
+ .elbow {
1277
+ position: relative;
1278
+ inline-size: 18px;
1279
+ flex: none;
1280
+ }
1281
+ .rail.on::before,
1282
+ .elbow::before {
1283
+ content: '';
1284
+ position: absolute;
1285
+ inset-inline-start: 9px;
1286
+ inset-block-start: 0;
1287
+ block-size: 100%;
1288
+ border-inline-start: 1px solid var(--kern-border-hairline);
1289
+ }
1290
+ .elbow::before {
1291
+ block-size: 50%;
1292
+ }
1293
+ .elbow.through::before {
1294
+ block-size: 100%;
1295
+ }
1296
+ .elbow::after {
1297
+ content: '';
1298
+ position: absolute;
1299
+ inset-inline-start: 9px;
1300
+ inset-block-start: 50%;
1301
+ inline-size: 9px;
1302
+ border-block-start: 1px solid var(--kern-border-hairline);
1303
+ }
1304
+
1305
+ .chev {
1306
+ display: flex;
1307
+ align-items: center;
1308
+ justify-content: center;
1309
+ inline-size: 18px;
1310
+ flex: none;
1311
+ color: var(--kern-ink-450);
1312
+ transition: transform var(--kern-dur-fast) var(--kern-ease-out);
1313
+ }
1314
+ .chev.folded {
1315
+ transform: rotate(-90deg);
1316
+ }
1317
+ /* A closed branch points the way the language runs. */
1318
+ :global([dir='rtl']) .chev.folded {
1319
+ transform: rotate(90deg);
1320
+ }
1321
+
1322
+ .nname {
1323
+ align-self: center;
1324
+ padding-inline: 6px;
1325
+ font-size: 13.5px;
1326
+ font-weight: 500;
1327
+ white-space: nowrap;
1328
+ }
1329
+ .code {
1330
+ align-self: center;
1331
+ font-family: var(--kern-font-mono);
1332
+ font-size: 11.5px;
1333
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
1334
+ color: var(--kern-ink-500);
1335
+ }
1336
+ .head {
1337
+ display: inline-flex;
1338
+ align-items: center;
1339
+ align-self: center;
1340
+ gap: 4px;
1341
+ margin-inline-start: 10px;
1342
+ font-size: 12px;
1343
+ color: var(--kern-ink-500);
1344
+ white-space: nowrap;
1345
+ }
1346
+ .spacer {
1347
+ flex: 1;
1348
+ min-inline-size: 24px;
1349
+ }
1350
+ .pill {
1351
+ display: inline-flex;
1352
+ align-items: center;
1353
+ align-self: center;
1354
+ gap: 4px;
1355
+ padding-inline: 7px;
1356
+ block-size: 20px;
1357
+ border-radius: var(--kern-r-full);
1358
+ background: var(--kern-surface-chip);
1359
+ font-size: 11.5px;
1360
+ color: var(--kern-ink-600);
1361
+ font-variant-numeric: tabular-nums;
1362
+ }
1363
+
1364
+ /* ---- the detail panel ---- */
1365
+ .detail {
1366
+ display: flex;
1367
+ flex-direction: column;
1368
+ gap: 12px;
1369
+ padding: 16px;
1370
+ border: 1px solid var(--kern-border);
1371
+ border-radius: var(--kern-r-2xl);
1372
+ background: var(--kern-surface-raised);
1373
+ position: sticky;
1374
+ inset-block-start: 8px;
1375
+ }
1376
+ @media (max-width: 900px) {
1377
+ .detail {
1378
+ position: static;
1379
+ }
1380
+ }
1381
+ .crumbs {
1382
+ display: flex;
1383
+ align-items: center;
1384
+ flex-wrap: wrap;
1385
+ gap: 6px;
1386
+ font-size: 12px;
1387
+ color: var(--kern-ink-500);
1388
+ }
1389
+ .crumb {
1390
+ display: inline-flex;
1391
+ align-items: center;
1392
+ /* A 16px line of text is a target nobody can hit, and these sit next to each other. */
1393
+ min-block-size: 24px;
1394
+ background: none;
1395
+ border: 0;
1396
+ padding: 0;
1397
+ font: inherit;
1398
+ color: var(--kern-ink-500);
1399
+ text-decoration: underline;
1400
+ text-underline-offset: 2px;
1401
+ cursor: pointer;
1402
+ }
1403
+ .crumb:hover {
1404
+ color: var(--kern-ink-800);
1405
+ }
1406
+ .crumb.here {
1407
+ color: var(--kern-ink-700);
1408
+ text-decoration: none;
1409
+ }
1410
+ .sep {
1411
+ color: var(--kern-ink-450);
1412
+ }
1413
+ .dtitle {
1414
+ display: flex;
1415
+ align-items: center;
1416
+ gap: 8px;
1417
+ margin: 0;
1418
+ font-size: 17px;
1419
+ font-weight: 600;
1420
+ letter-spacing: -0.01em;
1421
+ color: var(--kern-ink-900);
1422
+ }
1423
+ .headrow {
1424
+ display: flex;
1425
+ align-items: center;
1426
+ gap: 8px;
1427
+ flex-wrap: wrap;
1428
+ }
1429
+ .dlabel {
1430
+ font-size: 11px;
1431
+ font-weight: 600;
1432
+ letter-spacing: 0.06em;
1433
+ text-transform: uppercase;
1434
+ color: var(--kern-ink-500);
1435
+ }
1436
+ .hname {
1437
+ display: inline-flex;
1438
+ align-items: center;
1439
+ gap: 5px;
1440
+ font-size: 13px;
1441
+ color: var(--kern-ink-800);
1442
+ }
1443
+ .hname.muted {
1444
+ color: var(--kern-ink-500);
1445
+ }
1446
+ .metrics {
1447
+ display: grid;
1448
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1449
+ gap: 8px;
1450
+ }
1451
+ .metric {
1452
+ display: flex;
1453
+ flex-direction: column;
1454
+ gap: 2px;
1455
+ padding: 8px 10px;
1456
+ border-radius: var(--kern-r-lg);
1457
+ background: var(--kern-surface);
1458
+ border: 1px solid var(--kern-border-hairline);
1459
+ }
1460
+ .mv {
1461
+ font-size: 18px;
1462
+ font-weight: 600;
1463
+ line-height: 1.1;
1464
+ color: var(--kern-ink-900);
1465
+ font-variant-numeric: tabular-nums;
1466
+ }
1467
+ .ml {
1468
+ font-size: 11px;
1469
+ color: var(--kern-ink-500);
1470
+ text-wrap: pretty;
1471
+ }
1472
+ .kids {
1473
+ list-style: none;
1474
+ margin: 0;
1475
+ padding: 0;
1476
+ display: grid;
1477
+ gap: 2px;
1478
+ }
1479
+ .kid {
1480
+ display: flex;
1481
+ align-items: center;
1482
+ justify-content: space-between;
1483
+ gap: 8px;
1484
+ inline-size: 100%;
1485
+ min-block-size: 32px;
1486
+ padding-inline: 8px;
1487
+ border: 0;
1488
+ border-radius: var(--kern-r-md);
1489
+ background: none;
1490
+ font: inherit;
1491
+ color: var(--kern-ink-800);
1492
+ cursor: pointer;
1493
+ text-align: start;
1494
+ }
1495
+ .kid:hover {
1496
+ background: var(--kern-surface-hover);
1497
+ }
1498
+ .kname {
1499
+ min-inline-size: 0;
1500
+ overflow: hidden;
1501
+ text-overflow: ellipsis;
1502
+ white-space: nowrap;
1503
+ font-size: 13px;
1504
+ }
1505
+ .acts {
1506
+ display: flex;
1507
+ flex-wrap: wrap;
1508
+ gap: 6px;
1509
+ padding-block-start: 4px;
1510
+ border-block-start: 1px solid var(--kern-border-hairline);
1511
+ }
1512
+
1513
+ /* ---- positions ---- */
1514
+ .table {
1515
+ --hr-position-cols: minmax(180px, 1.6fr) 110px minmax(120px, 1fr) 90px 36px;
1516
+ width: 100%;
1517
+ }
1518
+ .thead,
1519
+ .trow {
1520
+ display: grid;
1521
+ grid-template-columns: var(--hr-position-cols);
1522
+ gap: 12px;
1523
+ align-items: center;
1524
+ padding-inline: 12px;
1525
+ }
1526
+ .thead {
1527
+ height: 32px;
1528
+ border-block-end: 1px solid var(--kern-border);
1529
+ font-size: 11px;
1530
+ font-weight: 600;
1531
+ letter-spacing: 0.06em;
1532
+ text-transform: uppercase;
1533
+ color: var(--kern-ink-500);
1534
+ }
1535
+ .trow {
1536
+ min-height: 48px;
1537
+ border-block-end: 1px solid var(--kern-border-hairline);
1538
+ }
1539
+ .trow:last-child {
1540
+ border-block-end: 0;
1541
+ }
1542
+ .trow:hover {
1543
+ background: var(--kern-surface-hover);
1544
+ }
1545
+ .cell {
1546
+ min-width: 0;
1547
+ overflow: hidden;
1548
+ text-overflow: ellipsis;
1549
+ white-space: nowrap;
1550
+ }
1551
+ .cell.name {
1552
+ font-size: 13.5px;
1553
+ font-weight: 500;
1554
+ }
1555
+ .cell.muted {
1556
+ font-size: 13px;
1557
+ color: var(--kern-ink-500);
1558
+ }
1559
+ .cell.mono {
1560
+ font-family: var(--kern-font-mono);
1561
+ font-size: 12px;
1562
+ }
1563
+ .cell.end {
1564
+ display: flex;
1565
+ justify-content: flex-end;
1566
+ }
1567
+ @media (max-width: 760px) {
1568
+ .table {
1569
+ --hr-position-cols: minmax(140px, 1.6fr) 100px 36px;
1570
+ }
1571
+ /* Job family and level are the columns a narrow screen can lose: both are refinements of a
1572
+ title that is still on the row. */
1573
+ .thead > :nth-child(3),
1574
+ .trow > :nth-child(3),
1575
+ .thead > :nth-child(4),
1576
+ .trow > :nth-child(4) {
1577
+ display: none;
1578
+ }
1579
+ }
1580
+
1581
+ /* ---- dialogs ---- */
1582
+ .form {
1583
+ display: grid;
1584
+ gap: 14px;
1585
+ }
1586
+ .body {
1587
+ margin: 0 0 8px;
1588
+ font-size: 13.5px;
1589
+ line-height: 1.55;
1590
+ color: var(--kern-ink-700);
1591
+ text-wrap: pretty;
1592
+ }
1593
+ .body.muted {
1594
+ color: var(--kern-ink-500);
1595
+ }
1596
+ .facts {
1597
+ margin: 0 0 12px;
1598
+ padding-inline-start: 18px;
1599
+ display: grid;
1600
+ gap: 4px;
1601
+ font-size: 13px;
1602
+ color: var(--kern-ink-700);
1603
+ }
1604
+ .hint {
1605
+ margin: 0;
1606
+ font-size: 12px;
1607
+ color: var(--kern-ink-500);
1608
+ text-wrap: pretty;
1609
+ }
1610
+ .err {
1611
+ margin: 8px 0 0;
1612
+ font-size: 12.5px;
1613
+ color: var(--kern-danger);
1614
+ }
1615
+ .sr-only {
1616
+ position: absolute;
1617
+ inline-size: 1px;
1618
+ block-size: 1px;
1619
+ overflow: hidden;
1620
+ clip-path: inset(50%);
1621
+ white-space: nowrap;
1622
+ }
1623
+ </style>