@kernhq/module-hr 0.15.0 → 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/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.map +1 -1
- package/dist/server/jobs.js +44 -7
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +118 -1
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +447 -273
- package/dist/server/router.js.map +1 -1
- package/package.json +1 -1
- package/src/client/messages.ts +17 -0
- package/src/client/widgets/ApprovalsWidget.svelte +119 -8
package/package.json
CHANGED
package/src/client/messages.ts
CHANGED
|
@@ -251,6 +251,8 @@ export const en: Record<string, Message> = {
|
|
|
251
251
|
'hr.approvals_none': 'Nothing waiting on you',
|
|
252
252
|
'hr.approvals_none_desc': 'Requests needing your decision appear here.',
|
|
253
253
|
'hr.approvals_on_behalf': 'On behalf of {name}',
|
|
254
|
+
'hr.approvals_open': 'Open Approvals',
|
|
255
|
+
'hr.approvals_open_hint': 'Not one to decide from here — the approvals page shows where it stands.',
|
|
254
256
|
'hr.approvals_request': 'Request',
|
|
255
257
|
'hr.approvals_requested': 'Requested',
|
|
256
258
|
'hr.approvals_requested_by': 'Requested by',
|
|
@@ -656,6 +658,7 @@ export const en: Record<string, Message> = {
|
|
|
656
658
|
'hr.decide_behalf_error':
|
|
657
659
|
'The decision was not recorded. The delegation may have ended — reload and try again.',
|
|
658
660
|
'hr.decide_error': 'The decision could not be recorded',
|
|
661
|
+
'hr.decide_moved_error': 'This is no longer yours to decide. The list has been updated.',
|
|
659
662
|
'hr.delegate': 'Delegate',
|
|
660
663
|
'hr.delegate_add': 'Add delegation',
|
|
661
664
|
'hr.delegate_all_types': 'All requests',
|
|
@@ -1688,6 +1691,8 @@ export const ar: Record<string, Message> = {
|
|
|
1688
1691
|
'hr.approvals_none': 'لا شيء بانتظارك',
|
|
1689
1692
|
'hr.approvals_none_desc': 'تظهر هنا الطلبات التي تنتظر قرارك.',
|
|
1690
1693
|
'hr.approvals_on_behalf': 'نيابةً عن {name}',
|
|
1694
|
+
'hr.approvals_open': 'فتح الموافقات',
|
|
1695
|
+
'hr.approvals_open_hint': 'ليس مما يُبَتّ فيه من هنا — صفحة الموافقات تُظهر أين وصل هذا الطلب.',
|
|
1691
1696
|
'hr.approvals_request': 'الطلب',
|
|
1692
1697
|
'hr.approvals_requested': 'تاريخ الطلب',
|
|
1693
1698
|
'hr.approvals_requested_by': 'مقدّم الطلب',
|
|
@@ -2115,6 +2120,7 @@ export const ar: Record<string, Message> = {
|
|
|
2115
2120
|
'hr.days': { zero: 'يوم', one: 'يوم', two: 'يومان', few: 'أيام', many: 'يومًا', other: 'يوم' },
|
|
2116
2121
|
'hr.decide_behalf_error': 'لم يُسجَّل القرار. ربما انتهى التفويض — أعد تحميل الصفحة وحاول مرة أخرى.',
|
|
2117
2122
|
'hr.decide_error': 'تعذّر تسجيل القرار',
|
|
2123
|
+
'hr.decide_moved_error': 'لم يعد القرار في هذا الطلب لك. وقد حُدِّثت القائمة.',
|
|
2118
2124
|
'hr.delegate': 'تفويض',
|
|
2119
2125
|
'hr.delegate_add': 'إضافة تفويض',
|
|
2120
2126
|
'hr.delegate_all_types': 'كل الطلبات',
|
|
@@ -3172,6 +3178,9 @@ export const de: Record<string, Message> = {
|
|
|
3172
3178
|
'hr.approvals_none': 'Keine offenen Freigaben',
|
|
3173
3179
|
'hr.approvals_none_desc': 'Anträge, die auf Ihre Entscheidung warten, erscheinen hier.',
|
|
3174
3180
|
'hr.approvals_on_behalf': 'Im Namen von {name}',
|
|
3181
|
+
'hr.approvals_open': 'Freigaben öffnen',
|
|
3182
|
+
'hr.approvals_open_hint':
|
|
3183
|
+
'Von hier aus nicht zu entscheiden — die Freigabenseite zeigt, wo der Antrag steht.',
|
|
3175
3184
|
'hr.approvals_request': 'Antrag',
|
|
3176
3185
|
'hr.approvals_requested': 'Eingereicht',
|
|
3177
3186
|
'hr.approvals_requested_by': 'Beantragt von',
|
|
@@ -3595,6 +3604,7 @@ export const de: Record<string, Message> = {
|
|
|
3595
3604
|
'hr.decide_behalf_error':
|
|
3596
3605
|
'Die Entscheidung wurde nicht erfasst. Die Vertretung ist möglicherweise abgelaufen — neu laden und erneut versuchen.',
|
|
3597
3606
|
'hr.decide_error': 'Die Entscheidung konnte nicht gespeichert werden',
|
|
3607
|
+
'hr.decide_moved_error': 'Diese Entscheidung liegt nicht mehr bei Ihnen. Die Liste wurde aktualisiert.',
|
|
3598
3608
|
'hr.delegate': 'Vertretung',
|
|
3599
3609
|
'hr.delegate_add': 'Vertretung hinzufügen',
|
|
3600
3610
|
'hr.delegate_all_types': 'Alle Anträge',
|
|
@@ -4586,6 +4596,9 @@ export const fa: Record<string, Message> = {
|
|
|
4586
4596
|
'hr.approvals_none': 'چیزی منتظر تصمیم شما نیست',
|
|
4587
4597
|
'hr.approvals_none_desc': 'درخواستهایی که باید تصمیم بگیرید اینجا میآیند.',
|
|
4588
4598
|
'hr.approvals_on_behalf': 'به نمایندگی از {name}',
|
|
4599
|
+
'hr.approvals_open': 'رفتن به تأییدها',
|
|
4600
|
+
'hr.approvals_open_hint':
|
|
4601
|
+
'از اینجا نمیشود دربارهٔ آن تصمیم گرفت — صفحهٔ تأییدها نشان میدهد در چه مرحلهای است.',
|
|
4589
4602
|
'hr.approvals_request': 'درخواست',
|
|
4590
4603
|
'hr.approvals_requested': 'زمان درخواست',
|
|
4591
4604
|
'hr.approvals_requested_by': 'درخواستدهنده',
|
|
@@ -4989,6 +5002,7 @@ export const fa: Record<string, Message> = {
|
|
|
4989
5002
|
'hr.decide_behalf_error':
|
|
4990
5003
|
'تصمیم ثبت نشد. شاید واگذاری به پایان رسیده باشد — صفحه را دوباره بارگذاری کنید و دوباره تلاش کنید.',
|
|
4991
5004
|
'hr.decide_error': 'تصمیم ثبت نشد',
|
|
5005
|
+
'hr.decide_moved_error': 'تصمیم دربارهٔ این درخواست دیگر با شما نیست. فهرست بهروز شد.',
|
|
4992
5006
|
'hr.delegate': 'واگذاری',
|
|
4993
5007
|
'hr.delegate_add': 'افزودن واگذاری',
|
|
4994
5008
|
'hr.delegate_all_types': 'همهٔ درخواستها',
|
|
@@ -5956,6 +5970,8 @@ export const tr: Record<string, Message> = {
|
|
|
5956
5970
|
'hr.approvals_none': 'Sizi bekleyen bir şey yok',
|
|
5957
5971
|
'hr.approvals_none_desc': 'Kararınızı bekleyen talepler burada görünür.',
|
|
5958
5972
|
'hr.approvals_on_behalf': '{name} adına',
|
|
5973
|
+
'hr.approvals_open': 'Onayları aç',
|
|
5974
|
+
'hr.approvals_open_hint': 'Buradan karara bağlanmaz — talebin nerede olduğunu Onaylar sayfası gösterir.',
|
|
5959
5975
|
'hr.approvals_request': 'Talep',
|
|
5960
5976
|
'hr.approvals_requested': 'Talep edildi',
|
|
5961
5977
|
'hr.approvals_requested_by': 'Talep eden',
|
|
@@ -6357,6 +6373,7 @@ export const tr: Record<string, Message> = {
|
|
|
6357
6373
|
'hr.decide_behalf_error':
|
|
6358
6374
|
'Karar kaydedilmedi. Yetki devri sona ermiş olabilir — sayfayı yenileyip yeniden deneyin.',
|
|
6359
6375
|
'hr.decide_error': 'Karar kaydedilemedi',
|
|
6376
|
+
'hr.decide_moved_error': 'Bu talebin kararı artık sizde değil. Liste güncellendi.',
|
|
6360
6377
|
'hr.delegate': 'Devret',
|
|
6361
6378
|
'hr.delegate_add': 'Devir ekle',
|
|
6362
6379
|
'hr.delegate_all_types': 'Tüm talepler',
|
|
@@ -14,8 +14,16 @@ 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
|
|
|
@@ -26,6 +34,62 @@ const inboxQuery = createQuery(() => ({
|
|
|
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;
|