@kernhq/module-hr 0.9.3 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +9 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +458 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +43 -0
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/approvals.ts +13 -1
- package/src/contract/capabilities.ts +17 -0
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
type BadgeTone,
|
|
6
6
|
Button,
|
|
7
7
|
Card,
|
|
8
|
-
coreApi,
|
|
9
8
|
EmptyState,
|
|
9
|
+
formatCount,
|
|
10
|
+
formatDate,
|
|
10
11
|
Input,
|
|
11
|
-
|
|
12
|
+
messageLocale,
|
|
12
13
|
navigation,
|
|
13
14
|
Page,
|
|
14
15
|
PageHeader,
|
|
@@ -20,17 +21,19 @@ import {
|
|
|
20
21
|
} from '@kernhq/ui'
|
|
21
22
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
22
23
|
import { getHrApi } from '../api-instance.js'
|
|
24
|
+
import DecisionDialog from '../components/DecisionDialog.svelte'
|
|
23
25
|
import PersonFormDialog from '../components/PersonFormDialog.svelte'
|
|
24
26
|
import PersonPanel from '../components/PersonPanel.svelte'
|
|
25
|
-
import type { CoreApi } from '../core-api.js'
|
|
26
27
|
import { t } from '../i18n.js'
|
|
28
|
+
import type { ApprovalRequest } from '../index.js'
|
|
27
29
|
import { canHr, HR_CAPABILITIES } from '../permissions.js'
|
|
28
30
|
import { formatDays, hrKeys } from '../query.js'
|
|
31
|
+
import { summarise } from '../summary.js'
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* The people view, laid out to DESIGN.md §3.12.
|
|
32
35
|
*
|
|
33
|
-
*
|
|
36
|
+
* A row of stat tiles, then `minmax(0,1fr) 320px`: a real table on the left — name, role, office,
|
|
34
37
|
* started, status on one grid so the columns line up down the page — and the things that need a
|
|
35
38
|
* decision on the right. A flat list of names would be a directory; this is the screen somebody
|
|
36
39
|
* actually opens in the morning, which is why what is waiting on them sits beside it.
|
|
@@ -39,7 +42,6 @@ import { formatDays, hrKeys } from '../query.js'
|
|
|
39
42
|
* one office is also the answer to "can I call them now".
|
|
40
43
|
*/
|
|
41
44
|
const api = getHrApi()
|
|
42
|
-
const core = coreApi<CoreApi>()
|
|
43
45
|
const queryClient = useQueryClient()
|
|
44
46
|
|
|
45
47
|
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
@@ -51,12 +53,21 @@ let officeTab = $state('all')
|
|
|
51
53
|
const selected = $derived(navigation.search.person)
|
|
52
54
|
const creating = $derived(navigation.search.new === '1')
|
|
53
55
|
|
|
54
|
-
const modulesQuery = createQuery(() => ({
|
|
55
|
-
queryKey: keys.modules(workspaceId),
|
|
56
|
-
enabled: Boolean(workspaceId),
|
|
57
|
-
queryFn: () => core.workspaces.modules.list({ workspaceId }),
|
|
58
|
-
}))
|
|
59
56
|
const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
|
|
57
|
+
/**
|
|
58
|
+
* Leave is a capability, and a workspace that never switched it on has no balance to show.
|
|
59
|
+
*
|
|
60
|
+
* Without this the main HR screen fired `leave.balance.get` on every load of a directory-only
|
|
61
|
+
* workspace and got a 404 back — the honest answer for a feature nobody enabled — leaving an
|
|
62
|
+
* "Available days" tile reading zero for ever beside three tiles that meant something.
|
|
63
|
+
*/
|
|
64
|
+
const showLeave = $derived(session.hasCapability('hr', HR_CAPABILITIES.leave))
|
|
65
|
+
/**
|
|
66
|
+
* And the tile needs the permission as well as the capability: `leave.balance.get` is behind
|
|
67
|
+
* `hr.leave.view`, and a tile reading "0" because the request was refused is a worse answer than
|
|
68
|
+
* no tile.
|
|
69
|
+
*/
|
|
70
|
+
const showBalance = $derived(showLeave && canHr('leaveView'))
|
|
60
71
|
|
|
61
72
|
/** Debounced: every keystroke would otherwise be a request, and the term is part of the cache key. */
|
|
62
73
|
let debounced = $state('')
|
|
@@ -96,7 +107,7 @@ const tabs = $derived([
|
|
|
96
107
|
|
|
97
108
|
const balancesQuery = createQuery(() => ({
|
|
98
109
|
queryKey: hrKeys.leaveBalance(workspaceId, undefined),
|
|
99
|
-
enabled: Boolean(workspaceId),
|
|
110
|
+
enabled: Boolean(workspaceId) && showBalance,
|
|
100
111
|
queryFn: () => api.leave.balance.get({ workspaceId }),
|
|
101
112
|
}))
|
|
102
113
|
|
|
@@ -107,12 +118,92 @@ const inboxQuery = createQuery(() => ({
|
|
|
107
118
|
}))
|
|
108
119
|
const waiting = $derived(inboxQuery.data?.items ?? [])
|
|
109
120
|
|
|
121
|
+
let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
|
|
122
|
+
let decideError = $state<string | null>(null)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `submitting` rather than `decide.isPending`: the disabled attribute only reaches the confirm
|
|
126
|
+
* button on the next render, so two quick clicks both fire and one request is decided twice. This
|
|
127
|
+
* is set in the same tick as the click.
|
|
128
|
+
*/
|
|
129
|
+
let submitting = $state(false)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The decision refusals this module has its own sentence for, keyed by the `reason` the router
|
|
133
|
+
* sends beside the refusal. The same shape as `ClockControls.svelte`, and empty for the same reason
|
|
134
|
+
* the widget's is: `approvals.decide` refuses through `KernError.conflict`, whose reason argument
|
|
135
|
+
* stays on the server, so a refusal arrives today as the sentence the router wrote for a reader.
|
|
136
|
+
*/
|
|
137
|
+
const decideRefusalMessages: Record<string, string> = {}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What a refused decision says to the person who made it.
|
|
141
|
+
*
|
|
142
|
+
* A decision is refused when the request is no longer theirs to decide — somebody else approved it,
|
|
143
|
+
* the requester cancelled it, a delegation moved the step — and that sentence is the only thing
|
|
144
|
+
* saying which. Everything else that can fail carries machine text in English, so it falls back to
|
|
145
|
+
* this module's own string. The test is the transport's `code`, never the sentence.
|
|
146
|
+
*/
|
|
147
|
+
function decideFailure(error: unknown): string {
|
|
148
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
149
|
+
if (failure.code !== 'CONFLICT') return t('decide_error')
|
|
150
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
151
|
+
const key = reason ? decideRefusalMessages[reason] : undefined
|
|
152
|
+
// `t()` answers a key it has no string for with the key itself, so a reason nothing is written
|
|
153
|
+
// for lands on the router's sentence rather than putting `hr.decide_refused_…` in front of a
|
|
154
|
+
// person.
|
|
155
|
+
const translated = key ? t(key) : undefined
|
|
156
|
+
return (translated && translated !== key ? translated : failure.message) || t('decide_error')
|
|
157
|
+
}
|
|
158
|
+
|
|
110
159
|
const decide = createMutation(() => ({
|
|
111
|
-
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
|
|
112
|
-
api.approvals.decide({
|
|
113
|
-
|
|
160
|
+
mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject'; comment: string }) =>
|
|
161
|
+
api.approvals.decide({
|
|
162
|
+
workspaceId,
|
|
163
|
+
requestId: vars.requestId,
|
|
164
|
+
decision: vars.decision,
|
|
165
|
+
comment: vars.comment.trim() || null,
|
|
166
|
+
}),
|
|
167
|
+
onSuccess: () => {
|
|
168
|
+
deciding = null
|
|
169
|
+
decideError = null
|
|
170
|
+
// Deciding moves a balance and a day sheet as well as the inbox, so the whole module's cache is
|
|
171
|
+
// invalidated rather than guessing which keys moved.
|
|
172
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
173
|
+
},
|
|
174
|
+
onError: (error) => {
|
|
175
|
+
decideError = decideFailure(error)
|
|
176
|
+
// A refusal is the server saying its inbox is not the one on screen, so the row behind the
|
|
177
|
+
// dialog is stale as well as the decision. Re-read all of HR exactly as a decision that landed
|
|
178
|
+
// does — without this the same dead row sits here and every retry earns the same sentence.
|
|
179
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
180
|
+
},
|
|
181
|
+
onSettled: () => {
|
|
182
|
+
submitting = false
|
|
183
|
+
},
|
|
114
184
|
}))
|
|
115
185
|
|
|
186
|
+
const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
|
|
187
|
+
decideError = null
|
|
188
|
+
deciding = { request, decision }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const confirmDecision = (comment: string) => {
|
|
192
|
+
if (!deciding || submitting) return
|
|
193
|
+
submitting = true
|
|
194
|
+
decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The same map as the approvals inbox: a card labelled "Leave" over an overtime request lies. */
|
|
198
|
+
const SUBJECT_LABELS: Record<string, () => string> = {
|
|
199
|
+
leave: () => t('leave_title'),
|
|
200
|
+
regularization: () => t('attendance_title'),
|
|
201
|
+
overtime: () => t('att_overtime'),
|
|
202
|
+
timesheet: () => t('approval_subject_timesheet'),
|
|
203
|
+
shift_swap: () => t('approval_subject_shift_swap'),
|
|
204
|
+
}
|
|
205
|
+
const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
|
|
206
|
+
|
|
116
207
|
const stats = $derived({
|
|
117
208
|
headcount: peopleQuery.data?.total ?? people.length,
|
|
118
209
|
offices: offices.length,
|
|
@@ -152,11 +243,15 @@ $effect(() => {
|
|
|
152
243
|
return () => clearInterval(handle)
|
|
153
244
|
})
|
|
154
245
|
|
|
246
|
+
/**
|
|
247
|
+
* `messageLocale()`, never the runtime default: a Persian reader gets Persian digits from `t()` in
|
|
248
|
+
* the same row, and a clock in Latin ones beside them is the one number nobody translated.
|
|
249
|
+
*/
|
|
155
250
|
function localTime(timezone: string | null, _tick: number): string | null {
|
|
156
251
|
void _tick
|
|
157
252
|
if (!timezone) return null
|
|
158
253
|
try {
|
|
159
|
-
return new Intl.DateTimeFormat(
|
|
254
|
+
return new Intl.DateTimeFormat(messageLocale(), {
|
|
160
255
|
timeZone: timezone,
|
|
161
256
|
hour: 'numeric',
|
|
162
257
|
minute: '2-digit',
|
|
@@ -168,11 +263,10 @@ function localTime(timezone: string | null, _tick: number): string | null {
|
|
|
168
263
|
}
|
|
169
264
|
|
|
170
265
|
const started = (iso: string | null) =>
|
|
171
|
-
iso
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
: '—'
|
|
266
|
+
iso ? formatDate(`${iso}T00:00:00`, { month: 'short', year: 'numeric' }) : '—'
|
|
267
|
+
|
|
268
|
+
/** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
|
|
269
|
+
const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
176
270
|
</script>
|
|
177
271
|
|
|
178
272
|
<PageHeader
|
|
@@ -189,15 +283,30 @@ const started = (iso: string | null) =>
|
|
|
189
283
|
|
|
190
284
|
<Page>
|
|
191
285
|
<div class="tiles">
|
|
192
|
-
<StatTile size="md" label={t('widget_headcount_title')} value={
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
286
|
+
<StatTile size="md" label={t('widget_headcount_title')} value={count(stats.headcount)} />
|
|
287
|
+
<!--
|
|
288
|
+
Only where the workspace has offices. It rendered unconditionally and read "Offices 0" on a
|
|
289
|
+
single-site workspace — a tile counting a feature nobody switched on, sitting beside three
|
|
290
|
+
that mean something. A capability that is off has no surface at all, tiles included.
|
|
291
|
+
-->
|
|
292
|
+
{#if showOffices}
|
|
293
|
+
<StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
|
|
294
|
+
{/if}
|
|
295
|
+
<StatTile size="md" label={t('status_on_leave')} value={count(stats.away)} />
|
|
296
|
+
<!-- Same rule for leave: the balance tile is the surface of a capability, so it goes with it. -->
|
|
297
|
+
{#if showBalance}
|
|
298
|
+
<StatTile
|
|
299
|
+
size="md"
|
|
300
|
+
label={t('available')}
|
|
301
|
+
value={formatDays(stats.balance, messageLocale())}
|
|
302
|
+
note={t('days', { count: stats.balance })}
|
|
303
|
+
/>
|
|
304
|
+
{/if}
|
|
196
305
|
</div>
|
|
197
306
|
|
|
198
307
|
<div class="split">
|
|
199
308
|
<section>
|
|
200
|
-
<SectionLabel label={t('title')} count={people.length} />
|
|
309
|
+
<SectionLabel label={t('title')} count={count(people.length)} />
|
|
201
310
|
|
|
202
311
|
<div class="filters">
|
|
203
312
|
{#if tabs.length > 1}
|
|
@@ -208,19 +317,17 @@ const started = (iso: string | null) =>
|
|
|
208
317
|
</div>
|
|
209
318
|
</div>
|
|
210
319
|
|
|
320
|
+
<!--
|
|
321
|
+
Held rows outrank the error. Every decision taken on this page invalidates all of `['hr']`,
|
|
322
|
+
so a failed background refetch leaves TanStack in `error` with the last good directory still
|
|
323
|
+
in `data` — an error branch above this one would blank a working table on a transient
|
|
324
|
+
failure. The error is the whole section only when there is nothing else to draw.
|
|
325
|
+
-->
|
|
211
326
|
{#if peopleQuery.isLoading}
|
|
212
327
|
<div class="rows">
|
|
213
328
|
{#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="48px" />{/each}
|
|
214
329
|
</div>
|
|
215
|
-
{:else if
|
|
216
|
-
<EmptyState icon="triangle-alert" title={t('people_error')}>
|
|
217
|
-
{#snippet actions()}
|
|
218
|
-
<Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('common.retry')}</Button>
|
|
219
|
-
{/snippet}
|
|
220
|
-
</EmptyState>
|
|
221
|
-
{:else if people.length === 0}
|
|
222
|
-
<EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
|
|
223
|
-
{:else}
|
|
330
|
+
{:else if people.length > 0}
|
|
224
331
|
<div class="table" role="table" aria-label={t('title')}>
|
|
225
332
|
<div class="thead" role="row">
|
|
226
333
|
<span role="columnheader">{t('title')}</span>
|
|
@@ -250,41 +357,51 @@ const started = (iso: string | null) =>
|
|
|
250
357
|
</a>
|
|
251
358
|
{/each}
|
|
252
359
|
</div>
|
|
360
|
+
{:else if peopleQuery.isError}
|
|
361
|
+
<EmptyState icon="triangle-alert" title={t('people_error')}>
|
|
362
|
+
{#snippet actions()}
|
|
363
|
+
<Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
|
|
364
|
+
{/snippet}
|
|
365
|
+
</EmptyState>
|
|
366
|
+
{:else}
|
|
367
|
+
<EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
|
|
253
368
|
{/if}
|
|
254
369
|
</section>
|
|
255
370
|
|
|
256
371
|
<aside>
|
|
257
|
-
<SectionLabel label={t('approvals_title')} count={waiting.length} />
|
|
372
|
+
<SectionLabel label={t('approvals_title')} count={formatCount(waiting.length)} />
|
|
373
|
+
<!-- Held cards outrank the error here too, and for the same reason: see the table above. -->
|
|
258
374
|
{#if inboxQuery.isLoading}
|
|
259
375
|
<Skeleton height="120px" />
|
|
260
|
-
{:else if waiting.length
|
|
261
|
-
<EmptyState bare compact icon="check-check" title={t('approvals_none')} />
|
|
262
|
-
{:else}
|
|
376
|
+
{:else if waiting.length > 0}
|
|
263
377
|
<div class="cards">
|
|
264
378
|
{#each waiting as item (item.id)}
|
|
265
379
|
<Card>
|
|
266
380
|
<div class="cardhead">
|
|
267
|
-
<Badge tone="
|
|
381
|
+
<Badge tone="grey">{subjectLabel(item.subjectType)}</Badge>
|
|
268
382
|
</div>
|
|
269
|
-
<p class="summary">{item
|
|
383
|
+
<p class="summary">{summarise(item)}</p>
|
|
270
384
|
<div class="cardactions">
|
|
271
|
-
<Button
|
|
272
|
-
|
|
273
|
-
disabled={decide.isPending}
|
|
274
|
-
onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
|
|
275
|
-
>{t('approve')}</Button
|
|
276
|
-
>
|
|
277
|
-
<Button
|
|
278
|
-
size="sm"
|
|
279
|
-
variant="secondary"
|
|
280
|
-
disabled={decide.isPending}
|
|
281
|
-
onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
|
|
282
|
-
>{t('reject')}</Button
|
|
283
|
-
>
|
|
385
|
+
<Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>{t('reject')}</Button>
|
|
386
|
+
<Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
|
|
284
387
|
</div>
|
|
285
388
|
</Card>
|
|
286
389
|
{/each}
|
|
287
390
|
</div>
|
|
391
|
+
{:else if inboxQuery.isError}
|
|
392
|
+
<!--
|
|
393
|
+
Without this the empty state below told somebody "Nothing waiting on you" when their inbox
|
|
394
|
+
had simply failed to load — the one sentence on this page nobody would think to check.
|
|
395
|
+
-->
|
|
396
|
+
<EmptyState bare compact icon="triangle-alert" title={t('approvals_error')}>
|
|
397
|
+
{#snippet actions()}
|
|
398
|
+
<Button size="sm" variant="secondary" onclick={() => void inboxQuery.refetch()}>
|
|
399
|
+
{t('retry')}
|
|
400
|
+
</Button>
|
|
401
|
+
{/snippet}
|
|
402
|
+
</EmptyState>
|
|
403
|
+
{:else}
|
|
404
|
+
<EmptyState bare compact icon="check-check" title={t('approvals_none')} />
|
|
288
405
|
{/if}
|
|
289
406
|
</aside>
|
|
290
407
|
</div>
|
|
@@ -294,6 +411,23 @@ const started = (iso: string | null) =>
|
|
|
294
411
|
<PersonPanel personId={selected} {workspaceId} {workspaceSlug} />
|
|
295
412
|
{/if}
|
|
296
413
|
|
|
414
|
+
<!--
|
|
415
|
+
The same dialog the approvals inbox uses. Rejecting somebody's leave is irreversible from the
|
|
416
|
+
interface and notifies them, so it is never one click from a sidebar card — and the confirmation
|
|
417
|
+
says what the decision does, per subject type and per position in the chain.
|
|
418
|
+
-->
|
|
419
|
+
<DecisionDialog
|
|
420
|
+
request={deciding?.request ?? null}
|
|
421
|
+
decision={deciding?.decision ?? 'approve'}
|
|
422
|
+
pending={submitting}
|
|
423
|
+
error={decideError}
|
|
424
|
+
onConfirm={confirmDecision}
|
|
425
|
+
onCancel={() => {
|
|
426
|
+
deciding = null
|
|
427
|
+
decideError = null
|
|
428
|
+
}}
|
|
429
|
+
/>
|
|
430
|
+
|
|
297
431
|
<PersonFormDialog
|
|
298
432
|
open={creating}
|
|
299
433
|
{workspaceId}
|
|
@@ -303,10 +437,13 @@ const started = (iso: string | null) =>
|
|
|
303
437
|
/>
|
|
304
438
|
|
|
305
439
|
<style>
|
|
306
|
-
/* §3.12:
|
|
440
|
+
/* §3.12: a row of stat tiles, then a 1fr / 320px split, gap 20. */
|
|
307
441
|
.tiles {
|
|
308
442
|
display: grid;
|
|
309
|
-
|
|
443
|
+
/* Not `repeat(4, …)`: the offices tile is only there when the capability is on, and a fixed
|
|
444
|
+
four-column track left a hole in the row where it used to be. */
|
|
445
|
+
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
|
446
|
+
grid-auto-flow: column;
|
|
310
447
|
gap: 12px;
|
|
311
448
|
margin-block-end: 20px;
|
|
312
449
|
}
|