@kernhq/module-hr 0.13.1 → 0.14.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.
Files changed (61) hide show
  1. package/dist/contract/attendance.d.ts +35 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +25 -1
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts.map +1 -1
  6. package/dist/contract/capabilities.js +10 -0
  7. package/dist/contract/capabilities.js.map +1 -1
  8. package/dist/contract/events.d.ts +17 -16
  9. package/dist/contract/events.d.ts.map +1 -1
  10. package/dist/contract/events.js +23 -16
  11. package/dist/contract/events.js.map +1 -1
  12. package/dist/contract/models.d.ts +1 -0
  13. package/dist/contract/models.d.ts.map +1 -1
  14. package/dist/contract/models.js +11 -0
  15. package/dist/contract/models.js.map +1 -1
  16. package/dist/contract/router.d.ts +12 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +11 -3
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/contract/settings.d.ts +0 -1
  21. package/dist/contract/settings.d.ts.map +1 -1
  22. package/dist/contract/settings.js +11 -5
  23. package/dist/contract/settings.js.map +1 -1
  24. package/dist/server/index.d.ts +0 -1
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +30 -0
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/jobs.d.ts.map +1 -1
  29. package/dist/server/jobs.js +13 -5
  30. package/dist/server/jobs.js.map +1 -1
  31. package/dist/server/router.d.ts +12 -0
  32. package/dist/server/router.d.ts.map +1 -1
  33. package/dist/server/router.js +374 -76
  34. package/dist/server/router.js.map +1 -1
  35. package/dist/server/services/access.d.ts +176 -0
  36. package/dist/server/services/access.d.ts.map +1 -0
  37. package/dist/server/services/access.js +260 -0
  38. package/dist/server/services/access.js.map +1 -0
  39. package/dist/server/services/people.d.ts +1 -0
  40. package/dist/server/services/people.d.ts.map +1 -1
  41. package/dist/server/services/people.js +3 -0
  42. package/dist/server/services/people.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/client/components/DayDetail.svelte +32 -14
  45. package/src/client/components/PersonPanel.svelte +66 -5
  46. package/src/client/components/redaction.ts +51 -0
  47. package/src/client/messages.ts +346 -77
  48. package/src/client/pages/AttendancePage.svelte +24 -1
  49. package/src/client/pages/DirectoryPage.svelte +285 -11
  50. package/src/client/pages/OfficesPage.svelte +6 -0
  51. package/src/client/pages/OrgPage.svelte +7 -0
  52. package/src/client/permissions.ts +12 -4
  53. package/src/client/settings/AccrualSettings.svelte +715 -95
  54. package/src/client/settings/GeneralSettings.svelte +12 -9
  55. package/src/client/settings/SchedulesSettings.svelte +31 -78
  56. package/src/contract/attendance.ts +27 -1
  57. package/src/contract/capabilities.ts +10 -0
  58. package/src/contract/events.ts +23 -19
  59. package/src/contract/models.ts +11 -0
  60. package/src/contract/router.ts +11 -3
  61. package/src/contract/settings.ts +11 -5
@@ -18,6 +18,7 @@ import { getHrApi } from '../api-instance.js'
18
18
  import ClockControls from '../components/ClockControls.svelte'
19
19
  import DayDetail from '../components/DayDetail.svelte'
20
20
  import { t } from '../i18n.js'
21
+ import { canHr } from '../permissions.js'
21
22
  import { formatDuration, hrKeys, monthRange } from '../query.js'
22
23
 
