@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.
Files changed (90) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +9 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +458 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1896 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +146 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +53 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +101 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +14 -0
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +14 -3
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -6
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -13
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/policies.d.ts +6 -0
  44. package/dist/server/services/policies.d.ts.map +1 -1
  45. package/dist/server/services/policies.js +6 -0
  46. package/dist/server/services/policies.js.map +1 -1
  47. package/migrations/0005_approval_requester.sql +30 -0
  48. package/migrations/0006_schedule_no_overlap.sql +85 -0
  49. package/migrations/0007_hot_path_indexes.sql +38 -0
  50. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  51. package/migrations/meta/0005_snapshot.json +4047 -0
  52. package/migrations/meta/0007_snapshot.json +4225 -0
  53. package/migrations/meta/0009_snapshot.json +4231 -0
  54. package/migrations/meta/_journal.json +28 -0
  55. package/package.json +7 -7
  56. package/src/client/capabilities.ts +34 -0
  57. package/src/client/components/ClockControls.svelte +127 -20
  58. package/src/client/components/ClockControls.test.ts +446 -0
  59. package/src/client/components/DecisionDialog.svelte +90 -0
  60. package/src/client/components/DelegationDialog.svelte +219 -0
  61. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  62. package/src/client/components/PersonFormDialog.svelte +53 -3
  63. package/src/client/components/PersonPanel.svelte +134 -26
  64. package/src/client/i18n.ts +5 -625
  65. package/src/client/index.ts +1 -20
  66. package/src/client/messages.test.ts +154 -0
  67. package/src/client/messages.ts +3388 -0
  68. package/src/client/mock.ts +1691 -162
  69. package/src/client/module.ts +17 -2
  70. package/src/client/pages/ApprovalsPage.svelte +332 -60
  71. package/src/client/pages/AttendancePage.svelte +106 -20
  72. package/src/client/pages/DirectoryPage.svelte +192 -55
  73. package/src/client/pages/LeavePage.svelte +280 -25
  74. package/src/client/pages/OfficesPage.svelte +181 -39
  75. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  76. package/src/client/permissions.ts +2 -1
  77. package/src/client/query.ts +22 -1
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +43 -0
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/approvals.ts +13 -1
  90. package/src/contract/capabilities.ts +17 -0
@@ -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
- permission: 'core.workspace.manage',
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 { Badge, Button, Card, EmptyState, navigation, Page, PageHeader, Skeleton, session } from '@kernhq/ui'
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 server only
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: false }),
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({ workspaceId, ...vars }),
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
- const subjectLabel = (subjectType: string) =>
43
- subjectType === 'leave' ? t('leave_title') : t('attendance_title')
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 when = (iso: string) =>
46
- new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
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
- <Skeleton height="140px" />
57
- {:else if items.length === 0}
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
- {: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>
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
- ul {
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
- flex-wrap: wrap;
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
- .meta {
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 { Badge, EmptyState, navigation, Page, PageHeader, Skeleton, StatTile, session } from '@kernhq/ui'
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
- new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(
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
- <Page>
80
- <ClockControls {workspaceId} />
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
- <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)} />
122
+ {#each [1, 2, 3] as n (n)}<Skeleton height="86px" />{/each}
86
123
  </div>
124
+ {/snippet}
87
125
 
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
- />
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">{formatDuration(day.workedMinutes, words)}</span>
156
+ <span class="worked">{duration(day.workedMinutes)}</span>
102
157
  {#if day.overtimeMinutes > 0}
103
- <span class="ot">+{formatDuration(day.overtimeMinutes, words)}</span>
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-bottom: 1px solid var(--kern-border);
221
+ border-block-end: 1px solid var(--kern-border);
136
222
  }
137
223
  .date {
138
224
  flex: 1;