@kernhq/module-hr 0.14.1 → 0.16.0
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/router.d.ts +14 -2
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +11 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +51 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +5 -2
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +109 -2
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +122 -2
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +448 -274
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +85 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +32 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +150 -6
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +428 -26
- package/dist/server/services/approvals.js.map +1 -1
- package/migrations/0010_approval_timeouts.sql +28 -0
- package/migrations/meta/0010_snapshot.json +4263 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +1 -1
- package/src/client/components/DecisionDialog.svelte +99 -7
- package/src/client/messages.ts +85 -0
- package/src/client/mock.ts +256 -15
- package/src/client/pages/ApprovalsPage.svelte +256 -27
- package/src/client/pages/DirectoryPage.svelte +1 -1
- package/src/client/query.ts +2 -2
- package/src/client/widgets/ApprovalsWidget.svelte +120 -9
- package/src/contract/router.ts +11 -2
|
@@ -21,7 +21,7 @@ import DelegationDialog from '../components/DelegationDialog.svelte'
|
|
|
21
21
|
import { t } from '../i18n.js'
|
|
22
22
|
import type { ApprovalRequest } from '../index.js'
|
|
23
23
|
import { canHr, HR_CAPABILITIES } from '../permissions.js'
|
|
24
|
-
import { hrKeys } from '../query.js'
|
|
24
|
+
import { hrKeys, isoDate } from '../query.js'
|
|
25
25
|
import { summarise } from '../summary.js'
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -38,6 +38,13 @@ import { summarise } from '../summary.js'
|
|
|
38
38
|
* Who signs what is not set here — `approvals.chains.*` is a workspace policy and lives in settings.
|
|
39
39
|
* `approvals.get` is not called either: the inbox already returns each request with its steps and
|
|
40
40
|
* decisions, so a detail fetch would re-ask a question this page has the answer to.
|
|
41
|
+
*
|
|
42
|
+
* **A delegate decides here too, and the row says whose decision it is.** `approvals.inbox` already
|
|
43
|
+
* returns what somebody's delegate may act on — the engine matches a step against the reader *and*
|
|
44
|
+
* everyone who has delegated to them — but the decision was always filed as the reader's own,
|
|
45
|
+
* which the server then refused, because the reader is not on that step. Sending `onBehalfOfId` is
|
|
46
|
+
* what makes those rows decidable, and it is the one input on this page where being wrong writes
|
|
47
|
+
* the wrong name into an audit trail. So nothing is inferred that is not certain: see `describe`.
|
|
41
48
|
*/
|
|
42
49
|
const api = getHrApi()
|
|
43
50
|
const queryClient = useQueryClient()
|
|
@@ -47,12 +54,15 @@ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSl
|
|
|
47
54
|
const workspaceId = $derived(workspace?.id ?? '')
|
|
48
55
|
|
|
49
56
|
let tab = $state('waiting')
|
|
50
|
-
const
|
|
57
|
+
const inboxStatus = $derived(tab === 'decided' ? ('decided' as const) : ('pending' as const))
|
|
51
58
|
|
|
52
59
|
let deciding = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
|
|
53
60
|
let delegating = $state(false)
|
|
54
61
|
let decideError = $state<string | null>(null)
|
|
55
62
|
|
|
63
|
+
/** One person this reader may file a decision as: themselves (`null`), or somebody who delegated. */
|
|
64
|
+
type Identity = { onBehalfOfId: string | null; label: string }
|
|
65
|
+
|
|
56
66
|
/**
|
|
57
67
|
* `submitting` rather than `decide.isPending`: the disabled attribute only reaches the confirm
|
|
58
68
|
* button on the next render, so two quick clicks both fire and one request is decided twice. This
|
|
@@ -73,19 +83,183 @@ const hasChains = $derived(session.hasCapability('hr', HR_CAPABILITIES.approvals
|
|
|
73
83
|
const showDelegation = $derived(hasChains && canHr('approvalDelegate'))
|
|
74
84
|
|
|
75
85
|
const inboxQuery = createQuery(() => ({
|
|
76
|
-
queryKey: hrKeys.approvalInbox(workspaceId,
|
|
86
|
+
queryKey: hrKeys.approvalInbox(workspaceId, inboxStatus),
|
|
77
87
|
enabled: Boolean(workspaceId),
|
|
78
|
-
queryFn: () => api.approvals.inbox({ workspaceId, limit: 50,
|
|
88
|
+
queryFn: () => api.approvals.inbox({ workspaceId, limit: 50, status: inboxStatus }),
|
|
79
89
|
}))
|
|
80
90
|
const items = $derived(inboxQuery.data?.items ?? [])
|
|
81
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Which employee the reader is. No permission — `people.me` is the caller's own record, and a
|
|
94
|
+
* member who has none gets an empty inbox from the server anyway.
|
|
95
|
+
*
|
|
96
|
+
* Without it a delegated row cannot be told from one that has not reached the reader's step yet,
|
|
97
|
+
* which is why every claim below is conditional on it having arrived.
|
|
98
|
+
*/
|
|
99
|
+
const meQuery = createQuery(() => ({
|
|
100
|
+
queryKey: hrKeys.me(workspaceId),
|
|
101
|
+
enabled: Boolean(workspaceId),
|
|
102
|
+
queryFn: () => api.people.me({ workspaceId }),
|
|
103
|
+
}))
|
|
104
|
+
const myPersonId = $derived(meQuery.data?.id ?? null)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The delegations in play, from the same key `DelegationDialog` fills — revoking one there moves
|
|
108
|
+
* this page without a second request.
|
|
109
|
+
*
|
|
110
|
+
* Both halves of `showDelegation` are load-bearing: the procedure sits behind the `approvals`
|
|
111
|
+
* capability *and* `hr.approval.delegate`, and a delegation cannot exist without the capability, so
|
|
112
|
+
* a workspace with it off has nothing here to miss.
|
|
113
|
+
*/
|
|
114
|
+
const delegationsQuery = createQuery(() => ({
|
|
115
|
+
queryKey: hrKeys.delegations(workspaceId),
|
|
116
|
+
enabled: showDelegation && Boolean(workspaceId),
|
|
117
|
+
queryFn: () => api.approvals.delegations({ workspaceId }),
|
|
118
|
+
}))
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Delegations the reader holds *today*, in the reader's own zone.
|
|
122
|
+
*
|
|
123
|
+
* The server asks the same question in its zone, so the two disagree for a few hours at either end
|
|
124
|
+
* of a delegation's last day. Erring towards offering it is the right way round: the server refuses
|
|
125
|
+
* what it will not accept, and the refusal says so, whereas hiding the control leaves somebody
|
|
126
|
+
* covering a colleague with no way to act and nothing on screen to explain it.
|
|
127
|
+
*/
|
|
128
|
+
const heldToday = $derived.by(() => {
|
|
129
|
+
if (!myPersonId) return []
|
|
130
|
+
const today = isoDate()
|
|
131
|
+
return (delegationsQuery.data ?? []).filter(
|
|
132
|
+
(d) => d.toPersonId === myPersonId && d.startsOn <= today && today <= d.endsOn,
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/** Only once the list has actually arrived is silence about a delegation evidence of anything. */
|
|
137
|
+
const knowsDelegations = $derived(showDelegation && delegationsQuery.isSuccess)
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Names for the people behind the ids, and only where a name is needed.
|
|
141
|
+
*
|
|
142
|
+
* Steps and decisions carry person ids alone — `hydrateApproval` resolves the requester and nobody
|
|
143
|
+
* else — so a directory read is what turns "on behalf of 0192…" into a sentence. It is skipped
|
|
144
|
+
* entirely in the ordinary case of a reader who holds no delegation and is looking at no delegated
|
|
145
|
+
* decision, which is almost everybody, almost always.
|
|
146
|
+
*
|
|
147
|
+
* Not filtered to `active`: the colleague who handed their approvals over is exactly the one likely
|
|
148
|
+
* to be on leave, and a status filter would drop the one name this page must be able to print.
|
|
149
|
+
*/
|
|
150
|
+
const hasDelegatedDecision = $derived(
|
|
151
|
+
items.some((r) => r.steps.some((s) => (s.decisions ?? []).some((d) => d.onBehalfOfId))),
|
|
152
|
+
)
|
|
153
|
+
const needsNames = $derived(
|
|
154
|
+
canHr('personView') && Boolean(workspaceId) && (heldToday.length > 0 || hasDelegatedDecision),
|
|
155
|
+
)
|
|
156
|
+
const peopleQuery = createQuery(() => ({
|
|
157
|
+
queryKey: hrKeys.people(workspaceId, { forApprovalNames: true }),
|
|
158
|
+
enabled: needsNames,
|
|
159
|
+
queryFn: () => api.people.list({ workspaceId, limit: 200 }),
|
|
160
|
+
}))
|
|
161
|
+
const peopleById = $derived(
|
|
162
|
+
new Map((peopleQuery.data?.items ?? []).map((p) => [p.id, p.displayName] as const)),
|
|
163
|
+
)
|
|
164
|
+
/** A name, or an honest stand-in — never a raw id, which names nobody. */
|
|
165
|
+
const personName = (id: string) => peopleById.get(id) ?? t('approvals_behalf_someone')
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Who the reader may decide as on the step this request is actually on, and why they may not.
|
|
169
|
+
*
|
|
170
|
+
* The rule is that nothing is claimed without the facts a claim needs: the reader's own person id,
|
|
171
|
+
* a delegation list that has arrived, and a step that came back with its approvers. Missing any of
|
|
172
|
+
* them, the page offers exactly what it offered before delegation worked — one decision, the
|
|
173
|
+
* reader's own — and lets the server be the authority. That fallback is also what keeps the mock
|
|
174
|
+
* and any older server, whose steps carry no `approverIds`, working unchanged.
|
|
175
|
+
*
|
|
176
|
+
* Two things are deliberately excluded rather than offered and refused:
|
|
177
|
+
* - an identity that has already decided on this step, which the unique index refuses;
|
|
178
|
+
* - a delegation scoped to another subject type. The contract says `subjectType: null` delegates
|
|
179
|
+
* everything and a value delegates that one kind — **`ApprovalService.mayActFor` does not check
|
|
180
|
+
* it**, so a leave-only delegate can decide a correction today. This is the narrower of the two
|
|
181
|
+
* readings and the one the contract states; the server is where it has to be fixed.
|
|
182
|
+
*/
|
|
183
|
+
function describe(request: ApprovalRequest) {
|
|
184
|
+
const step = request.steps.find((s) => s.stepIndex === request.currentStep) ?? null
|
|
185
|
+
const approvers = step?.approverIds ?? []
|
|
186
|
+
const decisions = step?.decisions ?? []
|
|
187
|
+
const self: Identity = { onBehalfOfId: null, label: t('approvals_as_self') }
|
|
188
|
+
|
|
189
|
+
let identities: Identity[]
|
|
190
|
+
if (!myPersonId || !knowsDelegations || approvers.length === 0) identities = [self]
|
|
191
|
+
else {
|
|
192
|
+
const settled = new Set(decisions.map((d) => d.approverId))
|
|
193
|
+
identities = approvers.includes(myPersonId) && !settled.has(myPersonId) ? [self] : []
|
|
194
|
+
const delegators = new Set(
|
|
195
|
+
heldToday
|
|
196
|
+
.filter((d) => d.subjectType === null || d.subjectType === request.subjectType)
|
|
197
|
+
.map((d) => d.fromPersonId),
|
|
198
|
+
)
|
|
199
|
+
for (const id of delegators)
|
|
200
|
+
if (id !== myPersonId && approvers.includes(id) && !settled.has(id))
|
|
201
|
+
identities.push({ onBehalfOfId: id, label: personName(id) })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const only = identities.length === 1 ? identities[0]! : null
|
|
205
|
+
return {
|
|
206
|
+
identities,
|
|
207
|
+
/** The one delegation this row would be decided under, when there is exactly one and no choice. */
|
|
208
|
+
behalf: only?.onBehalfOfId ? only : null,
|
|
209
|
+
choice: identities.length > 1,
|
|
210
|
+
/**
|
|
211
|
+
* Why a pending row has no buttons. Both were an approve button that always failed: the server
|
|
212
|
+
* refuses a second decision from the same approver, and refuses anybody who is not on the step
|
|
213
|
+
* the request has reached.
|
|
214
|
+
*/
|
|
215
|
+
waiting:
|
|
216
|
+
request.status === 'pending' && identities.length === 0
|
|
217
|
+
? decisions.some((d) => d.approverId === myPersonId)
|
|
218
|
+
? ('decided' as const)
|
|
219
|
+
: ('later' as const)
|
|
220
|
+
: null,
|
|
221
|
+
/**
|
|
222
|
+
* Delegated decisions already recorded. `approverId` is whose decision it is and
|
|
223
|
+
* `onBehalfOfId` is whose hands it was — the field is named for the *input* to `decide`, which
|
|
224
|
+
* means the opposite thing, so read them from the schema rather than from the name.
|
|
225
|
+
*/
|
|
226
|
+
recorded: request.steps.flatMap((s) =>
|
|
227
|
+
(s.decisions ?? [])
|
|
228
|
+
.filter((d) => d.onBehalfOfId)
|
|
229
|
+
.map((d) => ({ actor: personName(d.onBehalfOfId as string), person: personName(d.approverId) })),
|
|
230
|
+
),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const rows = $derived(items.map((item) => ({ item, ...describe(item) })))
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The open dialog's view of the request, from the live row where there still is one.
|
|
238
|
+
*
|
|
239
|
+
* Derived rather than captured at the click, so a name or a delegation arriving while the dialog is
|
|
240
|
+
* open reaches it. It falls back to the snapshot the click carried because a refused decision
|
|
241
|
+
* re-reads the inbox on purpose, and a row that came back decided by somebody else must not take
|
|
242
|
+
* the refusal's sentence off the screen with it.
|
|
243
|
+
*/
|
|
244
|
+
const decidingView = $derived.by(() => {
|
|
245
|
+
if (!deciding) return null
|
|
246
|
+
const open = deciding.request
|
|
247
|
+
return rows.find((r) => r.item.id === open.id) ?? describe(open)
|
|
248
|
+
})
|
|
249
|
+
|
|
82
250
|
const decide = createMutation(() => ({
|
|
83
|
-
mutationFn: (vars: {
|
|
251
|
+
mutationFn: (vars: {
|
|
252
|
+
requestId: string
|
|
253
|
+
decision: 'approve' | 'reject'
|
|
254
|
+
comment: string
|
|
255
|
+
onBehalfOfId: string | null
|
|
256
|
+
}) =>
|
|
84
257
|
api.approvals.decide({
|
|
85
258
|
workspaceId,
|
|
86
259
|
requestId: vars.requestId,
|
|
87
260
|
decision: vars.decision,
|
|
88
261
|
comment: vars.comment.trim() || null,
|
|
262
|
+
onBehalfOfId: vars.onBehalfOfId,
|
|
89
263
|
}),
|
|
90
264
|
onSuccess: () => {
|
|
91
265
|
deciding = null
|
|
@@ -94,8 +268,8 @@ const decide = createMutation(() => ({
|
|
|
94
268
|
// is invalidated rather than guessing which keys moved.
|
|
95
269
|
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
96
270
|
},
|
|
97
|
-
onError: (error) => {
|
|
98
|
-
decideError = decideFailure(error)
|
|
271
|
+
onError: (error, vars) => {
|
|
272
|
+
decideError = decideFailure(error, vars.onBehalfOfId !== null)
|
|
99
273
|
// A refusal is the server saying its inbox is not the one on screen, so the row behind the
|
|
100
274
|
// dialog is stale as well as the decision. Re-read all of HR exactly as a decision that landed
|
|
101
275
|
// does — without this the same dead row sits in the table and every retry earns the same
|
|
@@ -132,9 +306,16 @@ const decideRefusalMessages: Record<string, string> = {}
|
|
|
132
306
|
* The test is the transport's `code`, never the sentence: `KernError.conflict` is what arrives as
|
|
133
307
|
* CONFLICT, so a refusal added to `decide()` later reaches the reader without anyone editing this
|
|
134
308
|
* file. The same shape as `ClockControls.svelte` and the approvals widget.
|
|
309
|
+
*
|
|
310
|
+
* A delegated decision has one refusal worth naming, and it is a FORBIDDEN rather than a conflict:
|
|
311
|
+
* the delegation the reader was acting under is not one the server can see — usually because it
|
|
312
|
+
* ended between the page loading and the click. `delegated` is what this client *sent*, not a
|
|
313
|
+
* sentence parsed back out of the server, so the reader gets a translated instruction instead of
|
|
314
|
+
* "The decision could not be recorded", which tells somebody covering a colleague nothing.
|
|
135
315
|
*/
|
|
136
|
-
function decideFailure(error: unknown): string {
|
|
316
|
+
function decideFailure(error: unknown, delegated: boolean): string {
|
|
137
317
|
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
318
|
+
if (delegated && failure.code === 'FORBIDDEN') return t('decide_behalf_error')
|
|
138
319
|
if (failure.code !== 'CONFLICT') return t('decide_error')
|
|
139
320
|
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
140
321
|
const key = reason ? decideRefusalMessages[reason] : undefined
|
|
@@ -150,10 +331,15 @@ const ask = (request: ApprovalRequest, decision: 'approve' | 'reject') => {
|
|
|
150
331
|
deciding = { request, decision }
|
|
151
332
|
}
|
|
152
333
|
|
|
153
|
-
|
|
334
|
+
/**
|
|
335
|
+
* `onBehalfOfId` comes from the dialog, which will not confirm until it has one it can name. A
|
|
336
|
+
* decision filed against the wrong person is the worst thing this screen can do, so the identity
|
|
337
|
+
* travels with the click rather than being read back out of state here.
|
|
338
|
+
*/
|
|
339
|
+
const confirmDecision = (comment: string, onBehalfOfId: string | null) => {
|
|
154
340
|
if (!deciding || submitting) return
|
|
155
341
|
submitting = true
|
|
156
|
-
decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment })
|
|
342
|
+
decide.mutate({ requestId: deciding.request.id, decision: deciding.decision, comment, onBehalfOfId })
|
|
157
343
|
}
|
|
158
344
|
|
|
159
345
|
const SUBJECT_LABELS: Record<string, () => string> = {
|
|
@@ -209,7 +395,7 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
209
395
|
</div>
|
|
210
396
|
|
|
211
397
|
<SectionLabel
|
|
212
|
-
label={
|
|
398
|
+
label={inboxStatus === 'decided' ? t('approvals_decided') : t('approvals_waiting')}
|
|
213
399
|
count={formatCount(items.length, 999)}
|
|
214
400
|
/>
|
|
215
401
|
|
|
@@ -229,32 +415,55 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
229
415
|
<span role="columnheader">{t('approvals_request')}</span>
|
|
230
416
|
<span role="columnheader">{t('approvals_requested_by')}</span>
|
|
231
417
|
<span role="columnheader">{t('approvals_requested')}</span>
|
|
232
|
-
<span role="columnheader">{
|
|
418
|
+
<span role="columnheader">{inboxStatus === 'decided' ? t('status') : t('approvals_step')}</span>
|
|
233
419
|
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
234
420
|
</div>
|
|
235
|
-
{#each
|
|
421
|
+
{#each rows as row (row.item.id)}
|
|
236
422
|
<div class="trow" role="row">
|
|
237
423
|
<span class="cell what" role="cell">
|
|
238
|
-
<
|
|
239
|
-
|
|
424
|
+
<span class="line">
|
|
425
|
+
<Badge tone="grey">{subjectLabel(row.item.subjectType)}</Badge>
|
|
426
|
+
<span class="summary">{summarise(row.item)}</span>
|
|
427
|
+
</span>
|
|
428
|
+
<!--
|
|
429
|
+
Whose decision this is, on the row rather than only in the dialog: somebody clearing an
|
|
430
|
+
inbox of six rows should not have to open one to find out that two of them are a
|
|
431
|
+
colleague's. A pending row says what the click would file; a decided one says what was
|
|
432
|
+
filed, which is the other half of the same promise.
|
|
433
|
+
-->
|
|
434
|
+
{#if row.behalf}
|
|
435
|
+
<Badge tone="info">{t('approvals_on_behalf', { name: row.behalf.label })}</Badge>
|
|
436
|
+
{:else if row.choice}
|
|
437
|
+
<Badge tone="info">{t('approvals_behalf_pick')}</Badge>
|
|
438
|
+
{:else if row.recorded.length > 0}
|
|
439
|
+
{#each row.recorded as entry, i (i)}
|
|
440
|
+
<span class="behalf">
|
|
441
|
+
{t('approvals_decided_behalf', { actor: entry.actor, person: entry.person })}
|
|
442
|
+
</span>
|
|
443
|
+
{/each}
|
|
444
|
+
{/if}
|
|
240
445
|
</span>
|
|
241
|
-
<span class="cell muted" role="cell">{item.requesterName ?? '—'}</span>
|
|
242
|
-
<span class="cell muted" role="cell">{formatDateTime(item.requestedAt)}</span>
|
|
446
|
+
<span class="cell muted" role="cell">{row.item.requesterName ?? '—'}</span>
|
|
447
|
+
<span class="cell muted" role="cell">{formatDateTime(row.item.requestedAt)}</span>
|
|
243
448
|
<span class="cell" role="cell">
|
|
244
|
-
{#if
|
|
245
|
-
<Badge tone={STATUS_TONES[item.status] ?? 'grey'}>
|
|
246
|
-
{STATUS_LABELS[item.status]?.() ?? item.status}
|
|
449
|
+
{#if inboxStatus === 'decided'}
|
|
450
|
+
<Badge tone={STATUS_TONES[row.item.status] ?? 'grey'}>
|
|
451
|
+
{STATUS_LABELS[row.item.status]?.() ?? row.item.status}
|
|
247
452
|
</Badge>
|
|
248
|
-
{:else if item.steps.length > 1}
|
|
249
|
-
<span class="muted">{stepOf(item)}</span>
|
|
453
|
+
{:else if row.item.steps.length > 1}
|
|
454
|
+
<span class="muted">{stepOf(row.item)}</span>
|
|
250
455
|
{/if}
|
|
251
456
|
</span>
|
|
252
457
|
<span class="cell actions" role="cell">
|
|
253
|
-
{#if item.status === 'pending'}
|
|
254
|
-
<Button size="sm" variant="secondary" onclick={() => ask(item, 'reject')}>
|
|
458
|
+
{#if row.item.status === 'pending' && row.identities.length > 0}
|
|
459
|
+
<Button size="sm" variant="secondary" onclick={() => ask(row.item, 'reject')}>
|
|
255
460
|
{t('reject')}
|
|
256
461
|
</Button>
|
|
257
|
-
<Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
|
|
462
|
+
<Button size="sm" onclick={() => ask(row.item, 'approve')}>{t('approve')}</Button>
|
|
463
|
+
{:else if row.waiting}
|
|
464
|
+
<span class="muted">
|
|
465
|
+
{row.waiting === 'decided' ? t('approvals_you_decided') : t('approvals_later_step')}
|
|
466
|
+
</span>
|
|
258
467
|
{/if}
|
|
259
468
|
</span>
|
|
260
469
|
</div>
|
|
@@ -269,8 +478,8 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
269
478
|
{:else}
|
|
270
479
|
<EmptyState
|
|
271
480
|
icon="check-check"
|
|
272
|
-
title={
|
|
273
|
-
description={
|
|
481
|
+
title={inboxStatus === 'decided' ? t('approvals_decided_none') : t('approvals_none')}
|
|
482
|
+
description={inboxStatus === 'decided' ? t('approvals_decided_none_desc') : t('approvals_none_desc')}
|
|
274
483
|
/>
|
|
275
484
|
{/if}
|
|
276
485
|
|
|
@@ -287,6 +496,7 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
287
496
|
<DecisionDialog
|
|
288
497
|
request={deciding?.request ?? null}
|
|
289
498
|
decision={deciding?.decision ?? 'approve'}
|
|
499
|
+
identities={decidingView?.identities ?? []}
|
|
290
500
|
pending={submitting}
|
|
291
501
|
error={decideError}
|
|
292
502
|
onConfirm={confirmDecision}
|
|
@@ -349,10 +559,29 @@ const stepOf = (request: ApprovalRequest) =>
|
|
|
349
559
|
text-overflow: ellipsis;
|
|
350
560
|
white-space: nowrap;
|
|
351
561
|
}
|
|
562
|
+
/*
|
|
563
|
+
A column, not a row: the subject and its sentence sit on one line and the delegation note under
|
|
564
|
+
it, so a name long enough to matter cannot push the summary out of the cell.
|
|
565
|
+
*/
|
|
352
566
|
.what {
|
|
567
|
+
display: flex;
|
|
568
|
+
flex-direction: column;
|
|
569
|
+
justify-content: center;
|
|
570
|
+
align-items: flex-start;
|
|
571
|
+
gap: 4px;
|
|
572
|
+
}
|
|
573
|
+
.line {
|
|
353
574
|
display: flex;
|
|
354
575
|
align-items: center;
|
|
355
576
|
gap: 8px;
|
|
577
|
+
min-width: 0;
|
|
578
|
+
max-width: 100%;
|
|
579
|
+
overflow: hidden;
|
|
580
|
+
}
|
|
581
|
+
.behalf {
|
|
582
|
+
font-size: 12px;
|
|
583
|
+
/* A colour, not opacity — this line names a person and has to stay readable. */
|
|
584
|
+
color: var(--kern-ink-500);
|
|
356
585
|
}
|
|
357
586
|
.summary {
|
|
358
587
|
min-width: 0;
|
|
@@ -276,7 +276,7 @@ const balancesQuery = createQuery(() => ({
|
|
|
276
276
|
const inboxQuery = createQuery(() => ({
|
|
277
277
|
queryKey: hrKeys.approvalInbox(workspaceId),
|
|
278
278
|
enabled: Boolean(workspaceId),
|
|
279
|
-
queryFn: () => api.approvals.inbox({ workspaceId, limit: 6,
|
|
279
|
+
queryFn: () => api.approvals.inbox({ workspaceId, limit: 6, status: 'pending' }),
|
|
280
280
|
}))
|
|
281
281
|
const waiting = $derived(inboxQuery.data?.items ?? [])
|
|
282
282
|
|
package/src/client/query.ts
CHANGED
|
@@ -35,8 +35,8 @@ export const hrKeys = {
|
|
|
35
35
|
* pending on, the decided one is history — so sharing a key would show one tab the other's
|
|
36
36
|
* contents for as long as the refetch takes.
|
|
37
37
|
*/
|
|
38
|
-
approvalInbox: (ws: string,
|
|
39
|
-
['hr', 'approvals', ws,
|
|
38
|
+
approvalInbox: (ws: string, status: 'pending' | 'decided' = 'pending') =>
|
|
39
|
+
['hr', 'approvals', ws, status] as const,
|
|
40
40
|
delegations: (ws: string) => ['hr', 'delegations', ws] as const,
|
|
41
41
|
calendar: (ws: string, id: string) => ['hr', 'calendar', ws, id] as const,
|
|
42
42
|
calendarWorkingDays: (ws: string, calendarId: string, from: string, to: string) =>
|
|
@@ -14,18 +14,82 @@ import { summarise } from '../summary.js'
|
|
|
14
14
|
* Acting on a row rather than linking away from it: the whole value of this card is approving three
|
|
15
15
|
* leave requests without leaving the dashboard, and a card that only counts them is a link with
|
|
16
16
|
* extra steps.
|
|
17
|
+
*
|
|
18
|
+
* **A button here only where the decision is certainly the reader's own.** `approvals.inbox` also
|
|
19
|
+
* returns rows the reader may decide *as somebody's delegate*, and rows resting on a step further
|
|
20
|
+
* down a chain they are named on. `approvals.decide` refuses both when the decision is filed as the
|
|
21
|
+
* reader — which is what every approve button on this card did to them — so neither gets one. Which
|
|
22
|
+
* of the two a row is, and whose name the decision would carry, is what the approvals page has the
|
|
23
|
+
* queries and the room to say; this card sends the reader there rather than guessing. See
|
|
24
|
+
* `actionFor`.
|
|
17
25
|
*/
|
|
18
|
-
const { workspaceId, editing }: WidgetProps = $props()
|
|
26
|
+
const { workspaceId, workspaceSlug, editing }: WidgetProps = $props()
|
|
19
27
|
const api = getHrApi()
|
|
20
28
|
const queryClient = useQueryClient()
|
|
21
29
|
|
|
22
30
|
const inboxQuery = createQuery(() => ({
|
|
23
31
|
queryKey: hrKeys.approvalInbox(workspaceId),
|
|
24
32
|
enabled: Boolean(workspaceId),
|
|
25
|
-
queryFn: () => api.approvals.inbox({ workspaceId, limit: 5,
|
|
33
|
+
queryFn: () => api.approvals.inbox({ workspaceId, limit: 5, status: 'pending' }),
|
|
26
34
|
}))
|
|
27
35
|
const items = $derived(inboxQuery.data?.items ?? [])
|
|
28
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Which employee the reader is — the one fact that separates a row this card may decide from one it
|
|
39
|
+
* may only point at.
|
|
40
|
+
*
|
|
41
|
+
* No permission: `people.me` is the caller's own record, and somebody with none gets an empty inbox
|
|
42
|
+
* from the server anyway. The same key the approvals page fills, so opening one warms the other.
|
|
43
|
+
*/
|
|
44
|
+
const meQuery = createQuery(() => ({
|
|
45
|
+
queryKey: hrKeys.me(workspaceId),
|
|
46
|
+
enabled: Boolean(workspaceId),
|
|
47
|
+
queryFn: () => api.people.me({ workspaceId }),
|
|
48
|
+
}))
|
|
49
|
+
const myPersonId = $derived(meQuery.data?.id ?? null)
|
|
50
|
+
|
|
51
|
+
/** What this card may offer on a row. */
|
|
52
|
+
type RowAction =
|
|
53
|
+
/** The reader's own decision, on the step the request has reached. */
|
|
54
|
+
| 'decide'
|
|
55
|
+
/** Theirs, and already made — a step still collecting its other approvers. */
|
|
56
|
+
| 'decided'
|
|
57
|
+
/** Not the reader's own: whose it is, and whether it can be filed at all, belongs to the page. */
|
|
58
|
+
| 'elsewhere'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether this card may file the decision on a row, and never as whom.
|
|
62
|
+
*
|
|
63
|
+
* A reduced form of `describe()` in `pages/ApprovalsPage.svelte`, which stays the authority: that
|
|
64
|
+
* one derives every identity the reader may file as — themselves, and each colleague who delegated
|
|
65
|
+
* to them — out of `people.me`, the live delegations and the step's `approverIds`, and hands them to
|
|
66
|
+
* `DecisionDialog` to be stated or chosen. This asks only the half a dashboard card can answer
|
|
67
|
+
* honestly on one line: **is this decision the reader's own?**
|
|
68
|
+
*
|
|
69
|
+
* The other half is deliberately not asked here. Naming a delegator needs the delegations list —
|
|
70
|
+
* behind the `approvals` capability *and* `hr.approval.delegate`, so a reader holding a delegation
|
|
71
|
+
* but not that key would learn nothing and be offered a decision as themselves, which is the refusal
|
|
72
|
+
* this file exists to stop — plus a directory read for the name, and then a second line to print it
|
|
73
|
+
* on, in a card whose smallest size is one 43px row. A decision filed under a person the reader was
|
|
74
|
+
* never shown is the worst thing this module can do, so a row this card cannot name is a link.
|
|
75
|
+
*
|
|
76
|
+
* The fallback is the page's, for the same reason: with no `myPersonId` yet, or against a server
|
|
77
|
+
* whose steps carry no `approverIds`, nothing is claimed and the card offers exactly what it offered
|
|
78
|
+
* before — the reader's own decision, with the server as the authority.
|
|
79
|
+
*/
|
|
80
|
+
function actionFor(request: ApprovalRequest): RowAction {
|
|
81
|
+
const step = request.steps.find((s) => s.stepIndex === request.currentStep) ?? null
|
|
82
|
+
const approvers = step?.approverIds ?? []
|
|
83
|
+
if (!myPersonId || approvers.length === 0) return 'decide'
|
|
84
|
+
if (!approvers.includes(myPersonId)) return 'elsewhere'
|
|
85
|
+
// The unique index on `(step_id, approver_id)` refuses a second decision from the same person, so
|
|
86
|
+
// an approve button on a step this reader has already settled is one that can only fail. It is a
|
|
87
|
+
// real row: an `all` step stays pending until everybody named on it has answered.
|
|
88
|
+
return (step?.decisions ?? []).some((d) => d.approverId === myPersonId) ? 'decided' : 'decide'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rows = $derived(items.map((item) => ({ item, action: actionFor(item) })))
|
|
92
|
+
|
|
29
93
|
let asked = $state<{ request: ApprovalRequest; decision: 'approve' | 'reject' } | null>(null)
|
|
30
94
|
let decideError = $state<string | null>(null)
|
|
31
95
|
|
|
@@ -43,6 +107,10 @@ const decide = createMutation(() => ({
|
|
|
43
107
|
requestId: vars.requestId,
|
|
44
108
|
decision: vars.decision,
|
|
45
109
|
comment: vars.comment.trim() || null,
|
|
110
|
+
// Stated rather than left out. The field is nullish, so both reach the server the same way —
|
|
111
|
+
// but this card offers no other identity, and `null` is that promise written where the call
|
|
112
|
+
// is made rather than inferred from the absence of a line.
|
|
113
|
+
onBehalfOfId: null,
|
|
46
114
|
}),
|
|
47
115
|
onSuccess: () => {
|
|
48
116
|
asked = null
|
|
@@ -79,9 +147,17 @@ const decideRefusalMessages: Record<string, string> = {}
|
|
|
79
147
|
* those happened, so it is repeated verbatim. Everything else that can fail carries machine text in
|
|
80
148
|
* English, so it falls back to this module's own string. The test is the transport's `code`, never
|
|
81
149
|
* the sentence.
|
|
150
|
+
*
|
|
151
|
+
* FORBIDDEN has its own, because this card no longer offers a decision that earns one by design:
|
|
152
|
+
* `actionFor` keeps the buttons on the steps the reader is named on, so a refusal means the step
|
|
153
|
+
* moved between the card being drawn and the click. The router's words for it — "You are not an
|
|
154
|
+
* approver on this step" — are machine text in English, and the only other FORBIDDEN `decide` can
|
|
155
|
+
* raise is for a caller with no employee record, whose inbox is empty and who therefore has nothing
|
|
156
|
+
* on this card to click.
|
|
82
157
|
*/
|
|
83
158
|
function decideFailure(error: unknown): string {
|
|
84
159
|
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
160
|
+
if (failure.code === 'FORBIDDEN') return t('decide_moved_error')
|
|
85
161
|
if (failure.code !== 'CONFLICT') return t('decide_error')
|
|
86
162
|
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
87
163
|
const key = reason ? decideRefusalMessages[reason] : undefined
|
|
@@ -107,25 +183,49 @@ const confirmDecision = (comment: string) => {
|
|
|
107
183
|
while `data` is still the last good inbox — an error branch above this one would blank a working
|
|
108
184
|
card, and take its approve buttons with it, on a transient failure. The error is only the whole
|
|
109
185
|
card when there is nothing else to draw.
|
|
186
|
+
|
|
187
|
+
`people.me` is waited for alongside the inbox, though. It decides which rows get buttons, and
|
|
188
|
+
drawing an approve button on a colleague's row for one frame and then taking it away is worse than
|
|
189
|
+
a skeleton that lasts as long — the two queries go out together, so it costs nothing. A `me` that
|
|
190
|
+
*fails* leaves `myPersonId` null, which is the fallback `actionFor` documents rather than a card
|
|
191
|
+
stuck loading.
|
|
110
192
|
-->
|
|
111
|
-
{#if inboxQuery.isLoading}
|
|
193
|
+
{#if inboxQuery.isLoading || meQuery.isLoading}
|
|
112
194
|
<Skeleton height="96px" />
|
|
113
|
-
{:else if
|
|
195
|
+
{:else if rows.length > 0}
|
|
114
196
|
<ul>
|
|
115
|
-
{#each
|
|
197
|
+
{#each rows as row (row.item.id)}
|
|
116
198
|
<li>
|
|
117
|
-
<span class="summary">{summarise(item)}</span>
|
|
199
|
+
<span class="summary">{summarise(row.item)}</span>
|
|
118
200
|
<!-- Row actions go while the grid is being rearranged: the data stays, the buttons do not. -->
|
|
119
201
|
{#if editing}
|
|
120
202
|
<Badge tone="upcoming">{t('leave_pending')}</Badge>
|
|
121
|
-
{:else}
|
|
203
|
+
{:else if row.action === 'decide'}
|
|
122
204
|
<!--
|
|
123
205
|
Never straight to `decide.mutate`: rejecting somebody's leave is irreversible from the
|
|
124
206
|
interface and notifies them, and a dashboard card is the easiest place in the product to
|
|
125
207
|
hit the wrong button. The dialog says what the decision does and to whom.
|
|
126
208
|
-->
|
|
127
|
-
<Button size="sm" variant="ghost" onclick={() => ask(item, 'reject')}>{t('reject')}</Button>
|
|
128
|
-
<Button size="sm" onclick={() => ask(item, 'approve')}>{t('approve')}</Button>
|
|
209
|
+
<Button size="sm" variant="ghost" onclick={() => ask(row.item, 'reject')}>{t('reject')}</Button>
|
|
210
|
+
<Button size="sm" onclick={() => ask(row.item, 'approve')}>{t('approve')}</Button>
|
|
211
|
+
{:else if row.action === 'decided'}
|
|
212
|
+
<span class="note">{t('approvals_you_decided')}</span>
|
|
213
|
+
{:else}
|
|
214
|
+
<!--
|
|
215
|
+
A link, not a button. This row is either a colleague's decision the reader holds by
|
|
216
|
+
delegation or a step the request has not reached them on, and the card cannot tell which
|
|
217
|
+
without the queries the approvals page makes — so it offers the one thing that is true
|
|
218
|
+
either way. The label carries it: an icon-only arrow here would be a control a screen
|
|
219
|
+
reader announces as "link" and nothing more.
|
|
220
|
+
-->
|
|
221
|
+
<Button
|
|
222
|
+
size="sm"
|
|
223
|
+
variant="ghost"
|
|
224
|
+
href={`/${workspaceSlug}/hr/approvals`}
|
|
225
|
+
title={t('approvals_open_hint')}
|
|
226
|
+
>
|
|
227
|
+
{t('approvals_open')}
|
|
228
|
+
</Button>
|
|
129
229
|
{/if}
|
|
130
230
|
</li>
|
|
131
231
|
{/each}
|
|
@@ -182,6 +282,17 @@ li {
|
|
|
182
282
|
white-space: nowrap;
|
|
183
283
|
font-size: 12px;
|
|
184
284
|
}
|
|
285
|
+
/*
|
|
286
|
+
Where a row's buttons would have been. `--kern-ink-600` rather than the `--kern-ink-500` the
|
|
287
|
+
approvals page mutes with: this sits on the card surface, which is the pair already measured for
|
|
288
|
+
`.msg` below — 9.86:1 in light, 8.96:1 in dark. Nowrap because the row is one line at every size
|
|
289
|
+
the card declares, and a wrapped word here is what pushes an `s` card past its 43px body.
|
|
290
|
+
*/
|
|
291
|
+
.note {
|
|
292
|
+
font-size: 12px;
|
|
293
|
+
white-space: nowrap;
|
|
294
|
+
color: var(--kern-ink-600);
|
|
295
|
+
}
|
|
185
296
|
.failed {
|
|
186
297
|
display: flex;
|
|
187
298
|
align-items: center;
|
package/src/contract/router.ts
CHANGED
|
@@ -1132,10 +1132,19 @@ export const hrContract = {
|
|
|
1132
1132
|
|
|
1133
1133
|
// ---------------------------------------------------------------- approvals
|
|
1134
1134
|
approvals: {
|
|
1135
|
-
/**
|
|
1135
|
+
/**
|
|
1136
|
+
* Everything waiting on the caller, across every subject type — or everything they have
|
|
1137
|
+
* already settled.
|
|
1138
|
+
*
|
|
1139
|
+
* `status` rather than the `includeDecided` boolean it replaces. That flag was *inclusive*
|
|
1140
|
+
* ("also give me the decided ones") while every caller used it as an exclusive two-tab switch,
|
|
1141
|
+
* so the screen's "Decided" tab asked for decided-as-well and got pending rows listed under a
|
|
1142
|
+
* heading that said somebody had decided them. Both halves read correctly on their own, which
|
|
1143
|
+
* is why it survived: an enum makes the two tabs exactly what they say.
|
|
1144
|
+
*/
|
|
1136
1145
|
inbox: baseContract
|
|
1137
1146
|
.route({ method: 'GET', path: '/approvals/inbox', tags: t })
|
|
1138
|
-
.input(ws.extend({ ...PageInput.shape,
|
|
1147
|
+
.input(ws.extend({ ...PageInput.shape, status: z.enum(['pending', 'decided']).default('pending') }))
|
|
1139
1148
|
.output(page(ApprovalRequest)),
|
|
1140
1149
|
get: baseContract
|
|
1141
1150
|
.route({ method: 'GET', path: '/approvals/{requestId}', tags: t })
|