@kernhq/module-hr 0.13.2 → 0.14.1

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 (55) 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/jobs.d.ts.map +1 -1
  27. package/dist/server/jobs.js +13 -5
  28. package/dist/server/jobs.js.map +1 -1
  29. package/dist/server/router.d.ts +12 -0
  30. package/dist/server/router.d.ts.map +1 -1
  31. package/dist/server/router.js +99 -16
  32. package/dist/server/router.js.map +1 -1
  33. package/dist/server/services/access.d.ts +176 -0
  34. package/dist/server/services/access.d.ts.map +1 -0
  35. package/dist/server/services/access.js +260 -0
  36. package/dist/server/services/access.js.map +1 -0
  37. package/dist/server/services/people.d.ts +1 -0
  38. package/dist/server/services/people.d.ts.map +1 -1
  39. package/dist/server/services/people.js +3 -0
  40. package/dist/server/services/people.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client/components/DayDetail.svelte +32 -14
  43. package/src/client/components/PersonPanel.svelte +66 -5
  44. package/src/client/components/redaction.ts +39 -0
  45. package/src/client/messages.ts +241 -22
  46. package/src/client/pages/DirectoryPage.svelte +48 -1
  47. package/src/client/permissions.ts +6 -8
  48. package/src/client/settings/AccrualSettings.svelte +596 -57
  49. package/src/client/settings/GeneralSettings.svelte +12 -9
  50. package/src/contract/attendance.ts +27 -1
  51. package/src/contract/capabilities.ts +10 -0
  52. package/src/contract/events.ts +23 -19
  53. package/src/contract/models.ts +11 -0
  54. package/src/contract/router.ts +11 -3
  55. package/src/contract/settings.ts +11 -5
