@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.
- package/dist/contract/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.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 +9 -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 +458 -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 +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- 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 +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- 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 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- 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 +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- 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 +43 -0
- 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/approvals.ts +13 -1
- package/src/contract/capabilities.ts +17 -0
package/src/client/module.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineClientModule } from '@kernhq/ui'
|
|
2
|
+
import { HR_PERMISSIONS } from '../contract/permissions.js'
|
|
3
|
+
import { HR_CAPABILITIES } from './capabilities.js'
|
|
2
4
|
import { hrMessageBundles, t } from './i18n.js'
|
|
3
|
-
import { HR_CAPABILITIES, HR_PERMISSIONS } from './permissions.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* HR as the shell sees it.
|
|
@@ -222,6 +223,17 @@ export const hrClientModule = defineClientModule({
|
|
|
222
223
|
* is no route file in the app to keep in step with it any more.
|
|
223
224
|
*/
|
|
224
225
|
settingsPages: [
|
|
226
|
+
{
|
|
227
|
+
id: 'general',
|
|
228
|
+
get label() {
|
|
229
|
+
return t('settings_general')
|
|
230
|
+
},
|
|
231
|
+
icon: 'sliders-vertical',
|
|
232
|
+
scope: 'workspace',
|
|
233
|
+
permission: 'core.modules.manage',
|
|
234
|
+
order: 1,
|
|
235
|
+
component: () => import('./settings/GeneralSettings.svelte'),
|
|
236
|
+
},
|
|
225
237
|
{
|
|
226
238
|
id: 'capabilities',
|
|
227
239
|
get label() {
|
|
@@ -229,7 +241,10 @@ export const hrClientModule = defineClientModule({
|
|
|
229
241
|
},
|
|
230
242
|
icon: 'toggle-left',
|
|
231
243
|
scope: 'workspace',
|
|
232
|
-
|
|
244
|
+
// `core.modules.manage`, not `core.workspace.manage`: core gates `modules.updateSettings` on
|
|
245
|
+
// the former, and this page's every control is a write to it. Offering the page on the wider
|
|
246
|
+
// permission showed the whole switchboard to somebody the server then refused on each switch.
|
|
247
|
+
permission: 'core.modules.manage',
|
|
233
248
|
order: 5,
|
|
234
249
|
component: () => import('./settings/CapabilitiesSettings.svelte'),
|
|
235
250
|
},
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
EmptyState,
|
|
7
|
+
formatCount,
|
|
8
|
+
formatDateTime,
|
|
9
|
+
navigation,
|
|
10
|
+
Page,
|
|
11
|
+
PageHeader,
|
|
12
|
+
SectionLabel,
|
|
13
|
+
SegmentedControl,
|
|
14
|
+
Skeleton,
|
|
15
|
+
session,
|
|
16
|
+
} from '@kernhq/ui'
|
|
3
17
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
18
|
import { getHrApi } from '../api-instance.js'
|
|
19
|
+
import DecisionDialog from '../components/DecisionDialog.svelte'
|
|
20
|
+
import DelegationDialog from '../components/DelegationDialog.svelte'
|
|
5
21
|
import { t } from '../i18n.js'
|
|
22
|
+
import type { ApprovalRequest } from '../index.js'
|
|
23
|
+
import { canHr, HR_CAPABILITIES } from '../permissions.js'
|
|
6
24
|
import { hrKeys } from '../query.js'
|
|
25
|
+
import { summarise } from '../summary.js'
|
|
7
26
|
|
|
8
27
|
/**
|
|
9
28
|
* Everything waiting on me, across every kind of request.
|
|
@@ -12,8 +31,13 @@ import { hrKeys } from '../query.js'
|
|
|
12
31
|
* leave request and an attendance correction arrive here the same way, and so will overtime and
|
|
13
32
|
* timesheets when they exist.
|
|
14
33
|
*
|
|
15
|
-
* No permission gate: an inbox of what *you* must decide is yours by definition, and the
|
|
16
|
-
* ever lists steps you are named on.
|
|
34
|
+
* No permission gate on the list: an inbox of what *you* must decide is yours by definition, and the
|
|
35
|
+
* server only ever lists steps you are named on. Delegation is gated, because letting somebody else
|
|
36
|
+
* decide in your place is a claim about them as well as about you.
|
|
37
|
+
*
|
|
38
|
+
* Who signs what is not set here — `approvals.chains.*` is a workspace policy and lives in settings.
|
|
39
|
+
* `approvals.get` is not called either: the inbox already returns each request with its steps and
|
|
40
|
+
* decisions, so a detail fetch would re-ask a question this page has the answer to.
|
|
17
41
|
*/
|
|
18
42
|
const api = getHrApi()
|
|
19
43
|
const queryClient = useQueryClient()
|
|
@@ -22,108 +46,356 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
|
22
46
|
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
23
47
|
const workspaceId = $derived(workspace?.id ?? '')
|
|
24
48
|
|
|
49
|
+
let tab = $state('waiting')
|
|
50
|
+
const includeDecided = $derived(tab === 'decided')
|
|
51
|
+
|
|
52
|
+
let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
|
|
53
|
+
let delegating = $state(false)
|
|
54
|
+
let decideError = $state<string | null>(null)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `submitting` rather than `decide.isPending`: the disabled attribute only reaches the confirm
|
|
58
|
+
* button on the next render, so two quick clicks both fire and one request is decided twice. This
|
|
59
|
+
* is set in the same tick as the click.
|
|
60
|
+
*/
|
|
61
|
+
let submitting = $state(false)
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Delegation is two questions, and the button was only asking one.
|
|
65
|
+
*
|
|
66
|
+
* `hr.approval.delegate` defaults to owner, admin and member, so every member saw the button — and
|
|
67
|
+
* `DelegationDialog` then calls `approvals.delegations`, which sits behind the `approvals`
|
|
68
|
+
* capability. That capability is off by default, so in a fresh workspace the dialog opened and
|
|
69
|
+
* 404'd on its first query. A capability that is off has no surface at all, not a surface that
|
|
70
|
+
* fails when you touch it.
|
|
71
|
+
*/
|
|
72
|
+
const hasChains = $derived(session.hasCapability('hr', HR_CAPABILITIES.approvals))
|
|
73
|
+
const showDelegation = $derived(hasChains && canHr('approvalDelegate'))
|
|
74
|
+
|
|
25
75
|
const inboxQuery = createQuery(() => ({
|
|
26
|
-
queryKey: hrKeys.approvalInbox(workspaceId),
|
|
76
|
+
queryKey: hrKeys.approvalInbox(workspaceId, includeDecided),
|
|
27
77
|
enabled: Boolean(workspaceId),
|
|
28
|
-
queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, includeDecided
|
|
78
|
+
queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, includeDecided }),
|
|
29
79
|
}))
|
|
30
80
|
const items = $derived(inboxQuery.data?.items ?? [])
|
|
31
81
|
|
|
32
82
|
const decide = createMutation(() => ({
|
|
33
|
-
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
|
|
34
|
-
api.approvals.decide({
|
|
83
|
+
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
|
|
84
|
+
api.approvals.decide({
|
|
85
|
+
workspaceId,
|
|
86
|
+
requestId: vars.requestId,
|
|
87
|
+
decision: vars.decision,
|
|
88
|
+
comment: vars.comment.trim() || null,
|
|
89
|
+
}),
|
|
35
90
|
onSuccess: () => {
|
|
91
|
+
deciding = null
|
|
92
|
+
decideError = null
|
|
36
93
|
// Deciding changes a balance and a day sheet as well as the inbox, so the whole module's cache
|
|
37
94
|
// is invalidated rather than guessing which keys moved.
|
|
38
95
|
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
39
96
|
},
|
|
97
|
+
onError: (error) => {
|
|
98
|
+
decideError = decideFailure(error)
|
|
99
|
+
// A refusal is the server saying its inbox is not the one on screen, so the row behind the
|
|
100
|
+
// dialog is stale as well as the decision. Re-read all of HR exactly as a decision that landed
|
|
101
|
+
// does — without this the same dead row sits in the table and every retry earns the same
|
|
102
|
+
// sentence.
|
|
103
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
104
|
+
},
|
|
105
|
+
onSettled: () => {
|
|
106
|
+
submitting = false
|
|
107
|
+
},
|
|
40
108
|
}))
|
|
41
109
|
|
|
42
|
-
|
|
43
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The decision refusals this module has its own sentence for, keyed by the `reason` the router
|
|
112
|
+
* sends beside the refusal — never by the sentence, because a list of sentences is a list somebody
|
|
113
|
+
* has to keep in sync and the day it drifts the reader is told nothing.
|
|
114
|
+
*
|
|
115
|
+
* Empty on purpose. `approvals.decide` refuses through `KernError.conflict`, whose reason argument
|
|
116
|
+
* is kept on the server and never serialised, so every refusal arrives today as the sentence the
|
|
117
|
+
* router wrote for a person. The day `decide()` sends a reason, its string lands here and nothing
|
|
118
|
+
* else has to change.
|
|
119
|
+
*/
|
|
120
|
+
const decideRefusalMessages: Record<string, string> = {}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a refused decision says to the person who made it.
|
|
124
|
+
*
|
|
125
|
+
* A decision is refused when the request is no longer theirs to decide — somebody else approved it,
|
|
126
|
+
* the requester cancelled it, or a delegation moved the step — and the router's sentence is the
|
|
127
|
+
* only thing that says which of those happened. It used to be thrown away here for a flat "The
|
|
128
|
+
* decision could not be recorded", which tells a manager staring at a live-looking row nothing at
|
|
129
|
+
* all. Everything else that can fail carries machine text in English, so it falls back to this
|
|
130
|
+
* module's own string.
|
|
131
|
+
*
|
|
132
|
+
* The test is the transport's `code`, never the sentence: `KernError.conflict` is what arrives as
|
|
133
|
+
* CONFLICT, so a refusal added to `decide()` later reaches the reader without anyone editing this
|
|
134
|
+
* file. The same shape as `ClockControls.svelte` and the approvals widget.
|
|
135
|
+
*/
|
|
136
|
+
function decideFailure(error: unknown): string {
|
|
137
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
138
|
+
if (failure.code !== 'CONFLICT') return t('decide_error')
|
|
139
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
140
|
+
const key = reason ? decideRefusalMessages[reason] : undefined
|
|
141
|
+
// `t()` answers a key it has no string for with the key itself, so both ways of not having one —
|
|
142
|
+
// a reason no key covers, and a key whose string has not been merged — land on the router's
|
|
143
|
+
// sentence rather than putting `hr.decide_refused_…` in front of somebody.
|
|
144
|
+
const translated = key ? t(key) : undefined
|
|
145
|
+
return (translated && translated !== key ? translated : failure.message) || t('decide_error')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
|
|
149
|
+
decideError = null
|
|
150
|
+
deciding = { request, decision }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const confirmDecision = (comment: string) => {
|
|
154
|
+
if (!deciding || submitting) return
|
|
155
|
+
submitting = true
|
|
156
|
+
decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const SUBJECT_LABELS: Record<string, () => string> = {
|
|
160
|
+
leave: () => t('leave_title'),
|
|
161
|
+
regularization: () => t('attendance_title'),
|
|
162
|
+
overtime: () => t('att_overtime'),
|
|
163
|
+
timesheet: () => t('approval_subject_timesheet'),
|
|
164
|
+
shift_swap: () => t('approval_subject_shift_swap'),
|
|
165
|
+
}
|
|
166
|
+
const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
|
|
44
167
|
|
|
45
|
-
const
|
|
46
|
-
|
|
168
|
+
const STATUS_TONES: Record<string, BadgeTone> = {
|
|
169
|
+
pending: 'upcoming',
|
|
170
|
+
approved: 'success',
|
|
171
|
+
rejected: 'danger',
|
|
172
|
+
cancelled: 'grey',
|
|
173
|
+
}
|
|
174
|
+
const STATUS_LABELS: Record<string, () => string> = {
|
|
175
|
+
pending: () => t('approval_status_pending'),
|
|
176
|
+
approved: () => t('approval_status_approved'),
|
|
177
|
+
rejected: () => t('approval_status_rejected'),
|
|
178
|
+
cancelled: () => t('approval_status_cancelled'),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const stepOf = (request: ApprovalRequest) =>
|
|
182
|
+
t('approvals_step_of', { n: String(request.currentStep + 1), total: String(request.steps.length) })
|
|
47
183
|
</script>
|
|
48
184
|
|
|
49
185
|
<PageHeader
|
|
50
186
|
crumbs={[{ label: workspace?.name ?? '' }, { label: t('approvals_title') }]}
|
|
51
187
|
title={t('approvals_title')}
|
|
52
|
-
|
|
188
|
+
>
|
|
189
|
+
{#snippet actions()}
|
|
190
|
+
{#if showDelegation}
|
|
191
|
+
<Button size="sm" variant="secondary" icon="users" onclick={() => (delegating = true)}>
|
|
192
|
+
{t('delegate')}
|
|
193
|
+
</Button>
|
|
194
|
+
{/if}
|
|
195
|
+
{/snippet}
|
|
196
|
+
</PageHeader>
|
|
53
197
|
|
|
54
198
|
<Page>
|
|
199
|
+
<div class="filters">
|
|
200
|
+
<SegmentedControl
|
|
201
|
+
size="sm"
|
|
202
|
+
label={t('approvals_title')}
|
|
203
|
+
bind:value={tab}
|
|
204
|
+
items={[
|
|
205
|
+
{ value: 'waiting', label: t('approvals_waiting') },
|
|
206
|
+
{ value: 'decided', label: t('approvals_decided') },
|
|
207
|
+
]}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<SectionLabel
|
|
212
|
+
label={includeDecided ? t('approvals_decided') : t('approvals_waiting')}
|
|
213
|
+
count={formatCount(items.length, 999)}
|
|
214
|
+
/>
|
|
215
|
+
|
|
216
|
+
<!--
|
|
217
|
+
Held rows outrank the error. Every decision taken here invalidates all of `['hr']`, so a failed
|
|
218
|
+
background refetch leaves TanStack in `error` with the last good inbox still in `data` — an
|
|
219
|
+
error branch above this one would blank a working table, and take its approve buttons with it,
|
|
220
|
+
on a transient failure. The error is the whole page only when there is nothing else to draw.
|
|
221
|
+
-->
|
|
55
222
|
{#if inboxQuery.isLoading}
|
|
56
|
-
<
|
|
57
|
-
|
|
223
|
+
<div class="rows">
|
|
224
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
|
|
225
|
+
</div>
|
|
226
|
+
{:else if items.length > 0}
|
|
227
|
+
<div class="table" role="table" aria-label={t('approvals_title')}>
|
|
228
|
+
<div class="thead" role="row">
|
|
229
|
+
<span role="columnheader">{t('approvals_request')}</span>
|
|
230
|
+
<span role="columnheader">{t('approvals_requested_by')}</span>
|
|
231
|
+
<span role="columnheader">{t('approvals_requested')}</span>
|
|
232
|
+
<span role="columnheader">{includeDecided ? t('status') : t('approvals_step')}</span>
|
|
233
|
+
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
234
|
+
</div>
|
|
235
|
+
{#each items as item (item.id)}
|
|
236
|
+
<div class="trow" role="row">
|
|
237
|
+
<span class="cell what" role="cell">
|
|
238
|
+
<Badge tone="grey">{subjectLabel(item.subjectType)}</Badge>
|
|
239
|
+
<span class="summary">{summarise(item)}</span>
|
|
240
|
+
</span>
|
|
241
|
+
<span class="cell muted" role="cell">{item.requesterName ?? '—'}</span>
|
|
242
|
+
<span class="cell muted" role="cell">{formatDateTime(item.requestedAt)}</span>
|
|
243
|
+
<span class="cell" role="cell">
|
|
244
|
+
{#if includeDecided}
|
|
245
|
+
<Badge tone={STATUS_TONES[item.status] ?? 'grey'}>
|
|
246
|
+
{STATUS_LABELS[item.status]?.() ?? item.status}
|
|
247
|
+
</Badge>
|
|
248
|
+
{:else if item.steps.length > 1}
|
|
249
|
+
<span class="muted">{stepOf(item)}</span>
|
|
250
|
+
{/if}
|
|
251
|
+
</span>
|
|
252
|
+
<span class="cell actions" role="cell">
|
|
253
|
+
{#if item.status === 'pending'}
|
|
254
|
+
<Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>
|
|
255
|
+
{t('reject')}
|
|
256
|
+
</Button>
|
|
257
|
+
<Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
|
|
258
|
+
{/if}
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
{/each}
|
|
262
|
+
</div>
|
|
263
|
+
{:else if inboxQuery.isError}
|
|
264
|
+
<EmptyState icon="triangle-alert" title={t('approvals_error')}>
|
|
265
|
+
{#snippet actions()}
|
|
266
|
+
<Button variant="secondary" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
|
|
267
|
+
{/snippet}
|
|
268
|
+
</EmptyState>
|
|
269
|
+
{:else}
|
|
58
270
|
<EmptyState
|
|
59
271
|
icon="check-check"
|
|
60
|
-
title={t('approvals_none')}
|
|
61
|
-
description={t('approvals_none_desc')}
|
|
272
|
+
title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
|
|
273
|
+
description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
|
|
62
274
|
/>
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<span class="meta">{when(item.requestedAt)}</span>
|
|
73
|
-
</div>
|
|
74
|
-
<div class="actions">
|
|
75
|
-
<Button
|
|
76
|
-
size="sm"
|
|
77
|
-
variant="secondary"
|
|
78
|
-
disabled={decide.isPending}
|
|
79
|
-
onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
|
|
80
|
-
>{t('reject')}</Button
|
|
81
|
-
>
|
|
82
|
-
<Button
|
|
83
|
-
size="sm"
|
|
84
|
-
disabled={decide.isPending}
|
|
85
|
-
onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
|
|
86
|
-
>{t('approve')}</Button
|
|
87
|
-
>
|
|
88
|
-
</div>
|
|
89
|
-
</div>
|
|
90
|
-
</Card>
|
|
91
|
-
</li>
|
|
92
|
-
{/each}
|
|
93
|
-
</ul>
|
|
275
|
+
{/if}
|
|
276
|
+
|
|
277
|
+
<!--
|
|
278
|
+
Only where the workspace has chains. With the capability off there is no chain to configure —
|
|
279
|
+
the requester's manager approves, implicitly — so this line sent a reader to a settings screen
|
|
280
|
+
that is not there.
|
|
281
|
+
-->
|
|
282
|
+
{#if hasChains}
|
|
283
|
+
<p class="hint">{t('approvals_chains_hint')}</p>
|
|
94
284
|
{/if}
|
|
95
285
|
</Page>
|
|
96
286
|
|
|
287
|
+
<DecisionDialog
|
|
288
|
+
request={deciding?.request ?? null}
|
|
289
|
+
decision={deciding?.decision ?? 'approve'}
|
|
290
|
+
pending={submitting}
|
|
291
|
+
error={decideError}
|
|
292
|
+
onConfirm={confirmDecision}
|
|
293
|
+
onCancel={() => {
|
|
294
|
+
deciding = null
|
|
295
|
+
decideError = null
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
|
|
299
|
+
<!-- Only where the workspace has approval chains: every procedure inside it is behind that one. -->
|
|
300
|
+
{#if showDelegation}
|
|
301
|
+
<DelegationDialog open={delegating} {workspaceId} onClose={() => (delegating = false)} />
|
|
302
|
+
{/if}
|
|
303
|
+
|
|
97
304
|
<style>
|
|
98
|
-
|
|
99
|
-
display: grid;
|
|
100
|
-
gap: 8px;
|
|
101
|
-
list-style: none;
|
|
102
|
-
margin: 0;
|
|
103
|
-
padding: 0;
|
|
104
|
-
}
|
|
105
|
-
.row {
|
|
305
|
+
.filters {
|
|
106
306
|
display: flex;
|
|
107
307
|
align-items: center;
|
|
108
308
|
gap: 12px;
|
|
109
|
-
|
|
309
|
+
margin-block-end: 8px;
|
|
310
|
+
}
|
|
311
|
+
.rows {
|
|
312
|
+
display: grid;
|
|
313
|
+
gap: 4px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
317
|
+
.table {
|
|
318
|
+
--hr-approval-cols: minmax(220px, 1.6fr) minmax(120px, 0.7fr) 170px 150px max-content;
|
|
319
|
+
width: 100%;
|
|
320
|
+
}
|
|
321
|
+
.thead,
|
|
322
|
+
.trow {
|
|
323
|
+
display: grid;
|
|
324
|
+
grid-template-columns: var(--hr-approval-cols);
|
|
325
|
+
gap: 12px;
|
|
326
|
+
align-items: center;
|
|
327
|
+
padding-inline: 12px;
|
|
328
|
+
}
|
|
329
|
+
.thead {
|
|
330
|
+
height: 34px;
|
|
331
|
+
border-block-end: 1px solid var(--kern-border);
|
|
332
|
+
font-size: 11px;
|
|
333
|
+
font-weight: 600;
|
|
334
|
+
letter-spacing: 0.06em;
|
|
335
|
+
text-transform: uppercase;
|
|
336
|
+
color: var(--kern-ink-500);
|
|
337
|
+
}
|
|
338
|
+
.trow {
|
|
339
|
+
min-height: 56px;
|
|
340
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
341
|
+
border-radius: var(--kern-r-md);
|
|
342
|
+
}
|
|
343
|
+
.trow:hover {
|
|
344
|
+
background: var(--kern-surface-raised);
|
|
345
|
+
}
|
|
346
|
+
.cell {
|
|
347
|
+
min-width: 0;
|
|
348
|
+
overflow: hidden;
|
|
349
|
+
text-overflow: ellipsis;
|
|
350
|
+
white-space: nowrap;
|
|
110
351
|
}
|
|
111
352
|
.what {
|
|
112
353
|
display: flex;
|
|
113
354
|
align-items: center;
|
|
114
355
|
gap: 8px;
|
|
115
|
-
flex: 1;
|
|
116
|
-
min-width: 0;
|
|
117
356
|
}
|
|
118
357
|
.summary {
|
|
358
|
+
min-width: 0;
|
|
359
|
+
overflow: hidden;
|
|
360
|
+
text-overflow: ellipsis;
|
|
361
|
+
font-size: 13.5px;
|
|
119
362
|
font-weight: 500;
|
|
120
363
|
}
|
|
121
|
-
.
|
|
364
|
+
.muted {
|
|
365
|
+
font-size: 13px;
|
|
366
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
122
367
|
color: var(--kern-ink-500);
|
|
123
|
-
font-size: 12px;
|
|
124
368
|
}
|
|
125
369
|
.actions {
|
|
126
370
|
display: flex;
|
|
127
371
|
gap: 8px;
|
|
372
|
+
justify-content: flex-end;
|
|
373
|
+
overflow: visible;
|
|
374
|
+
}
|
|
375
|
+
.hint {
|
|
376
|
+
margin-block-start: 16px;
|
|
377
|
+
font-size: 12px;
|
|
378
|
+
color: var(--kern-ink-500);
|
|
379
|
+
}
|
|
380
|
+
.sr-only {
|
|
381
|
+
position: absolute;
|
|
382
|
+
width: 1px;
|
|
383
|
+
height: 1px;
|
|
384
|
+
overflow: hidden;
|
|
385
|
+
clip-path: inset(50%);
|
|
386
|
+
white-space: nowrap;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
@media (max-width: 900px) {
|
|
390
|
+
.table {
|
|
391
|
+
--hr-approval-cols: minmax(160px, 1.6fr) minmax(100px, 0.7fr) max-content;
|
|
392
|
+
}
|
|
393
|
+
/* Requested-at and step survive on the row's second line rather than squeezing five columns. */
|
|
394
|
+
.thead > :nth-child(3),
|
|
395
|
+
.trow > :nth-child(3),
|
|
396
|
+
.thead > :nth-child(4),
|
|
397
|
+
.trow > :nth-child(4) {
|
|
398
|
+
display: none;
|
|
399
|
+
}
|
|
128
400
|
}
|
|
129
401
|
</style>
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
EmptyState,
|
|
6
|
+
formatDate,
|
|
7
|
+
messageLocale,
|
|
8
|
+
navigation,
|
|
9
|
+
Page,
|
|
10
|
+
PageHeader,
|
|
11
|
+
Skeleton,
|
|
12
|
+
StatTile,
|
|
13
|
+
session,
|
|
14
|
+
} from '@kernhq/ui'
|
|
3
15
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
16
|
import { getHrApi } from '../api-instance.js'
|
|
5
17
|
import ClockControls from '../components/ClockControls.svelte'
|
|
@@ -12,6 +24,12 @@ import { formatDuration, hrKeys, monthRange } from '../query.js'
|
|
|
12
24
|
* The totals come from the derived day sheet rather than being added up here — the server already
|
|
13
25
|
* knows what a day is worth, and a second implementation in the browser is how a screen starts
|
|
14
26
|
* disagreeing with a payslip.
|
|
27
|
+
*
|
|
28
|
+
* Which is also why a failed load may not reach the tiles. `days` is `[]` before an answer and `[]`
|
|
29
|
+
* after a refused one, so reducing it either way produces "0h worked, 0h scheduled, 0h overtime" —
|
|
30
|
+
* a confident statement about somebody's month made out of nothing, and the one figure on this page
|
|
31
|
+
* a person might take to their manager. Until there is a day sheet to add up the tiles are
|
|
32
|
+
* skeletons, and the month underneath says the load failed and offers a way to try again.
|
|
15
33
|
*/
|
|
16
34
|
const api = getHrApi()
|
|
17
35
|
|
|
@@ -28,6 +46,16 @@ const daysQuery = createQuery(() => ({
|
|
|
28
46
|
}))
|
|
29
47
|
const days = $derived(daysQuery.data?.items ?? [])
|
|
30
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A disabled query is not a loading one — it is `pending` and not fetching — so without the
|
|
51
|
+
* workspace test the first frame of this page falls through to the empty state and tells somebody
|
|
52
|
+
* with a full month that nothing is recorded.
|
|
53
|
+
*/
|
|
54
|
+
const loading = $derived(!workspaceId || daysQuery.isLoading)
|
|
55
|
+
|
|
56
|
+
/** Nothing to add up: no answer yet, or an answer that never arrived. */
|
|
57
|
+
const totalsUnknown = $derived(days.length === 0 && (loading || daysQuery.isError))
|
|
58
|
+
|
|
31
59
|
const words = {
|
|
32
60
|
hours: (n: string) => t('hours_short', { n }),
|
|
33
61
|
minutes: (n: string) => t('minutes_short', { n }),
|
|
@@ -39,6 +67,8 @@ const totals = $derived({
|
|
|
39
67
|
overtime: days.reduce((sum, d) => sum + d.overtimeMinutes, 0),
|
|
40
68
|
})
|
|
41
69
|
|
|
70
|
+
const duration = (minutes: number) => formatDuration(minutes, words, messageLocale())
|
|
71
|
+
|
|
42
72
|
const statusLabel = (s: string) =>
|
|
43
73
|
s === 'present'
|
|
44
74
|
? t('att_status_present')
|
|
@@ -65,10 +95,13 @@ const statusTone = (s: string) =>
|
|
|
65
95
|
? 'urgent'
|
|
66
96
|
: 'grey'
|
|
67
97
|
|
|
98
|
+
/**
|
|
99
|
+
* `formatDate` rather than `Intl` directly: it formats in the reader's *interface* language, which
|
|
100
|
+
* is the one the rest of this row is written in. A bare `Intl` call follows the browser instead, so
|
|
101
|
+
* a Persian interface in an English browser gets "Mon 4 Aug" beside its own digits.
|
|
102
|
+
*/
|
|
68
103
|
const dayLabel = (iso: string) =>
|
|
69
|
-
|
|
70
|
-
new Date(`${iso}T00:00:00`),
|
|
71
|
-
)
|
|
104
|
+
formatDate(`${iso}T00:00:00`, { weekday: 'short', day: 'numeric', month: 'short' })
|
|
72
105
|
</script>
|
|
73
106
|
|
|
74
107
|
<PageHeader
|
|
@@ -76,31 +109,53 @@ const dayLabel = (iso: string) =>
|
|
|
76
109
|
title={t('attendance_title')}
|
|
77
110
|
/>
|
|
78
111
|
|
|
79
|
-
|
|
80
|
-
<
|
|
112
|
+
{#snippet tiles()}
|
|
113
|
+
<div class="tiles">
|
|
114
|
+
<StatTile label={t('att_worked')} value={duration(totals.worked)} />
|
|
115
|
+
<StatTile label={t('att_scheduled')} value={duration(totals.scheduled)} />
|
|
116
|
+
<StatTile label={t('att_overtime')} value={duration(totals.overtime)} />
|
|
117
|
+
</div>
|
|
118
|
+
{/snippet}
|
|
81
119
|
|
|
120
|
+
{#snippet tilesUnknown()}
|
|
82
121
|
<div class="tiles">
|
|
83
|
-
|
|
84
|
-
<StatTile label={t('att_scheduled')} value={formatDuration(totals.scheduled, words)} />
|
|
85
|
-
<StatTile label={t('att_overtime')} value={formatDuration(totals.overtime, words)} />
|
|
122
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="86px" />{/each}
|
|
86
123
|
</div>
|
|
124
|
+
{/snippet}
|
|
87
125
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
title={t('attendance_none')}
|
|
94
|
-
description={t('attendance_none_desc')}
|
|
95
|
-
/>
|
|
126
|
+
<Page>
|
|
127
|
+
<ClockControls {workspaceId} />
|
|
128
|
+
|
|
129
|
+
{#if totalsUnknown}
|
|
130
|
+
{@render tilesUnknown()}
|
|
96
131
|
{:else}
|
|
132
|
+
{@render tiles()}
|
|
133
|
+
{/if}
|
|
134
|
+
|
|
135
|
+
{#if loading}
|
|
136
|
+
<div class="rows">
|
|
137
|
+
{#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="37px" />{/each}
|
|
138
|
+
</div>
|
|
139
|
+
{:else if days.length}
|
|
140
|
+
<!--
|
|
141
|
+
The month a person already has outranks the failure, because everything here is invalidated by
|
|
142
|
+
a punch: clocking out refetches the day sheet, and a refetch that fails leaves the query in
|
|
143
|
+
`error` with the last good month still in `data`. An error branch above this one would blank
|
|
144
|
+
the whole page — tiles included — for as long as core takes to come back.
|
|
145
|
+
-->
|
|
146
|
+
{#if daysQuery.isError}
|
|
147
|
+
<p class="stale" role="status">
|
|
148
|
+
<span>{t('attendance_stale')}</span>
|
|
149
|
+
<Button size="sm" variant="ghost" onclick={() => void daysQuery.refetch()}>{t('retry')}</Button>
|
|
150
|
+
</p>
|
|
151
|
+
{/if}
|
|
97
152
|
<ul>
|
|
98
153
|
{#each days as day (day.id)}
|
|
99
154
|
<li class="row">
|
|
100
155
|
<span class="date">{dayLabel(day.businessDate)}</span>
|
|
101
|
-
<span class="worked">{
|
|
156
|
+
<span class="worked">{duration(day.workedMinutes)}</span>
|
|
102
157
|
{#if day.overtimeMinutes > 0}
|
|
103
|
-
<span class="ot">+{
|
|
158
|
+
<span class="ot">+{duration(day.overtimeMinutes)}</span>
|
|
104
159
|
{/if}
|
|
105
160
|
<!-- An anomaly is why a day needs a human; saying so beats a silent zero. -->
|
|
106
161
|
{#if day.anomalies.length}
|
|
@@ -110,6 +165,14 @@ const dayLabel = (iso: string) =>
|
|
|
110
165
|
</li>
|
|
111
166
|
{/each}
|
|
112
167
|
</ul>
|
|
168
|
+
{:else if daysQuery.isError}
|
|
169
|
+
<EmptyState icon="triangle-alert" title={t('attendance_error')}>
|
|
170
|
+
{#snippet actions()}
|
|
171
|
+
<Button variant="secondary" onclick={() => void daysQuery.refetch()}>{t('retry')}</Button>
|
|
172
|
+
{/snippet}
|
|
173
|
+
</EmptyState>
|
|
174
|
+
{:else}
|
|
175
|
+
<EmptyState icon="timer" title={t('attendance_none')} description={t('attendance_none_desc')} />
|
|
113
176
|
{/if}
|
|
114
177
|
</Page>
|
|
115
178
|
|
|
@@ -120,6 +183,29 @@ const dayLabel = (iso: string) =>
|
|
|
120
183
|
gap: 12px;
|
|
121
184
|
margin-block: 16px 20px;
|
|
122
185
|
}
|
|
186
|
+
.rows {
|
|
187
|
+
display: grid;
|
|
188
|
+
gap: 4px;
|
|
189
|
+
}
|
|
190
|
+
/*
|
|
191
|
+
* The warning ink is 4.37:1 on `--kern-canvas`, which is what this page sits on — under the 4.5 a
|
|
192
|
+
* 12.5px line has to clear. On its own tint it is 4.58:1 in light and 5.28:1 in dark, and the tint
|
|
193
|
+
* is what makes the strip read as a notice rather than as another row.
|
|
194
|
+
*/
|
|
195
|
+
.stale {
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
justify-content: space-between;
|
|
199
|
+
flex-wrap: wrap;
|
|
200
|
+
gap: 8px;
|
|
201
|
+
margin-block: 0 8px;
|
|
202
|
+
padding-block: 6px;
|
|
203
|
+
padding-inline: 12px 8px;
|
|
204
|
+
border-radius: var(--kern-r-md);
|
|
205
|
+
background: var(--kern-warning-tint);
|
|
206
|
+
color: var(--kern-warning);
|
|
207
|
+
font-size: 12.5px;
|
|
208
|
+
}
|
|
123
209
|
ul {
|
|
124
210
|
display: grid;
|
|
125
211
|
gap: 4px;
|
|
@@ -132,7 +218,7 @@ ul {
|
|
|
132
218
|
align-items: center;
|
|
133
219
|
gap: 12px;
|
|
134
220
|
padding: 8px 12px;
|
|
135
|
-
border-
|
|
221
|
+
border-block-end: 1px solid var(--kern-border);
|
|
136
222
|
}
|
|
137
223
|
.date {
|
|
138
224
|
flex: 1;
|