@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.
- package/dist/contract/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -43,6 +43,27 @@
|
|
|
43
43
|
"when": 1787761769496,
|
|
44
44
|
"tag": "0005_approval_requester",
|
|
45
45
|
"breakpoints": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"idx": 6,
|
|
49
|
+
"version": "7",
|
|
50
|
+
"when": 1787848169496,
|
|
51
|
+
"tag": "0006_schedule_no_overlap",
|
|
52
|
+
"breakpoints": true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"idx": 7,
|
|
56
|
+
"version": "7",
|
|
57
|
+
"when": 1787848170496,
|
|
58
|
+
"tag": "0007_hot_path_indexes",
|
|
59
|
+
"breakpoints": true
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"idx": 9,
|
|
63
|
+
"version": "7",
|
|
64
|
+
"when": 1787759372069,
|
|
65
|
+
"tag": "0009_beyond_cap_minutes",
|
|
66
|
+
"breakpoints": true
|
|
46
67
|
}
|
|
47
68
|
]
|
|
48
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-hr",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Kern HR module: people, offices, org chart, calendars",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -43,10 +43,10 @@
|
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@biomejs/biome": "^2.0.0",
|
|
45
45
|
"@changesets/cli": "^2.27.0",
|
|
46
|
-
"@kernhq/contracts": "^0.
|
|
46
|
+
"@kernhq/contracts": "^0.6.0",
|
|
47
47
|
"@kernhq/kernel": "^0.7.0",
|
|
48
48
|
"@kernhq/tsconfig": "^0.1.0",
|
|
49
|
-
"@kernhq/ui": "^0.
|
|
49
|
+
"@kernhq/ui": "^0.10.0",
|
|
50
50
|
"@tanstack/svelte-query": "^6.1.0",
|
|
51
51
|
"@types/node": "^24.0.0",
|
|
52
52
|
"@types/pg": "^8.15.0",
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"vitest": "^4.0.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@kernhq/contracts": "^0.
|
|
61
|
+
"@kernhq/contracts": "^0.6.0",
|
|
62
62
|
"@kernhq/kernel": "^0.7.0",
|
|
63
|
-
"@kernhq/ui": "^0.
|
|
63
|
+
"@kernhq/ui": "^0.10.0",
|
|
64
64
|
"@tanstack/svelte-query": "^6.1.0",
|
|
65
65
|
"svelte": "^5.46.0"
|
|
66
66
|
},
|
|
@@ -86,10 +86,10 @@
|
|
|
86
86
|
"scripts": {
|
|
87
87
|
"build": "tsc -p tsconfig.json",
|
|
88
88
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
89
|
-
"typecheck": "tsc -p tsconfig.json
|
|
89
|
+
"typecheck": "tsc -p tsconfig.test.json && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
|
|
90
90
|
"test": "vitest run",
|
|
91
91
|
"db:generate": "drizzle-kit generate",
|
|
92
|
-
"lint": "biome check . && node scripts/check-ranges.mjs package.json && node scripts/check-icons.mjs",
|
|
92
|
+
"lint": "biome check . && node scripts/check-ranges.mjs package.json && node scripts/check-icons.mjs && node scripts/check-snapshot-drift.mjs",
|
|
93
93
|
"format": "biome check --write .",
|
|
94
94
|
"changeset": "changeset"
|
|
95
95
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Button, Skeleton } from '@kernhq/ui'
|
|
2
|
+
import { Button, localTime, Skeleton, toast } from '@kernhq/ui'
|
|
3
3
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
4
|
import { getHrApi } from '../api-instance.js'
|
|
5
5
|
import { t } from '../i18n.js'
|
|
@@ -11,12 +11,18 @@ import { formatDuration, hrKeys } from '../query.js'
|
|
|
11
11
|
* Only the transitions that are currently legal are offered, because the server refuses the others
|
|
12
12
|
* anyway and a button that always errors is worse than no button. Clocked out: one action. Clocked
|
|
13
13
|
* in: clock out, and start a break. On a break: end it.
|
|
14
|
+
*
|
|
15
|
+
* Every state of the query is drawn, including the two that used to render nothing: a failed state
|
|
16
|
+
* fetch took the clock off the attendance page and off the dashboard card with no message and no
|
|
17
|
+
* way back, and the only thing that could bring it back was a reload.
|
|
14
18
|
*/
|
|
15
19
|
interface Props {
|
|
16
20
|
workspaceId: string
|
|
17
21
|
}
|
|
18
22
|
const { workspaceId }: Props = $props()
|
|
19
23
|
|
|
24
|
+
type Punch = 'in' | 'out' | 'break_start' | 'break_end'
|
|
25
|
+
|
|
20
26
|
const api = getHrApi()
|
|
21
27
|
const queryClient = useQueryClient()
|
|
22
28
|
|
|
@@ -27,15 +33,43 @@ const stateQuery = createQuery(() => ({
|
|
|
27
33
|
// The elapsed total is computed server-side from an open span, so it goes stale on its own.
|
|
28
34
|
refetchInterval: 60_000,
|
|
29
35
|
}))
|
|
30
|
-
|
|
36
|
+
// Not `state`: a variable of that name turns every `$state(...)` in this file into a store read
|
|
37
|
+
// of it, and the rune stops existing.
|
|
38
|
+
const clock = $derived(stateQuery.data)
|
|
31
39
|
|
|
32
40
|
const words = {
|
|
33
41
|
hours: (n: string) => t('hours_short', { n }),
|
|
34
42
|
minutes: (n: string) => t('minutes_short', { n }),
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `punching` rather than `act.isPending`: the disabled attribute only reaches the button on the
|
|
47
|
+
* next render, so two quick clicks both fire and the day sheet grows a punch nobody made. This is
|
|
48
|
+
* set in the same tick as the first click.
|
|
49
|
+
*/
|
|
50
|
+
let punching = $state(false)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The reasons the server refuses a punch for, and this module's string for each.
|
|
54
|
+
*
|
|
55
|
+
* Keyed by the `reason` the router sends beside the refusal — never by the sentence, because a list
|
|
56
|
+
* of sentences is a list somebody has to keep in sync and the day it drifts the reader is told
|
|
57
|
+
* nothing. A reason missing from here is not a bug; it is the fallback doing its job: a sixth
|
|
58
|
+
* refusal added to `punch()` shows the sentence the router wrote until a string lands here.
|
|
59
|
+
*
|
|
60
|
+
* The message keys on the right are written out in full so `t()` passes them through untouched,
|
|
61
|
+
* which is what lets `onError` tell a resolved string from a key that resolved to itself.
|
|
62
|
+
*/
|
|
63
|
+
const punchRefusalMessages: Record<string, string> = {
|
|
64
|
+
'hr.clock.already_clocked_in': 'hr.clock_refused_already_in',
|
|
65
|
+
'hr.clock.not_clocked_in': 'hr.clock_refused_not_in',
|
|
66
|
+
'hr.clock.break_before_clock_in': 'hr.clock_refused_break_needs_in',
|
|
67
|
+
'hr.clock.already_on_break': 'hr.clock_refused_already_on_break',
|
|
68
|
+
'hr.clock.not_on_break': 'hr.clock_refused_not_on_break',
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
const act = createMutation(() => ({
|
|
38
|
-
mutationFn: async (action:
|
|
72
|
+
mutationFn: async (action: Punch) => {
|
|
39
73
|
if (action === 'in') return api.attendance.clockIn({ workspaceId })
|
|
40
74
|
if (action === 'out') return api.attendance.clockOut({ workspaceId })
|
|
41
75
|
if (action === 'break_start') return api.attendance.breakStart({ workspaceId })
|
|
@@ -45,55 +79,124 @@ const act = createMutation(() => ({
|
|
|
45
79
|
// A punch changes the day sheet as well as the clock, and both are on screen.
|
|
46
80
|
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
47
81
|
},
|
|
82
|
+
onError: (error) => {
|
|
83
|
+
// The router refuses an impossible transition — clocking in twice, a break before clocking in —
|
|
84
|
+
// and that refusal is the only thing that says why the button did nothing. It arrives as two
|
|
85
|
+
// pieces: a `reason` this file translates, and the English sentence the router wrote for a
|
|
86
|
+
// reader. Nothing else that can fail here has written anything for a person: a network drop, a
|
|
87
|
+
// 500 or a gateway carry machine text, in English, and a clock is the last place to paste one.
|
|
88
|
+
// So a refusal is explained and everything else falls back to this module's own string.
|
|
89
|
+
//
|
|
90
|
+
// The test is the transport's `code`, never the sentence: a list of sentences is a list
|
|
91
|
+
// somebody has to keep in sync, and the day it drifts the person is told nothing.
|
|
92
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
93
|
+
const refused = failure.code === 'CONFLICT'
|
|
94
|
+
const reason = refused && typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
95
|
+
const key = reason ? punchRefusalMessages[reason] : undefined
|
|
96
|
+
// `t()` answers a key it has no string for with the key itself, so a translation is only used
|
|
97
|
+
// when it is one, and both ways of not having one land on the server's sentence: a reason no
|
|
98
|
+
// key covers, and a key whose string has not been merged yet. Neither may put
|
|
99
|
+
// `hr.clock_refused_not_in` — or nothing at all — in front of a person.
|
|
100
|
+
const translated = key ? t(key) : undefined
|
|
101
|
+
const explained = translated && translated !== key ? translated : refused ? failure.message : undefined
|
|
102
|
+
toast.error(explained || t('clock_punch_error'))
|
|
103
|
+
// A refusal is the server saying its picture of the day is not the one on screen — most often
|
|
104
|
+
// because the auto clock-out sweep closed the shift, or a punch arrived from another device.
|
|
105
|
+
// Both of those wrote a punch, so the day sheet beside the clock is as wrong as the clock is:
|
|
106
|
+
// re-read all of HR, exactly as a punch that landed does. Without this the same wrong clock
|
|
107
|
+
// stays up and every retry earns the same toast with no route to the truth.
|
|
108
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
109
|
+
},
|
|
110
|
+
onSettled: () => {
|
|
111
|
+
punching = false
|
|
112
|
+
},
|
|
48
113
|
}))
|
|
49
114
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
115
|
+
const punch = (action: Punch) => {
|
|
116
|
+
if (punching) return
|
|
117
|
+
punching = true
|
|
118
|
+
act.mutate(action)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const since = $derived(clock?.since ? localTime(new Date(clock.since)) : null)
|
|
55
122
|
</script>
|
|
56
123
|
|
|
124
|
+
<!--
|
|
125
|
+
Held data outranks the error, because this query polls. A failed background refetch leaves
|
|
126
|
+
TanStack in `error` while `data` is still the last good clock, so an error branch above this one
|
|
127
|
+
took "Working since 09:00" *and the clock-out button* off the screen of somebody mid-shift for a
|
|
128
|
+
minute at a time — every core restart, every rolling deploy. The error is only the whole frame
|
|
129
|
+
when there is nothing else to draw.
|
|
130
|
+
-->
|
|
57
131
|
{#if stateQuery.isLoading}
|
|
58
132
|
<Skeleton height="72px" />
|
|
59
|
-
{:else if
|
|
133
|
+
{:else if clock}
|
|
60
134
|
<div class="clock">
|
|
61
135
|
<div class="status">
|
|
62
136
|
<span class="line">
|
|
63
|
-
{#if
|
|
137
|
+
{#if clock.onBreak && since}
|
|
64
138
|
{t('on_break_since', { time: since })}
|
|
65
|
-
{:else if
|
|
139
|
+
{:else if clock.clockedIn && since}
|
|
66
140
|
{t('clocked_in_since', { time: since })}
|
|
67
141
|
{:else}
|
|
68
142
|
{t('not_clocked_in')}
|
|
69
143
|
{/if}
|
|
70
144
|
</span>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
145
|
+
{#if stateQuery.isError}
|
|
146
|
+
<!--
|
|
147
|
+
The total is the number that drifts — it is counted from an open span, so it is only ever
|
|
148
|
+
as current as the last successful poll. Saying so in its place costs no height (the widget
|
|
149
|
+
body is about 44px) and takes nothing away: the poll is still running, and a punch
|
|
150
|
+
refreshes the clock either way, so there is nothing here for a person to do.
|
|
151
|
+
-->
|
|
152
|
+
<span class="total stale" role="status">{t('clock_stale')}</span>
|
|
153
|
+
{:else}
|
|
154
|
+
<span class="total">
|
|
155
|
+
{t('worked_today')}: {formatDuration(clock.workedMinutesToday, words)}
|
|
156
|
+
</span>
|
|
157
|
+
{/if}
|
|
74
158
|
</div>
|
|
75
159
|
|
|
76
160
|
<div class="actions">
|
|
77
|
-
{#if !
|
|
78
|
-
<Button size="sm" disabled={
|
|
161
|
+
{#if !clock.clockedIn}
|
|
162
|
+
<Button size="sm" disabled={punching} onclick={() => punch('in')}>
|
|
79
163
|
{t('clock_in')}
|
|
80
164
|
</Button>
|
|
81
165
|
{:else}
|
|
82
|
-
{#if
|
|
83
|
-
<Button size="sm" variant="secondary" disabled={
|
|
166
|
+
{#if clock.onBreak}
|
|
167
|
+
<Button size="sm" variant="secondary" disabled={punching} onclick={() => punch('break_end')}>
|
|
84
168
|
{t('break_end')}
|
|
85
169
|
</Button>
|
|
86
170
|
{:else}
|
|
87
|
-
<Button size="sm" variant="secondary" disabled={
|
|
171
|
+
<Button size="sm" variant="secondary" disabled={punching} onclick={() => punch('break_start')}>
|
|
88
172
|
{t('break_start')}
|
|
89
173
|
</Button>
|
|
90
174
|
{/if}
|
|
91
|
-
<Button size="sm" disabled={
|
|
175
|
+
<Button size="sm" disabled={punching} onclick={() => punch('out')}>
|
|
92
176
|
{t('clock_out')}
|
|
93
177
|
</Button>
|
|
94
178
|
{/if}
|
|
95
179
|
</div>
|
|
96
180
|
</div>
|
|
181
|
+
{:else if stateQuery.isError}
|
|
182
|
+
<!--
|
|
183
|
+
The same row as the clock, rather than an `EmptyState`: this is read most often inside the clock
|
|
184
|
+
widget, whose body is about one grid row — roughly 44px — and a compact `EmptyState` is more
|
|
185
|
+
than twice that, so its retry button sat below a fold nobody scrolls. Here the button lands
|
|
186
|
+
where the clock-out button would have been.
|
|
187
|
+
-->
|
|
188
|
+
<div class="clock" role="alert">
|
|
189
|
+
<span class="line">{t('clock_error')}</span>
|
|
190
|
+
<Button size="sm" variant="secondary" onclick={() => void stateQuery.refetch()}>
|
|
191
|
+
{t('retry')}
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
{:else}
|
|
195
|
+
<!--
|
|
196
|
+
No workspace yet. The query is disabled until one arrives, and a disabled query is not
|
|
197
|
+
"loading" — so without this branch the clock is simply absent for that first frame.
|
|
198
|
+
-->
|
|
199
|
+
<Skeleton height="72px" />
|
|
97
200
|
{/if}
|
|
98
201
|
|
|
99
202
|
<style>
|
|
@@ -120,6 +223,10 @@ const since = $derived(
|
|
|
120
223
|
font-size: 12px;
|
|
121
224
|
font-variant-numeric: tabular-nums;
|
|
122
225
|
}
|
|
226
|
+
/* 5.23:1 on --kern-surface in light, 6.80:1 in dark — this is 12px text, so it has to clear 4.5. */
|
|
227
|
+
.stale {
|
|
228
|
+
color: var(--kern-warning);
|
|
229
|
+
}
|
|
123
230
|
.actions {
|
|
124
231
|
display: flex;
|
|
125
232
|
gap: 8px;
|