@kernhq/module-hr 0.9.3 → 0.10.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 (90) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.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 +9 -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 +458 -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 +1896 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +146 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +53 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +101 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +14 -0
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +14 -3
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -6
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -13
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/policies.d.ts +6 -0
  44. package/dist/server/services/policies.d.ts.map +1 -1
  45. package/dist/server/services/policies.js +6 -0
  46. package/dist/server/services/policies.js.map +1 -1
  47. package/migrations/0005_approval_requester.sql +30 -0
  48. package/migrations/0006_schedule_no_overlap.sql +85 -0
  49. package/migrations/0007_hot_path_indexes.sql +38 -0
  50. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  51. package/migrations/meta/0005_snapshot.json +4047 -0
  52. package/migrations/meta/0007_snapshot.json +4225 -0
  53. package/migrations/meta/0009_snapshot.json +4231 -0
  54. package/migrations/meta/_journal.json +28 -0
  55. package/package.json +7 -7
  56. package/src/client/capabilities.ts +34 -0
  57. package/src/client/components/ClockControls.svelte +127 -20
  58. package/src/client/components/ClockControls.test.ts +446 -0
  59. package/src/client/components/DecisionDialog.svelte +90 -0
  60. package/src/client/components/DelegationDialog.svelte +219 -0
  61. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  62. package/src/client/components/PersonFormDialog.svelte +53 -3
  63. package/src/client/components/PersonPanel.svelte +134 -26
  64. package/src/client/i18n.ts +5 -625
  65. package/src/client/index.ts +1 -20
  66. package/src/client/messages.test.ts +154 -0
  67. package/src/client/messages.ts +3388 -0
  68. package/src/client/mock.ts +1691 -162
  69. package/src/client/module.ts +17 -2
  70. package/src/client/pages/ApprovalsPage.svelte +332 -60
  71. package/src/client/pages/AttendancePage.svelte +106 -20
  72. package/src/client/pages/DirectoryPage.svelte +192 -55
  73. package/src/client/pages/LeavePage.svelte +280 -25
  74. package/src/client/pages/OfficesPage.svelte +181 -39
  75. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  76. package/src/client/permissions.ts +2 -1
  77. package/src/client/query.ts +22 -1
  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 +43 -0
  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/approvals.ts +13 -1
  90. package/src/contract/capabilities.ts +17 -0
