@kernhq/module-hr 0.14.0 → 0.15.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 (35) hide show
  1. package/dist/contract/router.d.ts +14 -2
  2. package/dist/contract/router.d.ts.map +1 -1
  3. package/dist/contract/router.js +11 -2
  4. package/dist/contract/router.js.map +1 -1
  5. package/dist/server/jobs.d.ts +5 -2
  6. package/dist/server/jobs.d.ts.map +1 -1
  7. package/dist/server/jobs.js +72 -2
  8. package/dist/server/jobs.js.map +1 -1
  9. package/dist/server/router.d.ts +4 -1
  10. package/dist/server/router.d.ts.map +1 -1
  11. package/dist/server/router.js +1 -1
  12. package/dist/server/router.js.map +1 -1
  13. package/dist/server/schema.d.ts +85 -0
  14. package/dist/server/schema.d.ts.map +1 -1
  15. package/dist/server/schema.js +32 -0
  16. package/dist/server/schema.js.map +1 -1
  17. package/dist/server/services/approvals.d.ts +150 -6
  18. package/dist/server/services/approvals.d.ts.map +1 -1
  19. package/dist/server/services/approvals.js +428 -26
  20. package/dist/server/services/approvals.js.map +1 -1
  21. package/migrations/0010_approval_timeouts.sql +28 -0
  22. package/migrations/meta/0010_snapshot.json +4263 -0
  23. package/migrations/meta/_journal.json +8 -1
  24. package/package.json +1 -1
  25. package/src/client/components/DecisionDialog.svelte +99 -7
  26. package/src/client/components/PersonPanel.svelte +5 -5
  27. package/src/client/components/redaction.ts +28 -40
  28. package/src/client/messages.ts +68 -0
  29. package/src/client/mock.ts +256 -15
  30. package/src/client/pages/ApprovalsPage.svelte +256 -27
  31. package/src/client/pages/DirectoryPage.svelte +10 -7
  32. package/src/client/permissions.ts +6 -16
  33. package/src/client/query.ts +2 -2
  34. package/src/client/widgets/ApprovalsWidget.svelte +1 -1
  35. package/src/contract/router.ts +11 -2
@@ -21,7 +21,7 @@ import DelegationDialog from '../components/DelegationDialog.svelte'
21
21
  import { t } from '../i18n.js'
22
22
  import type { ApprovalRequest } from '../index.js'
23
23
  import { canHr, HR_CAPABILITIES } from '../permissions.js'
24
- import { hrKeys } from '../query.js'
24
+ import { hrKeys, isoDate } from '../query.js'
25
25
  import { summarise } from '../summary.js'
26
26
 
27
27
  /**
@@ -38,6 +38,13 @@ import { summarise } from '../summary.js'
38
38
  * Who signs what is not set here — `approvals.chains.*` is a workspace policy and lives in settings.
39
39
  * `approvals.get` is not called either: the inbox already returns each request with its steps and
40
40
  * decisions, so a detail fetch would re-ask a question this page has the answer to.
41
+ *
42
+ * **A delegate decides here too, and the row says whose decision it is.** `approvals.inbox` already
43
+ * returns what somebody's delegate may act on — the engine matches a step against the reader *and*
44
+ * everyone who has delegated to them — but the decision was always filed as the reader's own,
45
+ * which the server then refused, because the reader is not on that step. Sending `onBehalfOfId` is
46
+ * what makes those rows decidable, and it is the one input on this page where being wrong writes
47
+ * the wrong name into an audit trail. So nothing is inferred that is not certain: see `describe`.
41
48
  */
42
49
  const api = getHrApi()
43
50
  const queryClient = useQueryClient()
@@ -47,12 +54,15 @@ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSl
47
54
  const workspaceId = $derived(workspace?.id ?? '')
48
55
 
49
56
  let tab = $state('waiting')
50
- const includeDecided = $derived(tab === 'decided')
57
+ const inboxStatus = $derived(tab === 'decided' ? ('decided' as const) : ('pending' as const))
51
58
 
52
59
  let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
53
60
  let delegating = $state(false)
54
61
  let decideError = $state<string | null>(null)
55
62
 
