@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/module.ts
CHANGED
|
@@ -223,6 +223,17 @@ export const hrClientModule = defineClientModule({
|
|
|
223
223
|
* is no route file in the app to keep in step with it any more.
|
|
224
224
|
*/
|
|
225
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
|
+
},
|
|
226
237
|
{
|
|
227
238
|
id: 'capabilities',
|
|
228
239
|
get label() {
|
|
@@ -230,7 +241,10 @@ export const hrClientModule = defineClientModule({
|
|
|
230
241
|
},
|
|
231
242
|
icon: 'toggle-left',
|
|
232
243
|
scope: 'workspace',
|
|
233
|
-
|
|
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',
|
|
234
248
|
order: 5,
|
|
235
249
|
component: () => import('./settings/CapabilitiesSettings.svelte'),
|
|
236
250
|
},
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type BadgeTone,
|
|
5
5
|
Button,
|
|
6
6
|
EmptyState,
|
|
7
|
+
formatCount,
|
|
8
|
+
formatDateTime,
|
|
7
9
|
navigation,
|
|
8
10
|
Page,
|
|
9
11
|
PageHeader,
|
|
@@ -18,9 +20,9 @@ import DecisionDialog from '../components/DecisionDialog.svelte'
|
|
|
18
20
|
import DelegationDialog from '../components/DelegationDialog.svelte'
|
|
19
21
|
import { t } from '../i18n.js'
|
|
20
22
|
import type { ApprovalRequest } from '../index.js'
|
|
21
|
-
import { canHr } from '../permissions.js'
|
|
23
|
+
import { canHr, HR_CAPABILITIES } from '../permissions.js'
|
|
22
24
|
import { hrKeys } from '../query.js'
|
|
23
|
-
import {
|
|
25
|
+
import { summarise } from '../summary.js'
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Everything waiting on me, across every kind of request.
|
|
@@ -51,6 +53,25 @@ let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject'
|
|
|
51
53
|
let delegating = $state(false)
|
|
52
54
|
let decideError = $state<string | null>(null)
|
|
53
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
|
+
|
|
54
75
|
const inboxQuery = createQuery(() => ({
|
|
55
76
|
queryKey: hrKeys.approvalInbox(workspaceId, includeDecided),
|
|
56
77
|
enabled: Boolean(workspaceId),
|
|
@@ -73,11 +94,68 @@ const decide = createMutation(() => ({
|
|
|
73
94
|
// is invalidated rather than guessing which keys moved.
|
|
74
95
|
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
75
96
|
},
|
|
76
|
-
onError: () => {
|
|
77
|
-
decideError =
|
|
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
|
|
78
107
|
},
|
|
79
108
|
}))
|
|
80
109
|
|
|
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
|
+
|
|
81
159
|
const SUBJECT_LABELS: Record<string, () => string> = {
|
|
82
160
|
leave: () => t('leave_title'),
|
|
83
161
|
regularization: () => t('attendance_title'),
|
|
@@ -100,9 +178,6 @@ const STATUS_LABELS: Record<string, () => string> = {
|
|
|
100
178
|
cancelled: () => t('approval_status_cancelled'),
|
|
101
179
|
}
|
|
102
180
|
|
|
103
|
-
const when = (iso: string) =>
|
|
104
|
-
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
|
|
105
|
-
|
|
106
181
|
const stepOf = (request: ApprovalRequest) =>
|
|
107
182
|
t('approvals_step_of', { n: String(request.currentStep + 1), total: String(request.steps.length) })
|
|
108
183
|
</script>
|
|
@@ -112,7 +187,7 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
112
187
|
title={t('approvals_title')}
|
|
113
188
|
>
|
|
114
189
|
{#snippet actions()}
|
|
115
|
-
{#if
|
|
190
|
+
{#if showDelegation}
|
|
116
191
|
<Button size="sm" variant="secondary" icon="users" onclick={() => (delegating = true)}>
|
|
117
192
|
{t('delegate')}
|
|
118
193
|
</Button>
|
|
@@ -135,26 +210,20 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
135
210
|
|
|
136
211
|
<SectionLabel
|
|
137
212
|
label={includeDecided ? t('approvals_decided') : t('approvals_waiting')}
|
|
138
|
-
count={items.length}
|
|
213
|
+
count={formatCount(items.length, 999)}
|
|
139
214
|
/>
|
|
140
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
|
+
-->
|
|
141
222
|
{#if inboxQuery.isLoading}
|
|
142
223
|
<div class="rows">
|
|
143
224
|
{#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
|
|
144
225
|
</div>
|
|
145
|
-
{:else if
|
|
146
|
-
<EmptyState icon="triangle-alert" title={t('approvals_error')}>
|
|
147
|
-
{#snippet actions()}
|
|
148
|
-
<Button variant="secondary" onclick={() => void inboxQuery.refetch()}>{t('retry')}</Button>
|
|
149
|
-
{/snippet}
|
|
150
|
-
</EmptyState>
|
|
151
|
-
{:else if items.length === 0}
|
|
152
|
-
<EmptyState
|
|
153
|
-
icon="check-check"
|
|
154
|
-
title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
|
|
155
|
-
description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
|
|
156
|
-
/>
|
|
157
|
-
{:else}
|
|
226
|
+
{:else if items.length > 0}
|
|
158
227
|
<div class="table" role="table" aria-label={t('approvals_title')}>
|
|
159
228
|
<div class="thead" role="row">
|
|
160
229
|
<span role="columnheader">{t('approvals_request')}</span>
|
|
@@ -170,7 +239,7 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
170
239
|
<span class="summary">{summarise(item)}</span>
|
|
171
240
|
</span>
|
|
172
241
|
<span class="cell muted" role="cell">{item.requesterName ?? '—'}</span>
|
|
173
|
-
<span class="cell muted" role="cell">{
|
|
242
|
+
<span class="cell muted" role="cell">{formatDateTime(item.requestedAt)}</span>
|
|
174
243
|
<span class="cell" role="cell">
|
|
175
244
|
{#if includeDecided}
|
|
176
245
|
<Badge tone={STATUS_TONES[item.status] ?? 'grey'}>
|
|
@@ -182,50 +251,55 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
182
251
|
</span>
|
|
183
252
|
<span class="cell actions" role="cell">
|
|
184
253
|
{#if item.status === 'pending'}
|
|
185
|
-
<Button
|
|
186
|
-
size="sm"
|
|
187
|
-
variant="secondary"
|
|
188
|
-
onclick={() => {
|
|
189
|
-
decideError = null
|
|
190
|
-
deciding = { request: item, decision: 'reject' }
|
|
191
|
-
}}
|
|
192
|
-
>
|
|
254
|
+
<Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>
|
|
193
255
|
{t('reject')}
|
|
194
256
|
</Button>
|
|
195
|
-
<Button
|
|
196
|
-
size="sm"
|
|
197
|
-
onclick={() => {
|
|
198
|
-
decideError = null
|
|
199
|
-
deciding = { request: item, decision: 'approve' }
|
|
200
|
-
}}
|
|
201
|
-
>
|
|
202
|
-
{t('approve')}
|
|
203
|
-
</Button>
|
|
257
|
+
<Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
|
|
204
258
|
{/if}
|
|
205
259
|
</span>
|
|
206
260
|
</div>
|
|
207
261
|
{/each}
|
|
208
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}
|
|
270
|
+
<EmptyState
|
|
271
|
+
icon="check-check"
|
|
272
|
+
title={includeDecided ? t('approvals_decided_none') : t('approvals_none')}
|
|
273
|
+
description={includeDecided ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
|
|
274
|
+
/>
|
|
209
275
|
{/if}
|
|
210
276
|
|
|
211
|
-
|
|
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>
|
|
284
|
+
{/if}
|
|
212
285
|
</Page>
|
|
213
286
|
|
|
214
287
|
<DecisionDialog
|
|
215
288
|
request={deciding?.request ?? null}
|
|
216
289
|
decision={deciding?.decision ?? 'approve'}
|
|
217
|
-
pending={
|
|
290
|
+
pending={submitting}
|
|
218
291
|
error={decideError}
|
|
219
|
-
onConfirm={
|
|
220
|
-
if (deciding) decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
|
|
221
|
-
}}
|
|
292
|
+
onConfirm={confirmDecision}
|
|
222
293
|
onCancel={() => {
|
|
223
294
|
deciding = null
|
|
224
295
|
decideError = null
|
|
225
296
|
}}
|
|
226
297
|
/>
|
|
227
298
|
|
|
228
|
-
|
|
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}
|
|
229
303
|
|
|
230
304
|
<style>
|
|
231
305
|
.filters {
|
|
@@ -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;
|