@@ -5,10 +5,11 @@ import {
5
5
  type BadgeTone,
6
6
  Button,
7
7
  Card,
8
- coreApi,
9
8
  EmptyState,
9
+ formatCount,
10
+ formatDate,
10
11
  Input,
11
- keys,
12
+ messageLocale,
12
13
  navigation,
13
14
  Page,
14
15
  PageHeader,
@@ -20,17 +21,19 @@ import {
20
21
  } from '@kernhq/ui'
21
22
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
22
23
  import { getHrApi } from '../api-instance.js'
24
+ import DecisionDialog from '../components/DecisionDialog.svelte'
23
25
  import PersonFormDialog from '../components/PersonFormDialog.svelte'
24
26
  import PersonPanel from '../components/PersonPanel.svelte'
25
- import type { CoreApi } from '../core-api.js'
26
27
  import { t } from '../i18n.js'
28
+ import type { ApprovalRequest } from '../index.js'
27
29
  import { canHr, HR_CAPABILITIES } from '../permissions.js'
28
30
  import { formatDays, hrKeys } from '../query.js'
31
+ import { summarise } from '../summary.js'
29
32
 
30
33
  /**
31
34
  * The people view, laid out to DESIGN.md §3.12.
32
35
  *
33
- * Four stat tiles, then `minmax(0,1fr) 320px`: a real table on the left — name, role, office,
36
+ * A row of stat tiles, then `minmax(0,1fr) 320px`: a real table on the left — name, role, office,
34
37
  * started, status on one grid so the columns line up down the page — and the things that need a
35
38
  * decision on the right. A flat list of names would be a directory; this is the screen somebody
36
39
  * actually opens in the morning, which is why what is waiting on them sits beside it.
@@ -39,7 +42,6 @@ import { formatDays, hrKeys } from '../query.js'
39
42
  * one office is also the answer to "can I call them now".
40
43
  */
41
44
  const api = getHrApi()
42
- const core = coreApi<CoreApi>()
43
45
  const queryClient = useQueryClient()
44
46
 
45
47
  const workspaceSlug = $derived(navigation.workspaceSlug)
@@ -51,12 +53,21 @@ let officeTab = $state('all')
51
53
  const selected = $derived(navigation.search.person)
52
54
  const creating = $derived(navigation.search.new === '1')
53
55
 
54
- const modulesQuery = createQuery(() => ({
55
- queryKey: keys.modules(workspaceId),
56
- enabled: Boolean(workspaceId),
57
- queryFn: () => core.workspaces.modules.list({ workspaceId }),
58
- }))
59
56
  const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
57
+ /**
58
+ * Leave is a capability, and a workspace that never switched it on has no balance to show.
59
+ *
60
+ * Without this the main HR screen fired `leave.balance.get` on every load of a directory-only
61
+ * workspace and got a 404 back — the honest answer for a feature nobody enabled — leaving an
62
+ * "Available days" tile reading zero for ever beside three tiles that meant something.
63
+ */
64
+ const showLeave = $derived(session.hasCapability('hr', HR_CAPABILITIES.leave))
65
+ /**
66
+ * And the tile needs the permission as well as the capability: `leave.balance.get` is behind
67
+ * `hr.leave.view`, and a tile reading "0" because the request was refused is a worse answer than
68
+ * no tile.
69
+ */
70
+ const showBalance = $derived(showLeave && canHr('leaveView'))
60
71
 
61
72
  /** Debounced: every keystroke would otherwise be a request, and the term is part of the cache key. */
62
73
  let debounced = $state('')
@@ -96,7 +107,7 @@ const tabs = $derived([
96
107
 
97
108
  const balancesQuery = createQuery(() => ({
98
109
  queryKey: hrKeys.leaveBalance(workspaceId, undefined),
99
- enabled: Boolean(workspaceId),
110
+ enabled: Boolean(workspaceId) && showBalance,
100
111
  queryFn: () => api.leave.balance.get({ workspaceId }),
101
112
  }))
102
113
 
@@ -107,12 +118,92 @@ const inboxQuery = createQuery(() => ({
107
118
  }))
108
119
  const waiting = $derived(inboxQuery.data?.items ?? [])
109
120
 
121
+ let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
122
+ let decideError = $state<string | null>(null)
123
+
124
+ /**
125
+ * `submitting` rather than `decide.isPending`: the disabled attribute only reaches the confirm
126
+ * button on the next render, so two quick clicks both fire and one request is decided twice. This
127
+ * is set in the same tick as the click.
128
+ */
129
+ let submitting = $state(false)
130
+
131
+ /**
132
+ * The decision refusals this module has its own sentence for, keyed by the `reason` the router
133
+ * sends beside the refusal. The same shape as `ClockControls.svelte`, and empty for the same reason
134
+ * the widget's is: `approvals.decide` refuses through `KernError.conflict`, whose reason argument
135
+ * stays on the server, so a refusal arrives today as the sentence the router wrote for a reader.
136
+ */
137
+ const decideRefusalMessages: Record<string, string> = {}
138
+
139
+ /**
140
+ * What a refused decision says to the person who made it.
141
+ *
142
+ * A decision is refused when the request is no longer theirs to decide — somebody else approved it,
143
+ * the requester cancelled it, a delegation moved the step — and that sentence is the only thing
144
+ * saying which. Everything else that can fail carries machine text in English, so it falls back to
145
+ * this module's own string. The test is the transport's `code`, never the sentence.
146
+ */
147
+ function decideFailure(error: unknown): string {
148
+ const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
149
+ if (failure.code !== 'CONFLICT') return t('decide_error')
150
+ const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
151
+ const key = reason ? decideRefusalMessages[reason] : undefined
152
+ // `t()` answers a key it has no string for with the key itself, so a reason nothing is written
153
+ // for lands on the router's sentence rather than putting `hr.decide_refused_…` in front of a
154
+ // person.
155
+ const translated = key ? t(key) : undefined
156
+ return (translated && translated !== key ? translated : failure.message) || t('decide_error')
157
+ }
158
+
110
159
  const decide = createMutation(() => ({
111
- mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
112
- api.approvals.decide({ workspaceId, ...vars }),
113
- onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['hr'] }),
160
+ mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
161
+ api.approvals.decide({
162
+ workspaceId,
163
+ requestId: vars.requestId,
164
+ decision: vars.decision,
165
+ comment: vars.comment.trim() || null,
166
+ }),
167
+ onSuccess: () => {
168
+ deciding = null
169
+ decideError = null
170
+ // Deciding moves a balance and a day sheet as well as the inbox, so the whole module's cache is
171
+ // invalidated rather than guessing which keys moved.
172
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
173
+ },
174
+ onError: (error) => {
175
+ decideError = decideFailure(error)
176
+ // A refusal is the server saying its inbox is not the one on screen, so the row behind the
177
+ // dialog is stale as well as the decision. Re-read all of HR exactly as a decision that landed
178
+ // does — without this the same dead row sits here and every retry earns the same sentence.
179
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
180
+ },
181
+ onSettled: () => {
182
+ submitting = false
183
+ },
114
184
  }))
115
185
 
186
+ const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
187
+ decideError = null
188
+ deciding = { request, decision }
189
+ }
190
+
191
+ const confirmDecision = (comment: string) => {
192
+ if (!deciding || submitting) return
193
+ submitting = true
194
+ decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
195
+ }
196
+
197
+ /** The same map as the approvals inbox: a card labelled "Leave" over an overtime request lies. */
198
+ const SUBJECT_LABELS: Record<string, () => string> = {
199
+ leave: () => t('leave_title'),
200
+ regularization: () => t('attendance_title'),
201
+ overtime: () => t('att_overtime'),
202
+ timesheet: () => t('approval_subject_timesheet'),
203
+ shift_swap: () => t('approval_subject_shift_swap'),
204
+ }
205
+ const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
206
+
116
207
  const stats = $derived({
117
208
  headcount: peopleQuery.data?.total ?? people.length,
118
209
  offices: offices.length,
@@ -152,11 +243,15 @@ $effect(() => {
152
243
  return () => clearInterval(handle)
153
244
  })
154
245
 
246
+ /**
247
+ * `messageLocale()`, never the runtime default: a Persian reader gets Persian digits from `t()` in
248
+ * the same row, and a clock in Latin ones beside them is the one number nobody translated.
249
+ */
155
250
  function localTime(timezone: string | null, _tick: number): string | null {
156
251
  void _tick
157
252
  if (!timezone) return null
158
253
  try {
159
- return new Intl.DateTimeFormat(undefined, {
254
+ return new Intl.DateTimeFormat(messageLocale(), {
160
255
  timeZone: timezone,
161
256
  hour: 'numeric',
162
257
  minute: '2-digit',
@@ -168,11 +263,10 @@ function localTime(timezone: string | null, _tick: number): string | null {
168
263
  }
169
264
 
170
265
  const started = (iso: string | null) =>
171
- iso
172
- ? new Intl.DateTimeFormat(undefined, { month: 'short', year: 'numeric' }).format(
173
- new Date(`${iso}T00:00:00`),
174
- )
175
- : '—'
266
+ iso ? formatDate(`${iso}T00:00:00`, { month: 'short', year: 'numeric' }) : '—'
267
+
268
+ /** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
269
+ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
176
270
  </script>
177
271
 
178
272
  <PageHeader
@@ -189,15 +283,30 @@ const started = (iso: string | null) =>
189
283
 
190
284
  <Page>
191
285
  <div class="tiles">
192
- <StatTile size="md" label={t('widget_headcount_title')} value={new Intl.NumberFormat().format(stats.headcount)} />
193
- <StatTile size="md" label={t('offices_title')} value={new Intl.NumberFormat().format(stats.offices)} />
194
- <StatTile size="md" label={t('status_on_leave')} value={new Intl.NumberFormat().format(stats.away)} />
195
- <StatTile size="md" label={t('available')} value={formatDays(stats.balance)} note={t('days')} />
286
+ <StatTile size="md" label={t('widget_headcount_title')} value={count(stats.headcount)} />
287
+ <!--
288
+ Only where the workspace has offices. It rendered unconditionally and read "Offices 0" on a
289
+ single-site workspace a tile counting a feature nobody switched on, sitting beside three
290
+ that mean something. A capability that is off has no surface at all, tiles included.
291
+ -->
292
+ {#if showOffices}
293
+ <StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
294
+ {/if}
295
+ <StatTile size="md" label={t('status_on_leave')} value={count(stats.away)} />
296
+ <!-- Same rule for leave: the balance tile is the surface of a capability, so it goes with it. -->
297
+ {#if showBalance}
298
+ <StatTile
299
+ size="md"
300
+ label={t('available')}
301
+ value={formatDays(stats.balance, messageLocale())}
302
+ note={t('days', { count: stats.balance })}
303
+ />
304
+ {/if}
196
305
  </div>
197
306
 
198
307
  <div class="split">
199
308
  <section>
200
- <SectionLabel label={t('title')} count={people.length} />
309
+ <SectionLabel label={t('title')} count={count(people.length)} />
201
310
 
202
311
  <div class="filters">
203
312
  {#if tabs.length > 1}
@@ -208,19 +317,17 @@ const started = (iso: string | null) =>
208
317
  </div>
209
318
  </div>
210
319
 
320
+ <!--
321
+ Held rows outrank the error. Every decision taken on this page invalidates all of `['hr']`,
322
+ so a failed background refetch leaves TanStack in `error` with the last good directory still
323
+ in `data` — an error branch above this one would blank a working table on a transient
324
+ failure. The error is the whole section only when there is nothing else to draw.
325
+ -->
211
326
  {#if peopleQuery.isLoading}
212
327
  <div class="rows">
213
328
  {#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="48px" />{/each}
214
329
  </div>
215
- {:else if peopleQuery.isError}
216
- <EmptyState icon="triangle-alert" title={t('people_error')}>
217
- {#snippet actions()}
218
- <Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('common.retry')}</Button>
219
- {/snippet}
220
- </EmptyState>
221
- {:else if people.length === 0}
222
- <EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
223
- {:else}
330
+ {:else if people.length > 0}
224
331
  <div class="table" role="table" aria-label={t('title')}>
225
332
  <div class="thead" role="row">
226
333
  <span role="columnheader">{t('title')}</span>
@@ -250,41 +357,51 @@ const started = (iso: string | null) =>
250
357
  </a>
251
358
  {/each}
252
359
  </div>
360
+ {:else if peopleQuery.isError}
361
+ <EmptyState icon="triangle-alert" title={t('people_error')}>
362
+ {#snippet actions()}
363
+ <Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
364
+ {/snippet}
365
+ </EmptyState>
366
+ {:else}
367
+ <EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
253
368
  {/if}
254
369
  </section>
255
370
 
256
371
  <aside>
257
- <SectionLabel label={t('approvals_title')} count={waiting.length} />
372
+ <SectionLabel label={t('approvals_title')} count={formatCount(waiting.length)} />
373
+ <!-- Held cards outrank the error here too, and for the same reason: see the table above. -->
258
374
  {#if inboxQuery.isLoading}
259
375
  <Skeleton height="120px" />
260
- {:else if waiting.length === 0}
261
- <EmptyState bare compact icon="check-check" title={t('approvals_none')} />
262
- {:else}
376
+ {:else if waiting.length > 0}
263
377
  <div class="cards">
264
378
  {#each waiting as item (item.id)}
265
379
  <Card>
266
380
  <div class="cardhead">
267
- <Badge tone="upcoming">{t('leave_title')}</Badge>
381
+ <Badge tone="grey">{subjectLabel(item.subjectType)}</Badge>
268
382
  </div>
269
- <p class="summary">{item.summary}</p>
383
+ <p class="summary">{summarise(item)}</p>
270
384
  <div class="cardactions">
271
- <Button
272
- size="sm"
273
- disabled={decide.isPending}
274
- onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
275
- >{t('approve')}</Button
276
- >
277
- <Button
278
- size="sm"
279
- variant="secondary"
280
- disabled={decide.isPending}
281
- onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
282
- >{t('reject')}</Button
283
- >
385
+ <Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>{t('reject')}</Button>
386
+ <Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
284
387
  </div>
285
388
  </Card>
286
389
  {/each}
287
390
  </div>
391
+ {:else if inboxQuery.isError}
392
+ <!--
393
+ Without this the empty state below told somebody "Nothing waiting on you" when their inbox
394
+ had simply failed to load — the one sentence on this page nobody would think to check.
395
+ -->
396
+ <EmptyState bare compact icon="triangle-alert" title={t('approvals_error')}>
397
+ {#snippet actions()}
398
+ <Button size="sm" variant="secondary" onclick={() => void inboxQuery.refetch()}>
399
+ {t('retry')}
400
+ </Button>
401
+ {/snippet}
402
+ </EmptyState>
403
+ {:else}
404
+ <EmptyState bare compact icon="check-check" title={t('approvals_none')} />
288
405
  {/if}
289
406
  </aside>
290
407
  </div>
@@ -294,6 +411,23 @@ const started = (iso: string | null) =>
294
411
  <PersonPanel personId={selected} {workspaceId} {workspaceSlug} />
295
412
  {/if}
296
413
 
414
+ <!--
415
+ The same dialog the approvals inbox uses. Rejecting somebody's leave is irreversible from the
416
+ interface and notifies them, so it is never one click from a sidebar card — and the confirmation
417
+ says what the decision does, per subject type and per position in the chain.
418
+ -->
419
+ <DecisionDialog
420
+ request={deciding?.request ?? null}
421
+ decision={deciding?.decision ?? 'approve'}
422
+ pending={submitting}
423
+ error={decideError}
424
+ onConfirm={confirmDecision}
425
+ onCancel={() => {
426
+ deciding = null
427
+ decideError = null
428
+ }}
429
+ />
430
+
297
431
  <PersonFormDialog
298
432
  open={creating}
299
433
  {workspaceId}
@@ -303,10 +437,13 @@ const started = (iso: string | null) =>
303
437
  />
304
438
 
305
439
  <style>
306
- /* §3.12: four stat tiles, then a 1fr / 320px split, gap 20. */
440
+ /* §3.12: a row of stat tiles, then a 1fr / 320px split, gap 20. */
307
441
  .tiles {
308
442
  display: grid;
309
- grid-template-columns: repeat(4, minmax(0, 1fr));
443
+ /* Not `repeat(4, …)`: the offices tile is only there when the capability is on, and a fixed
444
+ four-column track left a hole in the row where it used to be. */
445
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
446
+ grid-auto-flow: column;
310
447
  gap: 12px;
311
448
  margin-block-end: 20px;
312
449
  }