63
+ /** One person this reader may file a decision as: themselves (`null`), or somebody who delegated. */
64
+ type Identity = { onBehalfOfId: string | null; label: string }
65
+
56
66
  /**
57
67
  * `submitting` rather than `decide.isPending`: the disabled attribute only reaches the confirm
58
68
  * button on the next render, so two quick clicks both fire and one request is decided twice. This
@@ -73,19 +83,183 @@ const hasChains = $derived(session.hasCapability('hr', HR_CAPABILITIES.approvals
73
83
  const showDelegation = $derived(hasChains && canHr('approvalDelegate'))
74
84
 
75
85
  const inboxQuery = createQuery(() => ({
76
- queryKey: hrKeys.approvalInbox(workspaceId, includeDecided),
86
+ queryKey: hrKeys.approvalInbox(workspaceId, inboxStatus),
77
87
  enabled: Boolean(workspaceId),
78
- queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, includeDecided }),
88
+ queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, status: inboxStatus }),
79
89
  }))
80
90
  const items = $derived(inboxQuery.data?.items ?? [])
81
91
 
92
+ /**
93
+ * Which employee the reader is. No permission — `people.me` is the caller's own record, and a
94
+ * member who has none gets an empty inbox from the server anyway.
95
+ *
96
+ * Without it a delegated row cannot be told from one that has not reached the reader's step yet,
97
+ * which is why every claim below is conditional on it having arrived.
98
+ */
99
+ const meQuery = createQuery(() => ({
100
+ queryKey: hrKeys.me(workspaceId),
101
+ enabled: Boolean(workspaceId),
102
+ queryFn: () => api.people.me({ workspaceId }),
103
+ }))
104
+ const myPersonId = $derived(meQuery.data?.id ?? null)
105
+
106
+ /**
107
+ * The delegations in play, from the same key `DelegationDialog` fills — revoking one there moves
108
+ * this page without a second request.
109
+ *
110
+ * Both halves of `showDelegation` are load-bearing: the procedure sits behind the `approvals`
111
+ * capability *and* `hr.approval.delegate`, and a delegation cannot exist without the capability, so
112
+ * a workspace with it off has nothing here to miss.
113
+ */
114
+ const delegationsQuery = createQuery(() => ({
115
+ queryKey: hrKeys.delegations(workspaceId),
116
+ enabled: showDelegation && Boolean(workspaceId),
117
+ queryFn: () => api.approvals.delegations({ workspaceId }),
118
+ }))
119
+
120
+ /**
121
+ * Delegations the reader holds *today*, in the reader's own zone.
122
+ *
123
+ * The server asks the same question in its zone, so the two disagree for a few hours at either end
124
+ * of a delegation's last day. Erring towards offering it is the right way round: the server refuses
125
+ * what it will not accept, and the refusal says so, whereas hiding the control leaves somebody
126
+ * covering a colleague with no way to act and nothing on screen to explain it.
127
+ */
128
+ const heldToday = $derived.by(() => {
129
+ if (!myPersonId) return []
130
+ const today = isoDate()
131
+ return (delegationsQuery.data ?? []).filter(
132
+ (d) => d.toPersonId === myPersonId && d.startsOn <= today && today <= d.endsOn,
133
+ )
134
+ })
135
+
136
+ /** Only once the list has actually arrived is silence about a delegation evidence of anything. */
137
+ const knowsDelegations = $derived(showDelegation && delegationsQuery.isSuccess)
138
+
139
+ /**
140
+ * Names for the people behind the ids, and only where a name is needed.
141
+ *
142
+ * Steps and decisions carry person ids alone — `hydrateApproval` resolves the requester and nobody
143
+ * else — so a directory read is what turns "on behalf of 0192…" into a sentence. It is skipped
144
+ * entirely in the ordinary case of a reader who holds no delegation and is looking at no delegated
145
+ * decision, which is almost everybody, almost always.
146
+ *
147
+ * Not filtered to `active`: the colleague who handed their approvals over is exactly the one likely
148
+ * to be on leave, and a status filter would drop the one name this page must be able to print.
149
+ */
150
+ const hasDelegatedDecision = $derived(
151
+ items.some((r) => r.steps.some((s) => (s.decisions ?? []).some((d) => d.onBehalfOfId))),
152
+ )
153
+ const needsNames = $derived(
154
+ canHr('personView') && Boolean(workspaceId) && (heldToday.length > 0 || hasDelegatedDecision),
155
+ )
156
+ const peopleQuery = createQuery(() => ({
157
+ queryKey: hrKeys.people(workspaceId, { forApprovalNames: true }),
158
+ enabled: needsNames,
159
+ queryFn: () => api.people.list({ workspaceId, limit: 200 }),
160
+ }))
161
+ const peopleById = $derived(
162
+ new Map((peopleQuery.data?.items ?? []).map((p) => [p.id, p.displayName] as const)),
163
+ )
164
+ /** A name, or an honest stand-in — never a raw id, which names nobody. */
165
+ const personName = (id: string) => peopleById.get(id) ?? t('approvals_behalf_someone')
166
+
167
+ /**
168
+ * Who the reader may decide as on the step this request is actually on, and why they may not.
169
+ *
170
+ * The rule is that nothing is claimed without the facts a claim needs: the reader's own person id,
171
+ * a delegation list that has arrived, and a step that came back with its approvers. Missing any of
172
+ * them, the page offers exactly what it offered before delegation worked — one decision, the
173
+ * reader's own — and lets the server be the authority. That fallback is also what keeps the mock
174
+ * and any older server, whose steps carry no `approverIds`, working unchanged.
175
+ *
176
+ * Two things are deliberately excluded rather than offered and refused:
177
+ * - an identity that has already decided on this step, which the unique index refuses;
178
+ * - a delegation scoped to another subject type. The contract says `subjectType: null` delegates
179
+ * everything and a value delegates that one kind — **`ApprovalService.mayActFor` does not check
180
+ * it**, so a leave-only delegate can decide a correction today. This is the narrower of the two
181
+ * readings and the one the contract states; the server is where it has to be fixed.
182
+ */
183
+ function describe(request: ApprovalRequest) {
184
+ const step = request.steps.find((s) => s.stepIndex === request.currentStep) ?? null
185
+ const approvers = step?.approverIds ?? []
186
+ const decisions = step?.decisions ?? []
187
+ const self: Identity = { onBehalfOfId: null, label: t('approvals_as_self') }
188
+
189
+ let identities: Identity[]
190
+ if (!myPersonId || !knowsDelegations || approvers.length === 0) identities = [self]
191
+ else {
192
+ const settled = new Set(decisions.map((d) => d.approverId))
193
+ identities = approvers.includes(myPersonId) && !settled.has(myPersonId) ? [self] : []
194
+ const delegators = new Set(
195
+ heldToday
196
+ .filter((d) => d.subjectType === null || d.subjectType === request.subjectType)
197
+ .map((d) => d.fromPersonId),
198
+ )
199
+ for (const id of delegators)
200
+ if (id !== myPersonId && approvers.includes(id) && !settled.has(id))
201
+ identities.push({ onBehalfOfId: id, label: personName(id) })
202
+ }
203
+
204
+ const only = identities.length === 1 ? identities[0]! : null
205
+ return {
206
+ identities,
207
+ /** The one delegation this row would be decided under, when there is exactly one and no choice. */
208
+ behalf: only?.onBehalfOfId ? only : null,
209
+ choice: identities.length > 1,
210
+ /**
211
+ * Why a pending row has no buttons. Both were an approve button that always failed: the server
212
+ * refuses a second decision from the same approver, and refuses anybody who is not on the step
213
+ * the request has reached.
214
+ */
215
+ waiting:
216
+ request.status === 'pending' && identities.length === 0
217
+ ? decisions.some((d) => d.approverId === myPersonId)
218
+ ? ('decided' as const)
219
+ : ('later' as const)
220
+ : null,
221
+ /**
222
+ * Delegated decisions already recorded. `approverId` is whose decision it is and
223
+ * `onBehalfOfId` is whose hands it was — the field is named for the *input* to `decide`, which
224
+ * means the opposite thing, so read them from the schema rather than from the name.
225
+ */
226
+ recorded: request.steps.flatMap((s) =>
227
+ (s.decisions ?? [])
228
+ .filter((d) => d.onBehalfOfId)
229
+ .map((d) => ({ actor: personName(d.onBehalfOfId as string), person: personName(d.approverId) })),
230
+ ),
231
+ }
232
+ }
233
+
234
+ const rows = $derived(items.map((item) => ({ item, ...describe(item) })))
235
+
236
+ /**
237
+ * The open dialog's view of the request, from the live row where there still is one.
238
+ *
239
+ * Derived rather than captured at the click, so a name or a delegation arriving while the dialog is
240
+ * open reaches it. It falls back to the snapshot the click carried because a refused decision
241
+ * re-reads the inbox on purpose, and a row that came back decided by somebody else must not take
242
+ * the refusal's sentence off the screen with it.
243
+ */
244
+ const decidingView = $derived.by(() => {
245
+ if (!deciding) return null
246
+ const open = deciding.request
247
+ return rows.find((r) => r.item.id === open.id) ?? describe(open)
248
+ })
249
+
82
250
  const decide = createMutation(() => ({
83
- mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
251
+ mutationFn: (vars: {
252
+ requestId: string
253
+ decision: 'approve' | 'reject'
254
+ comment: string
255
+ onBehalfOfId: string | null
256
+ }) =>
84
257
  api.approvals.decide({
85
258
  workspaceId,
86
259
  requestId: vars.requestId,
87
260
  decision: vars.decision,
88
261
  comment: vars.comment.trim() || null,
262
+ onBehalfOfId: vars.onBehalfOfId,
89
263
  }),
90
264
  onSuccess: () => {
91
265
  deciding = null
@@ -94,8 +268,8 @@ const decide = createMutation(() => ({
94
268
  // is invalidated rather than guessing which keys moved.
95
269
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
96
270
  },
97
- onError: (error) => {
98
- decideError = decideFailure(error)
271
+ onError: (error, vars) => {
272
+ decideError = decideFailure(error, vars.onBehalfOfId !== null)
99
273
  // A refusal is the server saying its inbox is not the one on screen, so the row behind the
100
274
  // dialog is stale as well as the decision. Re-read all of HR exactly as a decision that landed
101
275
  // does — without this the same dead row sits in the table and every retry earns the same
@@ -132,9 +306,16 @@ const decideRefusalMessages: Record<string, string> = {}
132
306
  * The test is the transport's `code`, never the sentence: `KernError.conflict` is what arrives as
133
307
  * CONFLICT, so a refusal added to `decide()` later reaches the reader without anyone editing this
134
308
  * file. The same shape as `ClockControls.svelte` and the approvals widget.
309
+ *
310
+ * A delegated decision has one refusal worth naming, and it is a FORBIDDEN rather than a conflict:
311
+ * the delegation the reader was acting under is not one the server can see — usually because it
312
+ * ended between the page loading and the click. `delegated` is what this client *sent*, not a
313
+ * sentence parsed back out of the server, so the reader gets a translated instruction instead of
314
+ * "The decision could not be recorded", which tells somebody covering a colleague nothing.
135
315
  */
136
- function decideFailure(error: unknown): string {
316
+ function decideFailure(error: unknown, delegated: boolean): string {
137
317
  const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
318
+ if (delegated && failure.code === 'FORBIDDEN') return t('decide_behalf_error')
138
319
  if (failure.code !== 'CONFLICT') return t('decide_error')
139
320
  const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
140
321
  const key = reason ? decideRefusalMessages[reason] : undefined
@@ -150,10 +331,15 @@ const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
150
331
  deciding = { request, decision }
151
332
  }
152
333
 
153
- const confirmDecision = (comment: string) => {
334
+ /**
335
+ * `onBehalfOfId` comes from the dialog, which will not confirm until it has one it can name. A
336
+ * decision filed against the wrong person is the worst thing this screen can do, so the identity
337
+ * travels with the click rather than being read back out of state here.
338
+ */
339
+ const confirmDecision = (comment: string, onBehalfOfId: string | null) => {
154
340
  if (!deciding || submitting) return
155
341
  submitting = true
156
- decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
342
+ decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment, onBehalfOfId })
157
343
  }
158
344
 
159
345
  const SUBJECT_LABELS: Record<string, () => string> = {
@@ -209,7 +395,7 @@ const stepOf = (request: ApprovalRequest) =>
209
395
  </div>
210
396
 
211
397
  <SectionLabel
212
- label={includeDecided ? t('approvals_decided') : t('approvals_waiting')}
398
+ label={inboxStatus === 'decided' ? t('approvals_decided') : t('approvals_waiting')}
213
399
  count={formatCount(items.length, 999)}
214
400
  />
215
401
 
@@ -229,32 +415,55 @@ const stepOf = (request: ApprovalRequest) =>
229
415
  <span role="columnheader">{t('approvals_request')}</span>
230
416
  <span role="columnheader">{t('approvals_requested_by')}</span>
231
417
  <span role="columnheader">{t('approvals_requested')}</span>
232
- <span role="columnheader">{includeDecided ? t('status') : t('approvals_step')}</span>
418
+ <span role="columnheader">{inboxStatus === 'decided' ? t('status') : t('approvals_step')}</span>
233
419
  <span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
234
420
  </div>
235
- {#each items as item (item.id)}
421
+ {#each rows as row (row.item.id)}
236
422
  <div class="trow" role="row">
237
423
  <span class="cell what" role="cell">
238
- <Badge tone="grey">{subjectLabel(item.subjectType)}</Badge>
239
- <span class="summary">{summarise(item)}</span>
424
+ <span class="line">
425
+ <Badge tone="grey">{subjectLabel(row.item.subjectType)}</Badge>
426
+ <span class="summary">{summarise(row.item)}</span>
427
+ </span>
428
+ <!--
429
+ Whose decision this is, on the row rather than only in the dialog: somebody clearing an
430
+ inbox of six rows should not have to open one to find out that two of them are a
431
+ colleague's. A pending row says what the click would file; a decided one says what was
432
+ filed, which is the other half of the same promise.
433
+ -->
434
+ {#if row.behalf}
435
+ <Badge tone="info">{t('approvals_on_behalf', { name: row.behalf.label })}</Badge>
436
+ {:else if row.choice}
437
+ <Badge tone="info">{t('approvals_behalf_pick')}</Badge>
438
+ {:else if row.recorded.length > 0}
439
+ {#each row.recorded as entry, i (i)}
440
+ <span class="behalf">
441
+ {t('approvals_decided_behalf', { actor: entry.actor, person: entry.person })}
442
+ </span>
443
+ {/each}
444
+ {/if}
240
445
  </span>
241
- <span class="cell muted" role="cell">{item.requesterName ?? '—'}</span>
242
- <span class="cell muted" role="cell">{formatDateTime(item.requestedAt)}</span>
446
+ <span class="cell muted" role="cell">{row.item.requesterName ?? '—'}</span>
447
+ <span class="cell muted" role="cell">{formatDateTime(row.item.requestedAt)}</span>
243
448
  <span class="cell" role="cell">
244
- {#if includeDecided}
245
- <Badge tone={STATUS_TONES[item.status] ?? 'grey'}>
246
- {STATUS_LABELS[item.status]?.() ?? item.status}
449
+ {#if inboxStatus === 'decided'}
450
+ <Badge tone={STATUS_TONES[row.item.status] ?? 'grey'}>
451
+ {STATUS_LABELS[row.item.status]?.() ?? row.item.status}
247
452
  </Badge>
248
- {:else if item.steps.length > 1}
249
- <span class="muted">{stepOf(item)}</span>
453
+ {:else if row.item.steps.length > 1}
454
+ <span class="muted">{stepOf(row.item)}</span>
250
455
  {/if}
251
456
  </span>
252
457
  <span class="cell actions" role="cell">
253
- {#if item.status === 'pending'}
254
- <Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>
458
+ {#if row.item.status === 'pending' && row.identities.length > 0}
459
+ <Button size="sm" variant="secondary" onclick={() => ask(row.item, 'reject')}>
255
460
  {t('reject')}
256
461
  </Button>
257
- <Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
462
+ <Button size="sm" onclick={() => ask(row.item, 'approve')}>{t('approve')}</Button>
463
+ {:else if row.waiting}
464
+ <span class="muted">
465
+ {row.waiting === 'decided' ? t('approvals_you_decided') : t('approvals_later_step')}
466
+ </span>
258
467
  {/if}
259
468
  </span>
260
469
  </div>
@@ -269,8 +478,8 @@ const stepOf = (request: ApprovalRequest) =>
269
478
  {:else}
270
479
  <EmptyState
271
480
  icon="check-check"
272
- title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
273
- description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
481
+ title={inboxStatus === 'decided' ? t('approvals_decided_none') : t('approvals_none')}
482
+ description={inboxStatus === 'decided' ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
274
483
  />
275
484
  {/if}
276
485
 
@@ -287,6 +496,7 @@ const stepOf = (request: ApprovalRequest) =>
287
496
  <DecisionDialog
288
497
  request={deciding?.request ?? null}
289
498
  decision={deciding?.decision ?? 'approve'}
499
+ identities={decidingView?.identities ?? []}
290
500
  pending={submitting}
291
501
  error={decideError}
292
502
  onConfirm={confirmDecision}
@@ -349,10 +559,29 @@ const stepOf = (request: ApprovalRequest) =>
349
559
  text-overflow: ellipsis;
350
560
  white-space: nowrap;
351
561
  }
562
+ /*
563
+ A column, not a row: the subject and its sentence sit on one line and the delegation note under
564
+ it, so a name long enough to matter cannot push the summary out of the cell.
565
+ */
352
566
  .what {
567
+ display: flex;
568
+ flex-direction: column;
569
+ justify-content: center;
570
+ align-items: flex-start;
571
+ gap: 4px;
572
+ }
573
+ .line {
353
574
  display: flex;
354
575
  align-items: center;
355
576
  gap: 8px;
577
+ min-width: 0;
578
+ max-width: 100%;
579
+ overflow: hidden;
580
+ }
581
+ .behalf {
582
+ font-size: 12px;
583
+ /* A colour, not opacity — this line names a person and has to stay readable. */
584
+ color: var(--kern-ink-500);
356
585
  }
357
586
  .summary {
358
587
  min-width: 0;
@@ -25,7 +25,7 @@ import { getHrApi } from '../api-instance.js'
25
25
  import DecisionDialog from '../components/DecisionDialog.svelte'
26
26
  import PersonFormDialog from '../components/PersonFormDialog.svelte'
27
27
  import PersonPanel from '../components/PersonPanel.svelte'
28
- import { personnelVisibility } from '../components/redaction.js'
28
+ import { personnelWithheld } from '../components/redaction.js'
29
29
  import { t } from '../i18n.js'
30
30
  import type { ApprovalRequest } from '../index.js'
31
31
  import { canHr, HR_CAPABILITIES } from '../permissions.js'
@@ -276,7 +276,7 @@ const balancesQuery = createQuery(() => ({
276
276
  const inboxQuery = createQuery(() => ({
277
277
  queryKey: hrKeys.approvalInbox(workspaceId),
278
278
  enabled: Boolean(workspaceId),
279
- queryFn: () => api.approvals.inbox({ workspaceId, limit: 6, includeDecided: false }),
279
+ queryFn: () => api.approvals.inbox({ workspaceId, limit: 6, status: 'pending' }),
280
280
  }))
281
281
  const waiting = $derived(inboxQuery.data?.items ?? [])
282
282
 
@@ -434,12 +434,15 @@ const started = (iso: string | null) =>
434
434
  * and it arrives here as a null like any other — so the em dash this column drew for it said
435
435
  * "never started", about everybody in the company, to every colleague without a widening key.
436
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.
437
+ * The record says which it is — `personnelHidden`, set at the one place that does the nulling so
438
+ * a row is marked only when the server actually withheld it, and a genuinely empty hire date keeps
439
+ * its dash. Marked once, under the table, rather than per row.
440
+ *
441
+ * The `!person.hiredOn` half stays deliberately: the mark only ever goes over a value that is
442
+ * absent, so a stale or unrecognised payload can never paint "Hidden" across data on screen.
440
443
  */
441
- const startWithheld = (person: { userId: string | null; hiredOn: string | null }) =>
442
- !person.hiredOn && personnelVisibility(person) === 'withheld'
444
+ const startWithheld = (person: { hiredOn: string | null; personnelHidden?: boolean }) =>
445
+ !person.hiredOn && personnelWithheld(person)
443
446
 
444
447
  /** Whether anything on the page is actually marked — the sentence must not outlive the marks. */
445
448
  const anyWithheld = $derived(people.some(startWithheld))
@@ -22,19 +22,9 @@ export function canHr(permission: HrPermission): boolean {
22
22
  return session.can(HR_PERMISSIONS[permission])
23
23
  }
24
24
 
25
- /**
26
- * Whether the viewer reads the personnel record behind a directory card, for anybody but themselves.
27
- *
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.
38
- */
39
- export const canSeeFullRecords = (): boolean =>
40
- canHr('personViewTeam') || canHr('personViewOffice') || canHr('personViewAll')
25
+ // No union helper over the three person-visibility keys. There has been one here twice —
26
+ // `canSeeOthers`, then `canSeeFullRecords` and both were dead the whole time, because the question
27
+ // a screen actually has is "was *this* record withheld", and the answer to that is on the record:
28
+ // `Person.personnelHidden`, set by the server at the one place that does the nulling. A client-side
29
+ // union can only say "the reader holds one of three keys somewhere", which is never enough to decide
30
+ // what to draw over one person's phone number. See `components/redaction.ts`.
@@ -35,8 +35,8 @@ export const hrKeys = {
35
35
  * pending on, the decided one is history — so sharing a key would show one tab the other's
36
36
  * contents for as long as the refetch takes.
37
37
  */
38
- approvalInbox: (ws: string, includeDecided = false) =>
39
- ['hr', 'approvals', ws, includeDecided ? 'decided' : 'waiting'] as const,
38
+ approvalInbox: (ws: string, status: 'pending' | 'decided' = 'pending') =>
39
+ ['hr', 'approvals', ws, status] as const,
40
40
  delegations: (ws: string) => ['hr', 'delegations', ws] as const,
41
41
  calendar: (ws: string, id: string) => ['hr', 'calendar', ws, id] as const,
42
42
  calendarWorkingDays: (ws: string, calendarId: string, from: string, to: string) =>
@@ -22,7 +22,7 @@ const queryClient = useQueryClient()
22
22
  const inboxQuery = createQuery(() => ({
23
23
  queryKey: hrKeys.approvalInbox(workspaceId),
24
24
  enabled: Boolean(workspaceId),
25
- queryFn: () => api.approvals.inbox({ workspaceId, limit: 5, includeDecided: false }),
25
+ queryFn: () => api.approvals.inbox({ workspaceId, limit: 5, status: 'pending' }),
26
26
  }))
27
27
  const items = $derived(inboxQuery.data?.items ?? [])
28
28
 
@@ -1132,10 +1132,19 @@ export const hrContract = {
1132
1132
 
1133
1133
  // ---------------------------------------------------------------- approvals
1134
1134
  approvals: {
1135
- /** Everything waiting on the caller, across every subject type. */
1135
+ /**
1136
+ * Everything waiting on the caller, across every subject type — or everything they have
1137
+ * already settled.
1138
+ *
1139
+ * `status` rather than the `includeDecided` boolean it replaces. That flag was *inclusive*
1140
+ * ("also give me the decided ones") while every caller used it as an exclusive two-tab switch,
1141
+ * so the screen's "Decided" tab asked for decided-as-well and got pending rows listed under a
1142
+ * heading that said somebody had decided them. Both halves read correctly on their own, which
1143
+ * is why it survived: an enum makes the two tabs exactly what they say.
1144
+ */
1136
1145
  inbox: baseContract
1137
1146
  .route({ method: 'GET', path: '/approvals/inbox', tags: t })
1138
- .input(ws.extend({ ...PageInput.shape, includeDecided: z.boolean().default(false) }))
1147
+ .input(ws.extend({ ...PageInput.shape, status: z.enum(['pending', 'decided']).default('pending') }))
1139
1148
  .output(page(ApprovalRequest)),
1140
1149
  get: baseContract
1141
1150
  .route({ method: 'GET', path: '/approvals/{requestId}', tags: t })