@kernhq/module-hr 0.10.4 → 0.11.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.
@@ -75,6 +75,16 @@ export const hrClientModule = defineClientModule({
75
75
  permission: HR_PERMISSIONS.officeView,
76
76
  capability: HR_CAPABILITIES.offices,
77
77
  },
78
+ {
79
+ path: '/hr/org',
80
+ component: () => import('./pages/OrgPage.svelte'),
81
+ get title() {
82
+ return t('org_title')
83
+ },
84
+ // No capability: departments and positions are part of `core`, and `hr.org.view` is a default
85
+ // member permission — the chart is the one HR screen most of a company opens.
86
+ permission: HR_PERMISSIONS.orgView,
87
+ },
78
88
  {
79
89
  // Last: the shell matches in order, and `/hr` would otherwise swallow the paths above it.
80
90
  path: '/hr',
@@ -296,6 +306,46 @@ export const hrClientModule = defineClientModule({
296
306
  order: 40,
297
307
  component: () => import('./settings/SchedulesSettings.svelte'),
298
308
  },
309
+ {
310
+ // Between leave and schedules, because accrual is how a balance comes to exist and the leave
311
+ // types it credits are the page above it.
312
+ id: 'accrual',
313
+ get label() {
314
+ return t('settings_accrual')
315
+ },
316
+ icon: 'gauge',
317
+ scope: 'workspace',
318
+ permission: HR_PERMISSIONS.policyManage,
319
+ capability: HR_CAPABILITIES.leaveAccrual,
320
+ order: 35,
321
+ component: () => import('./settings/AccrualSettings.svelte'),
322
+ },
323
+ {
324
+ id: 'approvals',
325
+ get label() {
326
+ return t('settings_approvals')
327
+ },
328
+ icon: 'list-checks',
329
+ scope: 'workspace',
330
+ permission: HR_PERMISSIONS.approvalManage,
331
+ capability: HR_CAPABILITIES.approvals,
332
+ order: 50,
333
+ component: () => import('./settings/ApprovalsSettings.svelte'),
334
+ },
335
+ {
336
+ // After approvals rather than beside it: locking a month is the last thing an admin does in a
337
+ // cycle, and it is the one entry here that stops other people's screens changing.
338
+ id: 'periods',
339
+ get label() {
340
+ return t('settings_periods')
341
+ },
342
+ icon: 'lock',
343
+ scope: 'workspace',
344
+ permission: HR_PERMISSIONS.periodManage,
345
+ capability: HR_CAPABILITIES.periods,
346
+ order: 60,
347
+ component: () => import('./settings/PeriodsSettings.svelte'),
348
+ },
299
349
  ],
300
350
 
301
351
  presenters: [
@@ -4,6 +4,7 @@ import {
4
4
  Button,
5
5
  EmptyState,
6
6
  formatDate,
7
+ Icon,
7
8
  messageLocale,
8
9
  navigation,
9
10
  Page,
@@ -15,6 +16,7 @@ import {
15
16
  import { createQuery } from '@tanstack/svelte-query'
16
17
  import { getHrApi } from '../api-instance.js'
17
18
  import ClockControls from '../components/ClockControls.svelte'
19
+ import DayDetail from '../components/DayDetail.svelte'
18
20
  import { t } from '../i18n.js'
19
21
  import { formatDuration, hrKeys, monthRange } from '../query.js'
20
22
 
@@ -30,6 +32,12 @@ import { formatDuration, hrKeys, monthRange } from '../query.js'
30
32
  * a confident statement about somebody's month made out of nothing, and the one figure on this page
31
33
  * a person might take to their manager. Until there is a day sheet to add up the tiles are
32
34
  * skeletons, and the month underneath says the load failed and offers a way to try again.
35
+ *
36
+ * A row opens. Underneath it are the punches the total was computed from, the anomalies as
37
+ * sentences rather than a number in a warning badge, and the two things somebody arguing with their
38
+ * timesheet can actually do — void a punch that is wrong, ask for one that is missing. All four
39
+ * procedures behind that were implemented and called from nowhere, which made
40
+ * `hr.attendance.manage` a permission to read.
33
41
  */
34
42
  const api = getHrApi()
35
43
 
@@ -46,6 +54,42 @@ const daysQuery = createQuery(() => ({
46
54
  }))
47
55
  const days = $derived(daysQuery.data?.items ?? [])
48
56
 
57
+ /**
58
+ * The corrections this person has already asked for, once for the month rather than once per day.
59
+ *
60
+ * `regularizations.list` answers for one person and has no date filter, so a query per open day
61
+ * would be the same request repeated. It is read here and handed down filtered, which also lets a
62
+ * day that has one say so *before* it is opened — the reason somebody opens a day is usually the
63
+ * reason they already raised.
64
+ */
65
+ const correctionsQuery = createQuery(() => ({
66
+ // The same literal shape `hrKeys` builds — `['hr', entity, workspace, …scope]` — so the module's
67
+ // blanket `['hr']` invalidation after a correction reaches it.
68
+ queryKey: ['hr', 'regularizations', workspaceId, 'me', 'pending'] as const,
69
+ enabled: Boolean(workspaceId),
70
+ queryFn: () => api.attendance.regularizations.list({ workspaceId, status: ['pending'], limit: 100 }),
71
+ }))
72
+ const corrections = $derived({
73
+ items: correctionsQuery.data?.items ?? [],
74
+ loading: !workspaceId || correctionsQuery.isLoading,
75
+ // The retained list decides, never the status: a failed background refetch leaves `error` beside
76
+ // a perfectly good list, and treating that as a failure would hide corrections that exist.
77
+ failed: correctionsQuery.isError && (correctionsQuery.data?.items ?? []).length === 0,
78
+ retry: () => void correctionsQuery.refetch(),
79
+ })
80
+ const correctionDates = $derived(new Set(corrections.items.map((r) => r.businessDate)))
81
+
82
+ /**
83
+ * One day open at a time.
84
+ *
85
+ * An accordion rather than a set: the panel is tall — punches, anomalies, corrections — and a month
86
+ * with five of them open is a page nobody can find the row they wanted in.
87
+ */
88
+ let openDay = $state<string | null>(null)
89
+ const toggleDay = (id: string) => {
90
+ openDay = openDay === id ? null : id
91
+ }
92
+
49
93
  /**
50
94
  * A disabled query is not a loading one — it is `pending` and not fetching — so without the
51
95
  * workspace test the first frame of this page falls through to the empty state and tells somebody
@@ -151,17 +195,52 @@ const dayLabel = (iso: string) =>
151
195
  {/if}
152
196
  <ul>
153
197
  {#each days as day (day.id)}
154
- <li class="row">
155
- <span class="date">{dayLabel(day.businessDate)}</span>
156
- <span class="worked">{duration(day.workedMinutes)}</span>
157
- {#if day.overtimeMinutes > 0}
158
- <span class="ot">+{duration(day.overtimeMinutes)}</span>
159
- {/if}
160
- <!-- An anomaly is why a day needs a human; saying so beats a silent zero. -->
161
- {#if day.anomalies.length}
162
- <Badge tone="warning">{day.anomalies.length}</Badge>
163
- {/if}
164
- <Badge tone={statusTone(day.status)}>{statusLabel(day.status)}</Badge>
198
+ {@const open = openDay === day.id}
199
+ <li>
200
+ <!--
201
+ The whole row is the control, because the thing somebody wants after reading "0h worked"
202
+ is everything underneath it — not a chevron they have to find first.
203
+ -->
204
+ <button
205
+ type="button"
206
+ class="row"
207
+ aria-expanded={open}
208
+ aria-controls={`hr-day-${day.id}`}
209
+ onclick={() => toggleDay(day.id)}
210
+ >
211
+ <!--
212
+ One icon rotated, as `SectionLabel` does it — a `chevron-right` for the closed state
213
+ points into the margin under `dir="rtl"`, and there is no logical property for a
214
+ rotation.
215
+ -->
216
+ <span class="chev" class:closed={!open}><Icon name="chevron-down" size={14} /></span>
217
+ <span class="date">{dayLabel(day.businessDate)}</span>
218
+ <span class="worked">{duration(day.workedMinutes)}</span>
219
+ {#if day.overtimeMinutes > 0}
220
+ <span class="ot">+{duration(day.overtimeMinutes)}</span>
221
+ {/if}
222
+ <!--
223
+ A number in a warning badge with no noun said nothing at all. It counts things a
224
+ person has to look at, so it says so — and the sentences behind it are one click away
225
+ rather than nowhere.
226
+ -->
227
+ {#if day.anomalies.length}
228
+ <Badge tone="warning">{t('att_anomalies_count', { count: day.anomalies.length })}</Badge>
229
+ {/if}
230
+ {#if correctionDates.has(day.businessDate)}
231
+ <Badge tone="upcoming">{t('att_correction_waiting')}</Badge>
232
+ {/if}
233
+ <Badge tone={statusTone(day.status)}>{statusLabel(day.status)}</Badge>
234
+ </button>
235
+ <!--
236
+ The container exists whether or not it is open, so `aria-controls` above always points
237
+ at something; the panel's queries only run once somebody asks for them.
238
+ -->
239
+ <div id={`hr-day-${day.id}`}>
240
+ {#if open}
241
+ <DayDetail {workspaceId} {day} {corrections} />
242
+ {/if}
243
+ </div>
165
244
  </li>
166
245
  {/each}
167
246
  </ul>
@@ -213,12 +292,38 @@ ul {
213
292
  margin: 0;
214
293
  padding: 0;
215
294
  }
295
+ /*
296
+ * A button, so the keyboard reaches every day the pointer does — and reset back to a row: a
297
+ * `<button>` inherits neither the page's font nor its text direction from the browser's defaults.
298
+ */
216
299
  .row {
217
300
  display: flex;
218
301
  align-items: center;
219
302
  gap: 12px;
303
+ width: 100%;
220
304
  padding: 8px 12px;
305
+ border: 0;
221
306
  border-block-end: 1px solid var(--kern-border);
307
+ border-radius: var(--kern-r-md);
308
+ background: none;
309
+ color: inherit;
310
+ font: inherit;
311
+ text-align: start;
312
+ cursor: pointer;
313
+ }
314
+ .row:hover {
315
+ background: var(--kern-surface-hover);
316
+ }
317
+ .chev {
318
+ display: inline-flex;
319
+ color: var(--kern-ink-500);
320
+ transition: transform 0.14s;
321
+ }
322
+ .chev.closed {
323
+ transform: rotate(-90deg);
324
+ }
325
+ :global([dir='rtl']) .chev.closed {
326
+ transform: rotate(90deg);
222
327
  }
223
328
  .date {
224
329
  flex: 1;
@@ -6,6 +6,7 @@ import {
6
6
  Dialog,
7
7
  EmptyState,
8
8
  formatDateRange,
9
+ Icon,
9
10
  messageLocale,
10
11
  navigation,
11
12
  Page,
@@ -17,6 +18,7 @@ import {
17
18
  } from '@kernhq/ui'
18
19
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
19
20
  import { getHrApi } from '../api-instance.js'
21
+ import LeaveLedgerPanel from '../components/LeaveLedgerPanel.svelte'
20
22
  import LeaveRequestDialog from '../components/LeaveRequestDialog.svelte'
21
23
  import { t } from '../i18n.js'
22
24
  import type { LeaveRequest } from '../index.js'
@@ -34,6 +36,10 @@ import { formatDays, hrKeys } from '../query.js'
34
36
  * Neither half may fail quietly. A refused balance used to take the strip off the page with no
35
37
  * message and leave "No time off booked" underneath it, which reads as a person with nothing —
36
38
  * no days left and none booked — rather than as a screen that never loaded.
39
+ *
40
+ * A tile is also the way in to the movements behind it. "How much do I have" and "why is it that"
41
+ * are the same question one step apart, and the second one had no answer on any screen: the ledger
42
+ * was readable over the API and nowhere else, so `hr.leave.view_ledger` gated nothing.
37
43
  */
38
44
  const api = getHrApi()
39
45
  const queryClient = useQueryClient()
@@ -84,6 +90,24 @@ const refetchAll = () => {
84
90
 
85
91
  const days = (n: number) => formatDays(n, messageLocale())
86
92
 
93
+ /**
94
+ * The unit a type is counted in decides the word beside the number. A type counted in hours read
95
+ * "7 days available" on this strip until the ledger needed the distinction anyway; half-days are
96
+ * still days.
97
+ */
98
+ const unitWord = (unit: string, count: number) => t(unit === 'hour' ? 'hours' : 'days', { count })
99
+
100
+ // ---------------------------------------------------------------- the ledger behind a tile
101
+
102
+ const canLedger = $derived(canHr('leaveViewLedger'))
103
+ /**
104
+ * The type whose ledger is open, rather than the row itself: held by id, the panel keeps reading the
105
+ * live query row, so an adjustment made inside it moves the figure at the top of the panel as soon
106
+ * as the invalidation lands. A captured copy would sit there stating the old number.
107
+ */
108
+ let ledgerTypeId = $state<string | null>(null)
109
+ const ledgerBalance = $derived(balances.find((b) => b.leaveTypeId === ledgerTypeId) ?? null)
110
+
87
111
  /**
88
112
  * The request waiting on a confirmation, and what the last attempt said.
89
113
  *
@@ -227,11 +251,37 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
227
251
  {:else if balances.length}
228
252
  <div class="tiles">
229
253
  {#each balances as balance (balance.leaveTypeId)}
230
- <StatTile
231
- label={balance.leaveTypeName}
232
- value={days(balance.available)}
233
- note={`${t('available')} · ${t('days', { count: balance.available })}`}
234
- />
254
+ {#if canLedger}
255
+ <!--
256
+ The whole tile is the control, so the target is the size of the thing somebody is
257
+ looking at rather than a link tucked in a corner. `aria-label` names the action, which
258
+ is what stops a screen reader reading the number twice and the verb never.
259
+ -->
260
+ <button
261
+ type="button"
262
+ class="tile-button"
263
+ aria-label={t('leave_ledger_open', { name: balance.leaveTypeName })}
264
+ onclick={() => (ledgerTypeId = balance.leaveTypeId)}
265
+ >
266
+ <StatTile
267
+ class="ledger-tile"
268
+ label={balance.leaveTypeName}
269
+ value={days(balance.available)}
270
+ note={`${t('available')} · ${unitWord(balance.unit, balance.available)}`}
271
+ >
272
+ <span class="cue">
273
+ <Icon name="scroll-text" size={13} strokeWidth={1.7} />
274
+ {t('leave_ledger')}
275
+ </span>
276
+ </StatTile>
277
+ </button>
278
+ {:else}
279
+ <StatTile
280
+ label={balance.leaveTypeName}
281
+ value={days(balance.available)}
282
+ note={`${t('available')} · ${unitWord(balance.unit, balance.available)}`}
283
+ />
284
+ {/if}
235
285
  {/each}
236
286
  </div>
237
287
  {:else if balanceQuery.isError}
@@ -314,6 +364,13 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
314
364
 
315
365
  <LeaveRequestDialog open={requesting} {workspaceId} {workspaceSlug} />
316
366
 
367
+ <LeaveLedgerPanel
368
+ balance={ledgerBalance}
369
+ {workspaceId}
370
+ personName={session.user?.name ?? ''}
371
+ onClose={() => (ledgerTypeId = null)}
372
+ />
373
+
317
374
  <!--
318
375
  Cancelling is not undoing, and the person clicking has to know what it costs before it happens: a
319
376
  small ghost button beside a status badge was wired straight to the mutation, so one misclick threw
@@ -365,6 +422,30 @@ const canCancel = (status: string) => canHr('leaveRequest') && (status === 'pend
365
422
  gap: 12px;
366
423
  margin-block-end: 20px;
367
424
  }
425
+ /*
426
+ * The tile is a button, so it needs the button reset undone: the global one strips background,
427
+ * border and padding, and the tile inside paints its own.
428
+ */
429
+ .tile-button {
430
+ display: block;
431
+ width: 100%;
432
+ text-align: start;
433
+ font: inherit;
434
+ color: inherit;
435
+ border-radius: var(--kern-r-2xl);
436
+ }
437
+ .tile-button:hover :global(.ledger-tile) {
438
+ background: var(--kern-surface-card-hover);
439
+ }
440
+ .cue {
441
+ display: flex;
442
+ align-items: center;
443
+ gap: 5px;
444
+ margin-block-start: 10px;
445
+ font-size: 12px;
446
+ /* A colour, not opacity: 6.74:1 on the tile in light, 6.12:1 in dark. */
447
+ color: var(--kern-ink-500);
448
+ }
368
449
  /* Keeps an error or empty balance on the same rhythm as the tiles it stands in for. */
369
450
  .tiles-slot {
370
451
  margin-block-end: 20px;