@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.
Files changed (31) hide show
  1. package/package.json +25 -5
  2. package/src/client/api-instance.ts +29 -0
  3. package/src/client/components/ClockControls.svelte +127 -0
  4. package/src/client/components/HrSidebar.svelte +73 -0
  5. package/src/client/components/LeaveRequestDialog.svelte +169 -0
  6. package/src/client/components/PersonFormDialog.svelte +184 -0
  7. package/src/client/components/PersonInline.svelte +49 -0
  8. package/src/client/components/PersonPanel.svelte +328 -0
  9. package/src/client/core-api.ts +34 -0
  10. package/src/client/i18n.ts +640 -0
  11. package/src/client/index.ts +3 -0
  12. package/src/client/mock.ts +447 -0
  13. package/src/client/module.ts +295 -0
  14. package/src/client/pages/ApprovalsPage.svelte +129 -0
  15. package/src/client/pages/AttendancePage.svelte +147 -0
  16. package/src/client/pages/DirectoryPage.svelte +428 -0
  17. package/src/client/pages/LeavePage.svelte +180 -0
  18. package/src/client/pages/OfficesPage.svelte +119 -0
  19. package/src/client/permissions.ts +31 -0
  20. package/src/client/query.test.ts +70 -0
  21. package/src/client/query.ts +69 -0
  22. package/src/client/settings/CalendarsSettings.svelte +21 -0
  23. package/src/client/settings/CapabilitiesSettings.svelte +122 -0
  24. package/src/client/settings/LeaveSettings.svelte +21 -0
  25. package/src/client/settings/OfficesSettings.svelte +21 -0
  26. package/src/client/settings/SchedulesSettings.svelte +21 -0
  27. package/src/client/widgets/ApprovalsWidget.svelte +86 -0
  28. package/src/client/widgets/ClockWidget.svelte +9 -0
  29. package/src/client/widgets/HeadcountWidget.svelte +29 -0
  30. package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
  31. 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>