@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,10 +1,21 @@
1
1
  <script lang="ts">
2
- import { Button, Dialog, Field, Input, navigation, Select, Textarea, toast } from '@kernhq/ui'
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ EmptyState,
6
+ Field,
7
+ Input,
8
+ navigation,
9
+ Select,
10
+ Skeleton,
11
+ Textarea,
12
+ toast,
13
+ } from '@kernhq/ui'
3
14
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
15
  import { getHrApi } from '../api-instance.js'
5
16
  import { t } from '../i18n.js'
6
17
  import { canHr } from '../permissions.js'
7
- import { formatDays, hrKeys, isoDate } from '../query.js'
18
+ import { hrKeys, isoDate } from '../query.js'
8
19
 
9
20
  /**
10
21
  * Book time off.
@@ -41,11 +52,26 @@ const typesQuery = createQuery(() => ({
41
52
  }))
42
53
  const types = $derived(typesQuery.data ?? [])
43
54
  const typeOptions = $derived(types.map((type) => ({ value: type.id, label: type.name })))
55
+ const selectedType = $derived(types.find((type) => type.id === leaveTypeId))
44
56
 
45
57
  $effect(() => {
46
58
  if (open && !leaveTypeId && types[0]) leaveTypeId = types[0].id
47
59
  })
48
60
 
61
+ /**
62
+ * The balance, from the same key the time-off page reads.
63
+ *
64
+ * Not a second opinion: `available` is the server's own figure in the type's own unit, and it is
65
+ * the number that page already shows — so opening this form costs nothing and the two screens
66
+ * cannot disagree.
67
+ */
68
+ const balanceQuery = createQuery(() => ({
69
+ queryKey: hrKeys.leaveBalance(workspaceId, undefined),
70
+ enabled: Boolean(workspaceId) && open,
71
+ queryFn: () => api.leave.balance.get({ workspaceId }),
72
+ }))
73
+ const balance = $derived(balanceQuery.data?.find((row) => row.leaveTypeId === leaveTypeId))
74
+
49
75
  const simQuery = createQuery(() => ({
50
76
  queryKey: ['hr', 'leave-sim', workspaceId, leaveTypeId, startsOn, endsOn] as const,
51
77
  enabled: Boolean(workspaceId && leaveTypeId && startsOn && endsOn && open),
@@ -58,13 +84,105 @@ const simQuery = createQuery(() => ({
58
84
  }),
59
85
  }))
60
86
  const sim = $derived(simQuery.data)
61
- const blocked = $derived((sim?.blockers.length ?? 0) > 0)
87
+ const blockers = $derived(sim?.blockers ?? [])
88
+ const blocked = $derived(blockers.length > 0)
89
+
90
+ /**
91
+ * What this would cost, in the unit the server reports the balance in.
92
+ *
93
+ * Both halves are the server's own numbers and both scale with whatever a working day is worth, so
94
+ * subtracting one from the other stays right the day that stops being eight hours. Dividing
95
+ * `balanceAfterMinutes` by a hard 480 here did not: it disagreed with the same figure on the
96
+ * time-off page, which has always shown `available`.
97
+ */
98
+ const cost = $derived(sim && balance ? (balance.unit === 'hour' ? sim.minutes / 60 : sim.workingDays) : null)
99
+ const after = $derived(balance && cost !== null ? Math.round((balance.available - cost) * 100) / 100 : null)
100
+
101
+ const availableLabel = $derived(
102
+ balance
103
+ ? balance.unit === 'hour'
104
+ ? t('leave_available_now_hours', { count: balance.available })
105
+ : t('leave_available_now', { count: balance.available })
106
+ : null,
107
+ )
108
+ const afterLabel = $derived(
109
+ balance && after !== null
110
+ ? balance.unit === 'hour'
111
+ ? t('leave_after_hours', { count: after })
112
+ : t('leave_after', { count: after })
113
+ : null,
114
+ )
115
+
116
+ /**
117
+ * The cost, and the one other number worth having beside it.
118
+ *
119
+ * Blocked, that is what they hold — "not enough" is only answerable next to it. Clear, it is what
120
+ * would be left. Neither appears until the balance has arrived: a figure this form worked out for
121
+ * itself is how the two screens came to disagree in the first place.
122
+ */
123
+ const costLine = $derived.by(() => {
124
+ if (!sim) return null
125
+ const spend = t('leave_would_cost', { count: sim.workingDays })
126
+ const tail = blocked ? availableLabel : afterLabel
127
+ return tail ? `${spend} · ${tail}` : spend
128
+ })
129
+
130
+ /**
131
+ * A blocker's `code`, as this module's own sentence.
132
+ *
133
+ * Keyed by the code `simulate` puts beside its prose for exactly this, never by the sentence: a
134
+ * list of sentences is a list somebody has to keep in sync, and the day it drifts the reader is
135
+ * told nothing. Every blocker is drawn, not the first — fixing the one on screen used to reveal a
136
+ * second the form had never mentioned.
137
+ */
138
+ const BLOCKER_KEYS: Record<string, string> = {
139
+ range: 'hr.leave_blocked_range',
140
+ archived: 'hr.leave_blocked_archived',
141
+ empty: 'hr.leave_blocked_empty',
142
+ insufficient: 'hr.leave_blocked_insufficient',
143
+ below_floor: 'hr.leave_blocked_below_floor',
144
+ overlap: 'hr.leave_blocked_overlap',
145
+ document_required: 'hr.leave_blocked_document_required',
146
+ }
147
+
148
+ /**
149
+ * Four of these name the leave type and one counts the days before a document is needed, and both
150
+ * facts live on the type this form is holding. `null` means the fact is not here — the type was
151
+ * archived out of the list between opening the form and simulating — and a sentence with `{type}`
152
+ * still in it is worse than the server's own.
153
+ */
154
+ function blockerParams(code: string): Record<string, string | number> | undefined | null {
155
+ if (code === 'range' || code === 'empty' || code === 'overlap') return undefined
156
+ const name = selectedType?.name
157
+ if (!name) return null
158
+ if (code !== 'document_required') return { type: name }
159
+ const days = selectedType?.requiresDocumentAfterDays
160
+ return days === null || days === undefined ? null : { type: name, count: days }
161
+ }
162
+
163
+ function blockerText(blocker: { code: string; message: string }): string {
164
+ const key = BLOCKER_KEYS[blocker.code]
165
+ const params = blockerParams(blocker.code)
166
+ // `t()` answers a key it has no string for with the key itself, so a translation is only used
167
+ // when it is one. Both ways of not having one — a code nothing covers here, and a key whose
168
+ // string has not been merged yet — land on the sentence the router wrote rather than on
169
+ // `hr.leave_blocked_overlap` in front of a person.
170
+ const text = key && params !== null ? t(key, params) : undefined
171
+ return text && text !== key ? text : blocker.message
172
+ }
62
173
 
63
174
  const close = () => {
64
175
  shown = false
65
176
  void navigation.go(`/${workspaceSlug}/hr/leave`, { replaceState: true, keepFocus: true, noScroll: true })
66
177
  }
67
178
 
179
+ /**
180
+ * `submitting` rather than `create.isPending`: the disabled attribute only reaches the button on
181
+ * the next render, so two quick clicks both fire, each with its own idempotency key — and the
182
+ * second earns "you already have leave booked on those days" for the request the first one made.
183
+ */
184
+ let submitting = $state(false)
185
+
68
186
  const create = createMutation(() => ({
69
187
  mutationFn: () =>
70
188
  api.leave.requests.create({
@@ -83,11 +201,42 @@ const create = createMutation(() => ({
83
201
  reason = ''
84
202
  close()
85
203
  },
86
- onError: (error: Error) => toast.error(error.message),
204
+ onError: (error) => {
205
+ // `create` runs the same `simulate` the preview does and refuses on its first blocker, so a
206
+ // refusal here is a blocker code with `hr.leave.` in front of it — a sentence this form can
207
+ // already write. Nothing else that can fail has written anything for a person: a network drop,
208
+ // a 500 or a gateway carry machine text, in English, so those get this module's own string.
209
+ //
210
+ // The test is the transport's `code`, never the sentence.
211
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
212
+ const refused = failure.code === 'CONFLICT'
213
+ const reasonCode = refused && typeof failure.data?.reason === 'string' ? failure.data.reason : null
214
+ const code = reasonCode?.startsWith('hr.leave.') ? reasonCode.slice('hr.leave.'.length) : null
215
+ const message = typeof failure.message === 'string' ? failure.message : ''
216
+ const explained = code ? blockerText({ code, message }) : refused ? message : ''
217
+ toast.error(explained || t('leave_request_error'))
218
+ // A refusal is the server saying the preview on screen is not what it sees — most often
219
+ // because the same days were booked from somewhere else, or a holiday moved into the range.
220
+ // So re-read the preview and the balance it is measured against, exactly as a request that
221
+ // landed does; without this every retry earns the same toast with no route to the truth.
222
+ void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-sim'] })
223
+ void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-balance'] })
224
+ void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-requests'] })
225
+ },
226
+ onSettled: () => {
227
+ submitting = false
228
+ },
87
229
  }))
88
230
 
231
+ const submit = () => {
232
+ if (submitting) return
233
+ submitting = true
234
+ create.mutate()
235
+ }
236
+
237
+ const canRequest = $derived(canHr('leaveRequest'))
89
238
  const canSubmit = $derived(
90
- Boolean(leaveTypeId && startsOn && endsOn) && !blocked && canHr('leaveRequest') && !simQuery.isFetching,
239
+ Boolean(leaveTypeId && startsOn && endsOn) && !blocked && canRequest && !simQuery.isFetching && !submitting,
91
240
  )
92
241
  </script>
93
242
 
@@ -99,11 +248,52 @@ const canSubmit = $derived(
99
248
  }}
100
249
  >
101
250
  <div class="form">
102
- <Field label={t('leave_type')} id="hr-leave-type" required>
103
- {#snippet children(id)}
104
- <Select {id} bind:value={leaveTypeId} options={typeOptions} />
105
- {/snippet}
106
- </Field>
251
+ <!--
252
+ The type list was never consulted here: a failed fetch left an empty dropdown and a dead
253
+ submit with nothing said, and a workspace with no types at all looked identical to one whose
254
+ network had dropped. Loading, then the list, then the failure, then the honest empty.
255
+ -->
256
+ {#if typesQuery.isLoading || !workspaceId}
257
+ <div class="skel">
258
+ <Skeleton width="48px" height="12px" />
259
+ <Skeleton height="34px" />
260
+ </div>
261
+ {:else if types.length}
262
+ <Field label={t('leave_type')} id="hr-leave-type" required>
263
+ {#snippet children(id)}
264
+ <Select {id} bind:value={leaveTypeId} options={typeOptions} />
265
+ {/snippet}
266
+ </Field>
267
+ {:else if typesQuery.isError}
268
+ <EmptyState compact icon="triangle-alert" title={t('leave_types_error')}>
269
+ {#snippet actions()}
270
+ <Button size="sm" variant="secondary" onclick={() => void typesQuery.refetch()}>
271
+ {t('retry')}
272
+ </Button>
273
+ {/snippet}
274
+ </EmptyState>
275
+ {:else}
276
+ <EmptyState
277
+ compact
278
+ icon="tree-palm"
279
+ title={t('leave_types_none')}
280
+ description={t('leave_types_none_desc')}
281
+ >
282
+ {#snippet actions()}
283
+ {#if canHr('leaveManage')}
284
+ <Button
285
+ size="sm"
286
+ variant="secondary"
287
+ icon="settings"
288
+ href={`/${workspaceSlug}/settings/hr/leave`}
289
+ >
290
+ {t('leave_types_manage')}
291
+ </Button>
292
+ {/if}
293
+ {/snippet}
294
+ </EmptyState>
295
+ {/if}
296
+
107
297
  <div class="dates">
108
298
  <Field label={t('leave_from')} id="hr-leave-from" required>
109
299
  {#snippet children(id)}
@@ -122,25 +312,40 @@ const canSubmit = $derived(
122
312
  {/snippet}
123
313
  </Field>
124
314
 
125
- {#if sim}
126
- {#if blocked}
127
- <p class="block" role="alert">
128
- {t('leave_blocked')}
129
- {sim.blockers[0]?.message ?? ''}
130
- </p>
131
- {:else}
132
- <p class="cost">
133
- {t('leave_would_cost', { days: formatDays(sim.workingDays) })}
134
- ·
135
- {t('leave_after', { days: formatDays(sim.balanceAfterMinutes / 480) })}
136
- </p>
315
+ {#if simQuery.isLoading}
316
+ <Skeleton width="70%" height="14px" />
317
+ {:else if sim}
318
+ {#if costLine}<p class="cost">{costLine}</p>{/if}
319
+ {#if blockers.length}
320
+ <div class="blocked" role="alert">
321
+ <p class="lead">{t('leave_blocked')}</p>
322
+ <ul>
323
+ {#each blockers as blocker (blocker.code)}
324
+ <li>{blockerText(blocker)}</li>
325
+ {/each}
326
+ </ul>
327
+ </div>
137
328
  {/if}
329
+ {:else if simQuery.isError}
330
+ <div class="blocked" role="alert">
331
+ <p class="lead">{t('leave_sim_error')}</p>
332
+ <!-- A wrapper, so the button sizes to its label rather than stretching across the grid. -->
333
+ <div>
334
+ <Button size="sm" variant="secondary" onclick={() => void simQuery.refetch()}>{t('retry')}</Button>
335
+ </div>
336
+ </div>
138
337
  {/if}
139
338
  </div>
140
339
 
141
340
  {#snippet footer()}
341
+ <!--
342
+ The URL reaches this form whether or not the person may use it — the Request time off button
343
+ is gated, a pasted `?new=1` is not — so the submit says why it is dead rather than looking
344
+ broken.
345
+ -->
346
+ {#if !canRequest}<span class="note">{t('leave_request_denied')}</span>{/if}
142
347
  <Button variant="ghost" onclick={close}>{t('common.cancel')}</Button>
143
- <Button onclick={() => create.mutate()} disabled={!canSubmit} loading={create.isPending}>
348
+ <Button onclick={submit} disabled={!canSubmit} loading={create.isPending}>
144
349
  {t('request_leave')}
145
350
  </Button>
146
351
  {/snippet}
@@ -156,14 +361,38 @@ const canSubmit = $derived(
156
361
  grid-template-columns: 1fr 1fr;
157
362
  gap: 12px;
158
363
  }
364
+ .skel {
365
+ display: grid;
366
+ gap: 6px;
367
+ }
159
368
  .cost {
160
369
  margin: 0;
161
370
  font-size: 13px;
162
371
  color: var(--kern-ink-500);
163
372
  }
164
- .block {
165
- margin: 0;
373
+ /* 6.33:1 on the dialog surface in light, 5.04:1 in dark — this is 13px text, so it clears 4.5. */
374
+ .blocked {
375
+ display: grid;
376
+ gap: 6px;
166
377
  font-size: 13px;
167
378
  color: var(--kern-danger);
168
379
  }
380
+ .lead {
381
+ margin: 0;
382
+ font-weight: 500;
383
+ }
384
+ .blocked ul {
385
+ display: grid;
386
+ gap: 4px;
387
+ margin: 0;
388
+ padding: 0;
389
+ list-style: none;
390
+ }
391
+ /* A colour, not opacity: opacity fades text against the dialog whatever token it names. */
392
+ .note {
393
+ margin-inline-end: auto;
394
+ align-self: center;
395
+ font-size: 12px;
396
+ color: var(--kern-ink-500);
397
+ }
169
398
  </style>
@@ -85,6 +85,33 @@ const reset = () => {
85
85
  employmentType = 'full_time'
86
86
  }
87
87
 
88
+ /**
89
+ * The sentence to put in front of somebody when the hire is refused.
90
+ *
91
+ * A refusal arrives as two pieces: a machine-readable `reason` a module translates, and the
92
+ * English sentence the router wrote for a reader. `people.create` sends no reason today — an
93
+ * employee number already taken and a field the server will not take are what it refuses on — so
94
+ * this uses the second, and only for the codes that carry a sentence somebody wrote. A network
95
+ * drop, a 500 and a gateway carry machine text, and `Forbidden` is one word; a toast is the last
96
+ * place to paste any of them, so they fall back to this module's own string.
97
+ *
98
+ * When `people.create` does grow a reason, it is read the way `ClockControls.svelte` reads a
99
+ * punch's — keyed by the code, never by the sentence.
100
+ */
101
+ const READABLE = new Set(['BAD_REQUEST', 'CONFLICT', 'NOT_FOUND'])
102
+ function explain(error: unknown, fallback: string): string {
103
+ const failure = error as { code?: unknown; message?: string }
104
+ const readable = typeof failure.code === 'string' && READABLE.has(failure.code)
105
+ return (readable ? failure.message : '') || fallback
106
+ }
107
+
108
+ /**
109
+ * `creating` rather than `create.isPending`: the disabled attribute only reaches the button on the
110
+ * next render, so two quick clicks both fire — and here that is two people in the directory with
111
+ * the same name and two employee numbers.
112
+ */
113
+ let creating = $state(false)
114
+
88
115
  const create = createMutation(() => ({
89
116
  mutationFn: () =>
90
117
  api.people.create({
@@ -110,10 +137,20 @@ const create = createMutation(() => ({
110
137
  shown = false
111
138
  dismiss()
112
139
  },
113
- onError: (error: Error) => toast.error(error.message),
140
+ onError: (error) => toast.error(explain(error, t('person_create_error'))),
141
+ onSettled: () => {
142
+ creating = false
143
+ },
114
144
  }))
115
145
 
116
- const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'))
146
+ const submit = () => {
147
+ if (creating) return
148
+ creating = true
149
+ create.mutate()
150
+ }
151
+
152
+ const canManage = $derived(canHr('personManage'))
153
+ const canSubmit = $derived(displayName.trim().length > 0 && canManage && !creating)
117
154
  </script>
118
155
 
119
156
  <Dialog
@@ -163,6 +200,12 @@ const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'
163
200
  </div>
164
201
 
165
202
  {#snippet footer()}
203
+ <!--
204
+ The URL reaches this dialog whether or not the person may use it — the Add person button is
205
+ gated, a pasted `?new=1` is not — so the submit says why it is dead rather than looking
206
+ broken.
207
+ -->
208
+ {#if !canManage}<span class="note">{t('person_manage_denied')}</span>{/if}
166
209
  <Button
167
210
  variant="ghost"
168
211
  onclick={() => {
@@ -170,7 +213,7 @@ const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'
170
213
  dismiss()
171
214
  }}>{t('common.cancel')}</Button
172
215
  >
173
- <Button onclick={() => create.mutate()} disabled={!canSubmit} loading={create.isPending}>
216
+ <Button onclick={submit} disabled={!canSubmit} loading={create.isPending}>
174
217
  {t('add_person')}
175
218
  </Button>
176
219
  {/snippet}
@@ -181,4 +224,11 @@ const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'
181
224
  display: grid;
182
225
  gap: 14px;
183
226
  }
227
+ /* A colour, not opacity: opacity fades text against the dialog whatever token it names. */
228
+ .note {
229
+ margin-inline-end: auto;
230
+ align-self: center;
231
+ font-size: 12px;
232
+ color: var(--kern-ink-500);
233
+ }
184
234
  </style>