@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.
@@ -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
+ }