@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
package/src/client/summary.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatDate, formatDateRange } from '@kernhq/ui'
|
|
1
2
|
import type { ApprovalRequest } from '../contract/index.js'
|
|
2
3
|
import { t } from './i18n.js'
|
|
3
4
|
|
|
@@ -16,7 +17,9 @@ export function summarise(request: ApprovalRequest): string {
|
|
|
16
17
|
if (!p) return request.summary
|
|
17
18
|
if (request.subjectType === 'leave' && p.from && p.to)
|
|
18
19
|
return t('approval_summary_leave', {
|
|
19
|
-
|
|
20
|
+
// A number, not a string: `count` is what selects the plural form, and passing it as text
|
|
21
|
+
// rendered "1 days" and left the digits Latin on a Persian screen. `t()` formats it.
|
|
22
|
+
count: Number(p.days ?? 0),
|
|
20
23
|
range: dateRange(String(p.from), String(p.to)),
|
|
21
24
|
})
|
|
22
25
|
if (request.subjectType === 'regularization' && p.date)
|
|
@@ -26,11 +29,15 @@ export function summarise(request: ApprovalRequest): string {
|
|
|
26
29
|
return request.summary
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* `formatDate`, not a bare `Intl.DateTimeFormat(undefined, …)`.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` means the browser's locale, which is not the one the reader chose — so the date half
|
|
36
|
+
* of a sentence came out in Latin digits while `t()` rendered the word half in Persian ones, in the
|
|
37
|
+
* same line. The shared helper passes `messageLocale()`, and it is the same call underneath.
|
|
38
|
+
* This matters wherever it is used, and `summarise()` is what all three approvals surfaces render.
|
|
39
|
+
*/
|
|
40
|
+
export const day = (iso: string) => formatDate(iso)
|
|
31
41
|
|
|
32
42
|
/** One range, not two dates and a dash — the separator is the locale's, and RTL needs its own. */
|
|
33
|
-
export const dateRange = (from: string, to: string) =>
|
|
34
|
-
from === to
|
|
35
|
-
? day(from)
|
|
36
|
-
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).formatRange(new Date(from), new Date(to))
|
|
43
|
+
export const dateRange = (from: string, to: string) => (from === to ? day(from) : formatDateRange(from, to))
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { Badge, Button, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
|
|
3
3
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
4
|
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import DecisionDialog from '../components/DecisionDialog.svelte'
|
|
5
6
|
import { t } from '../i18n.js'
|
|
7
|
+
import type { ApprovalRequest } from '../index.js'
|
|
6
8
|
import { hrKeys } from '../query.js'
|
|
9
|
+
import { summarise } from '../summary.js'
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Requests waiting on me, decidable from the card.
|
|
@@ -23,45 +26,141 @@ const inboxQuery = createQuery(() => ({
|
|
|
23
26
|
}))
|
|
24
27
|
const items = $derived(inboxQuery.data?.items ?? [])
|
|
25
28
|
|
|
29
|
+
let asked = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
|
|
30
|
+
let decideError = $state<string | null>(null)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `deciding` rather than `decide.isPending`: the disabled attribute only reaches the button on the
|
|
34
|
+
* next render, so two quick clicks both fire and the same request is decided twice. This is set in
|
|
35
|
+
* the same tick as the first click.
|
|
36
|
+
*/
|
|
37
|
+
let deciding = $state(false)
|
|
38
|
+
|
|
26
39
|
const decide = createMutation(() => ({
|
|
27
|
-
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
|
|
28
|
-
api.approvals.decide({
|
|
29
|
-
|
|
40
|
+
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
|
|
41
|
+
api.approvals.decide({
|
|
42
|
+
workspaceId,
|
|
43
|
+
requestId: vars.requestId,
|
|
44
|
+
decision: vars.decision,
|
|
45
|
+
comment: vars.comment.trim() || null,
|
|
46
|
+
}),
|
|
47
|
+
onSuccess: () => {
|
|
48
|
+
asked = null
|
|
49
|
+
decideError = null
|
|
50
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
51
|
+
},
|
|
52
|
+
onError: (error) => {
|
|
53
|
+
decideError = decideFailure(error)
|
|
54
|
+
// A refusal means the server's inbox is not the one on screen, so the row that was just clicked
|
|
55
|
+
// is stale as well as the decision. Re-read all of HR exactly as a decision that landed does —
|
|
56
|
+
// without this, a request somebody else already approved sits on the card for ever and every
|
|
57
|
+
// retry earns the same sentence.
|
|
58
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
59
|
+
},
|
|
60
|
+
onSettled: () => {
|
|
61
|
+
deciding = false
|
|
62
|
+
},
|
|
30
63
|
}))
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The decision refusals this module has its own sentence for, keyed by the `reason` the router
|
|
67
|
+
* sends beside the refusal. Empty because `approvals.decide` refuses through `KernError.conflict`,
|
|
68
|
+
* whose reason argument stays on the server — see `ApprovalsPage.svelte`, which carries the whole
|
|
69
|
+
* note and the same shape.
|
|
70
|
+
*/
|
|
71
|
+
const decideRefusalMessages: Record<string, string> = {}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a refused decision says to the person who made it.
|
|
75
|
+
*
|
|
76
|
+
* The commonest failure here is that the request is no longer yours to decide — somebody else
|
|
77
|
+
* approved it, the person cancelled it, or a delegation moved the step — and the router refuses
|
|
78
|
+
* that with a sentence it wrote for a reader. That sentence is the only thing that says which of
|
|
79
|
+
* those happened, so it is repeated verbatim. Everything else that can fail carries machine text in
|
|
80
|
+
* English, so it falls back to this module's own string. The test is the transport's `code`, never
|
|
81
|
+
* the sentence.
|
|
82
|
+
*/
|
|
83
|
+
function decideFailure(error: unknown): string {
|
|
84
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
85
|
+
if (failure.code !== 'CONFLICT') return t('decide_error')
|
|
86
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
87
|
+
const key = reason ? decideRefusalMessages[reason] : undefined
|
|
88
|
+
const translated = key ? t(key) : undefined
|
|
89
|
+
return (translated && translated !== key ? translated : failure.message) || t('decide_error')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
|
|
93
|
+
decideError = null
|
|
94
|
+
asked = { request, decision }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const confirmDecision = (comment: string) => {
|
|
98
|
+
if (!asked || deciding) return
|
|
99
|
+
deciding = true
|
|
100
|
+
decide.mutate({ requestId: asked.request.id, decision: asked.decision, comment })
|
|
101
|
+
}
|
|
31
102
|
</script>
|
|
32
103
|
|
|
104
|
+
<!--
|
|
105
|
+
Held rows outrank the error. `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and
|
|
106
|
+
every decision anywhere in the module, so a failed background refetch leaves TanStack in `error`
|
|
107
|
+
while `data` is still the last good inbox — an error branch above this one would blank a working
|
|
108
|
+
card, and take its approve buttons with it, on a transient failure. The error is only the whole
|
|
109
|
+
card when there is nothing else to draw.
|
|
110
|
+
-->
|
|
33
111
|
{#if inboxQuery.isLoading}
|
|
34
112
|
<Skeleton height="96px" />
|
|
35
|
-
{:else if items.length
|
|
36
|
-
<EmptyState bare compact icon="check-check" title={t('approvals_none')} />
|
|
37
|
-
{:else}
|
|
113
|
+
{:else if items.length > 0}
|
|
38
114
|
<ul>
|
|
39
115
|
{#each items as item (item.id)}
|
|
40
116
|
<li>
|
|
41
|
-
<span class="summary">{item
|
|
117
|
+
<span class="summary">{summarise(item)}</span>
|
|
42
118
|
<!-- Row actions go while the grid is being rearranged: the data stays, the buttons do not. -->
|
|
43
119
|
{#if editing}
|
|
44
120
|
<Badge tone="upcoming">{t('leave_pending')}</Badge>
|
|
45
121
|
{:else}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
>
|
|
53
|
-
<Button
|
|
54
|
-
size="sm"
|
|
55
|
-
disabled={decide.isPending}
|
|
56
|
-
onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
|
|
57
|
-
>{t('approve')}</Button
|
|
58
|
-
>
|
|
122
|
+
<!--
|
|
123
|
+
Never straight to `decide.mutate`: rejecting somebody's leave is irreversible from the
|
|
124
|
+
interface and notifies them, and a dashboard card is the easiest place in the product to
|
|
125
|
+
hit the wrong button. The dialog says what the decision does and to whom.
|
|
126
|
+
-->
|
|
127
|
+
<Button size="sm" variant="ghost" onclick={() => ask(item, 'reject')}>{t('reject')}</Button>
|
|
128
|
+
<Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
|
|
59
129
|
{/if}
|
|
60
130
|
</li>
|
|
61
131
|
{/each}
|
|
62
132
|
</ul>
|
|
133
|
+
{:else if inboxQuery.isError}
|
|
134
|
+
<!--
|
|
135
|
+
One row, not an `EmptyState`. This card's smallest declared size is `s`, whose body is 43px —
|
|
136
|
+
one grid row of 84px, less the frame's 41px header — and a compact `EmptyState` is 82px before
|
|
137
|
+
it is given an action, so its retry button sat below a fold nobody scrolls in a card this size.
|
|
138
|
+
Here the row lands where the first request would have been.
|
|
139
|
+
|
|
140
|
+
Without this branch the empty state below claimed "Nothing waiting on you" to a manager whose
|
|
141
|
+
inbox had simply failed to load, which is the one sentence on this card nobody would check.
|
|
142
|
+
-->
|
|
143
|
+
<div class="failed" role="alert">
|
|
144
|
+
<span class="msg">{t('approvals_error')}</span>
|
|
145
|
+
<Button size="xs" variant="ghost" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
|
|
146
|
+
</div>
|
|
147
|
+
{:else}
|
|
148
|
+
<EmptyState bare compact icon="check-check" title={t('approvals_none')} />
|
|
63
149
|
{/if}
|
|
64
150
|
|
|
151
|
+
<!-- Portalled, so it is a dialog over the dashboard rather than something inside an 84px card. -->
|
|
152
|
+
<DecisionDialog
|
|
153
|
+
request={asked?.request ?? null}
|
|
154
|
+
decision={asked?.decision ?? 'approve'}
|
|
155
|
+
pending={deciding}
|
|
156
|
+
error={decideError}
|
|
157
|
+
onConfirm={confirmDecision}
|
|
158
|
+
onCancel={() => {
|
|
159
|
+
asked = null
|
|
160
|
+
decideError = null
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
|
|
65
164
|
<style>
|
|
66
165
|
ul {
|
|
67
166
|
display: grid;
|
|
@@ -83,4 +182,18 @@ li {
|
|
|
83
182
|
white-space: nowrap;
|
|
84
183
|
font-size: 12px;
|
|
85
184
|
}
|
|
185
|
+
.failed {
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: space-between;
|
|
189
|
+
gap: 8px;
|
|
190
|
+
padding-block: 8px;
|
|
191
|
+
padding-inline: 14px;
|
|
192
|
+
}
|
|
193
|
+
/* Muted with a colour, never opacity: 9.86:1 on the card in light, 8.96:1 in dark. */
|
|
194
|
+
.msg {
|
|
195
|
+
min-width: 0;
|
|
196
|
+
font-size: 12.5px;
|
|
197
|
+
color: var(--kern-ink-600);
|
|
198
|
+
}
|
|
86
199
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Skeleton, StatTile, type WidgetProps } from '@kernhq/ui'
|
|
2
|
+
import { Button, formatCount, Skeleton, StatTile, type WidgetProps } from '@kernhq/ui'
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
4
|
import { getHrApi } from '../api-instance.js'
|
|
5
5
|
import { t } from '../i18n.js'
|
|
@@ -14,16 +14,82 @@ const peopleQuery = createQuery(() => ({
|
|
|
14
14
|
enabled: Boolean(workspaceId),
|
|
15
15
|
queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['active'] }),
|
|
16
16
|
}))
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `total` rather than `items.length`: the request asks for one row, because drawing a number does
|
|
19
|
+
* not need the list behind it.
|
|
20
|
+
*
|
|
21
|
+
* No `?? 0`. A tile reading "0 people" is a claim about the company, and it was the claim this card
|
|
22
|
+
* made every time the fetch failed — indistinguishable from a workspace nobody has been added to.
|
|
23
|
+
* `undefined` is what says "not known", and the branches below are ordered on it.
|
|
24
|
+
*/
|
|
25
|
+
const total = $derived(peopleQuery.data?.total)
|
|
26
|
+
/**
|
|
27
|
+
* `formatCount`'s default caps at "99+", which is right for a badge on a nav row and wrong for a
|
|
28
|
+
* headcount — a four-hundred-person company would read "99+". The cap is lifted rather than the
|
|
29
|
+
* locale dropped: a bare `Intl.NumberFormat()` follows the browser's language, so a Persian
|
|
30
|
+
* interface would be the one screen printing Latin digits.
|
|
31
|
+
*/
|
|
32
|
+
const people = $derived(total === undefined ? '' : formatCount(total, Number.MAX_SAFE_INTEGER))
|
|
20
33
|
</script>
|
|
21
34
|
|
|
35
|
+
<!--
|
|
36
|
+
The held number outranks the error, as everywhere else in this module:
|
|
37
|
+
`invalidateQueries({ queryKey: ['hr'] })` fires on every punch and every approval decision, so a
|
|
38
|
+
failed background refetch leaves TanStack in `error` while `data` is still the last good count.
|
|
39
|
+
Blanking a card that has a number, because the poll behind it missed once, is the louder bug.
|
|
40
|
+
-->
|
|
22
41
|
{#if peopleQuery.isLoading}
|
|
23
42
|
<Skeleton height="72px" />
|
|
43
|
+
{:else if total !== undefined}
|
|
44
|
+
<StatTile label={t('widget_headcount_title')} value={people} />
|
|
45
|
+
{:else if peopleQuery.isError}
|
|
46
|
+
<!--
|
|
47
|
+
The label stays and the number does not. This widget is `compact`, so the frame draws no header
|
|
48
|
+
and the tile's own label is the only thing naming the card — drop it and the board grows an
|
|
49
|
+
anonymous box. The reason takes the number's place, in the same footprint the tile occupied, so
|
|
50
|
+
nothing around it moves.
|
|
51
|
+
-->
|
|
52
|
+
<div class="failed" role="alert">
|
|
53
|
+
<div class="text">
|
|
54
|
+
<span class="label">{t('widget_headcount_title')}</span>
|
|
55
|
+
<span class="msg">{t('people_error')}</span>
|
|
56
|
+
</div>
|
|
57
|
+
<Button size="xs" variant="ghost" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
|
|
58
|
+
</div>
|
|
24
59
|
{:else}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
60
|
+
<!--
|
|
61
|
+
No workspace yet. The query is disabled until one arrives, and a disabled query is not
|
|
62
|
+
"loading" — so without this branch the card is simply absent for that first frame.
|
|
63
|
+
-->
|
|
64
|
+
<Skeleton height="72px" />
|
|
29
65
|
{/if}
|
|
66
|
+
|
|
67
|
+
<style>
|
|
68
|
+
/* The tile's own frame, so a card that fails keeps the shape it had a moment ago. */
|
|
69
|
+
.failed {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
gap: 8px;
|
|
74
|
+
padding-block: 14px;
|
|
75
|
+
padding-inline: 16px;
|
|
76
|
+
border: 1px solid var(--kern-border);
|
|
77
|
+
border-radius: var(--kern-r-2xl);
|
|
78
|
+
background: var(--kern-surface-raised);
|
|
79
|
+
}
|
|
80
|
+
.text {
|
|
81
|
+
display: flex;
|
|
82
|
+
flex-direction: column;
|
|
83
|
+
gap: 2px;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
}
|
|
86
|
+
.label {
|
|
87
|
+
font-size: 12px;
|
|
88
|
+
color: var(--kern-ink-450);
|
|
89
|
+
}
|
|
90
|
+
/* Muted with a colour, never opacity: 9.86:1 on the tile in light, 8.96:1 in dark. */
|
|
91
|
+
.msg {
|
|
92
|
+
font-size: 12.5px;
|
|
93
|
+
color: var(--kern-ink-600);
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
|
|
2
|
+
import { Button, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
4
|
import { getHrApi } from '../api-instance.js'
|
|
5
5
|
import { t } from '../i18n.js'
|
|
@@ -22,19 +22,40 @@ const balanceQuery = createQuery(() => ({
|
|
|
22
22
|
const balances = $derived(balanceQuery.data ?? [])
|
|
23
23
|
</script>
|
|
24
24
|
|
|
25
|
+
<!--
|
|
26
|
+
Held balances outrank the error. `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and
|
|
27
|
+
every approval decision anywhere in the module, so a failed background refetch leaves TanStack in
|
|
28
|
+
`error` while `data` is still the last good set of balances — an error branch above this one would
|
|
29
|
+
blank a working card on a transient failure. The error is only the whole card when there is
|
|
30
|
+
nothing else to draw.
|
|
31
|
+
-->
|
|
25
32
|
{#if balanceQuery.isLoading}
|
|
26
33
|
<Skeleton height="72px" />
|
|
27
|
-
{:else if balances.length
|
|
28
|
-
<EmptyState bare compact icon="tree-palm" title={t('leave_none')} />
|
|
29
|
-
{:else}
|
|
34
|
+
{:else if balances.length > 0}
|
|
30
35
|
<ul>
|
|
31
36
|
{#each balances as b (b.leaveTypeId)}
|
|
32
37
|
<li>
|
|
33
38
|
<span class="name">{b.leaveTypeName}</span>
|
|
34
|
-
<span class="value">{formatDays(b.available)} <span class="unit">{t('days')}</span></span>
|
|
39
|
+
<span class="value">{formatDays(b.available)} <span class="unit">{t('days', { count: b.available })}</span></span>
|
|
35
40
|
</li>
|
|
36
41
|
{/each}
|
|
37
42
|
</ul>
|
|
43
|
+
{:else if balanceQuery.isError}
|
|
44
|
+
<!--
|
|
45
|
+
One row, not an `EmptyState`. This card's smallest declared size is `s`, whose body is 43px —
|
|
46
|
+
one grid row of 84px, less the frame's 41px header — and a compact `EmptyState` is 82px before
|
|
47
|
+
it is given an action, so its retry button sat below a fold nobody scrolls in a card this size.
|
|
48
|
+
|
|
49
|
+
Without this branch the empty state below said "No time off booked", which on a card headed "My
|
|
50
|
+
time off" reads as a balance of nothing — the answer somebody plans a year around, given for a
|
|
51
|
+
request that never arrived.
|
|
52
|
+
-->
|
|
53
|
+
<div class="failed" role="alert">
|
|
54
|
+
<span class="msg">{t('balance_error')}</span>
|
|
55
|
+
<Button size="xs" variant="ghost" onclick={() => void balanceQuery.refetch()}>{t('retry')}</Button>
|
|
56
|
+
</div>
|
|
57
|
+
{:else}
|
|
58
|
+
<EmptyState bare compact icon="tree-palm" title={t('leave_none')} />
|
|
38
59
|
{/if}
|
|
39
60
|
|
|
40
61
|
<style>
|
|
@@ -64,4 +85,18 @@ li {
|
|
|
64
85
|
font-size: 12px;
|
|
65
86
|
color: var(--kern-ink-500);
|
|
66
87
|
}
|
|
88
|
+
.failed {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: space-between;
|
|
92
|
+
gap: 8px;
|
|
93
|
+
padding-block: 8px;
|
|
94
|
+
padding-inline: 14px;
|
|
95
|
+
}
|
|
96
|
+
/* Muted with a colour, never opacity: 9.86:1 on the card in light, 8.96:1 in dark. */
|
|
97
|
+
.msg {
|
|
98
|
+
min-width: 0;
|
|
99
|
+
font-size: 12.5px;
|
|
100
|
+
color: var(--kern-ink-600);
|
|
101
|
+
}
|
|
67
102
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Avatar, EmptyState, Skeleton, type WidgetProps } from '@kernhq/ui'
|
|
2
|
+
import { Avatar, Button, EmptyState, formatDateRange, Skeleton, type WidgetProps } from '@kernhq/ui'
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
4
|
import { getHrApi } from '../api-instance.js'
|
|
5
5
|
import { t } from '../i18n.js'
|
|
@@ -25,18 +25,26 @@ const outQuery = createQuery(() => ({
|
|
|
25
25
|
}))
|
|
26
26
|
const away = $derived(outQuery.data ?? [])
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* `formatDateRange` rather than `Intl` here: it formats in the reader's *interface* language, which
|
|
30
|
+
* `undefined` does not — that is the browser's language, so a Persian interface printed "6 Aug".
|
|
31
|
+
* `T00:00:00` stays on both ends: a bare `YYYY-MM-DD` is parsed as UTC midnight, which prints the
|
|
32
|
+
* day before west of Greenwich.
|
|
33
|
+
*/
|
|
28
34
|
const range = (a: string, b: string) =>
|
|
29
|
-
|
|
30
|
-
new Date(`${a}T00:00:00`),
|
|
31
|
-
new Date(`${b}T00:00:00`),
|
|
32
|
-
)
|
|
35
|
+
formatDateRange(`${a}T00:00:00`, `${b}T00:00:00`, { day: 'numeric', month: 'short' })
|
|
33
36
|
</script>
|
|
34
37
|
|
|
38
|
+
<!--
|
|
39
|
+
Held rows outrank the error. `invalidateQueries({ queryKey: ['hr'] })` fires on every punch and
|
|
40
|
+
every approval decision anywhere in the module, so a failed background refetch leaves TanStack in
|
|
41
|
+
`error` while `data` is still the last good calendar — an error branch above this one would blank
|
|
42
|
+
a working card on a transient failure. The error is only the whole card when there is nothing
|
|
43
|
+
else to draw.
|
|
44
|
+
-->
|
|
35
45
|
{#if outQuery.isLoading}
|
|
36
46
|
<Skeleton height="96px" />
|
|
37
|
-
{:else if away.length
|
|
38
|
-
<EmptyState bare compact icon="calendar-days" title={t('leave_none')} />
|
|
39
|
-
{:else}
|
|
47
|
+
{:else if away.length > 0}
|
|
40
48
|
<ul>
|
|
41
49
|
{#each away as person (person.requestId)}
|
|
42
50
|
<li>
|
|
@@ -46,6 +54,23 @@ const range = (a: string, b: string) =>
|
|
|
46
54
|
</li>
|
|
47
55
|
{/each}
|
|
48
56
|
</ul>
|
|
57
|
+
{:else if outQuery.isError}
|
|
58
|
+
<!--
|
|
59
|
+
One row, not an `EmptyState`, for the same reason as the rest of this module's cards: a compact
|
|
60
|
+
`EmptyState` is 82px before it is given an action, which is twice the 43px body a widget has at
|
|
61
|
+
size `s`. This card starts at `m` and has the room, but a person reading two failed HR cards
|
|
62
|
+
side by side should not be shown two different shapes of failure.
|
|
63
|
+
|
|
64
|
+
Without this branch the empty state below said "No time off booked" — read on a card headed
|
|
65
|
+
"Who's out" as *nobody is away*, which is the precise opposite of what is actually unknown, and
|
|
66
|
+
the one answer somebody schedules a week of work around.
|
|
67
|
+
-->
|
|
68
|
+
<div class="failed" role="alert">
|
|
69
|
+
<span class="msg">{t('whos_out_error')}</span>
|
|
70
|
+
<Button size="xs" variant="ghost" onclick={() => void outQuery.refetch()}>{t('retry')}</Button>
|
|
71
|
+
</div>
|
|
72
|
+
{:else}
|
|
73
|
+
<EmptyState bare compact icon="calendar-days" title={t('leave_none')} />
|
|
49
74
|
{/if}
|
|
50
75
|
|
|
51
76
|
<style>
|
|
@@ -72,4 +97,18 @@ li {
|
|
|
72
97
|
color: var(--kern-ink-500);
|
|
73
98
|
font-size: 12px;
|
|
74
99
|
}
|
|
100
|
+
.failed {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: space-between;
|
|
104
|
+
gap: 8px;
|
|
105
|
+
padding-block: 8px;
|
|
106
|
+
padding-inline: 14px;
|
|
107
|
+
}
|
|
108
|
+
/* Muted with a colour, never opacity: 9.86:1 on the card in light, 8.96:1 in dark. */
|
|
109
|
+
.msg {
|
|
110
|
+
min-width: 0;
|
|
111
|
+
font-size: 12.5px;
|
|
112
|
+
color: var(--kern-ink-600);
|
|
113
|
+
}
|
|
75
114
|
</style>
|
|
@@ -89,6 +89,15 @@ export const AttendanceDay = z.object({
|
|
|
89
89
|
workedMinutes: z.number().int(),
|
|
90
90
|
breakMinutes: z.number().int(),
|
|
91
91
|
overtimeMinutes: z.number().int(),
|
|
92
|
+
/**
|
|
93
|
+
* Overtime the policy's annual cap will not take — null where no cap was in force.
|
|
94
|
+
*
|
|
95
|
+
* Declared here or it does not exist: oRPC validates a procedure's output against this schema and
|
|
96
|
+
* zod strips what the schema does not name, so the column was written, stored, and then dropped
|
|
97
|
+
* on the way out. Null and zero mean different things and a compliance report has to tell them
|
|
98
|
+
* apart — null is "no ceiling applied to this day", zero is "one applied and nothing exceeded it".
|
|
99
|
+
*/
|
|
100
|
+
beyondCapMinutes: z.number().int().nullable(),
|
|
92
101
|
lateMinutes: z.number().int(),
|
|
93
102
|
earlyLeaveMinutes: z.number().int(),
|
|
94
103
|
status: AttendanceStatus,
|
|
@@ -132,6 +132,23 @@ export const hrCapabilities = defineCapabilities([
|
|
|
132
132
|
|
|
133
133
|
export type HrCapabilityId = (typeof hrCapabilities)[number]['id']
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The message ids a screen reads a capability's `label` and `description` through.
|
|
137
|
+
*
|
|
138
|
+
* `CapabilityDef.label` is documented as "an i18n message id or an English fallback", and putting
|
|
139
|
+
* the id *in* the field would leave the fallback nowhere: core's module admin and the shell's mock
|
|
140
|
+
* read this manifest raw, with no HR bundle merged, so they would print `hr.cap_leave` at a person.
|
|
141
|
+
* So the literals above stay the fallback and the id is derived from the capability's own id — a
|
|
142
|
+
* capability added to the list becomes translatable the moment somebody writes its two strings, and
|
|
143
|
+
* nothing that parses this list sees a new field.
|
|
144
|
+
*
|
|
145
|
+
* The `hr.` prefix is written out rather than left to `scopedT`, because a caller has to be able to
|
|
146
|
+
* tell a resolved string from a key that resolved to itself, and `t()` answers a miss with the
|
|
147
|
+
* *namespaced* key.
|
|
148
|
+
*/
|
|
149
|
+
export const capabilityLabelKey = (id: string) => `hr.cap_${id}`
|
|
150
|
+
export const capabilityDescriptionKey = (id: string) => `hr.cap_${id}_desc`
|
|
151
|
+
|
|
135
152
|
/**
|
|
136
153
|
* Which procedures sit behind which capability.
|
|
137
154
|
*
|