@kernhq/module-hr 0.10.0 → 0.10.2
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/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.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 +1 -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 +489 -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 +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- 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 +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- 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 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- 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 +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- 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 +14 -7
- 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/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
EmptyState,
|
|
6
|
+
Field,
|
|
7
|
+
Input,
|
|
8
|
+
navigation,
|
|
9
|
+
Select,
|
|
10
|
+
Skeleton,
|
|
11
|
+
Textarea,
|
|
12
|
+
toast,
|
|
13
|
+
} from '@kernhq/ui'
|
|
3
14
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
15
|
import { getHrApi } from '../api-instance.js'
|
|
5
16
|
import { t } from '../i18n.js'
|
|
6
17
|
import { canHr } from '../permissions.js'
|
|
7
|
-
import {
|
|
18
|
+
import { hrKeys, isoDate } from '../query.js'
|
|
8
19
|
|
|
9
20
|
/**
|
|
10
21
|
* Book time off.
|
|
@@ -41,11 +52,26 @@ const typesQuery = createQuery(() => ({
|
|
|
41
52
|
}))
|
|
42
53
|
const types = $derived(typesQuery.data ?? [])
|
|
43
54
|
const typeOptions = $derived(types.map((type) => ({ value: type.id, label: type.name })))
|
|
55
|
+
const selectedType = $derived(types.find((type) => type.id === leaveTypeId))
|
|
44
56
|
|
|
45
57
|
$effect(() => {
|
|
46
58
|
if (open && !leaveTypeId && types[0]) leaveTypeId = types[0].id
|
|
47
59
|
})
|
|
48
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The balance, from the same key the time-off page reads.
|
|
63
|
+
*
|
|
64
|
+
* Not a second opinion: `available` is the server's own figure in the type's own unit, and it is
|
|
65
|
+
* the number that page already shows — so opening this form costs nothing and the two screens
|
|
66
|
+
* cannot disagree.
|
|
67
|
+
*/
|
|
68
|
+
const balanceQuery = createQuery(() => ({
|
|
69
|
+
queryKey: hrKeys.leaveBalance(workspaceId, undefined),
|
|
70
|
+
enabled: Boolean(workspaceId) && open,
|
|
71
|
+
queryFn: () => api.leave.balance.get({ workspaceId }),
|
|
72
|
+
}))
|
|
73
|
+
const balance = $derived(balanceQuery.data?.find((row) => row.leaveTypeId === leaveTypeId))
|
|
74
|
+
|
|
49
75
|
const simQuery = createQuery(() => ({
|
|
50
76
|
queryKey: ['hr', 'leave-sim', workspaceId, leaveTypeId, startsOn, endsOn] as const,
|
|
51
77
|
enabled: Boolean(workspaceId && leaveTypeId && startsOn && endsOn && open),
|
|
@@ -58,13 +84,105 @@ const simQuery = createQuery(() => ({
|
|
|
58
84
|
}),
|
|
59
85
|
}))
|
|
60
86
|
const sim = $derived(simQuery.data)
|
|
61
|
-
const
|
|
87
|
+
const blockers = $derived(sim?.blockers ?? [])
|
|
88
|
+
const blocked = $derived(blockers.length > 0)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What this would cost, in the unit the server reports the balance in.
|
|
92
|
+
*
|
|
93
|
+
* Both halves are the server's own numbers and both scale with whatever a working day is worth, so
|
|
94
|
+
* subtracting one from the other stays right the day that stops being eight hours. Dividing
|
|
95
|
+
* `balanceAfterMinutes` by a hard 480 here did not: it disagreed with the same figure on the
|
|
96
|
+
* time-off page, which has always shown `available`.
|
|
97
|
+
*/
|
|
98
|
+
const cost = $derived(sim && balance ? (balance.unit === 'hour' ? sim.minutes / 60 : sim.workingDays) : null)
|
|
99
|
+
const after = $derived(balance && cost !== null ? Math.round((balance.available - cost) * 100) / 100 : null)
|
|
100
|
+
|
|
101
|
+
const availableLabel = $derived(
|
|
102
|
+
balance
|
|
103
|
+
? balance.unit === 'hour'
|
|
104
|
+
? t('leave_available_now_hours', { count: balance.available })
|
|
105
|
+
: t('leave_available_now', { count: balance.available })
|
|
106
|
+
: null,
|
|
107
|
+
)
|
|
108
|
+
const afterLabel = $derived(
|
|
109
|
+
balance && after !== null
|
|
110
|
+
? balance.unit === 'hour'
|
|
111
|
+
? t('leave_after_hours', { count: after })
|
|
112
|
+
: t('leave_after', { count: after })
|
|
113
|
+
: null,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The cost, and the one other number worth having beside it.
|
|
118
|
+
*
|
|
119
|
+
* Blocked, that is what they hold — "not enough" is only answerable next to it. Clear, it is what
|
|
120
|
+
* would be left. Neither appears until the balance has arrived: a figure this form worked out for
|
|
121
|
+
* itself is how the two screens came to disagree in the first place.
|
|
122
|
+
*/
|
|
123
|
+
const costLine = $derived.by(() => {
|
|
124
|
+
if (!sim) return null
|
|
125
|
+
const spend = t('leave_would_cost', { count: sim.workingDays })
|
|
126
|
+
const tail = blocked ? availableLabel : afterLabel
|
|
127
|
+
return tail ? `${spend} · ${tail}` : spend
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A blocker's `code`, as this module's own sentence.
|
|
132
|
+
*
|
|
133
|
+
* Keyed by the code `simulate` puts beside its prose for exactly this, never by the sentence: a
|
|
134
|
+
* list of sentences is a list somebody has to keep in sync, and the day it drifts the reader is
|
|
135
|
+
* told nothing. Every blocker is drawn, not the first — fixing the one on screen used to reveal a
|
|
136
|
+
* second the form had never mentioned.
|
|
137
|
+
*/
|
|
138
|
+
const BLOCKER_KEYS: Record<string, string> = {
|
|
139
|
+
range: 'hr.leave_blocked_range',
|
|
140
|
+
archived: 'hr.leave_blocked_archived',
|
|
141
|
+
empty: 'hr.leave_blocked_empty',
|
|
142
|
+
insufficient: 'hr.leave_blocked_insufficient',
|
|
143
|
+
below_floor: 'hr.leave_blocked_below_floor',
|
|
144
|
+
overlap: 'hr.leave_blocked_overlap',
|
|
145
|
+
document_required: 'hr.leave_blocked_document_required',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Four of these name the leave type and one counts the days before a document is needed, and both
|
|
150
|
+
* facts live on the type this form is holding. `null` means the fact is not here — the type was
|
|
151
|
+
* archived out of the list between opening the form and simulating — and a sentence with `{type}`
|
|
152
|
+
* still in it is worse than the server's own.
|
|
153
|
+
*/
|
|
154
|
+
function blockerParams(code: string): Record<string, string | number> | undefined | null {
|
|
155
|
+
if (code === 'range' || code === 'empty' || code === 'overlap') return undefined
|
|
156
|
+
const name = selectedType?.name
|
|
157
|
+
if (!name) return null
|
|
158
|
+
if (code !== 'document_required') return { type: name }
|
|
159
|
+
const days = selectedType?.requiresDocumentAfterDays
|
|
160
|
+
return days === null || days === undefined ? null : { type: name, count: days }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function blockerText(blocker: { code: string; message: string }): string {
|
|
164
|
+
const key = BLOCKER_KEYS[blocker.code]
|
|
165
|
+
const params = blockerParams(blocker.code)
|
|
166
|
+
// `t()` answers a key it has no string for with the key itself, so a translation is only used
|
|
167
|
+
// when it is one. Both ways of not having one — a code nothing covers here, and a key whose
|
|
168
|
+
// string has not been merged yet — land on the sentence the router wrote rather than on
|
|
169
|
+
// `hr.leave_blocked_overlap` in front of a person.
|
|
170
|
+
const text = key && params !== null ? t(key, params) : undefined
|
|
171
|
+
return text && text !== key ? text : blocker.message
|
|
172
|
+
}
|
|
62
173
|
|
|
63
174
|
const close = () => {
|
|
64
175
|
shown = false
|
|
65
176
|
void navigation.go(`/${workspaceSlug}/hr/leave`, { replaceState: true, keepFocus: true, noScroll: true })
|
|
66
177
|
}
|
|
67
178
|
|
|
179
|
+
/**
|
|
180
|
+
* `submitting` rather than `create.isPending`: the disabled attribute only reaches the button on
|
|
181
|
+
* the next render, so two quick clicks both fire, each with its own idempotency key — and the
|
|
182
|
+
* second earns "you already have leave booked on those days" for the request the first one made.
|
|
183
|
+
*/
|
|
184
|
+
let submitting = $state(false)
|
|
185
|
+
|
|
68
186
|
const create = createMutation(() => ({
|
|
69
187
|
mutationFn: () =>
|
|
70
188
|
api.leave.requests.create({
|
|
@@ -83,11 +201,42 @@ const create = createMutation(() => ({
|
|
|
83
201
|
reason = ''
|
|
84
202
|
close()
|
|
85
203
|
},
|
|
86
|
-
onError: (error
|
|
204
|
+
onError: (error) => {
|
|
205
|
+
// `create` runs the same `simulate` the preview does and refuses on its first blocker, so a
|
|
206
|
+
// refusal here is a blocker code with `hr.leave.` in front of it — a sentence this form can
|
|
207
|
+
// already write. Nothing else that can fail has written anything for a person: a network drop,
|
|
208
|
+
// a 500 or a gateway carry machine text, in English, so those get this module's own string.
|
|
209
|
+
//
|
|
210
|
+
// The test is the transport's `code`, never the sentence.
|
|
211
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
212
|
+
const refused = failure.code === 'CONFLICT'
|
|
213
|
+
const reasonCode = refused && typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
214
|
+
const code = reasonCode?.startsWith('hr.leave.') ? reasonCode.slice('hr.leave.'.length) : null
|
|
215
|
+
const message = typeof failure.message === 'string' ? failure.message : ''
|
|
216
|
+
const explained = code ? blockerText({ code, message }) : refused ? message : ''
|
|
217
|
+
toast.error(explained || t('leave_request_error'))
|
|
218
|
+
// A refusal is the server saying the preview on screen is not what it sees — most often
|
|
219
|
+
// because the same days were booked from somewhere else, or a holiday moved into the range.
|
|
220
|
+
// So re-read the preview and the balance it is measured against, exactly as a request that
|
|
221
|
+
// landed does; without this every retry earns the same toast with no route to the truth.
|
|
222
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-sim'] })
|
|
223
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-balance'] })
|
|
224
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-requests'] })
|
|
225
|
+
},
|
|
226
|
+
onSettled: () => {
|
|
227
|
+
submitting = false
|
|
228
|
+
},
|
|
87
229
|
}))
|
|
88
230
|
|
|
231
|
+
const submit = () => {
|
|
232
|
+
if (submitting) return
|
|
233
|
+
submitting = true
|
|
234
|
+
create.mutate()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const canRequest = $derived(canHr('leaveRequest'))
|
|
89
238
|
const canSubmit = $derived(
|
|
90
|
-
Boolean(leaveTypeId && startsOn && endsOn) && !blocked &&
|
|
239
|
+
Boolean(leaveTypeId && startsOn && endsOn) && !blocked && canRequest && !simQuery.isFetching && !submitting,
|
|
91
240
|
)
|
|
92
241
|
</script>
|
|
93
242
|
|
|
@@ -99,11 +248,52 @@ const canSubmit = $derived(
|
|
|
99
248
|
}}
|
|
100
249
|
>
|
|
101
250
|
<div class="form">
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
251
|
+
<!--
|
|
252
|
+
The type list was never consulted here: a failed fetch left an empty dropdown and a dead
|
|
253
|
+
submit with nothing said, and a workspace with no types at all looked identical to one whose
|
|
254
|
+
network had dropped. Loading, then the list, then the failure, then the honest empty.
|
|
255
|
+
-->
|
|
256
|
+
{#if typesQuery.isLoading || !workspaceId}
|
|
257
|
+
<div class="skel">
|
|
258
|
+
<Skeleton width="48px" height="12px" />
|
|
259
|
+
<Skeleton height="34px" />
|
|
260
|
+
</div>
|
|
261
|
+
{:else if types.length}
|
|
262
|
+
<Field label={t('leave_type')} id="hr-leave-type" required>
|
|
263
|
+
{#snippet children(id)}
|
|
264
|
+
<Select {id} bind:value={leaveTypeId} options={typeOptions} />
|
|
265
|
+
{/snippet}
|
|
266
|
+
</Field>
|
|
267
|
+
{:else if typesQuery.isError}
|
|
268
|
+
<EmptyState compact icon="triangle-alert" title={t('leave_types_error')}>
|
|
269
|
+
{#snippet actions()}
|
|
270
|
+
<Button size="sm" variant="secondary" onclick={() => void typesQuery.refetch()}>
|
|
271
|
+
{t('retry')}
|
|
272
|
+
</Button>
|
|
273
|
+
{/snippet}
|
|
274
|
+
</EmptyState>
|
|
275
|
+
{:else}
|
|
276
|
+
<EmptyState
|
|
277
|
+
compact
|
|
278
|
+
icon="tree-palm"
|
|
279
|
+
title={t('leave_types_none')}
|
|
280
|
+
description={t('leave_types_none_desc')}
|
|
281
|
+
>
|
|
282
|
+
{#snippet actions()}
|
|
283
|
+
{#if canHr('leaveManage')}
|
|
284
|
+
<Button
|
|
285
|
+
size="sm"
|
|
286
|
+
variant="secondary"
|
|
287
|
+
icon="settings"
|
|
288
|
+
href={`/${workspaceSlug}/settings/hr/leave`}
|
|
289
|
+
>
|
|
290
|
+
{t('leave_types_manage')}
|
|
291
|
+
</Button>
|
|
292
|
+
{/if}
|
|
293
|
+
{/snippet}
|
|
294
|
+
</EmptyState>
|
|
295
|
+
{/if}
|
|
296
|
+
|
|
107
297
|
<div class="dates">
|
|
108
298
|
<Field label={t('leave_from')} id="hr-leave-from" required>
|
|
109
299
|
{#snippet children(id)}
|
|
@@ -122,25 +312,40 @@ const canSubmit = $derived(
|
|
|
122
312
|
{/snippet}
|
|
123
313
|
</Field>
|
|
124
314
|
|
|
125
|
-
{#if
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
315
|
+
{#if simQuery.isLoading}
|
|
316
|
+
<Skeleton width="70%" height="14px" />
|
|
317
|
+
{:else if sim}
|
|
318
|
+
{#if costLine}<p class="cost">{costLine}</p>{/if}
|
|
319
|
+
{#if blockers.length}
|
|
320
|
+
<div class="blocked" role="alert">
|
|
321
|
+
<p class="lead">{t('leave_blocked')}</p>
|
|
322
|
+
<ul>
|
|
323
|
+
{#each blockers as blocker (blocker.code)}
|
|
324
|
+
<li>{blockerText(blocker)}</li>
|
|
325
|
+
{/each}
|
|
326
|
+
</ul>
|
|
327
|
+
</div>
|
|
137
328
|
{/if}
|
|
329
|
+
{:else if simQuery.isError}
|
|
330
|
+
<div class="blocked" role="alert">
|
|
331
|
+
<p class="lead">{t('leave_sim_error')}</p>
|
|
332
|
+
<!-- A wrapper, so the button sizes to its label rather than stretching across the grid. -->
|
|
333
|
+
<div>
|
|
334
|
+
<Button size="sm" variant="secondary" onclick={() => void simQuery.refetch()}>{t('retry')}</Button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
138
337
|
{/if}
|
|
139
338
|
</div>
|
|
140
339
|
|
|
141
340
|
{#snippet footer()}
|
|
341
|
+
<!--
|
|
342
|
+
The URL reaches this form whether or not the person may use it — the Request time off button
|
|
343
|
+
is gated, a pasted `?new=1` is not — so the submit says why it is dead rather than looking
|
|
344
|
+
broken.
|
|
345
|
+
-->
|
|
346
|
+
{#if !canRequest}<span class="note">{t('leave_request_denied')}</span>{/if}
|
|
142
347
|
<Button variant="ghost" onclick={close}>{t('common.cancel')}</Button>
|
|
143
|
-
<Button onclick={
|
|
348
|
+
<Button onclick={submit} disabled={!canSubmit} loading={create.isPending}>
|
|
144
349
|
{t('request_leave')}
|
|
145
350
|
</Button>
|
|
146
351
|
{/snippet}
|
|
@@ -156,14 +361,38 @@ const canSubmit = $derived(
|
|
|
156
361
|
grid-template-columns: 1fr 1fr;
|
|
157
362
|
gap: 12px;
|
|
158
363
|
}
|
|
364
|
+
.skel {
|
|
365
|
+
display: grid;
|
|
366
|
+
gap: 6px;
|
|
367
|
+
}
|
|
159
368
|
.cost {
|
|
160
369
|
margin: 0;
|
|
161
370
|
font-size: 13px;
|
|
162
371
|
color: var(--kern-ink-500);
|
|
163
372
|
}
|
|
164
|
-
.
|
|
165
|
-
|
|
373
|
+
/* 6.33:1 on the dialog surface in light, 5.04:1 in dark — this is 13px text, so it clears 4.5. */
|
|
374
|
+
.blocked {
|
|
375
|
+
display: grid;
|
|
376
|
+
gap: 6px;
|
|
166
377
|
font-size: 13px;
|
|
167
378
|
color: var(--kern-danger);
|
|
168
379
|
}
|
|
380
|
+
.lead {
|
|
381
|
+
margin: 0;
|
|
382
|
+
font-weight: 500;
|
|
383
|
+
}
|
|
384
|
+
.blocked ul {
|
|
385
|
+
display: grid;
|
|
386
|
+
gap: 4px;
|
|
387
|
+
margin: 0;
|
|
388
|
+
padding: 0;
|
|
389
|
+
list-style: none;
|
|
390
|
+
}
|
|
391
|
+
/* A colour, not opacity: opacity fades text against the dialog whatever token it names. */
|
|
392
|
+
.note {
|
|
393
|
+
margin-inline-end: auto;
|
|
394
|
+
align-self: center;
|
|
395
|
+
font-size: 12px;
|
|
396
|
+
color: var(--kern-ink-500);
|
|
397
|
+
}
|
|
169
398
|
</style>
|
|
@@ -85,6 +85,33 @@ const reset = () => {
|
|
|
85
85
|
employmentType = 'full_time'
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The sentence to put in front of somebody when the hire is refused.
|
|
90
|
+
*
|
|
91
|
+
* A refusal arrives as two pieces: a machine-readable `reason` a module translates, and the
|
|
92
|
+
* English sentence the router wrote for a reader. `people.create` sends no reason today — an
|
|
93
|
+
* employee number already taken and a field the server will not take are what it refuses on — so
|
|
94
|
+
* this uses the second, and only for the codes that carry a sentence somebody wrote. A network
|
|
95
|
+
* drop, a 500 and a gateway carry machine text, and `Forbidden` is one word; a toast is the last
|
|
96
|
+
* place to paste any of them, so they fall back to this module's own string.
|
|
97
|
+
*
|
|
98
|
+
* When `people.create` does grow a reason, it is read the way `ClockControls.svelte` reads a
|
|
99
|
+
* punch's — keyed by the code, never by the sentence.
|
|
100
|
+
*/
|
|
101
|
+
const READABLE = new Set(['BAD_REQUEST', 'CONFLICT', 'NOT_FOUND'])
|
|
102
|
+
function explain(error: unknown, fallback: string): string {
|
|
103
|
+
const failure = error as { code?: unknown; message?: string }
|
|
104
|
+
const readable = typeof failure.code === 'string' && READABLE.has(failure.code)
|
|
105
|
+
return (readable ? failure.message : '') || fallback
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `creating` rather than `create.isPending`: the disabled attribute only reaches the button on the
|
|
110
|
+
* next render, so two quick clicks both fire — and here that is two people in the directory with
|
|
111
|
+
* the same name and two employee numbers.
|
|
112
|
+
*/
|
|
113
|
+
let creating = $state(false)
|
|
114
|
+
|
|
88
115
|
const create = createMutation(() => ({
|
|
89
116
|
mutationFn: () =>
|
|
90
117
|
api.people.create({
|
|
@@ -110,10 +137,20 @@ const create = createMutation(() => ({
|
|
|
110
137
|
shown = false
|
|
111
138
|
dismiss()
|
|
112
139
|
},
|
|
113
|
-
onError: (error
|
|
140
|
+
onError: (error) => toast.error(explain(error, t('person_create_error'))),
|
|
141
|
+
onSettled: () => {
|
|
142
|
+
creating = false
|
|
143
|
+
},
|
|
114
144
|
}))
|
|
115
145
|
|
|
116
|
-
const
|
|
146
|
+
const submit = () => {
|
|
147
|
+
if (creating) return
|
|
148
|
+
creating = true
|
|
149
|
+
create.mutate()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const canManage = $derived(canHr('personManage'))
|
|
153
|
+
const canSubmit = $derived(displayName.trim().length > 0 && canManage && !creating)
|
|
117
154
|
</script>
|
|
118
155
|
|
|
119
156
|
<Dialog
|
|
@@ -163,6 +200,12 @@ const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'
|
|
|
163
200
|
</div>
|
|
164
201
|
|
|
165
202
|
{#snippet footer()}
|
|
203
|
+
<!--
|
|
204
|
+
The URL reaches this dialog whether or not the person may use it — the Add person button is
|
|
205
|
+
gated, a pasted `?new=1` is not — so the submit says why it is dead rather than looking
|
|
206
|
+
broken.
|
|
207
|
+
-->
|
|
208
|
+
{#if !canManage}<span class="note">{t('person_manage_denied')}</span>{/if}
|
|
166
209
|
<Button
|
|
167
210
|
variant="ghost"
|
|
168
211
|
onclick={() => {
|
|
@@ -170,7 +213,7 @@ const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'
|
|
|
170
213
|
dismiss()
|
|
171
214
|
}}>{t('common.cancel')}</Button
|
|
172
215
|
>
|
|
173
|
-
<Button onclick={
|
|
216
|
+
<Button onclick={submit} disabled={!canSubmit} loading={create.isPending}>
|
|
174
217
|
{t('add_person')}
|
|
175
218
|
</Button>
|
|
176
219
|
{/snippet}
|
|
@@ -181,4 +224,11 @@ const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'
|
|
|
181
224
|
display: grid;
|
|
182
225
|
gap: 14px;
|
|
183
226
|
}
|
|
227
|
+
/* A colour, not opacity: opacity fades text against the dialog whatever token it names. */
|
|
228
|
+
.note {
|
|
229
|
+
margin-inline-end: auto;
|
|
230
|
+
align-self: center;
|
|
231
|
+
font-size: 12px;
|
|
232
|
+
color: var(--kern-ink-500);
|
|
233
|
+
}
|
|
184
234
|
</style>
|