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