@kernhq/module-hr 0.10.0 → 0.10.2

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.
Files changed (90) hide show
  1. package/dist/contract/attendance.d.ts +1 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +9 -0
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +1 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +489 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1888 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +154 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +17 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +81 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +0 -2
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +12 -4
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -8
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -14
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/ledger.d.ts +0 -2
  44. package/dist/server/services/ledger.d.ts.map +1 -1
  45. package/dist/server/services/ledger.js +1 -2
  46. package/dist/server/services/ledger.js.map +1 -1
  47. package/dist/server/services/policies.d.ts +6 -2
  48. package/dist/server/services/policies.d.ts.map +1 -1
  49. package/dist/server/services/policies.js +7 -2
  50. package/dist/server/services/policies.js.map +1 -1
  51. package/migrations/0005_approval_requester.sql +11 -1
  52. package/migrations/0006_schedule_no_overlap.sql +85 -0
  53. package/migrations/0007_hot_path_indexes.sql +38 -0
  54. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  55. package/migrations/meta/0005_snapshot.json +4047 -0
  56. package/migrations/meta/0007_snapshot.json +4225 -0
  57. package/migrations/meta/0009_snapshot.json +4231 -0
  58. package/migrations/meta/_journal.json +21 -0
  59. package/package.json +7 -7
  60. package/src/client/components/ClockControls.svelte +127 -20
  61. package/src/client/components/ClockControls.test.ts +446 -0
  62. package/src/client/components/DelegationDialog.svelte +12 -3
  63. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  64. package/src/client/components/PersonFormDialog.svelte +53 -3
  65. package/src/client/components/PersonPanel.svelte +134 -26
  66. package/src/client/i18n.ts +5 -927
  67. package/src/client/messages.test.ts +154 -0
  68. package/src/client/messages.ts +3388 -0
  69. package/src/client/mock.ts +1576 -161
  70. package/src/client/module.ts +15 -1
  71. package/src/client/pages/ApprovalsPage.svelte +120 -46
  72. package/src/client/pages/AttendancePage.svelte +106 -20
  73. package/src/client/pages/DirectoryPage.svelte +192 -55
  74. package/src/client/pages/LeavePage.svelte +280 -25
  75. package/src/client/pages/OfficesPage.svelte +26 -8
  76. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  77. package/src/client/query.ts +12 -0
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +14 -7
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/attendance.ts +9 -0
  90. package/src/contract/capabilities.ts +17 -0
@@ -1,21 +1,1191 @@
1
1
  <script lang="ts">
2
- import { EmptyState, SettingsPage } from '@kernhq/ui'
2
+ import {
3
+ Badge,
4
+ type BadgeTone,
5
+ Button,
6
+ Checkbox,
7
+ Dialog,
8
+ DropdownMenu,
9
+ EmptyState,
10
+ Field,
11
+ formatCount,
12
+ IconButton,
13
+ Input,
14
+ type MenuItem,
15
+ messageLocale,
16
+ navigation,
17
+ SectionLabel,
18
+ SegmentedControl,
19
+ Select,
20
+ type SelectOption,
21
+ SettingsPage,
22
+ SettingsSection,
23
+ Sheet,
24
+ Skeleton,
25
+ StatTile,
26
+ session,
27
+ toast,
28
+ } from '@kernhq/ui'
29
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
30
+ import { getHrApi } from '../api-instance.js'
31
+ import { HR_CAPABILITIES } from '../capabilities.js'
3
32
  import { t } from '../i18n.js'
33
+ import type { Office } from '../index.js'
34
+ import { canHr } from '../permissions.js'
35
+ import { hrKeys, isoDate } from '../query.js'
4
36
 