23
24
  /**
@@ -47,6 +48,26 @@ const workspaceId = $derived(workspace?.id ?? '')
47
48
 
48
49
  const range = $derived(monthRange())
49
50
 
51
+ /**
52
+ * The clock is the punch permission, and nothing else on this page is.
53
+ *
54
+ * `hr.attendance.view` is what opens this route — reading your own month — while every one of the
55
+ * four transitions behind `ClockControls` is `requires('hr.attendance.punch')` on the server. A
56
+ * workspace revokes that from somebody whose punches arrive from a badge terminal or from their
57
+ * manager, and for them the clock row was a set of buttons that can only answer 403 — an offer the
58
+ * product cannot honour.
59
+ *
60
+ * Hidden rather than disabled-with-a-reason, because this is a permission and not a state: they may
61
+ * never do it, there is nothing to wait for, and "you cannot clock in" repeated on every visit
62
+ * teaches nothing. It is also the same answer the dashboard already gives — `module.ts` declares the
63
+ * clock widget with `permission: attendancePunch`, so the shell has always hidden this exact
64
+ * component for these people. The page was the one place it leaked.
65
+ *
66
+ * The status line goes with the buttons it captions; the month underneath is what a viewer keeps,
67
+ * and today is a row in it.
68
+ */
69
+ const canPunch = $derived(canHr('attendancePunch'))
70
+
50
71
  const daysQuery = createQuery(() => ({
51
72
  queryKey: hrKeys.attendanceDays(workspaceId, undefined, range.from, range.to),
52
73
  enabled: Boolean(workspaceId),
@@ -168,7 +189,9 @@ const dayLabel = (iso: string) =>
168
189
  {/snippet}
169
190
 
170
191
  <Page>
171
- <ClockControls {workspaceId} />
192
+ {#if canPunch}
193
+ <ClockControls {workspaceId} />
194
+ {/if}
172
195
 
173
196
  {#if totalsUnknown}
174
197
  {@render tilesUnknown()}
@@ -8,6 +8,7 @@ import {
8
8
  EmptyState,
9
9
  formatCount,
10
10
  formatDate,
11
+ Icon,
11
12
  Input,
12
13
  messageLocale,
13
14
  navigation,
@@ -24,6 +25,7 @@ import { getHrApi } from '../api-instance.js'
24
25
  import DecisionDialog from '../components/DecisionDialog.svelte'
25
26
  import PersonFormDialog from '../components/PersonFormDialog.svelte'
26
27
  import PersonPanel from '../components/PersonPanel.svelte'
28
+ import { personnelVisibility } from '../components/redaction.js'
27
29
  import { t } from '../i18n.js'
28
30
  import type { ApprovalRequest } from '../index.js'
29
31
  import { canHr, HR_CAPABILITIES } from '../permissions.js'
@@ -48,11 +50,101 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
48
50
  const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
49
51
  const workspaceId = $derived(workspace?.id ?? '')
50
52
 
53
+ /**
54
+ * A query parameter is whatever somebody pasted into the address bar, and the contract types both
55
+ * filters as uuids — so a truncated or hand-edited link would be refused by the server and this
56
+ * screen would draw a red error where a filter was meant. Anything that is not a uuid is no filter.
57
+ */
58
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
59
+ const asId = (value: string | undefined): string | null => (value && UUID.test(value) ? value : null)
60
+
51
61
  let search = $state('')
52
- let officeTab = $state('all')
62
+ /**
63
+ * The filter the directory was opened with.
64
+ *
65
+ * Both are links from somewhere else — an office row on the offices screen, "See the people" on a
66
+ * department — and until this read the query string they landed on the unfiltered company
67
+ * directory, which reads as the link being broken rather than as a filter that never applied.
68
+ *
69
+ * Seeded here rather than in the `$effect` below, and that is load-bearing: an effect runs after
70
+ * the first render, so `people.list` would fetch and draw the whole company for a beat before
71
+ * narrowing to the office somebody actually clicked.
72
+ */
73
+ let officeTab = $state(asId(navigation.search.officeId) ?? 'all')
74
+ let orgUnitId = $state<string | null>(asId(navigation.search.orgUnit))
53
75
  const selected = $derived(navigation.search.person)
54
76
  const creating = $derived(navigation.search.new === '1')
55
77
 
78
+ /**
79
+ * The URL keeps seeding the filter after that first render: arriving from the offices screen while
80
+ * the directory is already mounted changes the query string without remounting anything, and the
81
+ * back button is a filter change too. It only ever writes what it does not read, so there is no loop.
82
+ */
83
+ $effect(() => {
84
+ officeTab = asId(navigation.search.officeId) ?? 'all'
85
+ orgUnitId = asId(navigation.search.orgUnit)
86
+ })
87
+
88
+ /**
89
+ * A filter change is written to the URL as well as to state, so the address bar always describes
90
+ * what is on screen: a reload, a shared link and the back button all land on the same directory.
91
+ * `person` and `new` are carried through rather than rebuilt, or switching office would close a
92
+ * panel somebody has open.
93
+ *
94
+ * `replaceState` because a filter is not a place — the way back from a filtered directory is the
95
+ * screen that linked into it, not one history entry per pill.
96
+ */
97
+ function writeFilter(patch: { officeId?: string | null; orgUnit?: string | null }) {
98
+ const params = new URLSearchParams(navigation.search)
99
+ for (const [key, value] of Object.entries(patch)) {
100
+ if (value) params.set(key, value)
101
+ else params.delete(key)
102
+ }
103
+ const query = params.toString()
104
+ navigation.go(`/${workspaceSlug}/hr${query ? `?${query}` : ''}`, {
105
+ replaceState: true,
106
+ keepFocus: true,
107
+ noScroll: true,
108
+ })
109
+ }
110
+
111
+ /** State first, URL second: the shell's router is asynchronous and the pills must not lag a click. */
112
+ function chooseOffice(value: string) {
113
+ officeTab = value
114
+ writeFilter({ officeId: value === 'all' ? null : value })
115
+ }
116
+
117
+ /**
118
+ * Clears the search along with the office and the department.
119
+ *
120
+ * The empty state that offers this button is reached when a filter is on, which includes the case
121
+ * where a *search* inside that filter is what found nobody. Clearing only the filter there leaves
122
+ * the term in the box, so the table can still be empty after the click — a button that visibly does
123
+ * nothing, on the one screen where the reader has already failed to find someone.
124
+ */
125
+ function clearFilters() {
126
+ officeTab = 'all'
127
+ orgUnitId = null
128
+ search = ''
129
+ debounced = ''
130
+ writeFilter({ officeId: null, orgUnit: null })
131
+ }
132
+
133
+ /**
134
+ * A row link carries the filter it was opened from.
135
+ *
136
+ * Now that the filter is in the query string, a bare `?person=…` is also a request to show the
137
+ * whole company — so opening somebody from an office directory would quietly re-fetch and redraw
138
+ * every person in the workspace behind the panel. `new` is dropped rather than kept: two dialogs
139
+ * over one directory is not a state anything should be able to link to.
140
+ */
141
+ function personHref(personId: string): string {
142
+ const params = new URLSearchParams(navigation.search)
143
+ params.delete('new')
144
+ params.set('person', personId)
145
+ return `/${workspaceSlug}/hr?${params.toString()}`
146
+ }
147
+
56
148
  const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
57
149
  /**
58
150
  * Leave is a capability, and a workspace that never switched it on has no balance to show.
@@ -79,6 +171,23 @@ $effect(() => {
79
171
  return () => clearTimeout(handle)
80
172
  })
81
173
 
174
+ /**
175
+ * What the filter asks the server for, shared by the table and the counts above it.
176
+ *
177
+ * `includeDescendants` is passed rather than left to the contract's default: the number on the
178
+ * "See the people" button that links here is the department's *subtree* total, and a directory
179
+ * holding fewer people than the link promised is the same defect one layer down.
180
+ */
181
+ const scopeArgs = $derived({
182
+ ...(officeTab !== 'all' ? { officeId: officeTab } : {}),
183
+ ...(orgUnitId ? { orgUnitId, includeDescendants: true } : {}),
184
+ })
185
+ /**
186
+ * The same filter as a cache key. `'all'` is spelled out rather than left absent, so that no two
187
+ * scopes ever hash alike and the cache cannot answer a filtered directory with the whole company.
188
+ */
189
+ const scopeKey = $derived({ officeId: officeTab, orgUnitId: orgUnitId ?? 'all' })
190
+
82
191
  const officesQuery = createQuery(() => ({
83
192
  queryKey: hrKeys.offices(workspaceId),
84
193
  enabled: Boolean(workspaceId) && showOffices && canHr('officeView'),
@@ -87,24 +196,77 @@ const officesQuery = createQuery(() => ({
87
196
  const offices = $derived(officesQuery.data ?? [])
88
197
 
89
198
  const peopleQuery = createQuery(() => ({
90
- queryKey: hrKeys.people(workspaceId, { q: debounced, officeId: officeTab }),
199
+ queryKey: hrKeys.people(workspaceId, { ...scopeKey, q: debounced }),
91
200
  enabled: Boolean(workspaceId),
92
201
  queryFn: () =>
93
202
  api.people.list({
94
203
  workspaceId,
95
204
  limit: 100,
96
205
  ...(debounced ? { q: debounced } : {}),
97
- ...(officeTab !== 'all' ? { officeId: officeTab } : {}),
206
+ ...scopeArgs,
98
207
  }),
99
208
  }))
100
209
  const people = $derived(peopleQuery.data?.items ?? [])
101
210
 
211
+ /**
212
+ * The tiles count the company, not the page.
213
+ *
214
+ * `people.list` returns a `total` for the whole filter, so both numbers are asked of the server
215
+ * with `limit: 1` rather than counted off the rows on screen. Counting the rows made "On leave"
216
+ * fall as somebody typed a name — the table narrows on every keystroke and stops at a hundred rows
217
+ * either way — which is a tile reporting the search box while wearing the label of a company fact.
218
+ *
219
+ * The search is deliberately not part of these: a tile describes the scope the directory is
220
+ * showing, and finding one person inside it does not change how many of them are away. The filter
221
+ * *is* part of them, so an office's directory shows that office's numbers.
222
+ */
223
+ const headcountQuery = createQuery(() => ({
224
+ queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'headcount' }),
225
+ enabled: Boolean(workspaceId),
226
+ queryFn: () => api.people.list({ workspaceId, limit: 1, ...scopeArgs }),
227
+ }))
228
+
229
+ const onLeaveQuery = createQuery(() => ({
230
+ queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'on_leave' }),
231
+ enabled: Boolean(workspaceId),
232
+ queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['on_leave'], ...scopeArgs }),
233
+ }))
234
+
235
+ /**
236
+ * Only to name the department in the filter chip, so it is asked for only when there is one to
237
+ * name. It shares `hrKeys.orgUnits` with the org chart, which is where "See the people" is clicked
238
+ * — so arriving from there costs no request at all.
239
+ */
240
+ const orgUnitsQuery = createQuery(() => ({
241
+ queryKey: hrKeys.orgUnits(workspaceId),
242
+ enabled: Boolean(workspaceId) && orgUnitId !== null && canHr('orgView'),
243
+ queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
244
+ }))
245
+ const orgUnitName = $derived(orgUnitsQuery.data?.find((u) => u.id === orgUnitId)?.name ?? null)
246
+
102
247
  /** Office tabs only once there is more than one place of work — otherwise they say nothing. */
103
248
  const tabs = $derived([
104
249
  { value: 'all', label: t('title') },
105
250
  ...offices.map((o) => ({ value: o.id, label: o.name })),
106
251
  ])
107
252
 
253
+ /**
254
+ * Whether the office filter has to be said in words.
255
+ *
256
+ * A selected pill already says it, so this is for what the tabs cannot cover: a workspace with one
257
+ * office (no tabs at all), a viewer without `hr.office.view`, and an id that is not in the list any
258
+ * more — an archived office whose link somebody kept. There is no name to give in any of those
259
+ * cases, which is why the chip's wording does not promise one.
260
+ *
261
+ * While the offices are still loading there is nothing to say yet and the table is drawing
262
+ * skeletons, so it waits rather than flashing a sentence a pill is about to replace.
263
+ */
264
+ const officeNeedsChip = $derived(
265
+ officeTab !== 'all' && !officesQuery.isLoading && !tabs.some((tab) => tab.value === officeTab),
266
+ )
267
+ /** Any filter at all — what decides whether an empty table is "nobody yet" or "nobody here". */
268
+ const filtered = $derived(officeTab !== 'all' || orgUnitId !== null)
269
+
108
270
  const balancesQuery = createQuery(() => ({
109
271
  queryKey: hrKeys.leaveBalance(workspaceId, undefined),
110
272
  enabled: Boolean(workspaceId) && showBalance,
@@ -205,9 +367,9 @@ const SUBJECT_LABELS: Record<string, () => string> = {
205
367
  const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
206
368
 
207
369
  const stats = $derived({
208
- headcount: peopleQuery.data?.total ?? people.length,
370
+ headcount: headcountQuery.data?.total ?? null,
209
371
  offices: offices.length,
210
- away: people.filter((p) => p.status === 'on_leave').length,
372
+ away: onLeaveQuery.data?.total ?? null,
211
373
  balance: balancesQuery.data?.[0]?.available ?? 0,
212
374
  })
213
375
 
@@ -265,8 +427,33 @@ function localTime(timezone: string | null, _tick: number): string | null {
265
427
  const started = (iso: string | null) =>
266
428
  iso ? formatDate(`${iso}T00:00:00`, { month: 'short', year: 'numeric' }) : '—'
267
429
 
430
+ /**
431
+ * Whether this row's start date is blank because the server withheld it.
432
+ *
433
+ * The hire date is one of the four fields `HrAccessService` nulls for a reader outside its scope,
434
+ * and it arrives here as a null like any other — so the em dash this column drew for it said
435
+ * "never started", about everybody in the company, to every colleague without a widening key.
436
+ *
437
+ * `personnelVisibility` answers `unknown` for a reader whose scope only the server can resolve, and
438
+ * an unknown row keeps the dash: a dash over an empty field is imprecise, and "Hidden" over one is
439
+ * a lie. Both are marked once, under the table, rather than per row.
440
+ */
441
+ const startWithheld = (person: { userId: string | null; hiredOn: string | null }) =>
442
+ !person.hiredOn && personnelVisibility(person) === 'withheld'
443
+
444
+ /** Whether anything on the page is actually marked — the sentence must not outlive the marks. */
445
+ const anyWithheld = $derived(people.some(startWithheld))
446
+
268
447
  /** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
269
448
  const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
449
+
450
+ /**
451
+ * A count nobody has yet is an em dash, never a zero.
452
+ *
453
+ * "0 on leave" is a fact about the company, and a tile stating it while the request is still in
454
+ * flight — or after it failed — is the same lie the tile was counting rows to tell.
455
+ */
456
+ const tileCount = (n: number | null) => (n === null ? '—' : count(n))
270
457
  </script>
271
458
 
272
459
  <PageHeader
@@ -283,7 +470,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
283
470
 
284
471
  <Page>
285
472
  <div class="tiles">
286
- <StatTile size="md" label={t('widget_headcount_title')} value={count(stats.headcount)} />
473
+ <StatTile size="md" label={t('widget_headcount_title')} value={tileCount(stats.headcount)} />
287
474
  <!--
288
475
  Only where the workspace has offices. It rendered unconditionally and read "Offices 0" on a
289
476
  single-site workspace — a tile counting a feature nobody switched on, sitting beside three
@@ -292,7 +479,11 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
292
479
  {#if showOffices}
293
480
  <StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
294
481
  {/if}
295
- <StatTile size="md" label={t('status_on_leave')} value={count(stats.away)} />
482
+ <!--
483
+ The number comes from the server, and the note says which number it is: "On leave" over a
484
+ figure could as easily mean this week or this month, and it means neither.
485
+ -->
486
+ <StatTile size="md" label={t('status_on_leave')} value={tileCount(stats.away)} note={t('on_leave_note')} />
296
487
  <!-- Same rule for leave: the balance tile is the surface of a capability, so it goes with it. -->
297
488
  {#if showBalance}
298
489
  <StatTile
@@ -310,7 +501,28 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
310
501
 
311
502
  <div class="filters">
312
503
  {#if tabs.length > 1}
313
- <Tabs items={tabs} value={officeTab} variant="pill" onValueChange={(v) => (officeTab = v)} />
504
+ <Tabs items={tabs} value={officeTab} variant="pill" label={t('office')} onValueChange={chooseOffice} />
505
+ {/if}
506
+ <!--
507
+ What the pills cannot say. A directory that opened filtered and says nothing about it is
508
+ indistinguishable from a directory that lost half the company, so anything the tabs are not
509
+ already showing gets a chip — and one control puts every filter back at once.
510
+ -->
511
+ {#if officeNeedsChip || orgUnitId}
512
+ <div class="active">
513
+ {#if officeNeedsChip}
514
+ <span class="chip">
515
+ <Icon name="building" size={12} strokeWidth={1.8} />{t('filter_office_unnamed')}
516
+ </span>
517
+ {/if}
518
+ {#if orgUnitId}
519
+ <span class="chip">
520
+ <Icon name="git-branch" size={12} strokeWidth={1.8} />
521
+ {orgUnitName ? t('filter_department', { name: orgUnitName }) : t('filter_department_unnamed')}
522
+ </span>
523
+ {/if}
524
+ <Button size="xs" variant="ghost" onclick={clearFilters}>{t('filter_clear')}</Button>
525
+ </div>
314
526
  {/if}
315
527
  <div class="search">
316
528
  <Input bind:value={search} placeholder={t('search_people')} type="search" size="sm" />
@@ -339,7 +551,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
339
551
  </div>
340
552
  {#each people as person (person.id)}
341
553
  {@const time = localTime(person.timezone, tick)}
342
- <a class="trow" role="row" href={`/${workspaceSlug}/hr?person=${person.id}`}>
554
+ <a class="trow" role="row" href={personHref(person.id)}>
343
555
  <span class="cell who" role="cell">
344
556
  <Avatar name={person.displayName} id={person.id} size={28} />
345
557
  <span class="stack">
@@ -349,7 +561,11 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
349
561
  </span>
350
562
  <span class="cell role" role="cell">{person.employeeNo ?? '—'}</span>
351
563
  <span class="cell muted" role="cell">{person.officeName ?? '—'}</span>
352
- <span class="cell muted" role="cell">{started(person.hiredOn)}</span>
564
+ <span class="cell muted" role="cell">
565
+ {#if startWithheld(person)}
566
+ <span class="withheld"><Icon name="eye-off" size={12} strokeWidth={1.8} />{t('person_hidden')}</span>
567
+ {:else}{started(person.hiredOn)}{/if}
568
+ </span>
353
569
  <span class="cell num" role="cell" title={person.timezone ?? ''}>{time ?? '—'}</span>
354
570
  <span class="cell" role="cell">
355
571
  <Badge tone={statusTone(person.status)}>{statusLabel(person.status)}</Badge>
@@ -363,9 +579,30 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
363
579
  <Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
364
580
  {/snippet}
365
581
  </EmptyState>
582
+ <!--
583
+ "Nobody here yet — add the first person" is the right sentence for an empty company and the
584
+ wrong one for an office with nobody in it: it invites somebody to create a second record for
585
+ a person the filter is simply hiding. A filtered miss offers the way out instead.
586
+ -->
587
+ {:else if filtered}
588
+ <EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_filtered')}>
589
+ {#snippet actions()}
590
+ <Button variant="secondary" onclick={clearFilters}>{t('filter_clear')}</Button>
591
+ {/snippet}
592
+ </EmptyState>
593
+ {:else if debounced}
594
+ <EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_search')} />
366
595
  {:else}
367
596
  <EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
368
597
  {/if}
598
+
599
+ <!--
600
+ Once for the table, not once per row. A marked cell says which fact is missing and this says
601
+ why it is missing — a column of "Hidden" with nothing accounting for it reads as a fault.
602
+ -->
603
+ {#if anyWithheld}
604
+ <p class="hint">{t('person_hidden_hint')}</p>
605
+ {/if}
369
606
  </section>
370
607
 
371
608
  <aside>
@@ -458,11 +695,34 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
458
695
  display: flex;
459
696
  align-items: center;
460
697
  gap: 12px;
698
+ /* A department name is as long as somebody named it, and Persian and German run longer than the
699
+ English it was laid out in. The row wraps rather than crushing the search box. */
700
+ flex-wrap: wrap;
461
701
  margin-block: 4px 8px;
462
702
  }
463
703
  .search {
464
704
  margin-inline-start: auto;
465
- width: min(260px, 40%);
705
+ inline-size: min(260px, 40%);
706
+ }
707
+ .active {
708
+ display: flex;
709
+ align-items: center;
710
+ gap: 6px;
711
+ flex-wrap: wrap;
712
+ min-inline-size: 0;
713
+ }
714
+ .chip {
715
+ display: inline-flex;
716
+ align-items: center;
717
+ gap: 5px;
718
+ padding-inline: 9px;
719
+ block-size: 24px;
720
+ border-radius: var(--kern-r-full);
721
+ background: var(--kern-surface-chip);
722
+ font-size: 12px;
723
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
724
+ color: var(--kern-ink-700);
725
+ white-space: nowrap;
466
726
  }
467
727
  .rows {
468
728
  display: grid;
@@ -529,6 +789,20 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
529
789
  .role {
530
790
  font-size: 13px;
531
791
  }
792
+ /* A colour, never opacity, for the same reason `.sub` and `.muted` above use one. */
793
+ .withheld {
794
+ display: inline-flex;
795
+ align-items: center;
796
+ gap: 5px;
797
+ min-inline-size: 0;
798
+ }
799
+ .hint {
800
+ margin: 10px 0 0;
801
+ max-inline-size: 68ch;
802
+ font-size: 12px;
803
+ line-height: 1.45;
804
+ color: var(--kern-ink-500);
805
+ }
532
806
  .num {
533
807
  font-size: 13px;
534
808
  color: var(--kern-ink-500);
@@ -160,6 +160,12 @@ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'gre
160
160
  <span role="columnheader">{t('local_time')}</span>
161
161
  </div>
162
162
  {#each offices as office (office.id)}
163
+ <!--
164
+ `officeId` is read by the directory, which seeds its office filter from it and shows the
165
+ filter it landed with. The parameter name is the whole contract between the two screens:
166
+ renaming it here alone breaks nothing loudly — the link still resolves, and quietly lands
167
+ on the unfiltered company directory, which is exactly how it shipped.
168
+ -->
163
169
  <a
164
170
  class="trow"
165
171
  role="row"
@@ -871,6 +871,13 @@ const tabs = $derived([
871
871
  </div>
872
872
 
873
873
  {#if selected.total > 0}
874
+ <!--
875
+ `orgUnit` is read by the directory, which seeds an org-unit filter from it and asks
876
+ for the whole subtree — which is why the count here is `total` and not `headcount`.
877
+ The parameter name is the whole contract between the two screens: renaming it here
878
+ alone breaks nothing loudly, it just lands on the unfiltered company directory,
879
+ which is exactly how it shipped.
880
+ -->
874
881
  <Button
875
882
  size="sm"
876
883
  variant="secondary"
@@ -23,10 +23,18 @@ export function canHr(permission: HrPermission): boolean {
23
23
  }
24
24
 
25
25
  /**
26
- * Whether the viewer can see anybody's record but their own.
26
+ * Whether the viewer reads the personnel record behind a directory card, for anybody but themselves.
27
27
  *
28
- * Three widths that do not imply one another a country HR manager must not silently become a
29
- * global one so "can this person open somebody else's page" is asked once, here.
28
+ * Not "can they open somebody else's page"everybody can, and should. `hr.person.view` is a
29
+ * `member` default and the directory is meant to be read. What the three widening keys decide is
30
+ * how much of each person comes back: personal email, phone, hire date and termination date are the
31
+ * personnel record, and the server nulls all four for anybody outside the reader's scope.
32
+ *
33
+ * The three do not imply one another — a country HR manager must not silently become a global one —
34
+ * so a screen asks the union once, here. It is only a hint: which *people* fall inside a team or an
35
+ * office is resolved on the server from the org chart, so a screen may not conclude from `true`
36
+ * that a particular person's record is readable. Render what came back; use this to decide whether
37
+ * a "personal details" section is worth offering at all.
30
38
  */
31
- export const canSeeOthers = (): boolean =>
39
+ export const canSeeFullRecords = (): boolean =>
32
40
  canHr('personViewTeam') || canHr('personViewOffice') || canHr('personViewAll')