@kernhq/module-hr 0.8.0 → 0.9.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/package.json +25 -5
- package/src/client/api-instance.ts +29 -0
- package/src/client/components/ClockControls.svelte +127 -0
- package/src/client/components/HrSidebar.svelte +73 -0
- package/src/client/components/LeaveRequestDialog.svelte +169 -0
- package/src/client/components/PersonFormDialog.svelte +184 -0
- package/src/client/components/PersonInline.svelte +49 -0
- package/src/client/components/PersonPanel.svelte +328 -0
- package/src/client/core-api.ts +34 -0
- package/src/client/i18n.ts +640 -0
- package/src/client/index.ts +3 -0
- package/src/client/mock.ts +447 -0
- package/src/client/module.ts +295 -0
- package/src/client/pages/ApprovalsPage.svelte +129 -0
- package/src/client/pages/AttendancePage.svelte +147 -0
- package/src/client/pages/DirectoryPage.svelte +428 -0
- package/src/client/pages/LeavePage.svelte +180 -0
- package/src/client/pages/OfficesPage.svelte +119 -0
- package/src/client/permissions.ts +31 -0
- package/src/client/query.test.ts +70 -0
- package/src/client/query.ts +69 -0
- package/src/client/settings/CalendarsSettings.svelte +21 -0
- package/src/client/settings/CapabilitiesSettings.svelte +122 -0
- package/src/client/settings/LeaveSettings.svelte +21 -0
- package/src/client/settings/OfficesSettings.svelte +21 -0
- package/src/client/settings/SchedulesSettings.svelte +21 -0
- package/src/client/widgets/ApprovalsWidget.svelte +86 -0
- package/src/client/widgets/ClockWidget.svelte +9 -0
- package/src/client/widgets/HeadcountWidget.svelte +29 -0
- package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
- package/src/client/widgets/WhosOutWidget.svelte +75 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { defineClientModule } from '@kernhq/ui'
|
|
2
|
+
import { hrMessageBundles, t } from './i18n.js'
|
|
3
|
+
import { HR_CAPABILITIES, HR_PERMISSIONS } from './permissions.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HR as the shell sees it.
|
|
7
|
+
*
|
|
8
|
+
* Every contribution carries two independent gates, and they answer different questions:
|
|
9
|
+
*
|
|
10
|
+
* - `permission` — may *this person* reach it. Someone else in the workspace may well see it.
|
|
11
|
+
* - `capability` — does *this workspace* have the feature at all. When it is off nobody sees it,
|
|
12
|
+
* including an owner, and the API behind it answers 404 rather than 403.
|
|
13
|
+
*
|
|
14
|
+
* That second gate is why this module can be a staff directory for one company and a shift-rostering
|
|
15
|
+
* system for another without a line of conditional code. A workspace that never switches attendance
|
|
16
|
+
* on has no clock widget, no attendance nav, no schedule settings and no attendance commands — not
|
|
17
|
+
* greyed out, not there.
|
|
18
|
+
*
|
|
19
|
+
* Labels are getters rather than strings because a module is defined once at import time while the
|
|
20
|
+
* interface language can change afterwards; reading them on render keeps the rail and the palette in
|
|
21
|
+
* the language the person actually chose.
|
|
22
|
+
*/
|
|
23
|
+
export const hrClientModule = defineClientModule({
|
|
24
|
+
id: 'hr',
|
|
25
|
+
name: 'People',
|
|
26
|
+
icon: 'users',
|
|
27
|
+
messages: hrMessageBundles,
|
|
28
|
+
|
|
29
|
+
nav: [
|
|
30
|
+
{
|
|
31
|
+
id: 'hr',
|
|
32
|
+
get label() {
|
|
33
|
+
return t('nav')
|
|
34
|
+
},
|
|
35
|
+
icon: 'users',
|
|
36
|
+
href: '/hr',
|
|
37
|
+
order: 30,
|
|
38
|
+
permission: HR_PERMISSIONS.personView,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
|
|
42
|
+
routes: [
|
|
43
|
+
{
|
|
44
|
+
path: '/hr/leave',
|
|
45
|
+
component: () => import('./pages/LeavePage.svelte'),
|
|
46
|
+
get title() {
|
|
47
|
+
return t('leave_title')
|
|
48
|
+
},
|
|
49
|
+
permission: HR_PERMISSIONS.leaveView,
|
|
50
|
+
capability: HR_CAPABILITIES.leave,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
path: '/hr/attendance',
|
|
54
|
+
component: () => import('./pages/AttendancePage.svelte'),
|
|
55
|
+
get title() {
|
|
56
|
+
return t('attendance_title')
|
|
57
|
+
},
|
|
58
|
+
permission: HR_PERMISSIONS.attendanceView,
|
|
59
|
+
capability: HR_CAPABILITIES.attendance,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
path: '/hr/approvals',
|
|
63
|
+
component: () => import('./pages/ApprovalsPage.svelte'),
|
|
64
|
+
get title() {
|
|
65
|
+
return t('approvals_title')
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
path: '/hr/offices',
|
|
70
|
+
component: () => import('./pages/OfficesPage.svelte'),
|
|
71
|
+
get title() {
|
|
72
|
+
return t('offices_title')
|
|
73
|
+
},
|
|
74
|
+
permission: HR_PERMISSIONS.officeView,
|
|
75
|
+
capability: HR_CAPABILITIES.offices,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// Last: the shell matches in order, and `/hr` would otherwise swallow the paths above it.
|
|
79
|
+
path: '/hr',
|
|
80
|
+
component: () => import('./pages/DirectoryPage.svelte'),
|
|
81
|
+
get title() {
|
|
82
|
+
return t('title')
|
|
83
|
+
},
|
|
84
|
+
permission: HR_PERMISSIONS.personView,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
|
|
88
|
+
widgets: [
|
|
89
|
+
{
|
|
90
|
+
id: 'hr.clock',
|
|
91
|
+
get title() {
|
|
92
|
+
return t('widget_clock_title')
|
|
93
|
+
},
|
|
94
|
+
get description() {
|
|
95
|
+
return t('widget_clock_desc')
|
|
96
|
+
},
|
|
97
|
+
icon: 'timer',
|
|
98
|
+
permission: HR_PERMISSIONS.attendancePunch,
|
|
99
|
+
capability: HR_CAPABILITIES.attendance,
|
|
100
|
+
sizes: ['s', 'm'],
|
|
101
|
+
defaultSize: 's',
|
|
102
|
+
compact: true,
|
|
103
|
+
order: 10,
|
|
104
|
+
component: () => import('./widgets/ClockWidget.svelte'),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'hr.my-leave',
|
|
108
|
+
get title() {
|
|
109
|
+
return t('widget_balance_title')
|
|
110
|
+
},
|
|
111
|
+
get description() {
|
|
112
|
+
return t('widget_balance_desc')
|
|
113
|
+
},
|
|
114
|
+
icon: 'tree-palm',
|
|
115
|
+
permission: HR_PERMISSIONS.leaveView,
|
|
116
|
+
capability: HR_CAPABILITIES.leave,
|
|
117
|
+
sizes: ['s', 'm'],
|
|
118
|
+
defaultSize: 'm',
|
|
119
|
+
order: 20,
|
|
120
|
+
component: () => import('./widgets/LeaveBalanceWidget.svelte'),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'hr.whos-out',
|
|
124
|
+
get title() {
|
|
125
|
+
return t('widget_whos_out_title')
|
|
126
|
+
},
|
|
127
|
+
get description() {
|
|
128
|
+
return t('widget_whos_out_desc')
|
|
129
|
+
},
|
|
130
|
+
icon: 'calendar-days',
|
|
131
|
+
permission: HR_PERMISSIONS.leaveViewTeam,
|
|
132
|
+
capability: HR_CAPABILITIES.leave,
|
|
133
|
+
sizes: ['m', 'l'],
|
|
134
|
+
defaultSize: 'm',
|
|
135
|
+
order: 30,
|
|
136
|
+
component: () => import('./widgets/WhosOutWidget.svelte'),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'hr.approvals',
|
|
140
|
+
get title() {
|
|
141
|
+
return t('widget_approvals_title')
|
|
142
|
+
},
|
|
143
|
+
get description() {
|
|
144
|
+
return t('widget_approvals_desc')
|
|
145
|
+
},
|
|
146
|
+
icon: 'check-check',
|
|
147
|
+
sizes: ['s', 'm', 'l'],
|
|
148
|
+
defaultSize: 'm',
|
|
149
|
+
order: 40,
|
|
150
|
+
component: () => import('./widgets/ApprovalsWidget.svelte'),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'hr.headcount',
|
|
154
|
+
get title() {
|
|
155
|
+
return t('widget_headcount_title')
|
|
156
|
+
},
|
|
157
|
+
get description() {
|
|
158
|
+
return t('widget_headcount_desc')
|
|
159
|
+
},
|
|
160
|
+
icon: 'users',
|
|
161
|
+
permission: HR_PERMISSIONS.personView,
|
|
162
|
+
sizes: ['s'],
|
|
163
|
+
defaultSize: 's',
|
|
164
|
+
compact: true,
|
|
165
|
+
order: 50,
|
|
166
|
+
component: () => import('./widgets/HeadcountWidget.svelte'),
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
|
|
170
|
+
commands: [
|
|
171
|
+
{
|
|
172
|
+
id: 'hr.directory',
|
|
173
|
+
get label() {
|
|
174
|
+
return t('cmd_directory')
|
|
175
|
+
},
|
|
176
|
+
icon: 'users',
|
|
177
|
+
permission: HR_PERMISSIONS.personView,
|
|
178
|
+
run: (ctx) => ctx.navigate('/hr'),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'hr.request-leave',
|
|
182
|
+
get label() {
|
|
183
|
+
return t('cmd_request_leave')
|
|
184
|
+
},
|
|
185
|
+
icon: 'tree-palm',
|
|
186
|
+
permission: HR_PERMISSIONS.leaveRequest,
|
|
187
|
+
capability: HR_CAPABILITIES.leave,
|
|
188
|
+
run: (ctx) => ctx.navigate('/hr/leave?new=1'),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'hr.my-attendance',
|
|
192
|
+
get label() {
|
|
193
|
+
return t('cmd_attendance')
|
|
194
|
+
},
|
|
195
|
+
icon: 'timer',
|
|
196
|
+
permission: HR_PERMISSIONS.attendanceView,
|
|
197
|
+
capability: HR_CAPABILITIES.attendance,
|
|
198
|
+
run: (ctx) => ctx.navigate('/hr/attendance'),
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'hr.approvals',
|
|
202
|
+
get label() {
|
|
203
|
+
return t('cmd_approvals')
|
|
204
|
+
},
|
|
205
|
+
icon: 'check-check',
|
|
206
|
+
run: (ctx) => ctx.navigate('/hr/approvals'),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
|
|
210
|
+
sidebar: [
|
|
211
|
+
{
|
|
212
|
+
id: 'hr',
|
|
213
|
+
match: ['hr'],
|
|
214
|
+
permission: HR_PERMISSIONS.personView,
|
|
215
|
+
component: () => import('./components/HrSidebar.svelte'),
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Where HR is configured. The shell builds the settings nav from these, and the route is
|
|
221
|
+
* conventional (`/<ws>/settings/hr/<id>`), and the shell mounts whatever is declared here — there
|
|
222
|
+
* is no route file in the app to keep in step with it any more.
|
|
223
|
+
*/
|
|
224
|
+
settingsPages: [
|
|
225
|
+
{
|
|
226
|
+
id: 'capabilities',
|
|
227
|
+
get label() {
|
|
228
|
+
return t('settings_capabilities')
|
|
229
|
+
},
|
|
230
|
+
icon: 'toggle-left',
|
|
231
|
+
scope: 'workspace',
|
|
232
|
+
permission: 'core.workspace.manage',
|
|
233
|
+
order: 5,
|
|
234
|
+
component: () => import('./settings/CapabilitiesSettings.svelte'),
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: 'offices',
|
|
238
|
+
get label() {
|
|
239
|
+
return t('settings_offices')
|
|
240
|
+
},
|
|
241
|
+
icon: 'building',
|
|
242
|
+
scope: 'workspace',
|
|
243
|
+
permission: HR_PERMISSIONS.officeManage,
|
|
244
|
+
capability: HR_CAPABILITIES.offices,
|
|
245
|
+
order: 10,
|
|
246
|
+
component: () => import('./settings/OfficesSettings.svelte'),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'calendars',
|
|
250
|
+
get label() {
|
|
251
|
+
return t('settings_calendars')
|
|
252
|
+
},
|
|
253
|
+
icon: 'calendar',
|
|
254
|
+
scope: 'workspace',
|
|
255
|
+
permission: HR_PERMISSIONS.calendarManage,
|
|
256
|
+
capability: HR_CAPABILITIES.calendars,
|
|
257
|
+
order: 20,
|
|
258
|
+
component: () => import('./settings/CalendarsSettings.svelte'),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: 'leave',
|
|
262
|
+
get label() {
|
|
263
|
+
return t('settings_leave')
|
|
264
|
+
},
|
|
265
|
+
icon: 'tree-palm',
|
|
266
|
+
scope: 'workspace',
|
|
267
|
+
permission: HR_PERMISSIONS.leaveManage,
|
|
268
|
+
capability: HR_CAPABILITIES.leave,
|
|
269
|
+
order: 30,
|
|
270
|
+
component: () => import('./settings/LeaveSettings.svelte'),
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: 'schedules',
|
|
274
|
+
get label() {
|
|
275
|
+
return t('settings_schedules')
|
|
276
|
+
},
|
|
277
|
+
icon: 'clock',
|
|
278
|
+
scope: 'workspace',
|
|
279
|
+
permission: HR_PERMISSIONS.attendanceManage,
|
|
280
|
+
capability: HR_CAPABILITIES.attendance,
|
|
281
|
+
order: 40,
|
|
282
|
+
component: () => import('./settings/SchedulesSettings.svelte'),
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
|
|
286
|
+
presenters: [
|
|
287
|
+
{
|
|
288
|
+
type: 'person',
|
|
289
|
+
inline: () => import('./components/PersonInline.svelte'),
|
|
290
|
+
page: (id, workspaceSlug) => `/${workspaceSlug}/hr?person=${encodeURIComponent(id)}`,
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
export default hrClientModule
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, Button, Card, EmptyState, navigation, Page, PageHeader, Skeleton, session } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { hrKeys } from '../query.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Everything waiting on me, across every kind of request.
|
|
10
|
+
*
|
|
11
|
+
* One inbox rather than one per feature, because the approval engine is keyed by subject type — a
|
|
12
|
+
* leave request and an attendance correction arrive here the same way, and so will overtime and
|
|
13
|
+
* timesheets when they exist.
|
|
14
|
+
*
|
|
15
|
+
* No permission gate: an inbox of what *you* must decide is yours by definition, and the server only
|
|
16
|
+
* ever lists steps you are named on.
|
|
17
|
+
*/
|
|
18
|
+
const api = getHrApi()
|
|
19
|
+
const queryClient = useQueryClient()
|
|
20
|
+
|
|
21
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
22
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
23
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
24
|
+
|
|
25
|
+
const inboxQuery = createQuery(() => ({
|
|
26
|
+
queryKey: hrKeys.approvalInbox(workspaceId),
|
|
27
|
+
enabled: Boolean(workspaceId),
|
|
28
|
+
queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, includeDecided: false }),
|
|
29
|
+
}))
|
|
30
|
+
const items = $derived(inboxQuery.data?.items ?? [])
|
|
31
|
+
|
|
32
|
+
const decide = createMutation(() => ({
|
|
33
|
+
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
|
|
34
|
+
api.approvals.decide({ workspaceId, ...vars }),
|
|
35
|
+
onSuccess: () => {
|
|
36
|
+
// Deciding changes a balance and a day sheet as well as the inbox, so the whole module's cache
|
|
37
|
+
// is invalidated rather than guessing which keys moved.
|
|
38
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
39
|
+
},
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
const subjectLabel = (subjectType: string) =>
|
|
43
|
+
subjectType === 'leave' ? t('leave_title') : t('attendance_title')
|
|
44
|
+
|
|
45
|
+
const when = (iso: string) =>
|
|
46
|
+
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<PageHeader
|
|
50
|
+
crumbs={[{ label: workspace?.name ?? '' }, { label: t('approvals_title') }]}
|
|
51
|
+
title={t('approvals_title')}
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<Page>
|
|
55
|
+
{#if inboxQuery.isLoading}
|
|
56
|
+
<Skeleton height="140px" />
|
|
57
|
+
{:else if items.length === 0}
|
|
58
|
+
<EmptyState
|
|
59
|
+
icon="check-check"
|
|
60
|
+
title={t('approvals_none')}
|
|
61
|
+
description={t('approvals_none_desc')}
|
|
62
|
+
/>
|
|
63
|
+
{:else}
|
|
64
|
+
<ul>
|
|
65
|
+
{#each items as item (item.id)}
|
|
66
|
+
<li>
|
|
67
|
+
<Card>
|
|
68
|
+
<div class="row">
|
|
69
|
+
<div class="what">
|
|
70
|
+
<Badge tone="upcoming">{subjectLabel(item.subjectType)}</Badge>
|
|
71
|
+
<span class="summary">{item.summary}</span>
|
|
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>
|
|
94
|
+
{/if}
|
|
95
|
+
</Page>
|
|
96
|
+
|
|
97
|
+
<style>
|
|
98
|
+
ul {
|
|
99
|
+
display: grid;
|
|
100
|
+
gap: 8px;
|
|
101
|
+
list-style: none;
|
|
102
|
+
margin: 0;
|
|
103
|
+
padding: 0;
|
|
104
|
+
}
|
|
105
|
+
.row {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 12px;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
}
|
|
111
|
+
.what {
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: 8px;
|
|
115
|
+
flex: 1;
|
|
116
|
+
min-width: 0;
|
|
117
|
+
}
|
|
118
|
+
.summary {
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
}
|
|
121
|
+
.meta {
|
|
122
|
+
color: var(--kern-ink-500);
|
|
123
|
+
font-size: 12px;
|
|
124
|
+
}
|
|
125
|
+
.actions {
|
|
126
|
+
display: flex;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, EmptyState, navigation, Page, PageHeader, Skeleton, StatTile, session } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import ClockControls from '../components/ClockControls.svelte'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import { formatDuration, hrKeys, monthRange } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* My attendance: the clock, then the month.
|
|
11
|
+
*
|
|
12
|
+
* The totals come from the derived day sheet rather than being added up here — the server already
|
|
13
|
+
* knows what a day is worth, and a second implementation in the browser is how a screen starts
|
|
14
|
+
* disagreeing with a payslip.
|
|
15
|
+
*/
|
|
16
|
+
const api = getHrApi()
|
|
17
|
+
|
|
18
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
19
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
20
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
21
|
+
|
|
22
|
+
const range = $derived(monthRange())
|
|
23
|
+
|
|
24
|
+
const daysQuery = createQuery(() => ({
|
|
25
|
+
queryKey: hrKeys.attendanceDays(workspaceId, undefined, range.from, range.to),
|
|
26
|
+
enabled: Boolean(workspaceId),
|
|
27
|
+
queryFn: () => api.attendance.days.list({ workspaceId, from: range.from, to: range.to, limit: 100 }),
|
|
28
|
+
}))
|
|
29
|
+
const days = $derived(daysQuery.data?.items ?? [])
|
|
30
|
+
|
|
31
|
+
const words = {
|
|
32
|
+
hours: (n: string) => t('hours_short', { n }),
|
|
33
|
+
minutes: (n: string) => t('minutes_short', { n }),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const totals = $derived({
|
|
37
|
+
worked: days.reduce((sum, d) => sum + d.workedMinutes, 0),
|
|
38
|
+
scheduled: days.reduce((sum, d) => sum + d.scheduledMinutes, 0),
|
|
39
|
+
overtime: days.reduce((sum, d) => sum + d.overtimeMinutes, 0),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const statusLabel = (s: string) =>
|
|
43
|
+
s === 'present'
|
|
44
|
+
? t('att_status_present')
|
|
45
|
+
: s === 'absent'
|
|
46
|
+
? t('att_status_absent')
|
|
47
|
+
: s === 'leave'
|
|
48
|
+
? t('att_status_leave')
|
|
49
|
+
: s === 'holiday'
|
|
50
|
+
? t('att_status_holiday')
|
|
51
|
+
: s === 'weekend'
|
|
52
|
+
? t('att_status_weekend')
|
|
53
|
+
: s === 'partial'
|
|
54
|
+
? t('att_status_partial')
|
|
55
|
+
: t('att_status_pending')
|
|
56
|
+
|
|
57
|
+
const statusTone = (s: string) =>
|
|
58
|
+
s === 'present'
|
|
59
|
+
? 'done'
|
|
60
|
+
: s === 'absent'
|
|
61
|
+
? 'declined'
|
|
62
|
+
: s === 'leave'
|
|
63
|
+
? 'on-leave'
|
|
64
|
+
: s === 'pending'
|
|
65
|
+
? 'urgent'
|
|
66
|
+
: 'grey'
|
|
67
|
+
|
|
68
|
+
const dayLabel = (iso: string) =>
|
|
69
|
+
new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(
|
|
70
|
+
new Date(`${iso}T00:00:00`),
|
|
71
|
+
)
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<PageHeader
|
|
75
|
+
crumbs={[{ label: workspace?.name ?? '' }, { label: t('attendance_title') }]}
|
|
76
|
+
title={t('attendance_title')}
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<Page>
|
|
80
|
+
<ClockControls {workspaceId} />
|
|
81
|
+
|
|
82
|
+
<div class="tiles">
|
|
83
|
+
<StatTile label={t('att_worked')} value={formatDuration(totals.worked, words)} />
|
|
84
|
+
<StatTile label={t('att_scheduled')} value={formatDuration(totals.scheduled, words)} />
|
|
85
|
+
<StatTile label={t('att_overtime')} value={formatDuration(totals.overtime, words)} />
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{#if daysQuery.isLoading}
|
|
89
|
+
<Skeleton height="200px" />
|
|
90
|
+
{:else if days.length === 0}
|
|
91
|
+
<EmptyState
|
|
92
|
+
icon="timer"
|
|
93
|
+
title={t('attendance_none')}
|
|
94
|
+
description={t('attendance_none_desc')}
|
|
95
|
+
/>
|
|
96
|
+
{:else}
|
|
97
|
+
<ul>
|
|
98
|
+
{#each days as day (day.id)}
|
|
99
|
+
<li class="row">
|
|
100
|
+
<span class="date">{dayLabel(day.businessDate)}</span>
|
|
101
|
+
<span class="worked">{formatDuration(day.workedMinutes, words)}</span>
|
|
102
|
+
{#if day.overtimeMinutes > 0}
|
|
103
|
+
<span class="ot">+{formatDuration(day.overtimeMinutes, words)}</span>
|
|
104
|
+
{/if}
|
|
105
|
+
<!-- An anomaly is why a day needs a human; saying so beats a silent zero. -->
|
|
106
|
+
{#if day.anomalies.length}
|
|
107
|
+
<Badge tone="warning">{day.anomalies.length}</Badge>
|
|
108
|
+
{/if}
|
|
109
|
+
<Badge tone={statusTone(day.status)}>{statusLabel(day.status)}</Badge>
|
|
110
|
+
</li>
|
|
111
|
+
{/each}
|
|
112
|
+
</ul>
|
|
113
|
+
{/if}
|
|
114
|
+
</Page>
|
|
115
|
+
|
|
116
|
+
<style>
|
|
117
|
+
.tiles {
|
|
118
|
+
display: grid;
|
|
119
|
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
120
|
+
gap: 12px;
|
|
121
|
+
margin-block: 16px 20px;
|
|
122
|
+
}
|
|
123
|
+
ul {
|
|
124
|
+
display: grid;
|
|
125
|
+
gap: 4px;
|
|
126
|
+
list-style: none;
|
|
127
|
+
margin: 0;
|
|
128
|
+
padding: 0;
|
|
129
|
+
}
|
|
130
|
+
.row {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 12px;
|
|
134
|
+
padding: 8px 12px;
|
|
135
|
+
border-bottom: 1px solid var(--kern-border);
|
|
136
|
+
}
|
|
137
|
+
.date {
|
|
138
|
+
flex: 1;
|
|
139
|
+
}
|
|
140
|
+
.worked,
|
|
141
|
+
.ot {
|
|
142
|
+
font-variant-numeric: tabular-nums;
|
|
143
|
+
}
|
|
144
|
+
.ot {
|
|
145
|
+
color: var(--kern-ink-500);
|
|
146
|
+
}
|
|
147
|
+
</style>
|