@kernhq/module-hr 0.10.4 → 0.11.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/package.json +5 -5
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1417 -89
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, Button, Dialog, Field, IconButton, Input, Select, Textarea, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
// The client barrel re-exports the models screens have needed so far, and a punch *direction* was
|
|
5
|
+
// not one of them. Straight from the contract rather than widening a barrel other screens share.
|
|
6
|
+
import type { PunchDirection } from '../../contract/attendance.js'
|
|
7
|
+
import { getHrApi } from '../api-instance.js'
|
|
8
|
+
import { t } from '../i18n.js'
|
|
9
|
+
import type { Punch } from '../index.js'
|
|
10
|
+
import { canHr } from '../permissions.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ask for a day to be recorded the way it actually happened.
|
|
14
|
+
*
|
|
15
|
+
* This writes nothing. It raises a request through the same approval engine leave uses, and only an
|
|
16
|
+
* approval turns the lines below into punches — voiding what they replace, keeping the originals.
|
|
17
|
+
* So the dialog is written as a *proposal*: these are the punches you say belong on this day, and
|
|
18
|
+
* this is why.
|
|
19
|
+
*
|
|
20
|
+
* The times are read from the device's clock, which is stated on the form rather than assumed. The
|
|
21
|
+
* one place that would silently go wrong is a night shift — 22:00 followed by 06:00 — so a line
|
|
22
|
+
* whose time runs backwards past the one above it moves to the next day and says so on the row.
|
|
23
|
+
*/
|
|
24
|
+
interface Props {
|
|
25
|
+
open: boolean
|
|
26
|
+
workspaceId: string
|
|
27
|
+
businessDate: string
|
|
28
|
+
/**
|
|
29
|
+
* The punch this correction replaces, when it was raised from one. Approving the request voids
|
|
30
|
+
* it, which is why the id travels with the proposal rather than being applied here.
|
|
31
|
+
*/
|
|
32
|
+
punch: Punch | null
|
|
33
|
+
/** The day's live punches, so correcting a whole day starts from what is already recorded. */
|
|
34
|
+
punches: Punch[]
|
|
35
|
+
onClose: () => void
|
|
36
|
+
}
|
|
37
|
+
const { open, workspaceId, businessDate, punch, punches, onClose }: Props = $props()
|
|
38
|
+
|
|
39
|
+
const api = getHrApi()
|
|
40
|
+
const queryClient = useQueryClient()
|
|
41
|
+
|
|
42
|
+
type Line = { key: string; direction: PunchDirection; time: string }
|
|
43
|
+
|
|
44
|
+
const DIRECTIONS: PunchDirection[] = ['in', 'out', 'break_start', 'break_end']
|
|
45
|
+
|
|
46
|
+
const directionLabel = (direction: PunchDirection) =>
|
|
47
|
+
direction === 'in'
|
|
48
|
+
? t('att_punch_in')
|
|
49
|
+
: direction === 'out'
|
|
50
|
+
? t('att_punch_out')
|
|
51
|
+
: direction === 'break_start'
|
|
52
|
+
? t('att_punch_break_start')
|
|
53
|
+
: t('att_punch_break_end')
|
|
54
|
+
|
|
55
|
+
const directionOptions = $derived(DIRECTIONS.map((d) => ({ value: d, label: directionLabel(d) })))
|
|
56
|
+
|
|
57
|
+
/** `HH:mm` in the device's zone — a value for `<input type="time">`, not a string anybody reads. */
|
|
58
|
+
const clockValue = (iso: string): string => {
|
|
59
|
+
const at = new Date(iso)
|
|
60
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
61
|
+
return `${pad(at.getHours())}:${pad(at.getMinutes())}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let nextKey = 0
|
|
65
|
+
const line = (direction: PunchDirection, time = ''): Line => ({
|
|
66
|
+
key: `l${nextKey++}`,
|
|
67
|
+
direction,
|
|
68
|
+
time,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
let lines = $state<Line[]>([])
|
|
72
|
+
let reason = $state('')
|
|
73
|
+
let submitting = $state(false)
|
|
74
|
+
let failure = $state<string | null>(null)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the form starts as, seeded once per opening.
|
|
78
|
+
*
|
|
79
|
+
* Correcting one punch starts from that punch. Correcting a day starts from what the day already
|
|
80
|
+
* has, because a correction is nearly always "these two are right and the third is missing" — and
|
|
81
|
+
* retyping the two that were right is how a correction acquires a new mistake. A day with nothing
|
|
82
|
+
* on it starts as an empty in and out: the shape of a working day, with no hours invented.
|
|
83
|
+
*
|
|
84
|
+
* `seeded` is what keeps it to once. `punches` comes from a query the panel behind this dialog
|
|
85
|
+
* keeps re-reading — every punch, void and decision invalidates it — so an effect that simply
|
|
86
|
+
* depended on it would rebuild the lines and blank the reason underneath somebody who was still
|
|
87
|
+
* typing, at a moment set by the network rather than by anything they did.
|
|
88
|
+
*/
|
|
89
|
+
let seeded: string | null = null
|
|
90
|
+
$effect(() => {
|
|
91
|
+
if (!open) {
|
|
92
|
+
seeded = null
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const opening = punch?.id ?? 'day'
|
|
96
|
+
if (seeded === opening) return
|
|
97
|
+
seeded = opening
|
|
98
|
+
nextKey = 0
|
|
99
|
+
lines = punch
|
|
100
|
+
? [line(punch.direction, clockValue(punch.at))]
|
|
101
|
+
: punches.length
|
|
102
|
+
? punches.map((p) => line(p.direction, clockValue(p.at)))
|
|
103
|
+
: [line('in'), line('out')]
|
|
104
|
+
reason = ''
|
|
105
|
+
failure = null
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const addLine = () => {
|
|
109
|
+
lines = [...lines, line('out')]
|
|
110
|
+
}
|
|
111
|
+
const removeLine = (key: string) => {
|
|
112
|
+
lines = lines.filter((l) => l.key !== key)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The lines as instants.
|
|
117
|
+
*
|
|
118
|
+
* A time that runs backwards past the one above it belongs to the next calendar day: a shift from
|
|
119
|
+
* 22:00 to 06:00 is filed on the date it *started*, so building both on `businessDate` would put
|
|
120
|
+
* the clock-out sixteen hours before the clock-in and hand the approver a day that computes to
|
|
121
|
+
* nothing. The rows say when this happened, so nobody has to infer it from a badge they cannot see.
|
|
122
|
+
*/
|
|
123
|
+
const proposed = $derived.by(() => {
|
|
124
|
+
let dayOffset = 0
|
|
125
|
+
let previous: string | null = null
|
|
126
|
+
return lines.map((l) => {
|
|
127
|
+
if (previous !== null && l.time && l.time < previous) dayOffset += 1
|
|
128
|
+
if (l.time) previous = l.time
|
|
129
|
+
const at = l.time ? new Date(`${businessDate}T${l.time}:00`) : null
|
|
130
|
+
if (at && dayOffset) at.setDate(at.getDate() + dayOffset)
|
|
131
|
+
return {
|
|
132
|
+
key: l.key,
|
|
133
|
+
direction: l.direction,
|
|
134
|
+
at: at && !Number.isNaN(at.getTime()) ? at.toISOString() : null,
|
|
135
|
+
rolled: dayOffset > 0,
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const canRequest = $derived(canHr('attendancePunch'))
|
|
141
|
+
const timesMissing = $derived(proposed.some((p) => p.at === null))
|
|
142
|
+
const reasonMissing = $derived(reason.trim().length === 0)
|
|
143
|
+
const blocker = $derived(
|
|
144
|
+
!canRequest
|
|
145
|
+
? t('att_correction_denied')
|
|
146
|
+
: timesMissing
|
|
147
|
+
? t('att_correction_needs_time')
|
|
148
|
+
: reasonMissing
|
|
149
|
+
? t('att_correction_needs_reason')
|
|
150
|
+
: null,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const create = createMutation(() => ({
|
|
154
|
+
mutationFn: () =>
|
|
155
|
+
api.attendance.regularizations.request({
|
|
156
|
+
workspaceId,
|
|
157
|
+
businessDate,
|
|
158
|
+
punchId: punch?.id ?? null,
|
|
159
|
+
proposed: proposed.map((p) => ({ direction: p.direction, at: p.at as string })),
|
|
160
|
+
reason: reason.trim(),
|
|
161
|
+
}),
|
|
162
|
+
onSuccess: () => {
|
|
163
|
+
toast.success(t('att_correction_sent'))
|
|
164
|
+
// A correction that auto-approves — a workspace with no chain, where the request is settled as
|
|
165
|
+
// it is raised — writes punches and recomputes the day, so the whole module's cache is re-read
|
|
166
|
+
// rather than guessing which keys moved.
|
|
167
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
168
|
+
onClose()
|
|
169
|
+
},
|
|
170
|
+
onError: (error) => {
|
|
171
|
+
// The router refuses through `KernError`, whose reason reaches the client as `data.reason` and
|
|
172
|
+
// whose sentence is the only thing written for a reader. Nothing else that can fail here has
|
|
173
|
+
// written anything for a person: a network drop, a 500 or a gateway carry machine text, in
|
|
174
|
+
// English, so those get this module's own string. The test is the transport's `code`, never
|
|
175
|
+
// the sentence.
|
|
176
|
+
const refused = error as { code?: unknown; message?: string }
|
|
177
|
+
failure = (refused.code === 'CONFLICT' ? refused.message : undefined) || t('att_correction_error')
|
|
178
|
+
},
|
|
179
|
+
onSettled: () => {
|
|
180
|
+
submitting = false
|
|
181
|
+
},
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `submitting` rather than `create.isPending`: the disabled attribute only reaches the button on
|
|
186
|
+
* the next render, so two quick clicks both fire and the approver is handed the same correction
|
|
187
|
+
* twice.
|
|
188
|
+
*/
|
|
189
|
+
const submit = () => {
|
|
190
|
+
if (submitting || blocker) return
|
|
191
|
+
submitting = true
|
|
192
|
+
failure = null
|
|
193
|
+
create.mutate()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const close = () => {
|
|
197
|
+
if (submitting) return
|
|
198
|
+
onClose()
|
|
199
|
+
}
|
|
200
|
+
</script>
|
|
201
|
+
|
|
202
|
+
<Dialog
|
|
203
|
+
{open}
|
|
204
|
+
title={t('att_correction_title')}
|
|
205
|
+
description={t('att_correction_desc')}
|
|
206
|
+
onOpenChange={(next) => {
|
|
207
|
+
if (!next) close()
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
<div class="form">
|
|
211
|
+
{#if punch}
|
|
212
|
+
<!--
|
|
213
|
+
Raised from one punch: approving it voids that punch and writes what is below in its place,
|
|
214
|
+
and somebody who does not know that will type a line that duplicates it.
|
|
215
|
+
-->
|
|
216
|
+
<p class="lead">
|
|
217
|
+
{t('att_correction_for_punch', {
|
|
218
|
+
punch: directionLabel(punch.direction),
|
|
219
|
+
time: clockValue(punch.at),
|
|
220
|
+
})}
|
|
221
|
+
</p>
|
|
222
|
+
{/if}
|
|
223
|
+
|
|
224
|
+
<div class="lines">
|
|
225
|
+
<h3>{t('att_correction_punches')}</h3>
|
|
226
|
+
{#each lines as l, index (l.key)}
|
|
227
|
+
<div class="line">
|
|
228
|
+
<Select
|
|
229
|
+
value={l.direction}
|
|
230
|
+
options={directionOptions}
|
|
231
|
+
size="sm"
|
|
232
|
+
ariaLabel={t('att_correction_direction')}
|
|
233
|
+
onValueChange={(v) => {
|
|
234
|
+
l.direction = v as PunchDirection
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
<Input type="time" size="sm" bind:value={l.time} aria-label={t('att_correction_time')} />
|
|
238
|
+
{#if proposed[index]?.rolled}
|
|
239
|
+
<Badge tone="info">{t('att_correction_next_day')}</Badge>
|
|
240
|
+
{/if}
|
|
241
|
+
<span class="spacer"></span>
|
|
242
|
+
<!--
|
|
243
|
+
One line is the minimum the contract accepts, so the last remove says why it is dead
|
|
244
|
+
rather than looking broken — and an icon button without a label is "button" to a screen
|
|
245
|
+
reader.
|
|
246
|
+
-->
|
|
247
|
+
<IconButton
|
|
248
|
+
icon="trash-2"
|
|
249
|
+
size={26}
|
|
250
|
+
variant="ghost"
|
|
251
|
+
label={t('att_correction_remove')}
|
|
252
|
+
disabled={lines.length < 2}
|
|
253
|
+
title={lines.length < 2 ? t('att_correction_needs_one') : undefined}
|
|
254
|
+
onclick={() => removeLine(l.key)}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
{/each}
|
|
258
|
+
<div>
|
|
259
|
+
<Button size="sm" variant="ghost" icon="plus" onclick={addLine}>{t('att_correction_add')}</Button>
|
|
260
|
+
</div>
|
|
261
|
+
<p class="hint">{t('att_correction_zone_hint')}</p>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<Field label={t('att_correction_reason')} hint={t('att_correction_reason_hint')} required>
|
|
265
|
+
{#snippet children(fieldId)}
|
|
266
|
+
<Textarea id={fieldId} bind:value={reason} rows={3} />
|
|
267
|
+
{/snippet}
|
|
268
|
+
</Field>
|
|
269
|
+
|
|
270
|
+
{#if failure}
|
|
271
|
+
<p class="failed" role="alert">{failure}</p>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{#snippet footer()}
|
|
276
|
+
<!-- Whatever is stopping the submit, said out loud. A dead button with no reason is a bug. -->
|
|
277
|
+
{#if blocker}<span class="note">{blocker}</span>{/if}
|
|
278
|
+
<Button variant="ghost" onclick={close} disabled={submitting}>{t('cancel')}</Button>
|
|
279
|
+
<Button onclick={submit} disabled={Boolean(blocker) || submitting} loading={submitting}>
|
|
280
|
+
{t('att_correction_submit')}
|
|
281
|
+
</Button>
|
|
282
|
+
{/snippet}
|
|
283
|
+
</Dialog>
|
|
284
|
+
|
|
285
|
+
<style>
|
|
286
|
+
.form {
|
|
287
|
+
display: grid;
|
|
288
|
+
gap: 14px;
|
|
289
|
+
}
|
|
290
|
+
.lead {
|
|
291
|
+
margin: 0;
|
|
292
|
+
font-size: 13px;
|
|
293
|
+
color: var(--kern-ink-700);
|
|
294
|
+
}
|
|
295
|
+
.lines {
|
|
296
|
+
display: grid;
|
|
297
|
+
gap: 8px;
|
|
298
|
+
}
|
|
299
|
+
h3 {
|
|
300
|
+
margin: 0;
|
|
301
|
+
font-size: 11px;
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
letter-spacing: 0.06em;
|
|
304
|
+
text-transform: uppercase;
|
|
305
|
+
color: var(--kern-ink-500);
|
|
306
|
+
}
|
|
307
|
+
.line {
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: 8px;
|
|
311
|
+
flex-wrap: wrap;
|
|
312
|
+
}
|
|
313
|
+
.spacer {
|
|
314
|
+
flex: 1;
|
|
315
|
+
}
|
|
316
|
+
/* A colour, not opacity: opacity fades text against the dialog whatever token it names. */
|
|
317
|
+
.hint {
|
|
318
|
+
margin: 0;
|
|
319
|
+
font-size: 12px;
|
|
320
|
+
color: var(--kern-ink-500);
|
|
321
|
+
}
|
|
322
|
+
/* 6.33:1 on the dialog surface in light, 5.04:1 in dark — 13px text has to clear 4.5. */
|
|
323
|
+
.failed {
|
|
324
|
+
margin: 0;
|
|
325
|
+
font-size: 13px;
|
|
326
|
+
color: var(--kern-danger);
|
|
327
|
+
}
|
|
328
|
+
.note {
|
|
329
|
+
margin-inline-end: auto;
|
|
330
|
+
align-self: center;
|
|
331
|
+
font-size: 12px;
|
|
332
|
+
color: var(--kern-ink-500);
|
|
333
|
+
}
|
|
334
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentence to put in front of somebody when a write on the person panel fails.
|
|
3
|
+
*
|
|
4
|
+
* A refusal arrives as two pieces: a machine-readable `reason` a module translates, and the English
|
|
5
|
+
* sentence the router wrote for a reader. Nothing under `people.*`, `employment.*` or `documents.*`
|
|
6
|
+
* sends a reason today — their refusals are a record that has gone, a date before the period it
|
|
7
|
+
* would change, and a field the server will not take — so this uses the second, and only for the
|
|
8
|
+
* codes that carry a sentence somebody wrote. Everything else is machine text in English: a network
|
|
9
|
+
* drop, a 500, a gateway, and `Forbidden` and `Unauthorized`, which are one word each. A toast is
|
|
10
|
+
* the last place to paste any of them, so they fall back to the caller's own string.
|
|
11
|
+
*
|
|
12
|
+
* When one of those procedures does grow a reason, it is read the way `ClockControls.svelte` reads
|
|
13
|
+
* a punch's — keyed by the code, never by the sentence.
|
|
14
|
+
*/
|
|
15
|
+
const READABLE = new Set(['BAD_REQUEST', 'CONFLICT', 'NOT_FOUND'])
|
|
16
|
+
|
|
17
|
+
export function explainRefusal(error: unknown, fallback: string): string {
|
|
18
|
+
const failure = error as { code?: unknown; message?: string }
|
|
19
|
+
const readable = typeof failure.code === 'string' && READABLE.has(failure.code)
|
|
20
|
+
return (readable ? failure.message : '') || fallback
|
|
21
|
+
}
|