@kernhq/module-hr 0.10.5 → 0.12.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 +3 -3
- 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 +1895 -102
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-hr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Kern HR module: people, offices, org chart, calendars",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@kernhq/contracts": "^0.7.0",
|
|
47
47
|
"@kernhq/kernel": "^0.7.0",
|
|
48
48
|
"@kernhq/tsconfig": "^0.1.0",
|
|
49
|
-
"@kernhq/ui": "^0.
|
|
49
|
+
"@kernhq/ui": "^0.12.0",
|
|
50
50
|
"@tanstack/svelte-query": "^6.1.0",
|
|
51
51
|
"@types/node": "^24.0.0",
|
|
52
52
|
"@types/pg": "^8.15.0",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@kernhq/contracts": "^0.7.0",
|
|
62
62
|
"@kernhq/kernel": "^0.7.0",
|
|
63
|
-
"@kernhq/ui": "^0.
|
|
63
|
+
"@kernhq/ui": "^0.12.0",
|
|
64
64
|
"@tanstack/svelte-query": "^6.1.0",
|
|
65
65
|
"svelte": "^5.46.0"
|
|
66
66
|
},
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Field,
|
|
8
|
+
formatDateTime,
|
|
9
|
+
localTime,
|
|
10
|
+
messageLocale,
|
|
11
|
+
Skeleton,
|
|
12
|
+
Textarea,
|
|
13
|
+
toast,
|
|
14
|
+
} from '@kernhq/ui'
|
|
15
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
16
|
+
import { getHrApi } from '../api-instance.js'
|
|
17
|
+
import { t } from '../i18n.js'
|
|
18
|
+
import type { AttendanceDay, Punch, Regularization } from '../index.js'
|
|
19
|
+
import { canHr } from '../permissions.js'
|
|
20
|
+
import { formatDuration, isoDate } from '../query.js'
|
|
21
|
+
import RegularizationDialog from './RegularizationDialog.svelte'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One day of the sheet, opened up.
|
|
25
|
+
*
|
|
26
|
+
* The person reading this is arguing with a number on their timesheet, so everything here either
|
|
27
|
+
* helps them make that argument or helps them withdraw it: the punches the total was computed from,
|
|
28
|
+
* the anomalies named rather than counted, the corrections they have already asked for, and the two
|
|
29
|
+
* things somebody can actually do — void a wrong punch, or ask for one that is missing.
|
|
30
|
+
*
|
|
31
|
+
* **A void is a correcting row, not an edit.** The original keeps its place in the list, struck
|
|
32
|
+
* through, and the reason travels with it. That is the whole reason attendance records punches the
|
|
33
|
+
* way it does, and a list that quietly dropped the voided row would be claiming the opposite.
|
|
34
|
+
*/
|
|
35
|
+
interface Props {
|
|
36
|
+
workspaceId: string
|
|
37
|
+
day: AttendanceDay
|
|
38
|
+
/**
|
|
39
|
+
* The corrections this person has waiting, from the page's one query — filtered to this day here.
|
|
40
|
+
* Its states travel with it, because a section that silently shows nothing when a fetch failed
|
|
41
|
+
* tells somebody they have asked for nothing.
|
|
42
|
+
*/
|
|
43
|
+
corrections: { items: Regularization[]; loading: boolean; failed: boolean; retry: () => void }
|
|
44
|
+
}
|
|
45
|
+
const { workspaceId, day, corrections }: Props = $props()
|
|
46
|
+
|
|
47
|
+
const api = getHrApi()
|
|
48
|
+
const queryClient = useQueryClient()
|
|
49
|
+
|
|
50
|
+
const canManage = $derived(canHr('attendanceManage'))
|
|
51
|
+
const canRequest = $derived(canHr('attendancePunch'))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `includeVoided`, because a voided punch is the point.
|
|
55
|
+
*
|
|
56
|
+
* The confirmation below promises the original stays on the record; a list that then dropped it
|
|
57
|
+
* would make that promise a lie the first time somebody used it.
|
|
58
|
+
*/
|
|
59
|
+
const punchesQuery = createQuery(() => ({
|
|
60
|
+
// The same literal shape `hrKeys` builds — `['hr', entity, workspace, …scope]` — so the module's
|
|
61
|
+
// blanket `['hr']` invalidation after a punch, a void or a correction reaches it.
|
|
62
|
+
queryKey: ['hr', 'punches', workspaceId, 'me', day.businessDate] as const,
|
|
63
|
+
enabled: Boolean(workspaceId),
|
|
64
|
+
queryFn: () =>
|
|
65
|
+
api.attendance.punches.list({
|
|
66
|
+
workspaceId,
|
|
67
|
+
from: day.businessDate,
|
|
68
|
+
to: day.businessDate,
|
|
69
|
+
includeVoided: true,
|
|
70
|
+
limit: 100,
|
|
71
|
+
}),
|
|
72
|
+
}))
|
|
73
|
+
const rows = $derived(punchesQuery.data?.items ?? [])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A void writes a correcting row that points at *itself*, and the server's own note beside it says
|
|
77
|
+
* it exists to carry the reason rather than to be counted as a punch. Drawn as one it would put a
|
|
78
|
+
* second "Clocked in 09:00" directly under the one it voided, which reads as a duplicate punch —
|
|
79
|
+
* so it is held back here and used for the reason it carries.
|
|
80
|
+
*/
|
|
81
|
+
const voidNotes = $derived(
|
|
82
|
+
new Map(rows.filter((p) => p.voidedByPunchId === p.id).map((p) => [p.id, p.note] as const)),
|
|
83
|
+
)
|
|
84
|
+
const punches = $derived(rows.filter((p) => p.voidedByPunchId !== p.id))
|
|
85
|
+
const live = $derived(punches.filter((p) => p.voidedByPunchId === null))
|
|
86
|
+
|
|
87
|
+
/** No answer yet, or an answer that never arrived — the retained list is what decides, not the status. */
|
|
88
|
+
const loading = $derived(!workspaceId || punchesQuery.isLoading)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The reason a void was given, out of the note the correcting row carries.
|
|
92
|
+
*
|
|
93
|
+
* The server writes `Voids <id>: <reason>`, so the id is peeled off to leave the sentence somebody
|
|
94
|
+
* typed. A note that does not match is shown whole rather than guessed at — losing the reason
|
|
95
|
+
* would take away the one thing a disputed punch is read for.
|
|
96
|
+
*/
|
|
97
|
+
const VOID_NOTE = /^Voids [0-9a-f-]{36}:\s*/i
|
|
98
|
+
const voidReason = (punch: Punch): string | null => {
|
|
99
|
+
const note = punch.voidedByPunchId ? (voidNotes.get(punch.voidedByPunchId) ?? null) : null
|
|
100
|
+
return note ? note.replace(VOID_NOTE, '') : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const directionLabel = (direction: string) =>
|
|
104
|
+
direction === 'in'
|
|
105
|
+
? t('att_punch_in')
|
|
106
|
+
: direction === 'out'
|
|
107
|
+
? t('att_punch_out')
|
|
108
|
+
: direction === 'break_start'
|
|
109
|
+
? t('att_punch_break_start')
|
|
110
|
+
: t('att_punch_break_end')
|
|
111
|
+
|
|
112
|
+
const methodLabel = (method: string) =>
|
|
113
|
+
method === 'web'
|
|
114
|
+
? t('att_method_web')
|
|
115
|
+
: method === 'mobile'
|
|
116
|
+
? t('att_method_mobile')
|
|
117
|
+
: method === 'kiosk'
|
|
118
|
+
? t('att_method_kiosk')
|
|
119
|
+
: method === 'qr'
|
|
120
|
+
? t('att_method_qr')
|
|
121
|
+
: method === 'device'
|
|
122
|
+
? t('att_method_device')
|
|
123
|
+
: method === 'import'
|
|
124
|
+
? t('att_method_import')
|
|
125
|
+
: t('att_method_manual')
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The time, with the date added only when it is not this day's.
|
|
129
|
+
*
|
|
130
|
+
* A night shift files its punches on the date it started, so the clock-out of a 22:00–06:00 shift
|
|
131
|
+
* reads "06:00" on a row headed by the previous day — and a bare time there is the one number on
|
|
132
|
+
* this panel somebody would misread.
|
|
133
|
+
*/
|
|
134
|
+
const timeLabel = (punch: Punch) =>
|
|
135
|
+
isoDate(new Date(punch.at)) === day.businessDate ? localTime(new Date(punch.at)) : formatDateTime(punch.at)
|
|
136
|
+
|
|
137
|
+
const words = {
|
|
138
|
+
hours: (n: string) => t('hours_short', { n }),
|
|
139
|
+
minutes: (n: string) => t('minutes_short', { n }),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How far the device's clock was out, when that is a fact worth stating.
|
|
144
|
+
*
|
|
145
|
+
* Under a minute is rounding, not disagreement, and "0 minutes out" beside a claimed punch reads
|
|
146
|
+
* as an accusation of nothing.
|
|
147
|
+
*/
|
|
148
|
+
const skewLabel = (punch: Punch): string | null =>
|
|
149
|
+
punch.skewMs !== null && Math.abs(punch.skewMs) >= 60_000
|
|
150
|
+
? t('att_punch_skew', {
|
|
151
|
+
amount: formatDuration(Math.round(Math.abs(punch.skewMs) / 60_000), words, messageLocale()),
|
|
152
|
+
})
|
|
153
|
+
: null
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What an anomaly means, in words.
|
|
157
|
+
*
|
|
158
|
+
* Keyed by the code `computeDay` writes, never by its position in the array. A code with no string
|
|
159
|
+
* yet falls back to the code itself: unlovely, and still more than the number that was here before.
|
|
160
|
+
*/
|
|
161
|
+
const ANOMALY_KEYS: Record<string, string> = {
|
|
162
|
+
double_clock_in: 'hr.att_anomaly_double_clock_in',
|
|
163
|
+
clock_out_without_in: 'hr.att_anomaly_clock_out_without_in',
|
|
164
|
+
missing_clock_out: 'hr.att_anomaly_missing_clock_out',
|
|
165
|
+
break_not_ended: 'hr.att_anomaly_break_not_ended',
|
|
166
|
+
double_break_start: 'hr.att_anomaly_double_break_start',
|
|
167
|
+
break_end_without_start: 'hr.att_anomaly_break_end_without_start',
|
|
168
|
+
overtime_beyond_cap: 'hr.att_anomaly_overtime_beyond_cap',
|
|
169
|
+
}
|
|
170
|
+
const anomalyLabel = (code: string): string => {
|
|
171
|
+
const key = ANOMALY_KEYS[code]
|
|
172
|
+
// `t()` answers a key it has no string for with the key itself, so both ways of not having one —
|
|
173
|
+
// a code nothing covers here, and a key whose string has not been merged yet — land on the code
|
|
174
|
+
// rather than on `hr.att_anomaly_…` in front of a person.
|
|
175
|
+
const text = key ? t(key) : undefined
|
|
176
|
+
return text && text !== key ? text : code
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** This day's corrections, newest first — the server orders by date, and one day is one date. */
|
|
180
|
+
const dayCorrections = $derived(corrections.items.filter((r) => r.businessDate === day.businessDate))
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A closed month cannot move, so a correction to it would change nothing.
|
|
184
|
+
*
|
|
185
|
+
* `recomputeDay` leaves a locked day exactly where it is, which means voiding a punch on one writes
|
|
186
|
+
* the correcting row and the totals above it stay as they were. Better to say so than to offer a
|
|
187
|
+
* button whose effect is invisible.
|
|
188
|
+
*/
|
|
189
|
+
const frozen = $derived(day.locked)
|
|
190
|
+
|
|
191
|
+
/** The punch waiting on a confirmation, and what the last attempt said. */
|
|
192
|
+
let voiding = $state<Punch | null>(null)
|
|
193
|
+
let voidReasonText = $state('')
|
|
194
|
+
let voidInFlight = $state(false)
|
|
195
|
+
let voidError = $state<string | null>(null)
|
|
196
|
+
|
|
197
|
+
let requestOpen = $state(false)
|
|
198
|
+
let requestPunch = $state<Punch | null>(null)
|
|
199
|
+
|
|
200
|
+
/** Reset between punches: yesterday's reason must not ride along on today's void. */
|
|
201
|
+
$effect(() => {
|
|
202
|
+
void voiding?.id
|
|
203
|
+
voidReasonText = ''
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The void refusals this module has its own sentence for, keyed by the `reason` the router sends
|
|
208
|
+
* beside the refusal — never by the sentence, because a list of sentences is a list somebody has to
|
|
209
|
+
* keep in sync and the day it drifts the reader is told nothing.
|
|
210
|
+
*
|
|
211
|
+
* Empty on purpose: `voidPunch` refuses an already-voided punch through `KernError.conflict` with
|
|
212
|
+
* no reason argument today, so its own sentence is the only thing that says which punch and why.
|
|
213
|
+
* That is the fallback below doing its job, and a reason added to the server later reaches a reader
|
|
214
|
+
* here the moment somebody writes its string.
|
|
215
|
+
*/
|
|
216
|
+
const voidRefusalMessages: Record<string, string> = {}
|
|
217
|
+
|
|
218
|
+
function voidFailure(error: unknown): string {
|
|
219
|
+
const failure = error as { code?: unknown; message?: string; data?: { reason?: unknown } }
|
|
220
|
+
if (failure.code !== 'CONFLICT') return t('att_void_error')
|
|
221
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
222
|
+
const key = reason ? voidRefusalMessages[reason] : undefined
|
|
223
|
+
const translated = key ? t(key) : undefined
|
|
224
|
+
return (translated && translated !== key ? translated : failure.message) || t('att_void_error')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const voidPunch = createMutation(() => ({
|
|
228
|
+
mutationFn: (input: { punchId: string; reason: string }) =>
|
|
229
|
+
api.attendance.punches.void({ workspaceId, punchId: input.punchId, reason: input.reason }),
|
|
230
|
+
onSuccess: () => {
|
|
231
|
+
voiding = null
|
|
232
|
+
voidError = null
|
|
233
|
+
toast.success(t('att_void_done'))
|
|
234
|
+
// A void rewrites the day sheet, the totals above it and this list, so the whole module's cache
|
|
235
|
+
// is re-read rather than guessing which keys moved.
|
|
236
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
237
|
+
},
|
|
238
|
+
onError: (error) => {
|
|
239
|
+
voidError = voidFailure(error)
|
|
240
|
+
// A refusal is the server saying its picture of this day is not the one on screen — most often
|
|
241
|
+
// because the punch was already voided from somewhere else. Re-read exactly as a void that
|
|
242
|
+
// landed does; without this the same dead row sits in the list and every retry earns the same
|
|
243
|
+
// sentence.
|
|
244
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
245
|
+
},
|
|
246
|
+
onSettled: () => {
|
|
247
|
+
voidInFlight = false
|
|
248
|
+
},
|
|
249
|
+
}))
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `voidInFlight` rather than `voidPunch.isPending`: the disabled attribute only reaches the button
|
|
253
|
+
* on the next render, so two quick clicks both fire — and the second arrives at a punch the first
|
|
254
|
+
* has already voided, which the server answers with a refusal the person did nothing to deserve.
|
|
255
|
+
*/
|
|
256
|
+
const confirmVoid = () => {
|
|
257
|
+
if (!voiding || voidInFlight || !voidReasonText.trim()) return
|
|
258
|
+
voidInFlight = true
|
|
259
|
+
voidError = null
|
|
260
|
+
voidPunch.mutate({ punchId: voiding.id, reason: voidReasonText.trim() })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const closeVoid = () => {
|
|
264
|
+
if (voidInFlight) return
|
|
265
|
+
voiding = null
|
|
266
|
+
voidError = null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const openRequest = (punch: Punch | null) => {
|
|
270
|
+
requestPunch = punch
|
|
271
|
+
requestOpen = true
|
|
272
|
+
}
|
|
273
|
+
</script>
|
|
274
|
+
|
|
275
|
+
<div class="detail">
|
|
276
|
+
{#if day.anomalies.length}
|
|
277
|
+
<!--
|
|
278
|
+
The count that used to be here said a number and no noun. These are the sentences behind it,
|
|
279
|
+
and they are the reason the day needs a person at all.
|
|
280
|
+
-->
|
|
281
|
+
<section class="block anomalies" aria-label={t('att_anomalies')}>
|
|
282
|
+
<h3>{t('att_anomalies')}</h3>
|
|
283
|
+
<!--
|
|
284
|
+
Keyed by position, not by the code: `computeDay` pushes one entry per occurrence, so a day
|
|
285
|
+
with two unmatched clock-ins carries `double_clock_in` twice — and a keyed `{#each}` with a
|
|
286
|
+
repeated key throws at render rather than drawing the second one.
|
|
287
|
+
-->
|
|
288
|
+
<ul class="plain">
|
|
289
|
+
{#each day.anomalies as code, index (index)}
|
|
290
|
+
<li>{anomalyLabel(code)}</li>
|
|
291
|
+
{/each}
|
|
292
|
+
</ul>
|
|
293
|
+
</section>
|
|
294
|
+
{/if}
|
|
295
|
+
|
|
296
|
+
<section class="block" aria-label={t('att_punches')}>
|
|
297
|
+
<h3>{t('att_punches')}</h3>
|
|
298
|
+
|
|
299
|
+
{#if loading}
|
|
300
|
+
<div class="skel">
|
|
301
|
+
{#each [1, 2] as n (n)}<Skeleton height="34px" />{/each}
|
|
302
|
+
</div>
|
|
303
|
+
{:else if punches.length}
|
|
304
|
+
<!--
|
|
305
|
+
The punches a person already has outrank the failure: everything here is invalidated by a
|
|
306
|
+
punch, a void and a decision, so a refetch failing while the last good list is still in
|
|
307
|
+
`data` is the ordinary case. An error branch above this one would blank the day somebody
|
|
308
|
+
opened it to read.
|
|
309
|
+
-->
|
|
310
|
+
{#if punchesQuery.isError}
|
|
311
|
+
<p class="stale" role="status">
|
|
312
|
+
<span>{t('att_punches_stale')}</span>
|
|
313
|
+
<Button size="sm" variant="ghost" onclick={() => void punchesQuery.refetch()}>{t('retry')}</Button>
|
|
314
|
+
</p>
|
|
315
|
+
{/if}
|
|
316
|
+
<ul class="plain punches">
|
|
317
|
+
{#each punches as punch (punch.id)}
|
|
318
|
+
{@const voided = punch.voidedByPunchId !== null}
|
|
319
|
+
{@const reason = voidReason(punch)}
|
|
320
|
+
{@const skew = skewLabel(punch)}
|
|
321
|
+
<li class="punch" class:voided>
|
|
322
|
+
<div class="line">
|
|
323
|
+
<span class="dir">{directionLabel(punch.direction)}</span>
|
|
324
|
+
<span class="time">{timeLabel(punch)}</span>
|
|
325
|
+
<Badge tone="grey">{methodLabel(punch.method)}</Badge>
|
|
326
|
+
{#if punch.trust === 'disputed'}
|
|
327
|
+
<Badge tone="declined">{t('att_trust_disputed')}</Badge>
|
|
328
|
+
{:else if punch.trust === 'claimed'}
|
|
329
|
+
<Badge tone="warning">{t('att_trust_claimed')}</Badge>
|
|
330
|
+
{/if}
|
|
331
|
+
{#if voided}<Badge tone="grey">{t('att_punch_voided')}</Badge>{/if}
|
|
332
|
+
<span class="spacer"></span>
|
|
333
|
+
{#if canManage && !voided}
|
|
334
|
+
<!--
|
|
335
|
+
Hidden from somebody who may never void, disabled with the reason stated below
|
|
336
|
+
when the month is closed — a control that does nothing and does not say why is
|
|
337
|
+
the defect this screen was opened to fix.
|
|
338
|
+
-->
|
|
339
|
+
<Button size="sm" variant="ghost" disabled={frozen} onclick={() => (voiding = punch)}>
|
|
340
|
+
{t('att_void')}
|
|
341
|
+
</Button>
|
|
342
|
+
{/if}
|
|
343
|
+
{#if canRequest && !voided}
|
|
344
|
+
<Button size="sm" variant="ghost" disabled={frozen} onclick={() => openRequest(punch)}>
|
|
345
|
+
{t('att_correct_this')}
|
|
346
|
+
</Button>
|
|
347
|
+
{/if}
|
|
348
|
+
</div>
|
|
349
|
+
<!--
|
|
350
|
+
The honest state of an offline punch, and what a void did — both under the row they
|
|
351
|
+
belong to rather than in a tooltip nobody opens.
|
|
352
|
+
-->
|
|
353
|
+
{#if punch.trust !== 'trusted' && skew}
|
|
354
|
+
<p class="sub warn">{skew}</p>
|
|
355
|
+
{/if}
|
|
356
|
+
{#if voided && reason}
|
|
357
|
+
<p class="sub">{t('att_punch_void_reason', { reason })}</p>
|
|
358
|
+
{/if}
|
|
359
|
+
</li>
|
|
360
|
+
{/each}
|
|
361
|
+
</ul>
|
|
362
|
+
{#if frozen}
|
|
363
|
+
<p class="sub note">{t('att_locked_no_change')}</p>
|
|
364
|
+
{/if}
|
|
365
|
+
{:else if punchesQuery.isError}
|
|
366
|
+
<EmptyState compact icon="triangle-alert" title={t('att_punches_error')}>
|
|
367
|
+
{#snippet actions()}
|
|
368
|
+
<Button size="sm" variant="secondary" onclick={() => void punchesQuery.refetch()}>
|
|
369
|
+
{t('retry')}
|
|
370
|
+
</Button>
|
|
371
|
+
{/snippet}
|
|
372
|
+
</EmptyState>
|
|
373
|
+
{:else}
|
|
374
|
+
<!--
|
|
375
|
+
Nothing recorded is the commonest reason somebody opens a day: they worked it and the clock
|
|
376
|
+
has no idea. So the empty state offers the thing that fills it, and only promises it to
|
|
377
|
+
somebody who may actually ask.
|
|
378
|
+
-->
|
|
379
|
+
<EmptyState
|
|
380
|
+
compact
|
|
381
|
+
icon="timer"
|
|
382
|
+
title={t('att_punches_none')}
|
|
383
|
+
description={canRequest ? t('att_punches_none_desc') : undefined}
|
|
384
|
+
>
|
|
385
|
+
{#snippet actions()}
|
|
386
|
+
{#if canRequest}
|
|
387
|
+
<Button size="sm" variant="secondary" disabled={frozen} onclick={() => openRequest(null)}>
|
|
388
|
+
{t('att_correction_request')}
|
|
389
|
+
</Button>
|
|
390
|
+
{/if}
|
|
391
|
+
{/snippet}
|
|
392
|
+
</EmptyState>
|
|
393
|
+
{#if frozen && canRequest}
|
|
394
|
+
<p class="sub note">{t('att_locked_no_change')}</p>
|
|
395
|
+
{/if}
|
|
396
|
+
{/if}
|
|
397
|
+
</section>
|
|
398
|
+
|
|
399
|
+
{#if corrections.loading}
|
|
400
|
+
<div class="skel"><Skeleton height="34px" /></div>
|
|
401
|
+
{:else if dayCorrections.length}
|
|
402
|
+
<section class="block" aria-label={t('att_corrections_pending')}>
|
|
403
|
+
<h3>{t('att_corrections_pending')}</h3>
|
|
404
|
+
<ul class="plain">
|
|
405
|
+
{#each dayCorrections as correction (correction.id)}
|
|
406
|
+
<li class="correction">
|
|
407
|
+
<div class="line">
|
|
408
|
+
<span class="proposal">
|
|
409
|
+
{correction.proposed
|
|
410
|
+
.map((p) => `${directionLabel(p.direction)} ${localTime(new Date(p.at))}`)
|
|
411
|
+
.join(' · ')}
|
|
412
|
+
</span>
|
|
413
|
+
<Badge tone="upcoming">{t('att_correction_waiting')}</Badge>
|
|
414
|
+
</div>
|
|
415
|
+
<p class="sub">{correction.reason}</p>
|
|
416
|
+
</li>
|
|
417
|
+
{/each}
|
|
418
|
+
</ul>
|
|
419
|
+
</section>
|
|
420
|
+
{:else if corrections.failed}
|
|
421
|
+
<!--
|
|
422
|
+
A failed list of corrections must not read as "you have asked for nothing" — that is exactly
|
|
423
|
+
the sentence somebody would act on by asking a second time.
|
|
424
|
+
-->
|
|
425
|
+
<p class="stale" role="status">
|
|
426
|
+
<span>{t('att_corrections_error')}</span>
|
|
427
|
+
<Button size="sm" variant="ghost" onclick={corrections.retry}>{t('retry')}</Button>
|
|
428
|
+
</p>
|
|
429
|
+
{/if}
|
|
430
|
+
|
|
431
|
+
{#if canRequest && punches.length > 0}
|
|
432
|
+
<div class="foot">
|
|
433
|
+
<Button size="sm" variant="secondary" icon="square-pen" disabled={frozen} onclick={() => openRequest(null)}>
|
|
434
|
+
{t('att_correction_request')}
|
|
435
|
+
</Button>
|
|
436
|
+
</div>
|
|
437
|
+
{/if}
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
<!--
|
|
441
|
+
Voiding is not deleting, and the person pressing it has to know that before it happens rather than
|
|
442
|
+
after: the record keeps the punch and gains a row saying who struck it out and why. Somebody who
|
|
443
|
+
believes they erased a punch will type a different reason from somebody who knows the sentence is
|
|
444
|
+
permanent.
|
|
445
|
+
-->
|
|
446
|
+
<Dialog
|
|
447
|
+
open={voiding !== null}
|
|
448
|
+
size="sm"
|
|
449
|
+
title={t('att_void_title')}
|
|
450
|
+
onOpenChange={(next) => {
|
|
451
|
+
if (!next) closeVoid()
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
{#if voiding}
|
|
455
|
+
<p class="body">
|
|
456
|
+
{t('att_void_body', { punch: directionLabel(voiding.direction), time: timeLabel(voiding) })}
|
|
457
|
+
</p>
|
|
458
|
+
<p class="body note">{t('att_void_kept')}</p>
|
|
459
|
+
{/if}
|
|
460
|
+
|
|
461
|
+
<Field label={t('att_void_reason_label')} hint={t('att_void_reason_hint')} required>
|
|
462
|
+
{#snippet children(fieldId)}
|
|
463
|
+
<Textarea id={fieldId} bind:value={voidReasonText} rows={3} />
|
|
464
|
+
{/snippet}
|
|
465
|
+
</Field>
|
|
466
|
+
|
|
467
|
+
{#if voidError}
|
|
468
|
+
<p class="body failed" role="alert">{voidError}</p>
|
|
469
|
+
{/if}
|
|
470
|
+
|
|
471
|
+
{#snippet footer()}
|
|
472
|
+
<!-- Says why the danger button is dead, rather than leaving somebody to guess at it. -->
|
|
473
|
+
{#if !voidReasonText.trim()}<span class="hint">{t('att_void_reason_missing')}</span>{/if}
|
|
474
|
+
<!--
|
|
475
|
+
Secondary, as in `DecisionDialog`: on a destructive confirmation the way out must not be the
|
|
476
|
+
faintest control on it.
|
|
477
|
+
-->
|
|
478
|
+
<Button variant="secondary" onclick={closeVoid} disabled={voidInFlight}>{t('att_void_keep')}</Button>
|
|
479
|
+
<Button
|
|
480
|
+
variant="danger"
|
|
481
|
+
loading={voidInFlight}
|
|
482
|
+
disabled={!voidReasonText.trim()}
|
|
483
|
+
onclick={confirmVoid}
|
|
484
|
+
>
|
|
485
|
+
{t('att_void')}
|
|
486
|
+
</Button>
|
|
487
|
+
{/snippet}
|
|
488
|
+
</Dialog>
|
|
489
|
+
|
|
490
|
+
<RegularizationDialog
|
|
491
|
+
open={requestOpen}
|
|
492
|
+
{workspaceId}
|
|
493
|
+
businessDate={day.businessDate}
|
|
494
|
+
punch={requestPunch}
|
|
495
|
+
punches={live}
|
|
496
|
+
onClose={() => {
|
|
497
|
+
requestOpen = false
|
|
498
|
+
requestPunch = null
|
|
499
|
+
}}
|
|
500
|
+
/>
|
|
501
|
+
|
|
502
|
+
<style>
|
|
503
|
+
.detail {
|
|
504
|
+
display: grid;
|
|
505
|
+
gap: 16px;
|
|
506
|
+
margin-block: 4px 12px;
|
|
507
|
+
margin-inline-start: 12px;
|
|
508
|
+
padding: 14px;
|
|
509
|
+
border: 1px solid var(--kern-border);
|
|
510
|
+
border-radius: var(--kern-r-md);
|
|
511
|
+
background: var(--kern-surface);
|
|
512
|
+
}
|
|
513
|
+
.block {
|
|
514
|
+
display: grid;
|
|
515
|
+
gap: 8px;
|
|
516
|
+
}
|
|
517
|
+
h3 {
|
|
518
|
+
margin: 0;
|
|
519
|
+
font-size: 11px;
|
|
520
|
+
font-weight: 600;
|
|
521
|
+
letter-spacing: 0.06em;
|
|
522
|
+
text-transform: uppercase;
|
|
523
|
+
color: var(--kern-ink-500);
|
|
524
|
+
}
|
|
525
|
+
.plain {
|
|
526
|
+
display: grid;
|
|
527
|
+
gap: 6px;
|
|
528
|
+
list-style: none;
|
|
529
|
+
margin: 0;
|
|
530
|
+
padding: 0;
|
|
531
|
+
}
|
|
532
|
+
.skel {
|
|
533
|
+
display: grid;
|
|
534
|
+
gap: 6px;
|
|
535
|
+
}
|
|
536
|
+
/*
|
|
537
|
+
* The warning ink is 4.58:1 on its own tint in light and 5.28:1 in dark, and the tint is what makes
|
|
538
|
+
* the block read as a notice rather than as another list.
|
|
539
|
+
*/
|
|
540
|
+
.anomalies ul {
|
|
541
|
+
padding: 8px 12px;
|
|
542
|
+
border-radius: var(--kern-r-md);
|
|
543
|
+
background: var(--kern-warning-tint);
|
|
544
|
+
color: var(--kern-warning);
|
|
545
|
+
font-size: 12.5px;
|
|
546
|
+
line-height: 1.5;
|
|
547
|
+
}
|
|
548
|
+
.punches {
|
|
549
|
+
gap: 2px;
|
|
550
|
+
}
|
|
551
|
+
.punch,
|
|
552
|
+
.correction {
|
|
553
|
+
display: grid;
|
|
554
|
+
gap: 2px;
|
|
555
|
+
padding-block: 6px;
|
|
556
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
557
|
+
}
|
|
558
|
+
.line {
|
|
559
|
+
display: flex;
|
|
560
|
+
align-items: center;
|
|
561
|
+
gap: 8px;
|
|
562
|
+
flex-wrap: wrap;
|
|
563
|
+
}
|
|
564
|
+
.spacer {
|
|
565
|
+
flex: 1;
|
|
566
|
+
}
|
|
567
|
+
.dir {
|
|
568
|
+
font-size: 13px;
|
|
569
|
+
font-weight: 500;
|
|
570
|
+
}
|
|
571
|
+
.time {
|
|
572
|
+
font-size: 13px;
|
|
573
|
+
font-variant-numeric: tabular-nums;
|
|
574
|
+
color: var(--kern-ink-700);
|
|
575
|
+
}
|
|
576
|
+
.proposal {
|
|
577
|
+
font-size: 13px;
|
|
578
|
+
font-variant-numeric: tabular-nums;
|
|
579
|
+
}
|
|
580
|
+
/*
|
|
581
|
+
* A voided punch is struck through and *still legible*: `opacity` would fade its text against the
|
|
582
|
+
* panel whatever token it names, and this row is the evidence that nothing was deleted.
|
|
583
|
+
*/
|
|
584
|
+
.voided .dir,
|
|
585
|
+
.voided .time {
|
|
586
|
+
color: var(--kern-ink-500);
|
|
587
|
+
text-decoration: line-through;
|
|
588
|
+
}
|
|
589
|
+
.sub {
|
|
590
|
+
margin: 0;
|
|
591
|
+
font-size: 12px;
|
|
592
|
+
line-height: 1.45;
|
|
593
|
+
color: var(--kern-ink-500);
|
|
594
|
+
}
|
|
595
|
+
/* 5.23:1 on --kern-surface in light, 6.80:1 in dark — 12px text has to clear 4.5. */
|
|
596
|
+
.warn {
|
|
597
|
+
color: var(--kern-warning);
|
|
598
|
+
}
|
|
599
|
+
.note {
|
|
600
|
+
margin-block-start: 2px;
|
|
601
|
+
}
|
|
602
|
+
.stale {
|
|
603
|
+
display: flex;
|
|
604
|
+
align-items: center;
|
|
605
|
+
justify-content: space-between;
|
|
606
|
+
flex-wrap: wrap;
|
|
607
|
+
gap: 8px;
|
|
608
|
+
margin: 0;
|
|
609
|
+
padding-block: 6px;
|
|
610
|
+
padding-inline: 12px 8px;
|
|
611
|
+
border-radius: var(--kern-r-md);
|
|
612
|
+
background: var(--kern-warning-tint);
|
|
613
|
+
color: var(--kern-warning);
|
|
614
|
+
font-size: 12.5px;
|
|
615
|
+
}
|
|
616
|
+
.foot {
|
|
617
|
+
display: flex;
|
|
618
|
+
}
|
|
619
|
+
.body {
|
|
620
|
+
margin: 0;
|
|
621
|
+
font-size: 13.5px;
|
|
622
|
+
line-height: 1.55;
|
|
623
|
+
color: var(--kern-ink-700);
|
|
624
|
+
}
|
|
625
|
+
/* A colour, not opacity: opacity fades the text against the dialog whatever token it names. */
|
|
626
|
+
.body.note {
|
|
627
|
+
margin-block: 8px 12px;
|
|
628
|
+
color: var(--kern-ink-500);
|
|
629
|
+
}
|
|
630
|
+
/* A dialog body sits on --kern-surface-raised, not the page: 6.33:1 there in light, 5.04:1 in dark. */
|
|
631
|
+
.failed {
|
|
632
|
+
margin-block-start: 8px;
|
|
633
|
+
color: var(--kern-danger);
|
|
634
|
+
}
|
|
635
|
+
.hint {
|
|
636
|
+
margin-inline-end: auto;
|
|
637
|
+
align-self: center;
|
|
638
|
+
font-size: 12px;
|
|
639
|
+
color: var(--kern-ink-500);
|
|
640
|
+
}
|
|
641
|
+
</style>
|