@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.
- package/package.json +5 -5
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1417 -89
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
package/src/client/module.ts
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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;
|