@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
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
EmptyState,
|
|
8
|
+
Field,
|
|
9
|
+
formatDateRange,
|
|
10
|
+
formatDateTime,
|
|
11
|
+
Input,
|
|
12
|
+
navigation,
|
|
13
|
+
Select,
|
|
14
|
+
SettingsPage,
|
|
15
|
+
SettingsSection,
|
|
16
|
+
Skeleton,
|
|
17
|
+
session,
|
|
18
|
+
Textarea,
|
|
19
|
+
toast,
|
|
20
|
+
} from '@kernhq/ui'
|
|
21
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
22
|
+
// The client barrel re-exports the models the screens have needed so far, and the period was not
|
|
23
|
+
// one of them. Straight from the contract rather than widening a barrel another screen shares.
|
|
24
|
+
import type { Period } from '../../contract/policies.js'
|
|
25
|
+
import { getHrApi } from '../api-instance.js'
|
|
26
|
+
import { HR_CAPABILITIES } from '../capabilities.js'
|
|
27
|
+
import { t } from '../i18n.js'
|
|
28
|
+
import { canHr } from '../permissions.js'
|
|
29
|
+
import { isoDate } from '../query.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The lock that makes a filed month stay filed.
|
|
33
|
+
*
|
|
34
|
+
* Everything else in this module respects a locked period — `PolicyService.assertOpen` refuses a
|
|
35
|
+
* write into one, and `attendance.days.recompute` skips the days inside it — and until this screen
|
|
36
|
+
* existed nothing could put one there. `periods.list`, `create`, `lock` and `unlock` were all
|
|
37
|
+
* implemented with no caller, so the mechanism the module is built around was unreachable.
|
|
38
|
+
*
|
|
39
|
+
* Three things about periods that the screen has to keep visible, because getting any of them
|
|
40
|
+
* wrong is a payroll problem rather than a display problem:
|
|
41
|
+
*
|
|
42
|
+
* **A period scopes to its legal entity.** `isLocked` reads a period with no entity as closing the
|
|
43
|
+
* whole workspace and one with an entity as closing only the people that entity employed *on that
|
|
44
|
+
* date*. So the list names what each period applies to, and the create form only offers the choice
|
|
45
|
+
* where the `legal_entities` capability is on — but a period that names an entity is still labelled
|
|
46
|
+
* as such when the capability is later switched off, because a screen that drew it as
|
|
47
|
+
* workspace-wide would be lying about who is frozen.
|
|
48
|
+
*
|
|
49
|
+
* **The kind is a label, not a switch.** `isLocked` does not filter on `kind`: an *attendance*
|
|
50
|
+
* period closes the same dates a *payroll* one would. The kind records why the month was closed,
|
|
51
|
+
* and the sections say so rather than implying two independent locks.
|
|
52
|
+
*
|
|
53
|
+
* **Reopening is the consequential act.** Locking is the safe direction — it stops figures moving.
|
|
54
|
+
* Unlocking lets a month move underneath a payroll somebody has already filed, which is why the
|
|
55
|
+
* contract makes the reason mandatory, the server writes it onto the period, and this screen shows
|
|
56
|
+
* the days that come back rather than a bare "done".
|
|
57
|
+
*/
|
|
58
|
+
const api = getHrApi()
|
|
59
|
+
const queryClient = useQueryClient()
|
|
60
|
+
|
|
61
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
62
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
63
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `hr.period.manage` is dangerous and owner-only by default, and the settings page is registered
|
|
67
|
+
* behind it — but the check is repeated here rather than assumed, because a role edit takes effect
|
|
68
|
+
* on the next render and a button that 403s is worse than no button.
|
|
69
|
+
*/
|
|
70
|
+
const manage = $derived(canHr('periodManage'))
|
|
71
|
+
const hasEntities = $derived(session.hasCapability('hr', HR_CAPABILITIES.legalEntities))
|
|
72
|
+
|
|
73
|
+
type Kind = Period['kind']
|
|
74
|
+
const KINDS: Kind[] = ['payroll', 'attendance']
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `[module, entity, …scope]`, the shape `hrKeys` uses. Spelled here rather than in `query.ts`
|
|
78
|
+
* because this is the only screen that asks.
|
|
79
|
+
*/
|
|
80
|
+
const periodsKey = (ws: string) => ['hr', 'periods', ws] as const
|
|
81
|
+
const entitiesKey = (ws: string) => ['hr', 'entities', ws] as const
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every period in one read.
|
|
85
|
+
*
|
|
86
|
+
* `periods.list` takes a `kind` but always answers `nextCursor: null`, so two filtered queries
|
|
87
|
+
* would be two round trips for one list that is already whole. 200 is the contract's maximum and
|
|
88
|
+
* some years of months; the overlap check below is only as good as what came back, which is why
|
|
89
|
+
* the server's exclusion constraint is still the thing that decides.
|
|
90
|
+
*/
|
|
91
|
+
const periodsQuery = createQuery(() => ({
|
|
92
|
+
queryKey: periodsKey(workspaceId),
|
|
93
|
+
enabled: Boolean(workspaceId),
|
|
94
|
+
queryFn: () => api.periods.list({ workspaceId, limit: 200 }),
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The retained value, never the query status. Locking invalidates the whole module, so a refetch
|
|
99
|
+
* failing while the last good list is still in `data` is the ordinary case — an error branch above
|
|
100
|
+
* the data would blank a working screen for as long as core takes to come back.
|
|
101
|
+
*/
|
|
102
|
+
const periods = $derived(periodsQuery.data?.items ?? [])
|
|
103
|
+
/** A disabled query is `pending` and not fetching, so it is not loading. */
|
|
104
|
+
const loading = $derived(!workspaceId || periodsQuery.isLoading)
|
|
105
|
+
const stale = $derived(periodsQuery.isError && periods.length > 0)
|
|
106
|
+
|
|
107
|
+
const entitiesQuery = createQuery(() => ({
|
|
108
|
+
queryKey: entitiesKey(workspaceId),
|
|
109
|
+
enabled: Boolean(workspaceId) && hasEntities,
|
|
110
|
+
queryFn: () => api.entities.list({ workspaceId, includeArchived: true }),
|
|
111
|
+
}))
|
|
112
|
+
const entities = $derived(entitiesQuery.data ?? [])
|
|
113
|
+
|
|
114
|
+
const byKind = (kind: Kind) => periods.filter((p) => p.kind === kind)
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The scope column appears when the workspace has entities — and also when it does not but a period
|
|
118
|
+
* names one anyway, which is what a workspace that switched `legal_entities` off leaves behind.
|
|
119
|
+
* Those rows cannot be named (the procedure that names them answers 404 with the capability off),
|
|
120
|
+
* so they say "one legal entity" rather than being drawn as if they closed everybody.
|
|
121
|
+
*/
|
|
122
|
+
const showScope = $derived(hasEntities || periods.some((p) => p.legalEntityId !== null))
|
|
123
|
+
|
|
124
|
+
const scopeLabel = (legalEntityId: string | null): string =>
|
|
125
|
+
legalEntityId === null
|
|
126
|
+
? t('periods_scope_all')
|
|
127
|
+
: (entities.find((e) => e.id === legalEntityId)?.name ?? t('periods_scope_entity'))
|
|
128
|
+
|
|
129
|
+
/** The range as one string: `formatRange` collapses the shared parts and reads correctly in RTL. */
|
|
130
|
+
const rangeOf = (period: { startsOn: string; endsOn: string }) =>
|
|
131
|
+
formatDateRange(`${period.startsOn}T00:00:00`, `${period.endsOn}T00:00:00`)
|
|
132
|
+
|
|
133
|
+
const statusTone = (status: Period['status']): BadgeTone => (status === 'locked' ? 'done' : 'grey')
|
|
134
|
+
const statusLabel = (status: Period['status']) =>
|
|
135
|
+
status === 'locked' ? t('periods_locked') : t('periods_open')
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A period change moves the attendance day sheet, the balances computed from it and anything a
|
|
139
|
+
* policy would have recomputed, so the module's cache is dropped whole rather than guessing which
|
|
140
|
+
* keys a lock touched.
|
|
141
|
+
*/
|
|
142
|
+
const refresh = () => {
|
|
143
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One click, one write.
|
|
148
|
+
*
|
|
149
|
+
* `disabled={mutation.isPending}` reaches the button on the next render, and two quick clicks are
|
|
150
|
+
* one render apart — which here means two periods, or a lock and its own reopen.
|
|
151
|
+
*/
|
|
152
|
+
let firing = $state(false)
|
|
153
|
+
function once(run: () => void) {
|
|
154
|
+
if (firing) return
|
|
155
|
+
firing = true
|
|
156
|
+
run()
|
|
157
|
+
}
|
|
158
|
+
const settled = () => {
|
|
159
|
+
firing = false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* What a refusal says to the person who asked for it.
|
|
164
|
+
*
|
|
165
|
+
* The map is empty because none of the four period procedures carries a machine-readable reason
|
|
166
|
+
* yet: `lock` refuses an already-locked period through `KernError.conflict` with a sentence and no
|
|
167
|
+
* code, and `create` is refused by the `hr_periods_no_overlap` exclusion constraint rather than by
|
|
168
|
+
* the router. So the router's own sentence is what reaches the reader, and this is where a reason
|
|
169
|
+
* goes the day one is added — never a match on the sentence, which drifts silently.
|
|
170
|
+
*/
|
|
171
|
+
const refusalMessages: Record<string, string> = {}
|
|
172
|
+
|
|
173
|
+
function failureText(error: unknown, fallbackKey: string): string {
|
|
174
|
+
const failure = error as { message?: string; data?: { reason?: unknown } }
|
|
175
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
176
|
+
const key = reason ? refusalMessages[reason] : undefined
|
|
177
|
+
// `t()` answers a key it has no string for with the key itself, so both ways of not having one —
|
|
178
|
+
// a reason no key covers, and a key whose string has not been merged — land on the router's
|
|
179
|
+
// sentence rather than putting `hr.periods_…` in front of somebody.
|
|
180
|
+
const translated = key ? t(key) : undefined
|
|
181
|
+
return (translated && translated !== key ? translated : failure.message) || t(fallbackKey)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------- create
|
|
185
|
+
|
|
186
|
+
type Draft = { kind: Kind; legalEntityId: string; startsOn: string; endsOn: string }
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The form is open or shut on its own flag, and the draft is never null.
|
|
190
|
+
*
|
|
191
|
+
* `bind:value` needs an assignable expression, and TypeScript does not carry an `{#if draft}`
|
|
192
|
+
* narrowing into the snippet the field is written in — so a nullable draft turns four date and
|
|
193
|
+
* select bindings into hand-written change handlers for nothing.
|
|
194
|
+
*/
|
|
195
|
+
let createOpen = $state(false)
|
|
196
|
+
let draft = $state<Draft>({ kind: 'payroll', legalEntityId: '', ...lastMonth() })
|
|
197
|
+
let createError = $state<string | null>(null)
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The month somebody has just finished is the one they came to close, so that is what the form
|
|
201
|
+
* opens on — not today's month, which is still running.
|
|
202
|
+
*/
|
|
203
|
+
function lastMonth(): { startsOn: string; endsOn: string } {
|
|
204
|
+
const now = new Date()
|
|
205
|
+
return {
|
|
206
|
+
startsOn: isoDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
|
|
207
|
+
endsOn: isoDate(new Date(now.getFullYear(), now.getMonth(), 0)),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function openCreate(kind: Kind) {
|
|
212
|
+
createError = null
|
|
213
|
+
draft = { kind, legalEntityId: '', ...lastMonth() }
|
|
214
|
+
createOpen = true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const rangeValid = $derived(draft.startsOn !== '' && draft.endsOn !== '' && draft.endsOn >= draft.startsOn)
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The period this one would overlap, found here rather than at the server.
|
|
221
|
+
*
|
|
222
|
+
* `hr_periods_no_overlap` excludes two periods of one kind covering the same day for the same
|
|
223
|
+
* employer, and a constraint violation arrives as an opaque failure with a Postgres sentence in it.
|
|
224
|
+
* Naming the period in the way is the difference between "that did not work" and "January is
|
|
225
|
+
* already there".
|
|
226
|
+
*/
|
|
227
|
+
const clash = $derived.by(() => {
|
|
228
|
+
if (!rangeValid) return null
|
|
229
|
+
const entity = draft.legalEntityId || null
|
|
230
|
+
return (
|
|
231
|
+
periods.find(
|
|
232
|
+
(p) =>
|
|
233
|
+
p.kind === draft.kind &&
|
|
234
|
+
p.legalEntityId === entity &&
|
|
235
|
+
p.startsOn <= draft.endsOn &&
|
|
236
|
+
p.endsOn >= draft.startsOn,
|
|
237
|
+
) ?? null
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const canCreate = $derived(manage && rangeValid && clash === null)
|
|
242
|
+
|
|
243
|
+
const create = createMutation(() => ({
|
|
244
|
+
mutationFn: (input: Draft) =>
|
|
245
|
+
api.periods.create({
|
|
246
|
+
workspaceId,
|
|
247
|
+
kind: input.kind,
|
|
248
|
+
legalEntityId: input.legalEntityId || null,
|
|
249
|
+
startsOn: input.startsOn,
|
|
250
|
+
endsOn: input.endsOn,
|
|
251
|
+
}),
|
|
252
|
+
onSuccess: (period: Period) => {
|
|
253
|
+
toast.success(t('periods_created', { range: rangeOf(period) }))
|
|
254
|
+
createOpen = false
|
|
255
|
+
createError = null
|
|
256
|
+
refresh()
|
|
257
|
+
},
|
|
258
|
+
onError: (error: Error) => {
|
|
259
|
+
createError = failureText(error, 'periods_create_error')
|
|
260
|
+
},
|
|
261
|
+
onSettled: settled,
|
|
262
|
+
}))
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------- lock
|
|
265
|
+
|
|
266
|
+
let locking = $state<Period | null>(null)
|
|
267
|
+
let lockNote = $state('')
|
|
268
|
+
let lockError = $state<string | null>(null)
|
|
269
|
+
|
|
270
|
+
function openLock(period: Period) {
|
|
271
|
+
locking = period
|
|
272
|
+
lockNote = ''
|
|
273
|
+
lockError = null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lock = createMutation(() => ({
|
|
277
|
+
mutationFn: (period: Period) =>
|
|
278
|
+
api.periods.lock({ workspaceId, periodId: period.id, note: lockNote.trim() || null }),
|
|
279
|
+
onSuccess: (result: Period & { lockedDays: number }) => {
|
|
280
|
+
// The count is the point of the response: "closed" says nothing about how much stopped moving.
|
|
281
|
+
toast.success(
|
|
282
|
+
result.lockedDays === 0
|
|
283
|
+
? t('periods_locked_none')
|
|
284
|
+
: t('periods_locked_toast', { count: result.lockedDays }),
|
|
285
|
+
)
|
|
286
|
+
locking = null
|
|
287
|
+
lockError = null
|
|
288
|
+
refresh()
|
|
289
|
+
},
|
|
290
|
+
onError: (error: Error) => {
|
|
291
|
+
lockError = failureText(error, 'periods_lock_error')
|
|
292
|
+
},
|
|
293
|
+
onSettled: settled,
|
|
294
|
+
}))
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------- reopen
|
|
297
|
+
|
|
298
|
+
let reopening = $state<Period | null>(null)
|
|
299
|
+
let reason = $state('')
|
|
300
|
+
let reopenError = $state<string | null>(null)
|
|
301
|
+
|
|
302
|
+
function openReopen(period: Period) {
|
|
303
|
+
reopening = period
|
|
304
|
+
reason = ''
|
|
305
|
+
reopenError = null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const reasonValid = $derived(reason.trim().length > 0)
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The other closed periods that would keep some of these days shut.
|
|
312
|
+
*
|
|
313
|
+
* `setPeriodLock` asks `isLocked` about every day it is about to reopen and leaves the ones another
|
|
314
|
+
* period still closes — so reopening an entity's January while the workspace's January is closed
|
|
315
|
+
* changes nothing at all. Two periods overlap in *people* unless both name an entity and the
|
|
316
|
+
* entities differ; either one naming nobody covers everybody the other does.
|
|
317
|
+
*/
|
|
318
|
+
const stillClosedBy = $derived.by(() => {
|
|
319
|
+
const period = reopening
|
|
320
|
+
if (!period) return []
|
|
321
|
+
return periods.filter(
|
|
322
|
+
(other) =>
|
|
323
|
+
other.id !== period.id &&
|
|
324
|
+
other.status === 'locked' &&
|
|
325
|
+
other.startsOn <= period.endsOn &&
|
|
326
|
+
other.endsOn >= period.startsOn &&
|
|
327
|
+
(other.legalEntityId === null ||
|
|
328
|
+
period.legalEntityId === null ||
|
|
329
|
+
other.legalEntityId === period.legalEntityId),
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const reopen = createMutation(() => ({
|
|
334
|
+
mutationFn: (period: Period) =>
|
|
335
|
+
api.periods.unlock({ workspaceId, periodId: period.id, reason: reason.trim() }),
|
|
336
|
+
onSuccess: (result: Period & { unlockedDays: number }) => {
|
|
337
|
+
toast.success(
|
|
338
|
+
result.unlockedDays === 0
|
|
339
|
+
? t('periods_reopened_none')
|
|
340
|
+
: t('periods_reopened_toast', { count: result.unlockedDays }),
|
|
341
|
+
)
|
|
342
|
+
reopening = null
|
|
343
|
+
reopenError = null
|
|
344
|
+
refresh()
|
|
345
|
+
},
|
|
346
|
+
onError: (error: Error) => {
|
|
347
|
+
reopenError = failureText(error, 'periods_reopen_error')
|
|
348
|
+
},
|
|
349
|
+
onSettled: settled,
|
|
350
|
+
}))
|
|
351
|
+
|
|
352
|
+
const kindOptions = $derived([
|
|
353
|
+
{ value: 'payroll', label: t('periods_payroll') },
|
|
354
|
+
{ value: 'attendance', label: t('periods_attendance') },
|
|
355
|
+
])
|
|
356
|
+
|
|
357
|
+
/** Archived entities are included: a period filed under one is still a period somebody must name. */
|
|
358
|
+
const entityOptions = $derived([
|
|
359
|
+
{ value: '', label: t('periods_scope_all') },
|
|
360
|
+
...entities.map((e) => ({ value: e.id, label: e.name })),
|
|
361
|
+
])
|
|
362
|
+
|
|
363
|
+
const sectionTitle = (kind: Kind) => (kind === 'payroll' ? t('periods_payroll') : t('periods_attendance'))
|
|
364
|
+
const sectionDesc = (kind: Kind) =>
|
|
365
|
+
kind === 'payroll' ? t('periods_payroll_desc') : t('periods_attendance_desc')
|
|
366
|
+
</script>
|
|
367
|
+
|
|
368
|
+
<SettingsPage title={t('settings_periods')} description={t('periods_desc')}>
|
|
369
|
+
{#if stale}
|
|
370
|
+
<p class="stale" role="status">
|
|
371
|
+
<span>{t('periods_stale')}</span>
|
|
372
|
+
<Button size="sm" variant="ghost" onclick={() => void periodsQuery.refetch()}>{t('retry')}</Button>
|
|
373
|
+
</p>
|
|
374
|
+
{/if}
|
|
375
|
+
|
|
376
|
+
{#if !manage}
|
|
377
|
+
<!-- The list is readable without the permission; nothing on it is actionable, and a screen that
|
|
378
|
+
simply omits every control reads as broken rather than as restricted. -->
|
|
379
|
+
<p class="note">{t('periods_read_only')}</p>
|
|
380
|
+
{/if}
|
|
381
|
+
|
|
382
|
+
{#if !loading && periodsQuery.isError && periods.length === 0}
|
|
383
|
+
<!--
|
|
384
|
+
One error, not one per section. Nothing was retained, so both lists below would say the same
|
|
385
|
+
thing twice — and the retry belongs to the single read that failed, which fills both.
|
|
386
|
+
-->
|
|
387
|
+
<SettingsSection title={t('settings_periods')}>
|
|
388
|
+
<EmptyState icon="triangle-alert" title={t('periods_error')}>
|
|
389
|
+
{#snippet actions()}
|
|
390
|
+
<Button variant="secondary" onclick={() => void periodsQuery.refetch()}>{t('retry')}</Button>
|
|
391
|
+
{/snippet}
|
|
392
|
+
</EmptyState>
|
|
393
|
+
</SettingsSection>
|
|
394
|
+
{:else}
|
|
395
|
+
{#each KINDS as kind (kind)}
|
|
396
|
+
{@const rows = byKind(kind)}
|
|
397
|
+
<SettingsSection title={sectionTitle(kind)} description={sectionDesc(kind)} flush={rows.length > 0}>
|
|
398
|
+
{#snippet action()}
|
|
399
|
+
{#if manage}
|
|
400
|
+
<Button size="sm" icon="plus" variant="secondary" onclick={() => openCreate(kind)}>
|
|
401
|
+
{t('periods_new')}
|
|
402
|
+
</Button>
|
|
403
|
+
{/if}
|
|
404
|
+
{/snippet}
|
|
405
|
+
|
|
406
|
+
{#if loading}
|
|
407
|
+
<div class="rows pad">
|
|
408
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
|
|
409
|
+
</div>
|
|
410
|
+
{:else if rows.length > 0}
|
|
411
|
+
<!-- The action column is dropped rather than left empty when the viewer cannot act: a 120px
|
|
412
|
+
gutter beside every row reads as a column that failed to load. -->
|
|
413
|
+
<div
|
|
414
|
+
class="table"
|
|
415
|
+
class:scoped={showScope}
|
|
416
|
+
class:readonly={!manage}
|
|
417
|
+
role="table"
|
|
418
|
+
aria-label={sectionTitle(kind)}
|
|
419
|
+
>
|
|
420
|
+
<div class="thead" role="row">
|
|
421
|
+
<span role="columnheader">{t('periods_range')}</span>
|
|
422
|
+
{#if showScope}<span role="columnheader">{t('periods_scope')}</span>{/if}
|
|
423
|
+
<span role="columnheader">{t('status')}</span>
|
|
424
|
+
{#if manage}<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>{/if}
|
|
425
|
+
</div>
|
|
426
|
+
{#each rows as period (period.id)}
|
|
427
|
+
<div class="trow" role="row">
|
|
428
|
+
<span class="cell what" role="cell">
|
|
429
|
+
<span class="strong">{rangeOf(period)}</span>
|
|
430
|
+
{#if period.lockedAt}
|
|
431
|
+
<span class="meta">{t('periods_closed_on', { date: formatDateTime(period.lockedAt) })}</span>
|
|
432
|
+
{/if}
|
|
433
|
+
{#if period.note}
|
|
434
|
+
<span class="meta">{period.note}</span>
|
|
435
|
+
{/if}
|
|
436
|
+
</span>
|
|
437
|
+
{#if showScope}
|
|
438
|
+
<span class="cell muted" role="cell">{scopeLabel(period.legalEntityId)}</span>
|
|
439
|
+
{/if}
|
|
440
|
+
<span class="cell" role="cell">
|
|
441
|
+
<Badge tone={statusTone(period.status)}>{statusLabel(period.status)}</Badge>
|
|
442
|
+
</span>
|
|
443
|
+
{#if manage}
|
|
444
|
+
<span class="cell actions" role="cell">
|
|
445
|
+
{#if period.status === 'locked'}
|
|
446
|
+
<Button size="sm" variant="secondary" icon="lock-open" onclick={() => openReopen(period)}>
|
|
447
|
+
{t('periods_reopen')}
|
|
448
|
+
</Button>
|
|
449
|
+
{:else}
|
|
450
|
+
<Button size="sm" variant="secondary" icon="lock" onclick={() => openLock(period)}>
|
|
451
|
+
{t('periods_lock')}
|
|
452
|
+
</Button>
|
|
453
|
+
{/if}
|
|
454
|
+
</span>
|
|
455
|
+
{/if}
|
|
456
|
+
</div>
|
|
457
|
+
{/each}
|
|
458
|
+
</div>
|
|
459
|
+
{:else}
|
|
460
|
+
<EmptyState icon="calendar-days" title={t('periods_none')} description={t('periods_none_desc')}>
|
|
461
|
+
{#snippet actions()}
|
|
462
|
+
{#if manage}
|
|
463
|
+
<Button icon="plus" onclick={() => openCreate(kind)}>{t('periods_new')}</Button>
|
|
464
|
+
{/if}
|
|
465
|
+
{/snippet}
|
|
466
|
+
</EmptyState>
|
|
467
|
+
{/if}
|
|
468
|
+
</SettingsSection>
|
|
469
|
+
{/each}
|
|
470
|
+
{/if}
|
|
471
|
+
</SettingsPage>
|
|
472
|
+
|
|
473
|
+
<!-- ---------------------------------------------------------------- a new period -->
|
|
474
|
+
<Dialog
|
|
475
|
+
open={createOpen}
|
|
476
|
+
title={t('periods_create_title')}
|
|
477
|
+
description={t('periods_create_desc')}
|
|
478
|
+
onOpenChange={(o) => {
|
|
479
|
+
if (!o && !create.isPending) createOpen = false
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
<div class="form">
|
|
483
|
+
<Field label={t('periods_kind')} hint={t('periods_kind_hint')}>
|
|
484
|
+
{#snippet children(id)}
|
|
485
|
+
<Select
|
|
486
|
+
{id}
|
|
487
|
+
value={draft.kind}
|
|
488
|
+
onValueChange={(v) => {
|
|
489
|
+
draft.kind = v as Kind
|
|
490
|
+
}}
|
|
491
|
+
options={kindOptions}
|
|
492
|
+
/>
|
|
493
|
+
{/snippet}
|
|
494
|
+
</Field>
|
|
495
|
+
|
|
496
|
+
{#if hasEntities}
|
|
497
|
+
<Field label={t('office_entity')} hint={t('periods_entity_hint')}>
|
|
498
|
+
{#snippet children(id)}
|
|
499
|
+
<Select
|
|
500
|
+
{id}
|
|
501
|
+
bind:value={draft.legalEntityId}
|
|
502
|
+
options={entityOptions}
|
|
503
|
+
disabled={entitiesQuery.isLoading}
|
|
504
|
+
/>
|
|
505
|
+
{/snippet}
|
|
506
|
+
</Field>
|
|
507
|
+
{/if}
|
|
508
|
+
|
|
509
|
+
<div class="pair">
|
|
510
|
+
<Field label={t('periods_starts')} required>
|
|
511
|
+
{#snippet children(id)}
|
|
512
|
+
<Input {id} type="date" bind:value={draft.startsOn} />
|
|
513
|
+
{/snippet}
|
|
514
|
+
</Field>
|
|
515
|
+
<Field
|
|
516
|
+
label={t('periods_ends')}
|
|
517
|
+
required
|
|
518
|
+
error={draft.endsOn && !rangeValid ? t('periods_range_invalid') : null}
|
|
519
|
+
>
|
|
520
|
+
{#snippet children(id)}
|
|
521
|
+
<Input {id} type="date" bind:value={draft.endsOn} />
|
|
522
|
+
{/snippet}
|
|
523
|
+
</Field>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
{#if clash}
|
|
527
|
+
<!-- Stated where the button is refused rather than only in the tooltip nobody opens: two
|
|
528
|
+
periods of one kind may not cover the same day for the same employer. -->
|
|
529
|
+
<p class="failed" role="alert">{t('periods_overlap', { range: rangeOf(clash) })}</p>
|
|
530
|
+
{/if}
|
|
531
|
+
{#if createError}
|
|
532
|
+
<p class="failed" role="alert">{createError}</p>
|
|
533
|
+
{/if}
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
{#snippet footer()}
|
|
537
|
+
<Button variant="secondary" onclick={() => (createOpen = false)} disabled={create.isPending}>
|
|
538
|
+
{t('cancel')}
|
|
539
|
+
</Button>
|
|
540
|
+
<Button
|
|
541
|
+
loading={create.isPending}
|
|
542
|
+
disabled={!canCreate}
|
|
543
|
+
onclick={() => once(() => create.mutate($state.snapshot(draft)))}
|
|
544
|
+
>
|
|
545
|
+
{t('common.create')}
|
|
546
|
+
</Button>
|
|
547
|
+
{/snippet}
|
|
548
|
+
</Dialog>
|
|
549
|
+
|
|
550
|
+
<!-- ---------------------------------------------------------------- close a period -->
|
|
551
|
+
<Dialog
|
|
552
|
+
open={locking !== null}
|
|
553
|
+
size="sm"
|
|
554
|
+
title={locking ? t('periods_lock_title', { range: rangeOf(locking) }) : ''}
|
|
555
|
+
onOpenChange={(o) => {
|
|
556
|
+
if (!o && !lock.isPending) locking = null
|
|
557
|
+
}}
|
|
558
|
+
>
|
|
559
|
+
{#if locking}
|
|
560
|
+
<p class="body">{t('periods_lock_body', { scope: scopeLabel(locking.legalEntityId) })}</p>
|
|
561
|
+
<p class="body muted">{t('periods_lock_adjustments')}</p>
|
|
562
|
+
|
|
563
|
+
<Field label={t('periods_lock_note')} hint={t('periods_lock_note_hint')}>
|
|
564
|
+
{#snippet children(id)}
|
|
565
|
+
<Textarea {id} bind:value={lockNote} rows={2} maxlength={500} />
|
|
566
|
+
{/snippet}
|
|
567
|
+
</Field>
|
|
568
|
+
{/if}
|
|
569
|
+
{#if lockError}
|
|
570
|
+
<p class="failed" role="alert">{lockError}</p>
|
|
571
|
+
{/if}
|
|
572
|
+
|
|
573
|
+
{#snippet footer()}
|
|
574
|
+
<Button variant="secondary" onclick={() => (locking = null)} disabled={lock.isPending}>
|
|
575
|
+
{t('cancel')}
|
|
576
|
+
</Button>
|
|
577
|
+
<Button
|
|
578
|
+
loading={lock.isPending}
|
|
579
|
+
onclick={() => {
|
|
580
|
+
if (locking) once(() => locking && lock.mutate(locking))
|
|
581
|
+
}}
|
|
582
|
+
>
|
|
583
|
+
{t('periods_lock_confirm')}
|
|
584
|
+
</Button>
|
|
585
|
+
{/snippet}
|
|
586
|
+
</Dialog>
|
|
587
|
+
|
|
588
|
+
<!-- ---------------------------------------------------------------- reopen one -->
|
|
589
|
+
<!--
|
|
590
|
+
The most consequential thing anyone does on this screen, so the dialog says what it costs before
|
|
591
|
+
it happens: the days come back, a payroll filed against them can move underneath the figures
|
|
592
|
+
somebody has already sent out, and the reason is kept on the period so the list says why.
|
|
593
|
+
-->
|
|
594
|
+
<Dialog
|
|
595
|
+
open={reopening !== null}
|
|
596
|
+
size="sm"
|
|
597
|
+
title={reopening ? t('periods_reopen_title', { range: rangeOf(reopening) }) : ''}
|
|
598
|
+
onOpenChange={(o) => {
|
|
599
|
+
if (!o && !reopen.isPending) reopening = null
|
|
600
|
+
}}
|
|
601
|
+
>
|
|
602
|
+
{#if reopening}
|
|
603
|
+
<p class="body">{t('periods_reopen_body', { scope: scopeLabel(reopening.legalEntityId) })}</p>
|
|
604
|
+
<p class="body warn">{t('periods_reopen_filed')}</p>
|
|
605
|
+
|
|
606
|
+
{#if stillClosedBy.length > 0}
|
|
607
|
+
<!-- `setPeriodLock` leaves a day that another closed period still covers, so reopening this
|
|
608
|
+
one can change nothing at all. Better said here than discovered in the count afterwards. -->
|
|
609
|
+
<div class="body">
|
|
610
|
+
<p class="note-line">{t('periods_reopen_overlap')}</p>
|
|
611
|
+
<ul class="overlaps">
|
|
612
|
+
{#each stillClosedBy as other (other.id)}
|
|
613
|
+
<li>
|
|
614
|
+
<span>{rangeOf(other)}</span>
|
|
615
|
+
{#if showScope}<span class="muted">{scopeLabel(other.legalEntityId)}</span>{/if}
|
|
616
|
+
</li>
|
|
617
|
+
{/each}
|
|
618
|
+
</ul>
|
|
619
|
+
</div>
|
|
620
|
+
{/if}
|
|
621
|
+
|
|
622
|
+
<Field label={t('periods_reopen_reason')} hint={t('periods_reopen_reason_hint')} required>
|
|
623
|
+
{#snippet children(id)}
|
|
624
|
+
<Textarea {id} bind:value={reason} rows={3} maxlength={500} />
|
|
625
|
+
{/snippet}
|
|
626
|
+
</Field>
|
|
627
|
+
{#if !reasonValid}
|
|
628
|
+
<!-- Why the button below is dead. Plain text rather than a live region: it is a standing
|
|
629
|
+
condition, and announcing it on every keystroke is noise. -->
|
|
630
|
+
<p class="hint">{t('periods_reopen_reason_required')}</p>
|
|
631
|
+
{/if}
|
|
632
|
+
{/if}
|
|
633
|
+
{#if reopenError}
|
|
634
|
+
<p class="failed" role="alert">{reopenError}</p>
|
|
635
|
+
{/if}
|
|
636
|
+
|
|
637
|
+
{#snippet footer()}
|
|
638
|
+
<!-- Secondary, as in `DecisionDialog`: on a destructive confirmation the way out must not be the
|
|
639
|
+
faintest control on it. And it says "Keep it closed" rather than the shared "Cancel", which
|
|
640
|
+
beside a period screen reads as cancelling the period itself. -->
|
|
641
|
+
<Button variant="secondary" onclick={() => (reopening = null)} disabled={reopen.isPending}>
|
|
642
|
+
{t('periods_reopen_keep')}
|
|
643
|
+
</Button>
|
|
644
|
+
<Button
|
|
645
|
+
variant="danger"
|
|
646
|
+
loading={reopen.isPending}
|
|
647
|
+
disabled={!reasonValid}
|
|
648
|
+
onclick={() => {
|
|
649
|
+
if (reopening) once(() => reopening && reopen.mutate(reopening))
|
|
650
|
+
}}
|
|
651
|
+
>
|
|
652
|
+
{t('periods_reopen')}
|
|
653
|
+
</Button>
|
|
654
|
+
{/snippet}
|
|
655
|
+
</Dialog>
|
|
656
|
+
|
|
657
|
+
<style>
|
|
658
|
+
.rows {
|
|
659
|
+
display: grid;
|
|
660
|
+
gap: 4px;
|
|
661
|
+
}
|
|
662
|
+
.pad {
|
|
663
|
+
padding: 2px 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
667
|
+
.table {
|
|
668
|
+
--hr-period-cols: minmax(180px, 1fr) 92px 120px;
|
|
669
|
+
width: 100%;
|
|
670
|
+
}
|
|
671
|
+
.table.scoped {
|
|
672
|
+
--hr-period-cols: minmax(180px, 1fr) minmax(120px, 0.7fr) 92px 120px;
|
|
673
|
+
}
|
|
674
|
+
.table.readonly {
|
|
675
|
+
--hr-period-cols: minmax(180px, 1fr) 92px;
|
|
676
|
+
}
|
|
677
|
+
.table.scoped.readonly {
|
|
678
|
+
--hr-period-cols: minmax(180px, 1fr) minmax(120px, 0.7fr) 92px;
|
|
679
|
+
}
|
|
680
|
+
.thead,
|
|
681
|
+
.trow {
|
|
682
|
+
display: grid;
|
|
683
|
+
grid-template-columns: var(--hr-period-cols);
|
|
684
|
+
gap: 12px;
|
|
685
|
+
align-items: center;
|
|
686
|
+
padding-inline: 18px;
|
|
687
|
+
}
|
|
688
|
+
.thead {
|
|
689
|
+
height: 32px;
|
|
690
|
+
border-block-end: 1px solid var(--kern-border);
|
|
691
|
+
font-size: 11px;
|
|
692
|
+
font-weight: 600;
|
|
693
|
+
letter-spacing: 0.06em;
|
|
694
|
+
text-transform: uppercase;
|
|
695
|
+
color: var(--kern-ink-500);
|
|
696
|
+
}
|
|
697
|
+
.trow {
|
|
698
|
+
min-height: 52px;
|
|
699
|
+
padding-block: 8px;
|
|
700
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
701
|
+
}
|
|
702
|
+
.trow:last-child {
|
|
703
|
+
border-block-end: none;
|
|
704
|
+
}
|
|
705
|
+
.cell {
|
|
706
|
+
min-width: 0;
|
|
707
|
+
overflow: hidden;
|
|
708
|
+
text-overflow: ellipsis;
|
|
709
|
+
}
|
|
710
|
+
.what {
|
|
711
|
+
display: grid;
|
|
712
|
+
gap: 2px;
|
|
713
|
+
}
|
|
714
|
+
.strong {
|
|
715
|
+
overflow: hidden;
|
|
716
|
+
text-overflow: ellipsis;
|
|
717
|
+
white-space: nowrap;
|
|
718
|
+
font-size: 13.5px;
|
|
719
|
+
font-weight: 500;
|
|
720
|
+
}
|
|
721
|
+
.meta {
|
|
722
|
+
overflow: hidden;
|
|
723
|
+
text-overflow: ellipsis;
|
|
724
|
+
white-space: nowrap;
|
|
725
|
+
font-size: 12px;
|
|
726
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
727
|
+
color: var(--kern-ink-500);
|
|
728
|
+
}
|
|
729
|
+
.muted {
|
|
730
|
+
font-size: 13px;
|
|
731
|
+
color: var(--kern-ink-500);
|
|
732
|
+
}
|
|
733
|
+
.actions {
|
|
734
|
+
display: flex;
|
|
735
|
+
justify-content: flex-end;
|
|
736
|
+
overflow: visible;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/*
|
|
740
|
+
* The warning ink is 4.37:1 on `--kern-canvas` and 4.58:1 in light / 5.28:1 in dark on its own
|
|
741
|
+
* tint, which is what a 12.5px line has to clear — and the tint is what makes the strip read as a
|
|
742
|
+
* notice rather than as another card.
|
|
743
|
+
*/
|
|
744
|
+
.stale {
|
|
745
|
+
display: flex;
|
|
746
|
+
align-items: center;
|
|
747
|
+
justify-content: space-between;
|
|
748
|
+
flex-wrap: wrap;
|
|
749
|
+
gap: 8px;
|
|
750
|
+
margin: 0;
|
|
751
|
+
padding-block: 6px;
|
|
752
|
+
padding-inline: 12px 8px;
|
|
753
|
+
border-radius: var(--kern-r-md);
|
|
754
|
+
background: var(--kern-warning-tint);
|
|
755
|
+
color: var(--kern-warning);
|
|
756
|
+
font-size: 12.5px;
|
|
757
|
+
}
|
|
758
|
+
.note {
|
|
759
|
+
margin: 0;
|
|
760
|
+
padding: 10px 12px;
|
|
761
|
+
border-radius: var(--kern-r-md2);
|
|
762
|
+
background: var(--kern-info-tint);
|
|
763
|
+
color: var(--kern-ink-700);
|
|
764
|
+
font-size: 12.5px;
|
|
765
|
+
line-height: 1.5;
|
|
766
|
+
}
|
|
767
|
+
.body {
|
|
768
|
+
margin: 0 0 12px;
|
|
769
|
+
font-size: 13.5px;
|
|
770
|
+
line-height: 1.5;
|
|
771
|
+
}
|
|
772
|
+
.body.muted {
|
|
773
|
+
font-size: 12.5px;
|
|
774
|
+
}
|
|
775
|
+
.warn {
|
|
776
|
+
padding: 10px 12px;
|
|
777
|
+
border-radius: var(--kern-r-md2);
|
|
778
|
+
background: var(--kern-warning-tint);
|
|
779
|
+
color: var(--kern-warning);
|
|
780
|
+
font-size: 12.5px;
|
|
781
|
+
}
|
|
782
|
+
.note-line {
|
|
783
|
+
margin: 0 0 4px;
|
|
784
|
+
font-size: 12.5px;
|
|
785
|
+
color: var(--kern-ink-700);
|
|
786
|
+
}
|
|
787
|
+
.overlaps {
|
|
788
|
+
display: grid;
|
|
789
|
+
gap: 2px;
|
|
790
|
+
margin: 0;
|
|
791
|
+
padding: 0;
|
|
792
|
+
list-style: none;
|
|
793
|
+
font-size: 12.5px;
|
|
794
|
+
}
|
|
795
|
+
.overlaps li {
|
|
796
|
+
display: flex;
|
|
797
|
+
align-items: baseline;
|
|
798
|
+
gap: 10px;
|
|
799
|
+
min-width: 0;
|
|
800
|
+
}
|
|
801
|
+
.hint {
|
|
802
|
+
margin: 6px 0 0;
|
|
803
|
+
font-size: 12px;
|
|
804
|
+
color: var(--kern-ink-500);
|
|
805
|
+
}
|
|
806
|
+
.failed {
|
|
807
|
+
margin: 8px 0 0;
|
|
808
|
+
font-size: 12.5px;
|
|
809
|
+
color: var(--kern-danger);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.form {
|
|
813
|
+
display: grid;
|
|
814
|
+
gap: 14px;
|
|
815
|
+
}
|
|
816
|
+
.pair {
|
|
817
|
+
display: grid;
|
|
818
|
+
grid-template-columns: 1fr 1fr;
|
|
819
|
+
gap: 12px;
|
|
820
|
+
align-items: start;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.sr-only {
|
|
824
|
+
position: absolute;
|
|
825
|
+
width: 1px;
|
|
826
|
+
height: 1px;
|
|
827
|
+
overflow: hidden;
|
|
828
|
+
clip-path: inset(50%);
|
|
829
|
+
white-space: nowrap;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
@media (max-width: 640px) {
|
|
833
|
+
.table,
|
|
834
|
+
.table.scoped {
|
|
835
|
+
--hr-period-cols: minmax(140px, 1fr) 108px;
|
|
836
|
+
}
|
|
837
|
+
.table.readonly,
|
|
838
|
+
.table.scoped.readonly {
|
|
839
|
+
--hr-period-cols: minmax(140px, 1fr) 92px;
|
|
840
|
+
}
|
|
841
|
+
/*
|
|
842
|
+
* Two columns, and which one survives depends on whether the viewer can act. With an action the
|
|
843
|
+
* status badge goes, because the action already states it — a row offering "Reopen" is a closed
|
|
844
|
+
* one. Without an action the badge is the only signal there is, so the scope goes instead.
|
|
845
|
+
*/
|
|
846
|
+
.table.scoped .thead > :nth-child(2),
|
|
847
|
+
.table.scoped .trow > :nth-child(2),
|
|
848
|
+
.table:not(.readonly) .thead > :nth-child(2),
|
|
849
|
+
.table:not(.readonly) .trow > :nth-child(2),
|
|
850
|
+
.table.scoped:not(.readonly) .thead > :nth-child(3),
|
|
851
|
+
.table.scoped:not(.readonly) .trow > :nth-child(3) {
|
|
852
|
+
display: none;
|
|
853
|
+
}
|
|
854
|
+
.pair {
|
|
855
|
+
grid-template-columns: 1fr;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
</style>
|