@kernhq/module-hr 0.9.3 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +9 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +458 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +43 -0
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/approvals.ts +13 -1
- package/src/contract/capabilities.ts +17 -0
|
@@ -1,21 +1,1158 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
EmptyState,
|
|
8
|
+
Field,
|
|
9
|
+
formatCount,
|
|
10
|
+
formatDate,
|
|
11
|
+
formatDateRange,
|
|
12
|
+
IconButton,
|
|
13
|
+
Input,
|
|
14
|
+
messageLocale,
|
|
15
|
+
navigation,
|
|
16
|
+
SectionLabel,
|
|
17
|
+
Select,
|
|
18
|
+
SettingsPage,
|
|
19
|
+
Skeleton,
|
|
20
|
+
Switch,
|
|
21
|
+
session,
|
|
22
|
+
toast,
|
|
23
|
+
} from '@kernhq/ui'
|
|
24
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
25
|
+
import { getHrApi } from '../api-instance.js'
|
|
3
26
|
import { t } from '../i18n.js'
|
|
27
|
+
import type { Schedule } from '../index.js'
|
|
28
|
+
import { canHr } from '../permissions.js'
|
|
29
|
+
import { formatDuration, hrKeys, isoDate } from '../query.js'
|
|
4
30
|
|
|
5
31
|
/**
|
|
6
|
-
*
|
|
32
|
+
* The week the day sheet is measured against.
|
|
7
33
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
34
|
+
* Everything attendance computes — scheduled minutes, lateness, early leaving, overtime, the
|
|
35
|
+
* automatic close of a forgotten shift — is read off a schedule and the person's assignment to it.
|
|
36
|
+
* Until this screen existed there was no way to make one, so every "Scheduled" and "Overtime" tile
|
|
37
|
+
* in the product was zero by construction rather than by fact.
|
|
38
|
+
*
|
|
39
|
+
* **The week is the screen.** `week` is per-weekday start, end and break kept as local wall-clock
|
|
40
|
+
* readings — `09:00` means nine o'clock wherever the person is, not an instant — so a four-day week
|
|
41
|
+
* or a half-day Thursday is an ordinary thing to set up, and a form of scalar fields cannot show
|
|
42
|
+
* it. The editor lays the seven days out as rows, which is also what makes it survive `dir="rtl"`:
|
|
43
|
+
* the week runs down the dialog rather than across it, and the label column lands on whichever side
|
|
44
|
+
* the reader's language starts from without a single `left` or `right` in the stylesheet.
|
|
45
|
+
*
|
|
46
|
+
* No stat tiles. Every number worth one here — how many schedules, how long each week is — is
|
|
47
|
+
* already a column of the table below, and OfficesPage learned the hard way that a tile restating
|
|
48
|
+
* the list is noise. The one figure that would earn a tile, how many people are on no schedule at
|
|
49
|
+
* all, is not answerable: `attendance.schedules.list` returns no assignment count, the way
|
|
50
|
+
* `offices.list` returns `headcount`, and there is no procedure that lists assignments.
|
|
51
|
+
*/
|
|
52
|
+
const api = getHrApi()
|
|
53
|
+
const queryClient = useQueryClient()
|
|
54
|
+
|
|
55
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
56
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
57
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The settings page is declared with this permission, so the shell only offers it to somebody who
|
|
61
|
+
* has it — but the module cannot assume the shell is the only thing that ever mounts it, and a
|
|
62
|
+
* read-only view of what the workspace has set up is useful in its own right.
|
|
63
|
+
*/
|
|
64
|
+
const manage = $derived(canHr('attendanceManage'))
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------- the week, as data
|
|
67
|
+
|
|
68
|
+
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
|
|
69
|
+
type DayKey = (typeof DAY_KEYS)[number]
|
|
70
|
+
type Shift = { start: string; end: string; breakMinutes: number }
|
|
71
|
+
type Week = Record<DayKey, Shift | null>
|
|
72
|
+
|
|
73
|
+
const DEFAULT_SHIFT: Shift = { start: '09:00', end: '18:00', breakMinutes: 60 }
|
|
74
|
+
|
|
75
|
+
const defaultWeek = (): Week => ({
|
|
76
|
+
mon: { ...DEFAULT_SHIFT },
|
|
77
|
+
tue: { ...DEFAULT_SHIFT },
|
|
78
|
+
wed: { ...DEFAULT_SHIFT },
|
|
79
|
+
thu: { ...DEFAULT_SHIFT },
|
|
80
|
+
fri: { ...DEFAULT_SHIFT },
|
|
81
|
+
sat: null,
|
|
82
|
+
sun: null,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Weekday names come from the reader's calendar rather than seven message keys per locale.
|
|
87
|
+
*
|
|
88
|
+
* 2024-01-01 was a Monday, so the first seven days of that January are Monday to Sunday in order.
|
|
89
|
+
* The anchor carries a midday time on purpose: `2024-01-01` alone parses as UTC midnight, which is
|
|
90
|
+
* still 31 December in every zone west of Greenwich — and the column would be headed "Sunday".
|
|
12
91
|
*/
|
|
92
|
+
const anchorDay = (index: number) => `2024-01-0${index + 1}`
|
|
93
|
+
const dayName = (index: number, weekday: 'long' | 'short') =>
|
|
94
|
+
formatDate(`${anchorDay(index)}T12:00:00`, { weekday })
|
|
95
|
+
|
|
96
|
+
const wallToMinutes = (wall: string) => {
|
|
97
|
+
const [h, m] = wall.split(':')
|
|
98
|
+
return Number(h) * 60 + Number(m)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `working-time.ts` calls a shift overnight when its end is at or before its start, so 22:00–06:00
|
|
103
|
+
* is eight hours and 09:00–09:00 is a full twenty-four. This screen has to agree with it exactly,
|
|
104
|
+
* or the hours it promises are not the hours payroll gets.
|
|
105
|
+
*/
|
|
106
|
+
const crossesMidnight = (shift: Shift) => wallToMinutes(shift.end) <= wallToMinutes(shift.start)
|
|
107
|
+
|
|
108
|
+
const netMinutes = (shift: Shift) => {
|
|
109
|
+
const raw = wallToMinutes(shift.end) - wallToMinutes(shift.start)
|
|
110
|
+
return Math.max((raw > 0 ? raw : raw + 1440) - shift.breakMinutes, 0)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const weekMinutes = (week: Week) =>
|
|
114
|
+
DAY_KEYS.reduce((sum, key) => {
|
|
115
|
+
const shift = week[key]
|
|
116
|
+
return sum + (shift ? netMinutes(shift) : 0)
|
|
117
|
+
}, 0)
|
|
118
|
+
|
|
119
|
+
const WALL_CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/
|
|
120
|
+
const shiftComplete = (shift: Shift) => WALL_CLOCK.test(shift.start) && WALL_CLOCK.test(shift.end)
|
|
121
|
+
|
|
122
|
+
/** Durations go through the module's own formatter so the digits match the language around them. */
|
|
123
|
+
const DURATION_WORDS = {
|
|
124
|
+
hours: (n: string) => t('hours_short', { n }),
|
|
125
|
+
minutes: (n: string) => t('minutes_short', { n }),
|
|
126
|
+
}
|
|
127
|
+
const duration = (minutes: number) => formatDuration(minutes, DURATION_WORDS, messageLocale())
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A shift as a range rather than two times and a dash.
|
|
131
|
+
*
|
|
132
|
+
* `formatRange` keeps the earlier reading first in Persian and Arabic; a hand-built `09:00–18:00`
|
|
133
|
+
* reads back to front there. The end is anchored to the next day when the shift crosses midnight,
|
|
134
|
+
* which is what the range actually is.
|
|
135
|
+
*/
|
|
136
|
+
const shiftRange = (shift: Shift) =>
|
|
137
|
+
formatDateRange(
|
|
138
|
+
`${anchorDay(0)}T${shift.start}:00`,
|
|
139
|
+
`${anchorDay(crossesMidnight(shift) ? 1 : 0)}T${shift.end}:00`,
|
|
140
|
+
{ hour: '2-digit', minute: '2-digit' },
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/** Reading a schedule from the server, which may leave a weekday out entirely. */
|
|
144
|
+
const readWeek = (week: Schedule['week']): Week => {
|
|
145
|
+
const out = {} as Week
|
|
146
|
+
for (const key of DAY_KEYS) {
|
|
147
|
+
const shift = week[key] ?? null
|
|
148
|
+
out[key] = shift ? { start: shift.start, end: shift.end, breakMinutes: shift.breakMinutes } : null
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------- the list
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Archived rows are fetched with the rest and hidden here.
|
|
157
|
+
*
|
|
158
|
+
* A workspace has a handful of schedules, so one request answers both views — and the toggle can
|
|
159
|
+
* say whether there is anything archived to look at, which a second query keyed on the flag could
|
|
160
|
+
* not do without asking twice.
|
|
161
|
+
*/
|
|
162
|
+
const schedulesQuery = createQuery(() => ({
|
|
163
|
+
queryKey: hrKeys.schedules(workspaceId),
|
|
164
|
+
enabled: Boolean(workspaceId),
|
|
165
|
+
queryFn: () => api.attendance.schedules.list({ workspaceId, includeArchived: true }),
|
|
166
|
+
}))
|
|
167
|
+
|
|
168
|
+
let showArchived = $state(false)
|
|
169
|
+
const allSchedules = $derived(schedulesQuery.data ?? [])
|
|
170
|
+
const archivedCount = $derived(allSchedules.filter((s) => s.archivedAt).length)
|
|
171
|
+
const schedules = $derived(showArchived ? allSchedules : allSchedules.filter((s) => !s.archivedAt))
|
|
172
|
+
|
|
173
|
+
const KIND_LABELS: Record<string, () => string> = {
|
|
174
|
+
fixed: () => t('schedule_kind_fixed'),
|
|
175
|
+
flexible: () => t('schedule_kind_flexible'),
|
|
176
|
+
shift: () => t('schedule_kind_shift'),
|
|
177
|
+
}
|
|
178
|
+
const kindLabel = (kind: string) => KIND_LABELS[kind]?.() ?? kind
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The chips are a picture, so the cell carries the sentence.
|
|
182
|
+
*
|
|
183
|
+
* `title` is not an accessible name a screen reader can rely on, and seven two-letter abbreviations
|
|
184
|
+
* read aloud one after another are noise — the cell says which days are worked and the chips are
|
|
185
|
+
* hidden from the reader that already heard it.
|
|
186
|
+
*/
|
|
187
|
+
const workingDaysLabel = (week: Week) => {
|
|
188
|
+
const names = DAY_KEYS.map((key, index) => (week[key] ? dayName(index, 'long') : null)).filter(
|
|
189
|
+
(n): n is string => n !== null,
|
|
190
|
+
)
|
|
191
|
+
return names.length ? names.join(', ') : t('schedule_week_empty')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------- the editor
|
|
195
|
+
|
|
196
|
+
/** `null` while closed; `{ schedule: null }` is a new one. */
|
|
197
|
+
let editing = $state<{ schedule: Schedule | null } | null>(null)
|
|
198
|
+
|
|
199
|
+
let name = $state('')
|
|
200
|
+
let kind = $state('fixed')
|
|
201
|
+
let week = $state<Week>(defaultWeek())
|
|
202
|
+
let tzMode = $state('office')
|
|
203
|
+
let tz = $state('')
|
|
204
|
+
let graceIn = $state('0')
|
|
205
|
+
let graceOut = $state('0')
|
|
206
|
+
let roundingStep = $state('0')
|
|
207
|
+
let roundingDirection = $state('nearest')
|
|
208
|
+
let autoClockOut = $state('')
|
|
209
|
+
let formError = $state<string | null>(null)
|
|
210
|
+
let saving = $state(false)
|
|
211
|
+
|
|
212
|
+
function openEditor(schedule: Schedule | null) {
|
|
213
|
+
editing = { schedule }
|
|
214
|
+
formError = null
|
|
215
|
+
name = schedule?.name ?? ''
|
|
216
|
+
kind = schedule?.kind ?? 'fixed'
|
|
217
|
+
week = schedule ? readWeek(schedule.week) : defaultWeek()
|
|
218
|
+
tzMode = schedule?.tzMode ?? 'office'
|
|
219
|
+
tz = schedule?.tz ?? ''
|
|
220
|
+
graceIn = String(schedule?.graceInMinutes ?? 0)
|
|
221
|
+
graceOut = String(schedule?.graceOutMinutes ?? 0)
|
|
222
|
+
roundingStep = String(schedule?.roundingStepMinutes ?? 0)
|
|
223
|
+
roundingDirection = schedule?.roundingDirection ?? 'nearest'
|
|
224
|
+
autoClockOut = schedule?.autoClockOutAfterMinutes ? String(schedule.autoClockOutAfterMinutes) : ''
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const isNew = $derived(editing?.schedule == null)
|
|
228
|
+
|
|
229
|
+
function setDay(key: DayKey, on: boolean) {
|
|
230
|
+
// A day switched back on takes the hours of the first day already working, so turning Saturday
|
|
231
|
+
// on in a Monday-to-Friday week does not make somebody retype 09:00 for the sixth time.
|
|
232
|
+
const template = DAY_KEYS.map((k) => week[k]).find((shift) => shift !== null)
|
|
233
|
+
week = { ...week, [key]: on ? { ...(template ?? DEFAULT_SHIFT) } : null }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function setShift(key: DayKey, patch: Partial<Shift>) {
|
|
237
|
+
const current = week[key]
|
|
238
|
+
if (!current) return
|
|
239
|
+
week = { ...week, [key]: { ...current, ...patch } }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const workingDays = $derived(DAY_KEYS.filter((key) => week[key] !== null))
|
|
243
|
+
|
|
244
|
+
function copyFirstDay() {
|
|
245
|
+
const first = workingDays[0]
|
|
246
|
+
if (!first) return
|
|
247
|
+
const template = week[first]
|
|
248
|
+
if (!template) return
|
|
249
|
+
const next = { ...week }
|
|
250
|
+
for (const key of workingDays) next[key] = { ...template }
|
|
251
|
+
week = next
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const clamp = (value: string, min: number, max: number) =>
|
|
255
|
+
Math.min(Math.max(Math.round(Number(value) || 0), min), max)
|
|
256
|
+
|
|
257
|
+
const timesMissing = $derived(
|
|
258
|
+
workingDays.some((key) => {
|
|
259
|
+
const shift = week[key]
|
|
260
|
+
return shift ? !shiftComplete(shift) : false
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
const noWorkingDays = $derived(workingDays.length === 0)
|
|
264
|
+
const zoneMissing = $derived(tzMode === 'fixed' && !tz)
|
|
265
|
+
|
|
266
|
+
const weekError = $derived(
|
|
267
|
+
noWorkingDays ? t('schedule_week_empty') : timesMissing ? t('schedule_time_missing') : null,
|
|
268
|
+
)
|
|
269
|
+
const canSave = $derived(manage && name.trim().length > 0 && !weekError && !zoneMissing)
|
|
270
|
+
|
|
271
|
+
/** Why Save is off, in the order somebody would fix them. Null when nothing is blocking. */
|
|
272
|
+
const blockedReason = $derived(
|
|
273
|
+
canSave
|
|
274
|
+
? null
|
|
275
|
+
: !manage
|
|
276
|
+
? t('schedules_readonly')
|
|
277
|
+
: !name.trim()
|
|
278
|
+
? t('schedule_name_required')
|
|
279
|
+
: (weekError ?? (zoneMissing ? t('schedule_tz_required') : null)),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
const save = createMutation(() => ({
|
|
283
|
+
mutationFn: () => {
|
|
284
|
+
const current = editing?.schedule ?? null
|
|
285
|
+
const common = {
|
|
286
|
+
workspaceId,
|
|
287
|
+
name: name.trim(),
|
|
288
|
+
week,
|
|
289
|
+
graceInMinutes: clamp(graceIn, 0, 240),
|
|
290
|
+
graceOutMinutes: clamp(graceOut, 0, 240),
|
|
291
|
+
roundingStepMinutes: clamp(roundingStep, 0, 60),
|
|
292
|
+
roundingDirection: roundingDirection as Schedule['roundingDirection'],
|
|
293
|
+
autoClockOutAfterMinutes: autoClockOut ? Number(autoClockOut) : null,
|
|
294
|
+
}
|
|
295
|
+
// Kind and time zone are create-only in the contract, and deliberately: the days already
|
|
296
|
+
// computed were measured with them, so changing one would rewrite history rather than the rule.
|
|
297
|
+
return current
|
|
298
|
+
? api.attendance.schedules.update({ ...common, scheduleId: current.id })
|
|
299
|
+
: api.attendance.schedules.create({
|
|
300
|
+
...common,
|
|
301
|
+
kind: kind as Schedule['kind'],
|
|
302
|
+
tzMode: tzMode as Schedule['tzMode'],
|
|
303
|
+
tz: tzMode === 'fixed' ? tz : null,
|
|
304
|
+
})
|
|
305
|
+
},
|
|
306
|
+
onSuccess: (schedule) => {
|
|
307
|
+
toast.success(isNew ? t('schedule_created', { name: schedule.name }) : t('schedule_saved'))
|
|
308
|
+
editing = null
|
|
309
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
310
|
+
},
|
|
311
|
+
onError: () => {
|
|
312
|
+
formError = t('schedule_save_error')
|
|
313
|
+
},
|
|
314
|
+
onSettled: () => {
|
|
315
|
+
saving = false
|
|
316
|
+
},
|
|
317
|
+
}))
|
|
318
|
+
|
|
319
|
+
function submit() {
|
|
320
|
+
// The disabled attribute lands a render after the click, and two quick clicks are one render
|
|
321
|
+
// apart — so the guard is a flag set in the same tick, not the mutation's own pending state.
|
|
322
|
+
if (saving || !canSave) return
|
|
323
|
+
saving = true
|
|
324
|
+
formError = null
|
|
325
|
+
save.mutate()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------- assigning
|
|
329
|
+
|
|
330
|
+
let assigning = $state<Schedule | null>(null)
|
|
331
|
+
let assignPersonId = $state('')
|
|
332
|
+
let assignFrom = $state(isoDate())
|
|
333
|
+
let assignError = $state<string | null>(null)
|
|
334
|
+
let assignBusy = $state(false)
|
|
335
|
+
|
|
336
|
+
const peopleQuery = createQuery(() => ({
|
|
337
|
+
queryKey: hrKeys.people(workspaceId, { forSchedule: true }),
|
|
338
|
+
enabled: Boolean(assigning) && Boolean(workspaceId),
|
|
339
|
+
queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
|
|
340
|
+
}))
|
|
341
|
+
const people = $derived(peopleQuery.data?.items ?? [])
|
|
342
|
+
const personOptions = $derived(people.map((person) => ({ value: person.id, label: person.displayName })))
|
|
343
|
+
|
|
344
|
+
function openAssign(schedule: Schedule) {
|
|
345
|
+
assigning = schedule
|
|
346
|
+
assignPersonId = ''
|
|
347
|
+
assignFrom = isoDate()
|
|
348
|
+
assignError = null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const assign = createMutation(() => ({
|
|
352
|
+
mutationFn: () =>
|
|
353
|
+
api.attendance.schedules.assign({
|
|
354
|
+
workspaceId,
|
|
355
|
+
scheduleId: assigning?.id ?? '',
|
|
356
|
+
personId: assignPersonId,
|
|
357
|
+
effectiveFrom: assignFrom,
|
|
358
|
+
}),
|
|
359
|
+
onSuccess: () => {
|
|
360
|
+
const person = people.find((p) => p.id === assignPersonId)
|
|
361
|
+
toast.success(
|
|
362
|
+
t('schedule_assigned', {
|
|
363
|
+
name: person?.displayName ?? '',
|
|
364
|
+
date: formatDate(assignFrom),
|
|
365
|
+
}),
|
|
366
|
+
)
|
|
367
|
+
assigning = null
|
|
368
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
369
|
+
},
|
|
370
|
+
onError: () => {
|
|
371
|
+
assignError = t('schedule_assign_error')
|
|
372
|
+
},
|
|
373
|
+
onSettled: () => {
|
|
374
|
+
assignBusy = false
|
|
375
|
+
},
|
|
376
|
+
}))
|
|
377
|
+
|
|
378
|
+
function submitAssign() {
|
|
379
|
+
if (assignBusy || !assignPersonId || !assignFrom) return
|
|
380
|
+
assignBusy = true
|
|
381
|
+
assignError = null
|
|
382
|
+
assign.mutate()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------- archiving
|
|
386
|
+
|
|
387
|
+
let archiving = $state<Schedule | null>(null)
|
|
388
|
+
let archiveError = $state<string | null>(null)
|
|
389
|
+
let archiveBusy = $state(false)
|
|
390
|
+
|
|
391
|
+
const archive = createMutation(() => ({
|
|
392
|
+
mutationFn: () => api.attendance.schedules.archive({ workspaceId, scheduleId: archiving?.id ?? '' }),
|
|
393
|
+
onSuccess: () => {
|
|
394
|
+
toast.success(t('schedule_archived_toast', { name: archiving?.name ?? '' }))
|
|
395
|
+
archiving = null
|
|
396
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
397
|
+
},
|
|
398
|
+
onError: () => {
|
|
399
|
+
archiveError = t('schedule_archive_error')
|
|
400
|
+
},
|
|
401
|
+
onSettled: () => {
|
|
402
|
+
archiveBusy = false
|
|
403
|
+
},
|
|
404
|
+
}))
|
|
405
|
+
|
|
406
|
+
function submitArchive() {
|
|
407
|
+
if (archiveBusy) return
|
|
408
|
+
archiveBusy = true
|
|
409
|
+
archiveError = null
|
|
410
|
+
archive.mutate()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------- option sets
|
|
414
|
+
|
|
415
|
+
const kindOptions = $derived([
|
|
416
|
+
{ value: 'fixed', label: t('schedule_kind_fixed'), description: t('schedule_kind_fixed_desc') },
|
|
417
|
+
{ value: 'flexible', label: t('schedule_kind_flexible'), description: t('schedule_kind_flexible_desc') },
|
|
418
|
+
{ value: 'shift', label: t('schedule_kind_shift'), description: t('schedule_kind_shift_desc') },
|
|
419
|
+
])
|
|
420
|
+
|
|
421
|
+
const tzModeOptions = $derived([
|
|
422
|
+
{ value: 'office', label: t('schedule_tz_office') },
|
|
423
|
+
{ value: 'person', label: t('schedule_tz_person') },
|
|
424
|
+
{ value: 'fixed', label: t('schedule_tz_fixed') },
|
|
425
|
+
])
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Every zone the runtime knows, grouped by area.
|
|
429
|
+
*
|
|
430
|
+
* `supportedValuesOf` is the only complete list available to a browser; where it is missing there
|
|
431
|
+
* is exactly one zone worth offering, the one this machine is in. Reading it is not formatting, so
|
|
432
|
+
* it does not go through the shared formatters.
|
|
433
|
+
*/
|
|
434
|
+
const ZONES: string[] = (() => {
|
|
435
|
+
try {
|
|
436
|
+
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf
|
|
437
|
+
const list = supported?.call(Intl, 'timeZone') ?? []
|
|
438
|
+
return list.length ? list : [Intl.DateTimeFormat().resolvedOptions().timeZone]
|
|
439
|
+
} catch {
|
|
440
|
+
return [Intl.DateTimeFormat().resolvedOptions().timeZone]
|
|
441
|
+
}
|
|
442
|
+
})()
|
|
443
|
+
const zoneOptions = ZONES.map((zone) => ({
|
|
444
|
+
value: zone,
|
|
445
|
+
label: zone.split('/').slice(1).join(' / ').replace(/_/g, ' ') || zone,
|
|
446
|
+
group: zone.split('/')[0]?.replace(/_/g, ' ') ?? '',
|
|
447
|
+
}))
|
|
448
|
+
|
|
449
|
+
/** A break somebody actually typed into the database keeps its place in the list. */
|
|
450
|
+
const breakOptions = (current: number) =>
|
|
451
|
+
[...new Set([0, 15, 30, 45, 60, 90, current])]
|
|
452
|
+
.sort((a, b) => a - b)
|
|
453
|
+
.map((minutes) => ({
|
|
454
|
+
value: String(minutes),
|
|
455
|
+
label: minutes === 0 ? t('schedule_break_none') : duration(minutes),
|
|
456
|
+
}))
|
|
457
|
+
|
|
458
|
+
const roundingStepOptions = $derived([
|
|
459
|
+
{ value: '0', label: t('schedule_rounding_off') },
|
|
460
|
+
...[5, 6, 10, 15, 30, 60].map((n) => ({ value: String(n), label: duration(n) })),
|
|
461
|
+
])
|
|
462
|
+
|
|
463
|
+
const roundingDirectionOptions = $derived([
|
|
464
|
+
{ value: 'nearest', label: t('schedule_rounding_nearest') },
|
|
465
|
+
{ value: 'employee', label: t('schedule_rounding_employee') },
|
|
466
|
+
{ value: 'employer', label: t('schedule_rounding_employer') },
|
|
467
|
+
])
|
|
468
|
+
|
|
469
|
+
const autoClockOutOptions = $derived([
|
|
470
|
+
{ value: '', label: t('schedule_auto_out_off') },
|
|
471
|
+
...[480, 600, 720, 960, 1440].map((n) => ({ value: String(n), label: duration(n) })),
|
|
472
|
+
])
|
|
13
473
|
</script>
|
|
14
474
|
|
|
15
|
-
<SettingsPage title={t('settings_schedules')}>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
475
|
+
<SettingsPage title={t('settings_schedules')} description={t('schedules_desc')}>
|
|
476
|
+
{#snippet actions()}
|
|
477
|
+
{#if manage}
|
|
478
|
+
<Button size="sm" icon="plus" onclick={() => openEditor(null)}>{t('schedule_new')}</Button>
|
|
479
|
+
{/if}
|
|
480
|
+
{/snippet}
|
|
481
|
+
|
|
482
|
+
<SectionLabel label={t('settings_schedules')} count={formatCount(schedules.length)}>
|
|
483
|
+
{#snippet trailing()}
|
|
484
|
+
{#if archivedCount > 0}
|
|
485
|
+
<Switch
|
|
486
|
+
size="sm"
|
|
487
|
+
checked={showArchived}
|
|
488
|
+
onCheckedChange={(on) => (showArchived = on)}
|
|
489
|
+
label={t('schedules_show_archived')}
|
|
490
|
+
/>
|
|
491
|
+
{/if}
|
|
492
|
+
{/snippet}
|
|
493
|
+
</SectionLabel>
|
|
494
|
+
|
|
495
|
+
{#if schedulesQuery.isLoading}
|
|
496
|
+
<div class="rows">
|
|
497
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
|
|
498
|
+
</div>
|
|
499
|
+
{:else if schedulesQuery.isError}
|
|
500
|
+
<EmptyState icon="triangle-alert" title={t('schedules_error')}>
|
|
501
|
+
{#snippet actions()}
|
|
502
|
+
<Button variant="secondary" onclick={() => void schedulesQuery.refetch()}>{t('retry')}</Button>
|
|
503
|
+
{/snippet}
|
|
504
|
+
</EmptyState>
|
|
505
|
+
{:else if schedules.length === 0}
|
|
506
|
+
<EmptyState icon="clock" title={t('schedules_none')} description={t('schedules_none_desc')}>
|
|
507
|
+
{#snippet actions()}
|
|
508
|
+
{#if manage}
|
|
509
|
+
<Button size="sm" icon="plus" onclick={() => openEditor(null)}>{t('schedule_new')}</Button>
|
|
510
|
+
{/if}
|
|
511
|
+
{/snippet}
|
|
512
|
+
</EmptyState>
|
|
513
|
+
{:else}
|
|
514
|
+
<div class="table" role="table" aria-label={t('settings_schedules')}>
|
|
515
|
+
<div class="thead" role="row">
|
|
516
|
+
<span role="columnheader">{t('schedule_name')}</span>
|
|
517
|
+
<span role="columnheader">{t('schedule_kind')}</span>
|
|
518
|
+
<span role="columnheader">{t('schedule_week')}</span>
|
|
519
|
+
<span role="columnheader">{t('schedule_weekly')}</span>
|
|
520
|
+
<span class="sr-only" role="columnheader">{t('schedule_actions')}</span>
|
|
521
|
+
</div>
|
|
522
|
+
{#each schedules as schedule (schedule.id)}
|
|
523
|
+
{@const days = readWeek(schedule.week)}
|
|
524
|
+
<div class="trow" role="row">
|
|
525
|
+
<span class="cell title" role="cell">
|
|
526
|
+
<span class="name">{schedule.name}</span>
|
|
527
|
+
{#if schedule.archivedAt}<Badge tone="grey">{t('schedule_archived')}</Badge>{/if}
|
|
528
|
+
</span>
|
|
529
|
+
<span class="cell" role="cell">
|
|
530
|
+
<Badge tone={schedule.kind === 'fixed' ? 'grey' : 'info'}>{kindLabel(schedule.kind)}</Badge>
|
|
531
|
+
</span>
|
|
532
|
+
<span class="cell" role="cell" aria-label={workingDaysLabel(days)}>
|
|
533
|
+
<!-- Seven chips rather than a sentence: which days are worked is the one thing you
|
|
534
|
+
scan a schedule list for, and the row order flips with the language for free. -->
|
|
535
|
+
<span class="chips" aria-hidden="true">
|
|
536
|
+
{#each DAY_KEYS as key, index (key)}
|
|
537
|
+
{@const shift = days[key]}
|
|
538
|
+
<span
|
|
539
|
+
class="chip"
|
|
540
|
+
class:on={shift !== null}
|
|
541
|
+
title={shift
|
|
542
|
+
? `${dayName(index, 'long')} · ${shiftRange(shift)}`
|
|
543
|
+
: `${dayName(index, 'long')} · ${t('schedule_day_off')}`}
|
|
544
|
+
>
|
|
545
|
+
{dayName(index, 'short')}
|
|
546
|
+
</span>
|
|
547
|
+
{/each}
|
|
548
|
+
</span>
|
|
549
|
+
</span>
|
|
550
|
+
<span class="cell num" role="cell">{duration(weekMinutes(days))}</span>
|
|
551
|
+
<span class="cell actions" role="cell">
|
|
552
|
+
{#if manage && !schedule.archivedAt}
|
|
553
|
+
<DropdownMenu
|
|
554
|
+
items={[
|
|
555
|
+
{ label: t('common.edit'), icon: 'pencil', onSelect: () => openEditor(schedule) },
|
|
556
|
+
{ label: t('schedule_assign'), icon: 'user-plus', onSelect: () => openAssign(schedule) },
|
|
557
|
+
{ type: 'separator' },
|
|
558
|
+
{
|
|
559
|
+
label: t('common.archive'),
|
|
560
|
+
icon: 'archive',
|
|
561
|
+
danger: true,
|
|
562
|
+
onSelect: () => {
|
|
563
|
+
archiveError = null
|
|
564
|
+
archiving = schedule
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
]}
|
|
568
|
+
>
|
|
569
|
+
{#snippet trigger(props)}
|
|
570
|
+
<IconButton
|
|
571
|
+
icon="ellipsis"
|
|
572
|
+
label={t('schedule_actions_for', { name: schedule.name })}
|
|
573
|
+
size={26}
|
|
574
|
+
{...props}
|
|
575
|
+
/>
|
|
576
|
+
{/snippet}
|
|
577
|
+
</DropdownMenu>
|
|
578
|
+
{/if}
|
|
579
|
+
</span>
|
|
580
|
+
</div>
|
|
581
|
+
{/each}
|
|
582
|
+
</div>
|
|
583
|
+
{/if}
|
|
584
|
+
|
|
585
|
+
<p class="hint">{manage ? t('schedules_hint') : t('schedules_readonly')}</p>
|
|
21
586
|
</SettingsPage>
|
|
587
|
+
|
|
588
|
+
<!-- ------------------------------------------------------------------ editor -->
|
|
589
|
+
|
|
590
|
+
<Dialog
|
|
591
|
+
open={editing !== null}
|
|
592
|
+
size="xl"
|
|
593
|
+
title={isNew ? t('schedule_new') : t('schedule_edit_title')}
|
|
594
|
+
description={t('schedule_edit_desc')}
|
|
595
|
+
onOpenChange={(open) => {
|
|
596
|
+
if (!open) editing = null
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
<div class="form">
|
|
600
|
+
<div class="pair">
|
|
601
|
+
<Field label={t('display_name')} required hint={t('schedule_name_hint')}>
|
|
602
|
+
{#snippet children(id)}
|
|
603
|
+
<Input {id} bind:value={name} placeholder={t('schedule_name_placeholder')} />
|
|
604
|
+
{/snippet}
|
|
605
|
+
</Field>
|
|
606
|
+
{#if isNew}
|
|
607
|
+
<Field label={t('schedule_kind')} hint={t('schedule_kind_hint')}>
|
|
608
|
+
{#snippet children(id)}
|
|
609
|
+
<Select {id} bind:value={kind} options={kindOptions} />
|
|
610
|
+
{/snippet}
|
|
611
|
+
</Field>
|
|
612
|
+
{:else}
|
|
613
|
+
<!-- Not a `Field`: it writes a `<label for>`, and `for` pointing at a paragraph names
|
|
614
|
+
nothing. A value nobody can change is a term and its definition. -->
|
|
615
|
+
<div class="ro">
|
|
616
|
+
<span class="rolabel">{t('schedule_kind')}</span>
|
|
617
|
+
<p class="rovalue">{kindLabel(kind)}</p>
|
|
618
|
+
<p class="note">{t('schedule_immutable_hint')}</p>
|
|
619
|
+
</div>
|
|
620
|
+
{/if}
|
|
621
|
+
</div>
|
|
622
|
+
|
|
623
|
+
<!--
|
|
624
|
+
The week, as a week.
|
|
625
|
+
|
|
626
|
+
Rows are weekdays and columns are the three readings a day is made of, so a four-day week and
|
|
627
|
+
a short Friday are both visible at a glance instead of hidden in a field somebody has to open.
|
|
628
|
+
A day that is off shows one line of text rather than three disabled controls: the switch
|
|
629
|
+
beside it is the reason, and three greyed-out boxes only ask the reader to work that out.
|
|
630
|
+
-->
|
|
631
|
+
<section class="weekblock" aria-labelledby="hr-week-label">
|
|
632
|
+
<div class="weekhead">
|
|
633
|
+
<span class="kern-section-label" id="hr-week-label">{t('schedule_week')}</span>
|
|
634
|
+
<span class="total">{t('schedule_week_total', { hours: duration(weekMinutes(week)) })}</span>
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
<div class="grid" role="table" aria-labelledby="hr-week-label">
|
|
638
|
+
<div class="ghead" role="row">
|
|
639
|
+
<span role="columnheader">{t('schedule_day')}</span>
|
|
640
|
+
<span role="columnheader">{t('schedule_start')}</span>
|
|
641
|
+
<span role="columnheader">{t('schedule_end')}</span>
|
|
642
|
+
<span role="columnheader">{t('schedule_break')}</span>
|
|
643
|
+
<span role="columnheader">{t('schedule_net')}</span>
|
|
644
|
+
</div>
|
|
645
|
+
|
|
646
|
+
{#each DAY_KEYS as key, index (key)}
|
|
647
|
+
{@const shift = week[key]}
|
|
648
|
+
<div class="grow" role="row" class:off={shift === null}>
|
|
649
|
+
<span class="gcell day" role="cell">
|
|
650
|
+
<Switch
|
|
651
|
+
size="sm"
|
|
652
|
+
checked={shift !== null}
|
|
653
|
+
onCheckedChange={(on) => setDay(key, on)}
|
|
654
|
+
ariaLabel={dayName(index, 'long')}
|
|
655
|
+
/>
|
|
656
|
+
<span class="dayname">{dayName(index, 'long')}</span>
|
|
657
|
+
</span>
|
|
658
|
+
|
|
659
|
+
{#if shift}
|
|
660
|
+
<span class="gcell" role="cell">
|
|
661
|
+
<Input
|
|
662
|
+
size="sm"
|
|
663
|
+
type="time"
|
|
664
|
+
value={shift.start}
|
|
665
|
+
aria-label={`${dayName(index, 'long')} — ${t('schedule_start')}`}
|
|
666
|
+
oninput={(event) => setShift(key, { start: event.currentTarget.value })}
|
|
667
|
+
/>
|
|
668
|
+
</span>
|
|
669
|
+
<span class="gcell" role="cell">
|
|
670
|
+
<Input
|
|
671
|
+
size="sm"
|
|
672
|
+
type="time"
|
|
673
|
+
value={shift.end}
|
|
674
|
+
aria-label={`${dayName(index, 'long')} — ${t('schedule_end')}`}
|
|
675
|
+
oninput={(event) => setShift(key, { end: event.currentTarget.value })}
|
|
676
|
+
/>
|
|
677
|
+
</span>
|
|
678
|
+
<span class="gcell" role="cell">
|
|
679
|
+
<Select
|
|
680
|
+
size="sm"
|
|
681
|
+
value={String(shift.breakMinutes)}
|
|
682
|
+
options={breakOptions(shift.breakMinutes)}
|
|
683
|
+
placeholder={`${dayName(index, 'long')} — ${t('schedule_break')}`}
|
|
684
|
+
onValueChange={(value) => setShift(key, { breakMinutes: Number(value) })}
|
|
685
|
+
/>
|
|
686
|
+
</span>
|
|
687
|
+
<span class="gcell net" role="cell">
|
|
688
|
+
{#if shiftComplete(shift)}
|
|
689
|
+
{duration(netMinutes(shift))}
|
|
690
|
+
{#if crossesMidnight(shift)}
|
|
691
|
+
<span class="overnight" title={t('schedule_overnight')} aria-hidden="true">+1</span>
|
|
692
|
+
<span class="sr-only">{t('schedule_overnight')}</span>
|
|
693
|
+
{/if}
|
|
694
|
+
{:else}
|
|
695
|
+
—
|
|
696
|
+
{/if}
|
|
697
|
+
</span>
|
|
698
|
+
{:else}
|
|
699
|
+
<span class="gcell dayoff" role="cell">{t('schedule_day_off')}</span>
|
|
700
|
+
{/if}
|
|
701
|
+
</div>
|
|
702
|
+
{/each}
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
<div class="weekfoot">
|
|
706
|
+
{#if weekError}
|
|
707
|
+
<p class="err" role="alert">{weekError}</p>
|
|
708
|
+
{:else}
|
|
709
|
+
<p class="note">{t('schedule_break_hint')}</p>
|
|
710
|
+
{/if}
|
|
711
|
+
{#if workingDays.length > 1}
|
|
712
|
+
<Button size="xs" variant="ghost" icon="copy" onclick={copyFirstDay}>
|
|
713
|
+
{t('schedule_copy_first')}
|
|
714
|
+
</Button>
|
|
715
|
+
{/if}
|
|
716
|
+
</div>
|
|
717
|
+
</section>
|
|
718
|
+
|
|
719
|
+
<!-- Time zone -->
|
|
720
|
+
{#if isNew}
|
|
721
|
+
<div class="pair">
|
|
722
|
+
<Field label={t('schedule_tz_mode')} hint={t('schedule_tz_hint')}>
|
|
723
|
+
{#snippet children(id)}
|
|
724
|
+
<Select {id} bind:value={tzMode} options={tzModeOptions} />
|
|
725
|
+
{/snippet}
|
|
726
|
+
</Field>
|
|
727
|
+
{#if tzMode === 'fixed'}
|
|
728
|
+
<Field
|
|
729
|
+
label={t('schedule_tz_label')}
|
|
730
|
+
required
|
|
731
|
+
error={zoneMissing ? t('schedule_tz_required') : null}
|
|
732
|
+
>
|
|
733
|
+
{#snippet children(id)}
|
|
734
|
+
<Select {id} bind:value={tz} options={zoneOptions} placeholder={t('schedule_tz_label')} />
|
|
735
|
+
{/snippet}
|
|
736
|
+
</Field>
|
|
737
|
+
{/if}
|
|
738
|
+
</div>
|
|
739
|
+
{:else}
|
|
740
|
+
<div class="ro">
|
|
741
|
+
<span class="rolabel">{t('schedule_tz_mode')}</span>
|
|
742
|
+
<p class="rovalue">
|
|
743
|
+
{tzModeOptions.find((option) => option.value === tzMode)?.label ?? tzMode}{#if tz} · {tz}{/if}
|
|
744
|
+
</p>
|
|
745
|
+
<p class="note">{t('schedule_immutable_hint')}</p>
|
|
746
|
+
</div>
|
|
747
|
+
{/if}
|
|
748
|
+
|
|
749
|
+
<!-- Grace -->
|
|
750
|
+
<div class="pair">
|
|
751
|
+
<Field label={t('schedule_grace_in')}>
|
|
752
|
+
{#snippet children(id)}
|
|
753
|
+
<Input {id} type="number" min={0} max={240} bind:value={graceIn} />
|
|
754
|
+
{/snippet}
|
|
755
|
+
</Field>
|
|
756
|
+
<Field label={t('schedule_grace_out')}>
|
|
757
|
+
{#snippet children(id)}
|
|
758
|
+
<Input {id} type="number" min={0} max={240} bind:value={graceOut} />
|
|
759
|
+
{/snippet}
|
|
760
|
+
</Field>
|
|
761
|
+
</div>
|
|
762
|
+
<p class="note">{t('schedule_grace_hint')}</p>
|
|
763
|
+
|
|
764
|
+
<!-- Rounding -->
|
|
765
|
+
<div class="pair">
|
|
766
|
+
<Field label={t('schedule_rounding_step')}>
|
|
767
|
+
{#snippet children(id)}
|
|
768
|
+
<Select {id} bind:value={roundingStep} options={roundingStepOptions} />
|
|
769
|
+
{/snippet}
|
|
770
|
+
</Field>
|
|
771
|
+
{#if roundingStep !== '0'}
|
|
772
|
+
<Field label={t('schedule_rounding_direction')}>
|
|
773
|
+
{#snippet children(id)}
|
|
774
|
+
<Select {id} bind:value={roundingDirection} options={roundingDirectionOptions} />
|
|
775
|
+
{/snippet}
|
|
776
|
+
</Field>
|
|
777
|
+
{/if}
|
|
778
|
+
</div>
|
|
779
|
+
{#if roundingStep !== '0'}
|
|
780
|
+
<p class="note">{t('schedule_rounding_hint')}</p>
|
|
781
|
+
{/if}
|
|
782
|
+
|
|
783
|
+
<!-- Automatic clock-out -->
|
|
784
|
+
<Field label={t('schedule_auto_out')} hint={t('schedule_auto_out_hint')}>
|
|
785
|
+
{#snippet children(id)}
|
|
786
|
+
<Select {id} bind:value={autoClockOut} options={autoClockOutOptions} />
|
|
787
|
+
{/snippet}
|
|
788
|
+
</Field>
|
|
789
|
+
|
|
790
|
+
{#if formError}<p class="err" role="alert">{formError}</p>{/if}
|
|
791
|
+
<!-- A disabled control with no reason is a bug, so the reason sits next to the control. -->
|
|
792
|
+
{#if blockedReason}<p class="note blocked">{blockedReason}</p>{/if}
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
{#snippet footer()}
|
|
796
|
+
<Button variant="ghost" onclick={() => (editing = null)} disabled={saving}>{t('cancel')}</Button>
|
|
797
|
+
<Button onclick={submit} disabled={!canSave} loading={saving}>
|
|
798
|
+
{isNew ? t('common.create') : t('common.save')}
|
|
799
|
+
</Button>
|
|
800
|
+
{/snippet}
|
|
801
|
+
</Dialog>
|
|
802
|
+
|
|
803
|
+
<!-- ------------------------------------------------------------------ assign -->
|
|
804
|
+
|
|
805
|
+
<Dialog
|
|
806
|
+
open={assigning !== null}
|
|
807
|
+
size="md"
|
|
808
|
+
title={t('schedule_assign_title', { name: assigning?.name ?? '' })}
|
|
809
|
+
description={t('schedule_assign_desc')}
|
|
810
|
+
onOpenChange={(open) => {
|
|
811
|
+
if (!open) assigning = null
|
|
812
|
+
}}
|
|
813
|
+
>
|
|
814
|
+
{#if peopleQuery.isLoading}
|
|
815
|
+
<Skeleton height="120px" />
|
|
816
|
+
{:else if peopleQuery.isError}
|
|
817
|
+
<EmptyState compact icon="triangle-alert" title={t('people_error')}>
|
|
818
|
+
{#snippet actions()}
|
|
819
|
+
<Button size="sm" variant="secondary" onclick={() => void peopleQuery.refetch()}>
|
|
820
|
+
{t('retry')}
|
|
821
|
+
</Button>
|
|
822
|
+
{/snippet}
|
|
823
|
+
</EmptyState>
|
|
824
|
+
{:else if personOptions.length === 0}
|
|
825
|
+
<EmptyState compact icon="user" title={t('no_people')} description={t('no_people_desc')} />
|
|
826
|
+
{:else}
|
|
827
|
+
<div class="form">
|
|
828
|
+
<Field label={t('schedule_assign_person')} required>
|
|
829
|
+
{#snippet children(id)}
|
|
830
|
+
<Select
|
|
831
|
+
{id}
|
|
832
|
+
bind:value={assignPersonId}
|
|
833
|
+
options={personOptions}
|
|
834
|
+
placeholder={t('schedule_assign_person')}
|
|
835
|
+
/>
|
|
836
|
+
{/snippet}
|
|
837
|
+
</Field>
|
|
838
|
+
<Field
|
|
839
|
+
label={t('schedule_effective_from')}
|
|
840
|
+
hint={t('schedule_effective_hint')}
|
|
841
|
+
error={assignError}
|
|
842
|
+
>
|
|
843
|
+
{#snippet children(id)}
|
|
844
|
+
<Input {id} type="date" bind:value={assignFrom} />
|
|
845
|
+
{/snippet}
|
|
846
|
+
</Field>
|
|
847
|
+
<!-- Same rule as the editor: the button is off, so it says why. -->
|
|
848
|
+
{#if !assignPersonId}<p class="note">{t('schedule_assign_required')}</p>{/if}
|
|
849
|
+
</div>
|
|
850
|
+
{/if}
|
|
851
|
+
|
|
852
|
+
{#snippet footer()}
|
|
853
|
+
<Button variant="ghost" onclick={() => (assigning = null)} disabled={assignBusy}>
|
|
854
|
+
{t('cancel')}
|
|
855
|
+
</Button>
|
|
856
|
+
<Button
|
|
857
|
+
onclick={submitAssign}
|
|
858
|
+
disabled={!assignPersonId || !assignFrom || personOptions.length === 0}
|
|
859
|
+
loading={assignBusy}
|
|
860
|
+
>
|
|
861
|
+
{t('schedule_assign')}
|
|
862
|
+
</Button>
|
|
863
|
+
{/snippet}
|
|
864
|
+
</Dialog>
|
|
865
|
+
|
|
866
|
+
<!-- ------------------------------------------------------------------ archive -->
|
|
867
|
+
|
|
868
|
+
<Dialog
|
|
869
|
+
open={archiving !== null}
|
|
870
|
+
size="sm"
|
|
871
|
+
title={t('schedule_archive_title', { name: archiving?.name ?? '' })}
|
|
872
|
+
description={t('schedule_archive_body')}
|
|
873
|
+
onOpenChange={(open) => {
|
|
874
|
+
if (!open) archiving = null
|
|
875
|
+
}}
|
|
876
|
+
>
|
|
877
|
+
<p class="note">{t('schedule_archive_people')}</p>
|
|
878
|
+
{#if archiveError}<p class="err" role="alert">{archiveError}</p>{/if}
|
|
879
|
+
|
|
880
|
+
{#snippet footer()}
|
|
881
|
+
<Button variant="secondary" onclick={() => (archiving = null)} disabled={archiveBusy}>
|
|
882
|
+
{t('cancel')}
|
|
883
|
+
</Button>
|
|
884
|
+
<Button variant="danger" onclick={submitArchive} loading={archiveBusy}>{t('common.archive')}</Button>
|
|
885
|
+
{/snippet}
|
|
886
|
+
</Dialog>
|
|
887
|
+
|
|
888
|
+
<style>
|
|
889
|
+
.rows {
|
|
890
|
+
display: grid;
|
|
891
|
+
gap: 4px;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
895
|
+
.table {
|
|
896
|
+
--hr-schedule-cols: minmax(150px, 1.3fr) 104px minmax(168px, auto) 84px max-content;
|
|
897
|
+
width: 100%;
|
|
898
|
+
}
|
|
899
|
+
.thead,
|
|
900
|
+
.trow {
|
|
901
|
+
display: grid;
|
|
902
|
+
grid-template-columns: var(--hr-schedule-cols);
|
|
903
|
+
gap: 12px;
|
|
904
|
+
align-items: center;
|
|
905
|
+
padding-inline: 10px;
|
|
906
|
+
}
|
|
907
|
+
.thead {
|
|
908
|
+
height: 34px;
|
|
909
|
+
border-block-end: 1px solid var(--kern-border);
|
|
910
|
+
font-size: 11px;
|
|
911
|
+
font-weight: 600;
|
|
912
|
+
letter-spacing: 0.06em;
|
|
913
|
+
text-transform: uppercase;
|
|
914
|
+
color: var(--kern-ink-500);
|
|
915
|
+
}
|
|
916
|
+
.trow {
|
|
917
|
+
min-height: 52px;
|
|
918
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
919
|
+
border-radius: var(--kern-r-md);
|
|
920
|
+
}
|
|
921
|
+
.trow:hover {
|
|
922
|
+
background: var(--kern-surface-raised);
|
|
923
|
+
}
|
|
924
|
+
.cell {
|
|
925
|
+
min-width: 0;
|
|
926
|
+
overflow: hidden;
|
|
927
|
+
text-overflow: ellipsis;
|
|
928
|
+
white-space: nowrap;
|
|
929
|
+
}
|
|
930
|
+
.title {
|
|
931
|
+
display: flex;
|
|
932
|
+
align-items: center;
|
|
933
|
+
gap: 8px;
|
|
934
|
+
}
|
|
935
|
+
.name {
|
|
936
|
+
min-width: 0;
|
|
937
|
+
overflow: hidden;
|
|
938
|
+
text-overflow: ellipsis;
|
|
939
|
+
font-size: 13.5px;
|
|
940
|
+
font-weight: 500;
|
|
941
|
+
}
|
|
942
|
+
.num {
|
|
943
|
+
font-size: 13px;
|
|
944
|
+
color: var(--kern-ink-500);
|
|
945
|
+
font-variant-numeric: tabular-nums;
|
|
946
|
+
}
|
|
947
|
+
.actions {
|
|
948
|
+
display: flex;
|
|
949
|
+
justify-content: flex-end;
|
|
950
|
+
overflow: visible;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/* The week at a glance: worked days filled, days off outlined. */
|
|
954
|
+
.chips {
|
|
955
|
+
display: flex;
|
|
956
|
+
gap: 3px;
|
|
957
|
+
}
|
|
958
|
+
.chip {
|
|
959
|
+
display: inline-flex;
|
|
960
|
+
align-items: center;
|
|
961
|
+
justify-content: center;
|
|
962
|
+
min-width: 26px;
|
|
963
|
+
height: 20px;
|
|
964
|
+
padding-inline: 5px;
|
|
965
|
+
border-radius: var(--kern-r-sm);
|
|
966
|
+
border: 1px solid var(--kern-border);
|
|
967
|
+
background: transparent;
|
|
968
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
969
|
+
color: var(--kern-ink-500);
|
|
970
|
+
font-size: 10.5px;
|
|
971
|
+
font-weight: 600;
|
|
972
|
+
letter-spacing: 0.02em;
|
|
973
|
+
}
|
|
974
|
+
.chip.on {
|
|
975
|
+
border-color: transparent;
|
|
976
|
+
background: var(--kern-accent-tint);
|
|
977
|
+
color: var(--kern-accent-text);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
.hint {
|
|
981
|
+
margin-block-start: 16px;
|
|
982
|
+
font-size: 12px;
|
|
983
|
+
line-height: 1.55;
|
|
984
|
+
color: var(--kern-ink-500);
|
|
985
|
+
}
|
|
986
|
+
.sr-only {
|
|
987
|
+
position: absolute;
|
|
988
|
+
width: 1px;
|
|
989
|
+
height: 1px;
|
|
990
|
+
overflow: hidden;
|
|
991
|
+
clip-path: inset(50%);
|
|
992
|
+
white-space: nowrap;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/* ---- dialogs ---- */
|
|
996
|
+
.form {
|
|
997
|
+
display: grid;
|
|
998
|
+
gap: 14px;
|
|
999
|
+
}
|
|
1000
|
+
.pair {
|
|
1001
|
+
display: grid;
|
|
1002
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
1003
|
+
gap: 14px;
|
|
1004
|
+
}
|
|
1005
|
+
.ro {
|
|
1006
|
+
display: flex;
|
|
1007
|
+
flex-direction: column;
|
|
1008
|
+
gap: 4px;
|
|
1009
|
+
min-width: 0;
|
|
1010
|
+
}
|
|
1011
|
+
.rolabel {
|
|
1012
|
+
font-size: 12.5px;
|
|
1013
|
+
font-weight: 500;
|
|
1014
|
+
color: var(--kern-ink-700);
|
|
1015
|
+
}
|
|
1016
|
+
.rovalue {
|
|
1017
|
+
margin: 0;
|
|
1018
|
+
padding-block: 5px;
|
|
1019
|
+
font-size: 13.5px;
|
|
1020
|
+
color: var(--kern-ink-900);
|
|
1021
|
+
}
|
|
1022
|
+
.blocked {
|
|
1023
|
+
color: var(--kern-ink-700);
|
|
1024
|
+
}
|
|
1025
|
+
.note {
|
|
1026
|
+
margin: 0;
|
|
1027
|
+
font-size: 12px;
|
|
1028
|
+
line-height: 1.55;
|
|
1029
|
+
color: var(--kern-ink-500);
|
|
1030
|
+
}
|
|
1031
|
+
.err {
|
|
1032
|
+
margin: 0;
|
|
1033
|
+
font-size: 12px;
|
|
1034
|
+
color: var(--kern-danger);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
.weekblock {
|
|
1038
|
+
display: grid;
|
|
1039
|
+
gap: 10px;
|
|
1040
|
+
padding: 14px;
|
|
1041
|
+
border: 1px solid var(--kern-border);
|
|
1042
|
+
border-radius: var(--kern-r-2xl);
|
|
1043
|
+
background: var(--kern-surface);
|
|
1044
|
+
}
|
|
1045
|
+
.weekhead {
|
|
1046
|
+
display: flex;
|
|
1047
|
+
align-items: baseline;
|
|
1048
|
+
justify-content: space-between;
|
|
1049
|
+
gap: 12px;
|
|
1050
|
+
}
|
|
1051
|
+
.total {
|
|
1052
|
+
font-size: 13px;
|
|
1053
|
+
font-weight: 500;
|
|
1054
|
+
color: var(--kern-ink-700);
|
|
1055
|
+
font-variant-numeric: tabular-nums;
|
|
1056
|
+
}
|
|
1057
|
+
.grid {
|
|
1058
|
+
display: grid;
|
|
1059
|
+
gap: 2px;
|
|
1060
|
+
}
|
|
1061
|
+
.ghead,
|
|
1062
|
+
.grow {
|
|
1063
|
+
display: grid;
|
|
1064
|
+
grid-template-columns: minmax(132px, 1.1fr) 118px 118px 118px 84px;
|
|
1065
|
+
gap: 8px;
|
|
1066
|
+
align-items: center;
|
|
1067
|
+
}
|
|
1068
|
+
.ghead {
|
|
1069
|
+
height: 26px;
|
|
1070
|
+
font-size: 10.5px;
|
|
1071
|
+
font-weight: 600;
|
|
1072
|
+
letter-spacing: 0.06em;
|
|
1073
|
+
text-transform: uppercase;
|
|
1074
|
+
color: var(--kern-ink-500);
|
|
1075
|
+
}
|
|
1076
|
+
.grow {
|
|
1077
|
+
min-height: 40px;
|
|
1078
|
+
padding-block: 2px;
|
|
1079
|
+
border-block-start: 1px solid var(--kern-border-hairline);
|
|
1080
|
+
}
|
|
1081
|
+
.gcell {
|
|
1082
|
+
min-width: 0;
|
|
1083
|
+
}
|
|
1084
|
+
.day {
|
|
1085
|
+
display: flex;
|
|
1086
|
+
align-items: center;
|
|
1087
|
+
gap: 10px;
|
|
1088
|
+
}
|
|
1089
|
+
.dayname {
|
|
1090
|
+
min-width: 0;
|
|
1091
|
+
overflow: hidden;
|
|
1092
|
+
text-overflow: ellipsis;
|
|
1093
|
+
white-space: nowrap;
|
|
1094
|
+
font-size: 13px;
|
|
1095
|
+
font-weight: 500;
|
|
1096
|
+
}
|
|
1097
|
+
.grow.off .dayname {
|
|
1098
|
+
font-weight: 400;
|
|
1099
|
+
color: var(--kern-ink-500);
|
|
1100
|
+
}
|
|
1101
|
+
.dayoff {
|
|
1102
|
+
grid-column: 2 / -1;
|
|
1103
|
+
font-size: 12.5px;
|
|
1104
|
+
color: var(--kern-ink-500);
|
|
1105
|
+
}
|
|
1106
|
+
.net {
|
|
1107
|
+
display: flex;
|
|
1108
|
+
align-items: baseline;
|
|
1109
|
+
gap: 4px;
|
|
1110
|
+
font-size: 12.5px;
|
|
1111
|
+
color: var(--kern-ink-700);
|
|
1112
|
+
font-variant-numeric: tabular-nums;
|
|
1113
|
+
}
|
|
1114
|
+
.overnight {
|
|
1115
|
+
font-size: 10.5px;
|
|
1116
|
+
font-weight: 600;
|
|
1117
|
+
color: var(--kern-accent-text);
|
|
1118
|
+
}
|
|
1119
|
+
.weekfoot {
|
|
1120
|
+
display: flex;
|
|
1121
|
+
align-items: center;
|
|
1122
|
+
justify-content: space-between;
|
|
1123
|
+
gap: 12px;
|
|
1124
|
+
flex-wrap: wrap;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
@media (max-width: 760px) {
|
|
1128
|
+
.table {
|
|
1129
|
+
--hr-schedule-cols: minmax(120px, 1.3fr) minmax(168px, auto) 84px max-content;
|
|
1130
|
+
}
|
|
1131
|
+
/* Type is the column a narrow screen can lose: nearly every schedule is a fixed one. */
|
|
1132
|
+
.thead > :nth-child(2),
|
|
1133
|
+
.trow > :nth-child(2) {
|
|
1134
|
+
display: none;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
@media (max-width: 620px) {
|
|
1139
|
+
/* Two controls per line rather than five columns squeezed to nothing. The header row goes with
|
|
1140
|
+
them — every control keeps its own accessible name, which is what the header was standing in
|
|
1141
|
+
for. */
|
|
1142
|
+
.ghead {
|
|
1143
|
+
display: none;
|
|
1144
|
+
}
|
|
1145
|
+
.ghead,
|
|
1146
|
+
.grow {
|
|
1147
|
+
grid-template-columns: 1fr 1fr;
|
|
1148
|
+
}
|
|
1149
|
+
.grow {
|
|
1150
|
+
padding-block: 8px;
|
|
1151
|
+
}
|
|
1152
|
+
.day,
|
|
1153
|
+
.net,
|
|
1154
|
+
.dayoff {
|
|
1155
|
+
grid-column: 1 / -1;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
</style>
|