@@ -7,6 +7,7 @@ import {
7
7
  EmptyState,
8
8
  Field,
9
9
  formatDate,
10
+ Icon,
10
11
  Input,
11
12
  navigation,
12
13
  RightPanel,
@@ -21,6 +22,7 @@ import { hrKeys, isoDate } from '../query.js'
21
22
  import PersonDocumentsSection from './PersonDocumentsSection.svelte'
22
23
  import PersonJobSection from './PersonJobSection.svelte'
23
24
  import PersonSensitiveSection from './PersonSensitiveSection.svelte'
25
+ import { personnelWithheld } from './redaction.js'
24
26
  import { explainRefusal } from './refusal.js'
25
27
 
26
28
  /**
@@ -188,8 +190,30 @@ function officeTime(timezone: string): string {
188
190
 
189
191
  const canManage = $derived(canHr('personManage'))
190
192
  const left = $derived(person?.status === 'terminated')
193
+
194
+ /**
195
+ * Whether the server withheld this person's personnel fields from this reader.
196
+ *
197
+ * The four it nulls arrive as nulls, so an empty phone row on this panel says "no phone number" —
198
+ * a different and wrong fact. The record carries the answer (`personnelHidden`, set where the
199
+ * nulling happens), so this is a read rather than an inference; the panel then explains itself once
200
+ * below the list rather than once per field.
201
+ */
202
+ const withheld = $derived(person ? personnelWithheld(person) : false)
203
+ /**
204
+ * And only where something on *this* panel is actually blank because of it. The two are the same
205
+ * thing while the client and the server agree about the reader's keys; tying the sentence to what
206
+ * is on screen means a disagreement leaves a stale sentence over nothing rather than over a value
207
+ * the reader can plainly see.
208
+ */
209
+ const hiddenHere = $derived(withheld && (!person?.phone || !person?.hiredOn))
191
210
  </script>
192
211
 
212
+ {#snippet hiddenValue()}
213
+ <!-- A value in its own right, not an absence: the row stays, and says what it is. -->
214
+ <span class="withheld"><Icon name="eye-off" size={12} strokeWidth={1.8} />{t('person_hidden')}</span>
215
+ {/snippet}
216
+
193
217
  {#snippet footerActions()}
194
218
  <div class="actions">
195
219
  <!-- Disabled with the reason beside it, not hidden: the button was there a moment ago, and a
@@ -225,7 +249,7 @@ const left = $derived(person?.status === 'terminated')
225
249
  </div>
226
250
  </div>
227
251
 
228
- <dl>
252
+ <dl class:tight={hiddenHere}>
229
253
  {#if resolution?.primaryOfficeName}
230
254
  <dt>{t('office')}</dt>
231
255
  <dd>{resolution.primaryOfficeName}</dd>
@@ -241,13 +265,22 @@ const left = $derived(person?.status === 'terminated')
241
265
  <dt>{t('employee_no')}</dt>
242
266
  <dd>{person.employeeNo}</dd>
243
267
  {/if}
244
- {#if person.hiredOn}
268
+ <!--
269
+ Two of the four fields the server redacts. A withheld one keeps its row and is marked, so
270
+ that "we are not showing you this" cannot be read as "there is nothing here"; one that is
271
+ merely empty keeps the behaviour it had, and drops out of the list.
272
+ -->
273
+ {#if person.hiredOn || withheld}
245
274
  <dt>{t('started')}</dt>
246
- <dd>{formatDate(`${person.hiredOn}T00:00:00`)}</dd>
275
+ <dd>
276
+ {#if person.hiredOn}{formatDate(`${person.hiredOn}T00:00:00`)}{:else}{@render hiddenValue()}{/if}
277
+ </dd>
247
278
  {/if}
248
- {#if person.phone}
279
+ {#if person.phone || withheld}
249
280
  <dt>{t('phone')}</dt>
250
- <dd>{person.phone}</dd>
281
+ <dd>
282
+ {#if person.phone}{person.phone}{:else}{@render hiddenValue()}{/if}
283
+ </dd>
251
284
  {/if}
252
285
  {#if resolution?.orgUnitPath}
253
286
  <dt>{t('department')}</dt>
@@ -259,6 +292,14 @@ const left = $derived(person?.status === 'terminated')
259
292
  {/if}
260
293
  </dl>
261
294
 
295
+ <!--
296
+ Once for the panel, never once per field. The same sentence beside every marked row is a
297
+ lecture; the marks say *which* fields, and this says why, once, under all of them.
298
+ -->
299
+ {#if hiddenHere}
300
+ <p class="hint">{t('person_hidden_hint')}</p>
301
+ {/if}
302
+
262
303
  <Badge tone={person.status === 'active' ? 'active' : person.status === 'on_leave' ? 'upcoming' : 'grey'}
263
304
  >{person.status === 'active'
264
305
  ? t('status_active')
@@ -389,6 +430,11 @@ dl {
389
430
  gap: 8px 16px;
390
431
  margin: 0 0 16px;
391
432
  }
433
+ /* The sentence below the list belongs to it, so the list gives up its own gap to keep them one
434
+ block rather than two things that happen to follow each other. */
435
+ dl.tight {
436
+ margin-block-end: 8px;
437
+ }
392
438
  dt {
393
439
  color: var(--kern-ink-500);
394
440
  font-size: 12px;
@@ -396,6 +442,21 @@ dt {
396
442
  dd {
397
443
  margin: 0;
398
444
  }
445
+ /* A colour and a smaller size, never opacity — a faded value is unreadable whatever token it
446
+ names, and this one has to be read to be believed. */
447
+ .withheld {
448
+ display: inline-flex;
449
+ align-items: center;
450
+ gap: 5px;
451
+ color: var(--kern-ink-500);
452
+ font-size: 12px;
453
+ }
454
+ .hint {
455
+ margin: 0 0 16px;
456
+ font-size: 12px;
457
+ line-height: 1.45;
458
+ color: var(--kern-ink-500);
459
+ }
399
460
  .actions {
400
461
  display: flex;
401
462
  gap: 8px;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Whether the personnel fields on a card were withheld, or are genuinely empty.
3
+ *
4
+ * `HrAccessService` nulls four of them — personal email, phone, hire date, termination date — for
5
+ * anybody outside the reader's record scope, and nulls them **in place**: a redacted card parses as
6
+ * an ordinary `Person`, so a blank phone number would otherwise arrive at a screen meaning either
7
+ * "we are not showing you this" or "this person never gave us one". Those are two different facts
8
+ * about a colleague, and printing the second when the first is true is the defect this exists to
9
+ * stop.
10
+ *
11
+ * **The record says which, and this only reads it.** `Person.personnelHidden` is set in `forViewer`,
12
+ * the single place that does the nulling — so the answer comes from the code that made the decision
13
+ * rather than from a client re-deriving it.
14
+ *
15
+ * That matters because the client cannot re-derive it. This helper used to infer the answer from
16
+ * the reader's own permission keys, which works for the two extremes — somebody holding
17
+ * `hr.person.view_all` or `hr.person.manage` has nothing hidden, somebody holding none of the
18
+ * widening keys has everything but their own record hidden — and fails in the middle. A line
19
+ * manager or a country HR person holds `view_team` or `view_office`, and **which** people those
20
+ * cover is resolved on the server from the org chart: headship of a unit over an ltree subtree,
21
+ * headship of an office, `manager_person_id`, all as of today, across rows the directory never
22
+ * fetches. Inferring it would have meant a second implementation of that resolution, and "all four
23
+ * fields are null" is not evidence — a person really can have no personal email, no phone and no
24
+ * recorded hire date.
25
+ */
26
+ export interface PersonnelSubject {
27
+ /**
28
+ * True when the server withheld the personnel fields on this record.
29
+ *
30
+ * Optional so a caller can pass a row that predates the field — `dev:mock` fixtures, an older
31
+ * cached payload — and get `false` rather than a crash. Absent means "not withheld", which is the
32
+ * safe direction: a screen that fails to mark a hidden field shows a blank, while one that marks
33
+ * an empty field asserts something false about a colleague.
34
+ */
35
+ personnelHidden?: boolean
36
+ }
37
+
38
+ /** Were this card's personnel fields withheld from the reader? */
39
+ export const personnelWithheld = (person: PersonnelSubject): boolean => person.personnelHidden === true