5
37
  /**
6
- * offices settings.
38
+ * Where the company works, and who it decides for.
7
39
  *
8
- * The API behind this is complete — the module's server implements the full surface — but the
9
- * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
- * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
- * because the capability is on.
40
+ * The read-only list lives on `/hr/offices`; this is the screen that creates one, and until it
41
+ * existed a workspace could never have a second office every procedure behind offices was
42
+ * implemented and none of them had a caller.
43
+ *
44
+ * **The rule this screen exists to keep visible:** a person may hold several offices at once, and
45
+ * exactly one of them is primary. Only the primary decides their holidays, their policies and the
46
+ * timezone their day is attributed to; the others grant presence — the directory, the local HR
47
+ * view, later the geofence. Somebody who assigns a second office expecting a calendar to change has
48
+ * been misled, so the roster labels every row with which kind it is and the assignment control says
49
+ * what switching it on replaces.
50
+ *
51
+ * There is no "show archived" here on purpose. The contract has no `unarchive`, so a list of
52
+ * archived offices would be rows nothing can act on; the archive confirmation says where the office
53
+ * goes instead.
54
+ */
55
+ const api = getHrApi()
56
+ const queryClient = useQueryClient()
57
+
58
+ const workspace = $derived(session.workspaces.find((w) => w.slug === navigation.workspaceSlug))
59
+ const workspaceId = $derived(workspace?.id ?? '')
60
+
61
+ const canManage = $derived(canHr('officeManage'))
62
+ const canAssign = $derived(canHr('officeAssign'))
63
+ const hasCalendars = $derived(session.hasCapability('hr', HR_CAPABILITIES.calendars))
64
+ const hasEntities = $derived(session.hasCapability('hr', HR_CAPABILITIES.legalEntities))
65
+
66
+ type OfficeRow = Office & { headcount: number }
67
+
68
+ /** The six kinds the contract declares, as the option list and the label map at once. */
69
+ const KINDS = ['head_office', 'branch', 'site', 'warehouse', 'store', 'remote'] as const
70
+ type Kind = (typeof KINDS)[number]
71
+ const KIND_LABELS: Record<Kind, () => string> = {
72
+ head_office: () => t('office_kind_head_office'),
73
+ branch: () => t('office_kind_branch'),
74
+ site: () => t('office_kind_site'),
75
+ warehouse: () => t('office_kind_warehouse'),
76
+ store: () => t('office_kind_store'),
77
+ remote: () => t('office_kind_remote'),
78
+ }
79
+ const kindLabel = (kind: string): string => KIND_LABELS[kind as Kind]?.() ?? kind
80
+ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'grey')
81
+
82
+ /** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
83
+ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
84
+
85
+ // ---------------------------------------------------------------- reference data
86
+
87
+ /**
88
+ * Every country the runtime can name, in the reader's own language.
89
+ *
90
+ * There is no `Intl.supportedValuesOf('region')`, so the set is found by asking `DisplayNames`
91
+ * about all 676 two-letter codes and keeping the ones it answers with a name rather than handing
92
+ * the code back. That costs a fraction of a millisecond once per locale, and it means the list is
93
+ * the same CLDR data the rest of the interface uses instead of a table in this file that goes
94
+ * stale the next time a country changes its name.
95
+ */
96
+ const COUNTRY_CACHE = new Map<string, SelectOption[]>()
97
+ /** Codes `DisplayNames` names that ISO 3166-1 does not: unions, outlying areas, reserved codes. */
98
+ const NOT_COUNTRIES = new Set(['AC', 'CP', 'DG', 'EA', 'EU', 'EZ', 'IC', 'QO', 'TA', 'UN', 'XA', 'XB', 'ZZ'])
99
+
100
+ function countryOptions(locale: string): SelectOption[] {
101
+ const cached = COUNTRY_CACHE.get(locale)
102
+ if (cached) return cached
103
+ const options: SelectOption[] = []
104
+ try {
105
+ const names = new Intl.DisplayNames(locale, { type: 'region' })
106
+ for (let first = 65; first <= 90; first++) {
107
+ for (let second = 65; second <= 90; second++) {
108
+ const code = String.fromCharCode(first, second)
109
+ if (NOT_COUNTRIES.has(code)) continue
110
+ const label = names.of(code)
111
+ if (!label || label === code) continue
112
+ options.push({ value: code, label })
113
+ }
114
+ }
115
+ options.sort((a, b) => a.label.localeCompare(b.label, locale))
116
+ } catch {
117
+ // A runtime without region display names must not take the form down with it; the code itself
118
+ // is still a valid answer, and the office being edited keeps whatever it already had.
119
+ }
120
+ COUNTRY_CACHE.set(locale, options)
121
+ return options
122
+ }
123
+
124
+ const countries = $derived(countryOptions(messageLocale()))
125
+ const countryNames = $derived(new Map(countries.map((c) => [c.value, c.label])))
126
+ const countryLabel = (code: string): string => countryNames.get(code) ?? code
127
+
128
+ /** The IANA zones the runtime knows. Empty on a runtime without `supportedValuesOf`. */
129
+ const ZONES: string[] = (() => {
130
+ try {
131
+ return [...Intl.supportedValuesOf('timeZone')]
132
+ } catch {
133
+ return []
134
+ }
135
+ })()
136
+
137
+ /**
138
+ * The browser's own zone as the starting point for a new office.
139
+ *
140
+ * `resolvedOptions()` reads a setting rather than formatting anything, so it is not the bare `Intl`
141
+ * call the house rule is about — nothing here is shown to the reader without going through the
142
+ * option list.
143
+ */
144
+ function browserZone(): string {
145
+ try {
146
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || ''
147
+ } catch {
148
+ return ''
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------- what is open
153
+
154
+ /**
155
+ * Every piece of "what is on screen" is declared before the first `createQuery`, and that ordering
156
+ * is load-bearing rather than tidy.
157
+ *
158
+ * `createQuery` evaluates its options function immediately to build the observer, so an `enabled`
159
+ * that reads a `$state` declared further down the file throws "Cannot access 'draft' before
160
+ * initialization" — at runtime only, on the first render, which nothing here type-checks.
161
+ */
162
+ interface Draft {
163
+ /** `null` while creating. The two procedures take different fields, so this decides which. */
164
+ id: string | null
165
+ name: string
166
+ code: string
167
+ kind: Kind
168
+ country: string
169
+ region: string
170
+ city: string
171
+ timezone: string
172
+ parentOfficeId: string
173
+ legalEntityId: string
174
+ calendarId: string
175
+ headPersonId: string
176
+ seedCalendarFromPack: boolean
177
+ }
178
+
179
+ let draft = $state<Draft | null>(null)
180
+ let formError = $state<string | null>(null)
181
+ /**
182
+ * `disabled={save.isPending}` reaches the button one render late, and two quick clicks are one
183
+ * render apart — which on create is two offices. The guard is set in the same tick as the click.
184
+ */
185
+ let saving = $state(false)
186
+
187
+ let rosterOfficeId = $state<string | null>(null)
188
+ let rosterFilter = $state('all')
189
+
190
+ // ---------------------------------------------------------------- queries
191
+
192
+ const officesQuery = createQuery(() => ({
193
+ queryKey: hrKeys.offices(workspaceId),
194
+ enabled: Boolean(workspaceId),
195
+ queryFn: () => api.offices.list({ workspaceId, includeArchived: false }),
196
+ }))
197
+ const offices = $derived((officesQuery.data ?? []) as OfficeRow[])
198
+
199
+ const stats = $derived({
200
+ offices: offices.length,
201
+ countries: new Set(offices.map((o) => o.country)).size,
202
+ people: offices.reduce((sum, o) => sum + o.headcount, 0),
203
+ })
204
+
205
+ /**
206
+ * The directory, for the two person pickers — head of office, and who to assign.
207
+ *
208
+ * Only fetched while one of them is on screen: a settings page nobody has opened a dialog on has
209
+ * no reason to pull two hundred people.
210
+ */
211
+ const directoryQuery = createQuery(() => ({
212
+ queryKey: hrKeys.people(workspaceId, { forOffices: true }),
213
+ enabled: Boolean(workspaceId) && (draft !== null || rosterOfficeId !== null),
214
+ queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
215
+ }))
216
+ const directory = $derived(directoryQuery.data?.items ?? [])
217
+
218
+ const calendarsQuery = createQuery(() => ({
219
+ queryKey: hrKeys.calendars(workspaceId),
220
+ enabled: Boolean(workspaceId) && hasCalendars && draft?.id != null,
221
+ queryFn: () => api.calendars.list({ workspaceId, includeArchived: false }),
222
+ }))
223
+
224
+ /**
225
+ * `[module, entity, …scope]`, the shape `hrKeys` uses. Spelled here rather than in `query.ts`
226
+ * because these two are the only screens that ask, and both belong to offices.
227
+ */
228
+ const entitiesKey = (ws: string) => ['hr', 'entities', ws] as const
229
+ const officePeopleKey = (ws: string, officeId: string, primaryOnly: boolean) =>
230
+ ['hr', 'office-people', ws, officeId, primaryOnly ? 'primary' : 'all'] as const
231
+
232
+ const entitiesQuery = createQuery(() => ({
233
+ queryKey: entitiesKey(workspaceId),
234
+ enabled: Boolean(workspaceId) && hasEntities && draft !== null,
235
+ queryFn: () => api.entities.list({ workspaceId, includeArchived: false }),
236
+ }))
237
+
238
+ // ---------------------------------------------------------------- the form
239
+
240
+ function openCreate() {
241
+ formError = null
242
+ draft = {
243
+ id: null,
244
+ name: '',
245
+ code: '',
246
+ kind: 'branch',
247
+ // No country is guessed. A second office is usually in a different one from the first, and a
248
+ // pre-filled country is the field nobody re-reads.
249
+ country: '',
250
+ region: '',
251
+ city: '',
252
+ timezone: browserZone(),
253
+ parentOfficeId: '',
254
+ legalEntityId: '',
255
+ calendarId: '',
256
+ headPersonId: '',
257
+ seedCalendarFromPack: true,
258
+ }
259
+ }
260
+
261
+ function openEdit(office: OfficeRow) {
262
+ formError = null
263
+ draft = {
264
+ id: office.id,
265
+ name: office.name,
266
+ code: office.code ?? '',
267
+ kind: office.kind as Kind,
268
+ country: office.country,
269
+ region: office.region ?? '',
270
+ city: office.city ?? '',
271
+ timezone: office.timezone,
272
+ parentOfficeId: office.parentOfficeId ?? '',
273
+ legalEntityId: office.legalEntityId ?? '',
274
+ calendarId: office.calendarId ?? '',
275
+ headPersonId: office.headPersonId ?? '',
276
+ // Only offered on create: the pack is what a new office's calendar is built from, and an
277
+ // existing one already has whatever it has.
278
+ seedCalendarFromPack: false,
279
+ }
280
+ }
281
+
282
+ const draftValid = $derived(
283
+ draft !== null && draft.name.trim().length > 0 && draft.country.length === 2 && draft.timezone !== '',
284
+ )
285
+
286
+ /**
287
+ * Zones as a grouped list, with the office's own zone forced in.
288
+ *
289
+ * A zone the runtime has retired (`Asia/Calcutta`) would otherwise be dropped from the options, the
290
+ * field would render empty, and saving would quietly move the office to a different one.
12
291
  */
292
+ const timezones = $derived.by((): SelectOption[] => {
293
+ const zones = new Set(ZONES)
294
+ if (draft?.timezone) zones.add(draft.timezone)
295
+ return [...zones].sort().map((zone) => {
296
+ const slash = zone.indexOf('/')
297
+ if (slash === -1) return { value: zone, label: zone }
298
+ return {
299
+ value: zone,
300
+ label: zone
301
+ .slice(slash + 1)
302
+ .replaceAll('_', ' ')
303
+ .replaceAll('/', ' / '),
304
+ group: zone.slice(0, slash),
305
+ }
306
+ })
307
+ })
308
+
309
+ const kindChoices = $derived(KINDS.map((kind) => ({ value: kind, label: KIND_LABELS[kind]() })))
310
+ const parentChoices = $derived([
311
+ { value: '', label: t('office_parent_none') },
312
+ ...offices.filter((o) => o.id !== draft?.id).map((o) => ({ value: o.id, label: o.name })),
313
+ ])
314
+ const entityChoices = $derived([
315
+ { value: '', label: t('office_entity_none') },
316
+ ...(entitiesQuery.data ?? []).map((e) => ({ value: e.id, label: e.name })),
317
+ ])
318
+ const calendarChoices = $derived([
319
+ { value: '', label: t('office_calendar_default') },
320
+ ...(calendarsQuery.data ?? []).map((c) => ({ value: c.id, label: c.name })),
321
+ ])
322
+ const peopleChoices = $derived(directory.map((p) => ({ value: p.id, label: p.displayName })))
323
+ /** Head of office is a field somebody can clear; who to assign is not, so only one carries a null. */
324
+ const headChoices = $derived([{ value: '', label: t('office_head_none') }, ...peopleChoices])
325
+
326
+ const save = createMutation(() => ({
327
+ mutationFn: (input: Draft) => {
328
+ const shared = {
329
+ workspaceId,
330
+ name: input.name.trim(),
331
+ kind: input.kind,
332
+ country: input.country,
333
+ region: input.region.trim() || null,
334
+ city: input.city.trim() || null,
335
+ timezone: input.timezone,
336
+ code: input.code.trim() || null,
337
+ legalEntityId: input.legalEntityId || null,
338
+ }
339
+ return input.id === null
340
+ ? api.offices.create({
341
+ ...shared,
342
+ parentOfficeId: input.parentOfficeId || null,
343
+ seedCalendarFromPack: input.seedCalendarFromPack,
344
+ })
345
+ : api.offices.update({
346
+ ...shared,
347
+ officeId: input.id,
348
+ calendarId: input.calendarId || null,
349
+ headPersonId: input.headPersonId || null,
350
+ })
351
+ },
352
+ onSuccess: (office, input) => {
353
+ toast.success(input.id === null ? t('office_created', { name: office.name }) : t('office_saved'))
354
+ draft = null
355
+ formError = null
356
+ // An office decides holidays, policies and a timezone, so a change to one moves resolutions and
357
+ // day sheets as well as this list. Invalidating the module is cheaper than guessing which.
358
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
359
+ },
360
+ onError: (error: Error) => {
361
+ formError = error.message || t('office_save_error')
362
+ },
363
+ onSettled: () => {
364
+ saving = false
365
+ },
366
+ }))
367
+
368
+ function submitDraft() {
369
+ if (!draft || !draftValid || saving) return
370
+ saving = true
371
+ formError = null
372
+ save.mutate($state.snapshot(draft) as Draft)
373
+ }
374
+
375
+ // ---------------------------------------------------------------- default and archive
376
+
377
+ let makingDefaultId = $state<string | null>(null)
378
+ let archivingId = $state<string | null>(null)
379
+ let actionError = $state<string | null>(null)
380
+ let acting = $state(false)
381
+
382
+ const makingDefault = $derived(offices.find((o) => o.id === makingDefaultId) ?? null)
383
+ const archiving = $derived(offices.find((o) => o.id === archivingId) ?? null)
384
+ const currentDefault = $derived(offices.find((o) => o.isDefault) ?? null)
385
+
386
+ const setDefault = createMutation(() => ({
387
+ mutationFn: (vars: { officeId: string; name: string }) =>
388
+ api.offices.setDefault({ workspaceId, officeId: vars.officeId }),
389
+ onSuccess: (_office, vars) => {
390
+ toast.success(t('office_default_toast', { name: vars.name }))
391
+ makingDefaultId = null
392
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
393
+ },
394
+ onError: (error: Error) => {
395
+ actionError = error.message || t('office_default_error')
396
+ },
397
+ onSettled: () => {
398
+ acting = false
399
+ },
400
+ }))
401
+
402
+ const archive = createMutation(() => ({
403
+ mutationFn: (vars: { officeId: string; name: string }) =>
404
+ api.offices.archive({ workspaceId, officeId: vars.officeId }),
405
+ onSuccess: (_ok, vars) => {
406
+ toast.success(t('office_archived_toast', { name: vars.name }))
407
+ archivingId = null
408
+ if (rosterOfficeId === vars.officeId) rosterOfficeId = null
409
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
410
+ },
411
+ onError: (error: Error) => {
412
+ actionError = error.message || t('office_archive_error')
413
+ },
414
+ onSettled: () => {
415
+ acting = false
416
+ },
417
+ }))
418
+
419
+ // ---------------------------------------------------------------- the roster
420
+
421
+ /** The live row, not a snapshot: the name and the headcount move while the panel is open. */
422
+ const rosterOffice = $derived(offices.find((o) => o.id === rosterOfficeId) ?? null)
423
+
424
+ const rosterQuery = createQuery(() => ({
425
+ queryKey: officePeopleKey(workspaceId, rosterOfficeId ?? '', rosterFilter === 'primary'),
426
+ enabled: Boolean(workspaceId) && rosterOfficeId !== null,
427
+ queryFn: () =>
428
+ api.offices.people({
429
+ workspaceId,
430
+ officeId: rosterOfficeId ?? '',
431
+ limit: 100,
432
+ primaryOnly: rosterFilter === 'primary',
433
+ }),
434
+ }))
435
+ const roster = $derived(rosterQuery.data?.items ?? [])
436
+
437
+ let assignPersonId = $state('')
438
+ let assignFrom = $state(isoDate())
439
+ let assignPrimary = $state(true)
440
+ let assignReason = $state('')
441
+ let assignError = $state<string | null>(null)
442
+ let assigning = $state(false)
443
+
444
+ /** A fresh panel starts with an empty form rather than the last office's half-typed one. */
445
+ $effect(() => {
446
+ void rosterOfficeId
447
+ assignPersonId = ''
448
+ assignFrom = isoDate()
449
+ assignPrimary = true
450
+ assignReason = ''
451
+ assignError = null
452
+ })
453
+
454
+ const assign = createMutation(() => ({
455
+ mutationFn: (vars: { officeId: string; personId: string; name: string }) =>
456
+ api.offices.assign({
457
+ workspaceId,
458
+ officeId: vars.officeId,
459
+ personId: vars.personId,
460
+ isPrimary: assignPrimary,
461
+ effectiveFrom: assignFrom,
462
+ reason: assignReason.trim() || null,
463
+ }),
464
+ onSuccess: (_assignments, vars) => {
465
+ toast.success(t('office_assigned_toast', { name: vars.name }))
466
+ assignPersonId = ''
467
+ assignReason = ''
468
+ assignError = null
469
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
470
+ },
471
+ onError: (error: Error) => {
472
+ assignError = error.message || t('office_assign_error')
473
+ },
474
+ onSettled: () => {
475
+ assigning = false
476
+ },
477
+ }))
478
+
479
+ function submitAssignment() {
480
+ const officeId = rosterOfficeId
481
+ if (!officeId || !assignPersonId || !assignFrom || assigning) return
482
+ assigning = true
483
+ assignError = null
484
+ assign.mutate({
485
+ officeId,
486
+ personId: assignPersonId,
487
+ name: directory.find((p) => p.id === assignPersonId)?.displayName ?? '',
488
+ })
489
+ }
490
+
491
+ let unassigning = $state<{ personId: string; name: string; primary: boolean } | null>(null)
492
+ let unassignUntil = $state(isoDate())
493
+
494
+ const unassign = createMutation(() => ({
495
+ mutationFn: (vars: { officeId: string; personId: string; name: string }) =>
496
+ api.offices.unassign({
497
+ workspaceId,
498
+ officeId: vars.officeId,
499
+ personId: vars.personId,
500
+ effectiveTo: unassignUntil,
501
+ }),
502
+ onSuccess: (_ok, vars) => {
503
+ toast.success(t('office_unassigned_toast', { name: vars.name }))
504
+ unassigning = null
505
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
506
+ },
507
+ onError: (error: Error) => {
508
+ actionError = error.message || t('office_unassign_error')
509
+ },
510
+ onSettled: () => {
511
+ acting = false
512
+ },
513
+ }))
514
+
515
+ function confirmUnassign() {
516
+ const officeId = rosterOfficeId
517
+ const target = unassigning
518
+ if (!officeId || !target || acting) return
519
+ acting = true
520
+ actionError = null
521
+ unassign.mutate({ officeId, personId: target.personId, name: target.name })
522
+ }
523
+
524
+ // ---------------------------------------------------------------- row actions
525
+
526
+ function actionsFor(office: OfficeRow): MenuItem[] {
527
+ const items: MenuItem[] = [
528
+ { label: t('office_people_manage'), icon: 'users', onSelect: () => (rosterOfficeId = office.id) },
529
+ ]
530
+ if (!canManage) return items
531
+ items.unshift({ label: t('common.edit'), icon: 'pencil', onSelect: () => openEdit(office) })
532
+ items.push({
533
+ label: t('office_make_default'),
534
+ icon: 'star',
535
+ disabled: office.isDefault,
536
+ hint: office.isDefault ? t('office_make_default_already') : undefined,
537
+ onSelect: () => {
538
+ actionError = null
539
+ makingDefaultId = office.id
540
+ },
541
+ })
542
+ items.push({ type: 'separator' })
543
+ items.push({
544
+ label: t('common.archive'),
545
+ icon: 'archive',
546
+ danger: true,
547
+ // The workspace must always have a default, so the server refuses this one. Saying why beats
548
+ // an error the person only meets after the confirmation.
549
+ disabled: office.isDefault,
550
+ hint: office.isDefault ? t('office_archive_blocked') : undefined,
551
+ onSelect: () => {
552
+ actionError = null
553
+ archivingId = office.id
554
+ },
555
+ })
556
+ return items
557
+ }
13
558
  </script>
14
559
 
15
- <SettingsPage title={t('settings_offices')}>
16
- <EmptyState
17
- icon="building"
18
- title={t('settings_offices')}
19
- description={t('settings_not_built')}
20
- />
560
+ <SettingsPage title={t('settings_offices')} description={t('offices_settings_desc')}>
561
+ {#snippet actions()}
562
+ {#if canManage}
563
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('office_add')}</Button>
564
+ {/if}
565
+ {/snippet}
566
+
567
+ {#if officesQuery.isLoading}
568
+ <div class="tiles">
569
+ {#each [1, 2, 3] as n (n)}<Skeleton height="86px" />{/each}
570
+ </div>
571
+ {:else if !officesQuery.isError && offices.length > 0}
572
+ <div class="tiles">
573
+ <StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
574
+ <StatTile size="md" label={t('offices_countries')} value={count(stats.countries)} />
575
+ <StatTile size="md" label={t('office_people')} value={count(stats.people)} />
576
+ </div>
577
+ {/if}
578
+
579
+ <SettingsSection flush>
580
+ {#if officesQuery.isLoading}
581
+ <div class="pad rows">
582
+ {#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
583
+ </div>
584
+ {:else if officesQuery.isError}
585
+ <div class="pad">
586
+ <EmptyState icon="triangle-alert" title={t('offices_error')}>
587
+ {#snippet actions()}
588
+ <Button variant="secondary" onclick={() => void officesQuery.refetch()}>{t('retry')}</Button>
589
+ {/snippet}
590
+ </EmptyState>
591
+ </div>
592
+ {:else if offices.length === 0}
593
+ <div class="pad">
594
+ <EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')}>
595
+ {#snippet actions()}
596
+ {#if canManage}
597
+ <Button icon="plus" onclick={openCreate}>{t('office_add')}</Button>
598
+ {/if}
599
+ {/snippet}
600
+ </EmptyState>
601
+ </div>
602
+ {:else}
603
+ <div class="table" role="table" aria-label={t('offices_title')}>
604
+ <div class="thead" role="row">
605
+ <span role="columnheader">{t('office')}</span>
606
+ <span role="columnheader">{t('office_kind')}</span>
607
+ <span role="columnheader">{t('office_country')}</span>
608
+ <span class="num" role="columnheader">{t('office_people')}</span>
609
+ <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
610
+ </div>
611
+ {#each offices as office (office.id)}
612
+ <div class="trow" role="row">
613
+ <span class="cell stack" role="cell">
614
+ <span class="name">
615
+ {office.name}
616
+ {#if office.isDefault}<Badge tone="accent">{t('office_default')}</Badge>{/if}
617
+ </span>
618
+ {#if office.code}<span class="sub mono">{office.code}</span>{/if}
619
+ </span>
620
+ <span class="cell" role="cell">
621
+ <Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge>
622
+ </span>
623
+ <span class="cell stack" role="cell">
624
+ <span class="sub">{office.city ? `${office.city}, ` : ''}{countryLabel(office.country)}</span>
625
+ <span class="sub mono">{office.timezone}</span>
626
+ </span>
627
+ <span class="cell num" role="cell">{count(office.headcount)}</span>
628
+ <span class="cell end" role="cell">
629
+ <DropdownMenu items={actionsFor(office)} align="end">
630
+ {#snippet trigger(props)}
631
+ <IconButton
632
+ {...props}
633
+ icon="ellipsis"
634
+ size={28}
635
+ label={t('office_actions', { name: office.name })}
636
+ />
637
+ {/snippet}
638
+ </DropdownMenu>
639
+ </span>
640
+ </div>
641
+ {/each}
642
+ </div>
643
+ {/if}
644
+ </SettingsSection>
21
645
  </SettingsPage>
646
+
647
+ <!-- ------------------------------------------------------------------ create and edit -->
648
+ <Dialog
649
+ open={draft !== null}
650
+ size="lg"
651
+ title={draft?.id ? t('office_edit_title', { name: draft.name }) : t('office_new')}
652
+ onOpenChange={(open) => {
653
+ if (!open) draft = null
654
+ }}
655
+ >
656
+ {#if draft}
657
+ <form
658
+ class="form"
659
+ onsubmit={(event) => {
660
+ event.preventDefault()
661
+ submitDraft()
662
+ }}
663
+ >
664
+ <SectionLabel sub label={t('office_form_what')} />
665
+ <div class="pair">
666
+ <Field label={t('display_name')} required>
667
+ {#snippet children(id)}
668
+ <Input {id} bind:value={draft!.name} autocomplete="off" />
669
+ {/snippet}
670
+ </Field>
671
+ <Field label={t('office_kind')}>
672
+ {#snippet children(id)}
673
+ <Select {id} bind:value={draft!.kind} options={kindChoices} placeholder={t('choose')} />
674
+ {/snippet}
675
+ </Field>
676
+ </div>
677
+ <div class="pair">
678
+ <Field label={t('office_code')} hint={t('office_code_hint')}>
679
+ {#snippet children(id)}
680
+ <Input {id} mono bind:value={draft!.code} autocomplete="off" />
681
+ {/snippet}
682
+ </Field>
683
+ {#if draft.id === null}
684
+ <Field label={t('office_parent')} hint={t('office_parent_hint')}>
685
+ {#snippet children(id)}
686
+ <Select
687
+ {id}
688
+ bind:value={draft!.parentOfficeId}
689
+ options={parentChoices}
690
+ placeholder={t('office_parent_none')}
691
+ />
692
+ {/snippet}
693
+ </Field>
694
+ {/if}
695
+ </div>
696
+
697
+ <SectionLabel sub label={t('office_form_where')} />
698
+ <div class="pair">
699
+ <Field label={t('office_country')} required>
700
+ {#snippet children(id)}
701
+ <Select {id} bind:value={draft!.country} options={countries} placeholder={t('choose')} />
702
+ {/snippet}
703
+ </Field>
704
+ <Field label={t('office_city')} hint={t('common.optional')}>
705
+ {#snippet children(id)}
706
+ <Input {id} bind:value={draft!.city} autocomplete="off" />
707
+ {/snippet}
708
+ </Field>
709
+ </div>
710
+ <div class="pair">
711
+ <Field label={t('office_region')} hint={t('office_region_hint')}>
712
+ {#snippet children(id)}
713
+ <Input {id} mono bind:value={draft!.region} autocomplete="off" />
714
+ {/snippet}
715
+ </Field>
716
+ <Field label={t('office_timezone')} hint={t('office_timezone_hint')} required>
717
+ {#snippet children(id)}
718
+ <Select {id} bind:value={draft!.timezone} options={timezones} placeholder={t('choose')} />
719
+ {/snippet}
720
+ </Field>
721
+ </div>
722
+
723
+ {#if draft.id === null || hasEntities || hasCalendars}
724
+ <SectionLabel sub label={t('office_form_follows')} />
725
+ {/if}
726
+ {#if draft.id === null}
727
+ <!--
728
+ The one control on this form that decides something a person cannot see: without the pack
729
+ the office shares the workspace calendar, and nobody would guess that from a checkbox.
730
+ -->
731
+ <Checkbox
732
+ bind:checked={draft!.seedCalendarFromPack}
733
+ label={t('office_seed')}
734
+ description={t('office_seed_desc')}
735
+ />
736
+ {/if}
737
+ <div class="pair">
738
+ {#if hasEntities}
739
+ <Field label={t('office_entity')} hint={t('office_entity_hint')}>
740
+ {#snippet children(id)}
741
+ <Select
742
+ {id}
743
+ bind:value={draft!.legalEntityId}
744
+ options={entityChoices}
745
+ disabled={entitiesQuery.isLoading}
746
+ placeholder={t('office_entity_none')}
747
+ />
748
+ {/snippet}
749
+ </Field>
750
+ {/if}
751
+ {#if draft.id !== null && hasCalendars}
752
+ <Field label={t('office_calendar')} hint={t('office_calendar_hint')}>
753
+ {#snippet children(id)}
754
+ <Select
755
+ {id}
756
+ bind:value={draft!.calendarId}
757
+ options={calendarChoices}
758
+ disabled={calendarsQuery.isLoading}
759
+ placeholder={t('office_calendar_default')}
760
+ />
761
+ {/snippet}
762
+ </Field>
763
+ {/if}
764
+ </div>
765
+ {#if draft.id !== null}
766
+ <Field label={t('office_head')} hint={t('common.optional')}>
767
+ {#snippet children(id)}
768
+ <Select
769
+ {id}
770
+ bind:value={draft!.headPersonId}
771
+ options={headChoices}
772
+ disabled={directoryQuery.isLoading}
773
+ placeholder={t('office_head_none')}
774
+ />
775
+ {/snippet}
776
+ </Field>
777
+ {/if}
778
+
779
+ {#if formError}<p class="err" role="alert">{formError}</p>{/if}
780
+ </form>
781
+ {/if}
782
+
783
+ {#snippet footer()}
784
+ <Button variant="ghost" onclick={() => (draft = null)}>{t('common.cancel')}</Button>
785
+ <Button onclick={submitDraft} disabled={!draftValid || !canManage} loading={saving}>
786
+ {draft?.id ? t('common.save') : t('office_add')}
787
+ </Button>
788
+ {/snippet}
789
+ </Dialog>
790
+
791
+ <!-- ------------------------------------------------------------------ make default -->
792
+ <Dialog
793
+ open={makingDefault !== null}
794
+ size="sm"
795
+ title={t('office_default_title', { name: makingDefault?.name ?? '' })}
796
+ onOpenChange={(open) => {
797
+ if (!open) makingDefaultId = null
798
+ }}
799
+ >
800
+ <p class="body">
801
+ {t('office_default_body', {
802
+ current: currentDefault?.name ?? '',
803
+ name: makingDefault?.name ?? '',
804
+ })}
805
+ </p>
806
+ {#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
807
+
808
+ {#snippet footer()}
809
+ <Button variant="ghost" onclick={() => (makingDefaultId = null)}>{t('common.cancel')}</Button>
810
+ <Button
811
+ loading={acting}
812
+ onclick={() => {
813
+ if (!makingDefault || acting) return
814
+ acting = true
815
+ actionError = null
816
+ setDefault.mutate({ officeId: makingDefault.id, name: makingDefault.name })
817
+ }}
818
+ >
819
+ {t('office_make_default')}
820
+ </Button>
821
+ {/snippet}
822
+ </Dialog>
823
+
824
+ <!-- ------------------------------------------------------------------ archive -->
825
+ <Dialog
826
+ open={archiving !== null}
827
+ size="sm"
828
+ title={t('office_archive_title', { name: archiving?.name ?? '' })}
829
+ onOpenChange={(open) => {
830
+ if (!open) archivingId = null
831
+ }}
832
+ >
833
+ <p class="body">
834
+ {archiving && archiving.headcount > 0
835
+ ? t('office_archive_body', { count: archiving.headcount })
836
+ : t('office_archive_body_empty')}
837
+ </p>
838
+ <p class="body muted">{t('office_archive_note')}</p>
839
+ {#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
840
+
841
+ {#snippet footer()}
842
+ <Button variant="ghost" onclick={() => (archivingId = null)}>{t('common.cancel')}</Button>
843
+ <Button
844
+ variant="danger"
845
+ loading={acting}
846
+ onclick={() => {
847
+ if (!archiving || acting) return
848
+ acting = true
849
+ actionError = null
850
+ archive.mutate({ officeId: archiving.id, name: archiving.name })
851
+ }}
852
+ >
853
+ {t('common.archive')}
854
+ </Button>
855
+ {/snippet}
856
+ </Dialog>
857
+
858
+ <!-- ------------------------------------------------------------------ the roster -->
859
+ <Sheet
860
+ open={rosterOffice !== null}
861
+ width={480}
862
+ title={t('office_people_title', { name: rosterOffice?.name ?? '' })}
863
+ onOpenChange={(open) => {
864
+ if (!open) rosterOfficeId = null
865
+ }}
866
+ >
867
+ <p class="explainer">{t('office_primary_explainer')}</p>
868
+
869
+ <div class="filter">
870
+ <SegmentedControl
871
+ size="sm"
872
+ label={t('office_people')}
873
+ bind:value={rosterFilter}
874
+ items={[
875
+ { value: 'all', label: t('office_people_all') },
876
+ { value: 'primary', label: t('office_people_primary_only') },
877
+ ]}
878
+ />
879
+ </div>
880
+
881
+ {#if rosterQuery.isLoading}
882
+ <div class="rows">
883
+ {#each [1, 2, 3] as n (n)}<Skeleton height="44px" />{/each}
884
+ </div>
885
+ {:else if rosterQuery.isError}
886
+ <EmptyState compact icon="triangle-alert" title={t('office_people_error')}>
887
+ {#snippet actions()}
888
+ <Button size="sm" variant="secondary" onclick={() => void rosterQuery.refetch()}>{t('retry')}</Button>
889
+ {/snippet}
890
+ </EmptyState>
891
+ {:else if roster.length === 0}
892
+ <!--
893
+ Two different emptinesses. "Nobody works here" is wrong when the filter is on and somebody
894
+ does work here without this being the office that decides for them.
895
+ -->
896
+ <EmptyState
897
+ compact
898
+ icon="user"
899
+ title={rosterFilter === 'primary' ? t('office_people_none_primary') : t('office_people_none')}
900
+ description={t('office_people_none_desc')}
901
+ />
902
+ {:else}
903
+ <ul class="roster">
904
+ {#each roster as person (person.id)}
905
+ <li>
906
+ <span class="stack">
907
+ <span class="name">{person.displayName}</span>
908
+ <span class="sub">
909
+ {#if person.isPrimaryHere}
910
+ <Badge tone="accent">{t('office_primary_here')}</Badge>
911
+ {:else}
912
+ <Badge tone="grey">{t('office_also_here')}</Badge>
913
+ {/if}
914
+ </span>
915
+ </span>
916
+ {#if canAssign}
917
+ <Button
918
+ size="sm"
919
+ variant="ghost"
920
+ aria-label={t('office_unassign_label', { name: person.displayName })}
921
+ onclick={() => {
922
+ actionError = null
923
+ unassignUntil = isoDate()
924
+ unassigning = {
925
+ personId: person.id,
926
+ name: person.displayName,
927
+ primary: person.isPrimaryHere,
928
+ }
929
+ }}
930
+ >
931
+ {t('common.remove')}
932
+ </Button>
933
+ {/if}
934
+ </li>
935
+ {/each}
936
+ </ul>
937
+ {/if}
938
+
939
+ {#if canAssign}
940
+ <form
941
+ class="assign"
942
+ onsubmit={(event) => {
943
+ event.preventDefault()
944
+ submitAssignment()
945
+ }}
946
+ >
947
+ <SectionLabel sub label={t('office_assign')} />
948
+ <Field label={t('office_assign_person')} required>
949
+ {#snippet children(id)}
950
+ <Select
951
+ {id}
952
+ bind:value={assignPersonId}
953
+ options={peopleChoices}
954
+ disabled={directoryQuery.isLoading}
955
+ placeholder={directory.length === 0 ? t('no_people') : t('choose')}
956
+ />
957
+ {/snippet}
958
+ </Field>
959
+ <Field label={t('office_assign_from')} required>
960
+ {#snippet children(id)}
961
+ <Input {id} type="date" bind:value={assignFrom} />
962
+ {/snippet}
963
+ </Field>
964
+ <Checkbox
965
+ bind:checked={assignPrimary}
966
+ label={t('office_assign_primary')}
967
+ description={t('office_assign_primary_desc')}
968
+ />
969
+ <Field label={t('office_assign_reason')} hint={t('common.optional')}>
970
+ {#snippet children(id)}
971
+ <Input {id} bind:value={assignReason} autocomplete="off" />
972
+ {/snippet}
973
+ </Field>
974
+ {#if assignError}<p class="err" role="alert">{assignError}</p>{/if}
975
+ <div class="end">
976
+ <Button
977
+ type="submit"
978
+ size="sm"
979
+ disabled={!assignPersonId || !assignFrom}
980
+ loading={assigning}
981
+ >
982
+ {t('office_assign_submit')}
983
+ </Button>
984
+ </div>
985
+ </form>
986
+ {/if}
987
+ </Sheet>
988
+
989
+ <!-- ------------------------------------------------------------------ unassign -->
990
+ <Dialog
991
+ open={unassigning !== null}
992
+ size="sm"
993
+ title={t('office_unassign_title', {
994
+ name: unassigning?.name ?? '',
995
+ office: rosterOffice?.name ?? '',
996
+ })}
997
+ onOpenChange={(open) => {
998
+ if (!open) unassigning = null
999
+ }}
1000
+ >
1001
+ <p class="body">
1002
+ {unassigning?.primary
1003
+ ? t('office_unassign_body_primary')
1004
+ : t('office_unassign_body_other', { office: rosterOffice?.name ?? '' })}
1005
+ </p>
1006
+ <Field label={t('office_unassign_until')} required>
1007
+ {#snippet children(id)}
1008
+ <Input {id} type="date" bind:value={unassignUntil} />
1009
+ {/snippet}
1010
+ </Field>
1011
+ {#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
1012
+
1013
+ {#snippet footer()}
1014
+ <Button variant="ghost" onclick={() => (unassigning = null)}>{t('common.cancel')}</Button>
1015
+ <Button variant="danger" loading={acting} disabled={!unassignUntil} onclick={confirmUnassign}>
1016
+ {t('common.remove')}
1017
+ </Button>
1018
+ {/snippet}
1019
+ </Dialog>
1020
+
1021
+ <style>
1022
+ .tiles {
1023
+ display: grid;
1024
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1025
+ gap: 12px;
1026
+ }
1027
+ .rows {
1028
+ display: grid;
1029
+ gap: 4px;
1030
+ }
1031
+ .pad {
1032
+ padding: 4px 18px 18px;
1033
+ }
1034
+
1035
+ /* One grid for the header and every row, so the columns line up down the page. */
1036
+ .table {
1037
+ --hr-office-cols: minmax(160px, 1.6fr) 116px minmax(130px, 1fr) 64px 36px;
1038
+ width: 100%;
1039
+ }
1040
+ .thead,
1041
+ .trow {
1042
+ display: grid;
1043
+ grid-template-columns: var(--hr-office-cols);
1044
+ gap: 12px;
1045
+ align-items: center;
1046
+ padding-inline: 18px;
1047
+ }
1048
+ .thead {
1049
+ height: 32px;
1050
+ border-block-end: 1px solid var(--kern-border);
1051
+ font-size: 11px;
1052
+ font-weight: 600;
1053
+ letter-spacing: 0.06em;
1054
+ text-transform: uppercase;
1055
+ color: var(--kern-ink-500);
1056
+ }
1057
+ .trow {
1058
+ min-height: 56px;
1059
+ border-block-end: 1px solid var(--kern-border-hairline);
1060
+ }
1061
+ .trow:last-child {
1062
+ border-block-end: 0;
1063
+ }
1064
+ .trow:hover {
1065
+ background: var(--kern-surface-hover);
1066
+ }
1067
+ .cell {
1068
+ min-width: 0;
1069
+ overflow: hidden;
1070
+ text-overflow: ellipsis;
1071
+ white-space: nowrap;
1072
+ }
1073
+ .stack {
1074
+ display: flex;
1075
+ flex-direction: column;
1076
+ gap: 2px;
1077
+ }
1078
+ .name {
1079
+ display: flex;
1080
+ align-items: center;
1081
+ gap: 8px;
1082
+ min-width: 0;
1083
+ font-size: 13.5px;
1084
+ font-weight: 500;
1085
+ }
1086
+ .sub {
1087
+ font-size: 12px;
1088
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
1089
+ color: var(--kern-ink-500);
1090
+ }
1091
+ .mono {
1092
+ font-family: var(--kern-font-mono);
1093
+ }
1094
+ .num {
1095
+ text-align: end;
1096
+ font-size: 13px;
1097
+ color: var(--kern-ink-500);
1098
+ font-variant-numeric: tabular-nums;
1099
+ }
1100
+ .end {
1101
+ display: flex;
1102
+ justify-content: flex-end;
1103
+ overflow: visible;
1104
+ }
1105
+ .sr-only {
1106
+ position: absolute;
1107
+ width: 1px;
1108
+ height: 1px;
1109
+ overflow: hidden;
1110
+ clip-path: inset(50%);
1111
+ white-space: nowrap;
1112
+ }
1113
+
1114
+ .form {
1115
+ display: grid;
1116
+ gap: 12px;
1117
+ }
1118
+ .pair {
1119
+ display: grid;
1120
+ grid-template-columns: 1fr 1fr;
1121
+ gap: 12px;
1122
+ }
1123
+ .body {
1124
+ margin: 0 0 12px;
1125
+ font-size: 13.5px;
1126
+ line-height: 1.55;
1127
+ color: var(--kern-ink-700);
1128
+ text-wrap: pretty;
1129
+ }
1130
+ .body.muted {
1131
+ color: var(--kern-ink-500);
1132
+ }
1133
+ .err {
1134
+ margin: 0;
1135
+ font-size: 12.5px;
1136
+ color: var(--kern-danger);
1137
+ }
1138
+
1139
+ .explainer {
1140
+ margin: 0 0 14px;
1141
+ padding: 10px 12px;
1142
+ border: 1px solid var(--kern-border-hairline);
1143
+ border-radius: var(--kern-r-lg);
1144
+ background: var(--kern-surface-chip);
1145
+ font-size: 12.5px;
1146
+ line-height: 1.55;
1147
+ color: var(--kern-ink-700);
1148
+ text-wrap: pretty;
1149
+ }
1150
+ .filter {
1151
+ margin-block-end: 10px;
1152
+ }
1153
+ .roster {
1154
+ display: grid;
1155
+ gap: 4px;
1156
+ list-style: none;
1157
+ margin: 0;
1158
+ padding: 0;
1159
+ }
1160
+ .roster li {
1161
+ display: flex;
1162
+ align-items: center;
1163
+ justify-content: space-between;
1164
+ gap: 12px;
1165
+ min-height: 44px;
1166
+ padding: 6px 10px;
1167
+ border: 1px solid var(--kern-border-hairline);
1168
+ border-radius: var(--kern-r-md);
1169
+ }
1170
+ .assign {
1171
+ display: grid;
1172
+ gap: 12px;
1173
+ margin-block-start: 20px;
1174
+ padding-block-start: 16px;
1175
+ border-block-start: 1px solid var(--kern-border);
1176
+ }
1177
+
1178
+ @media (max-width: 720px) {
1179
+ .table {
1180
+ --hr-office-cols: minmax(140px, 1.6fr) minmax(110px, 1fr) 64px 36px;
1181
+ }
1182
+ /* The kind badge is the column a narrow screen can lose: it is the least load-bearing fact. */
1183
+ .thead > :nth-child(2),
1184
+ .trow > :nth-child(2) {
1185
+ display: none;
1186
+ }
1187
+ .pair {
1188
+ grid-template-columns: 1fr;
1189
+ }
1190
+ }
1191
+ </style>