@kernhq/module-hr 0.10.0 → 0.10.2
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/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.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 +1 -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 +489 -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 +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- 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 +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- 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 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- 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 +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- 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 +14 -7
- 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/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -1,21 +1,893 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
Dialog,
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
EmptyState,
|
|
9
|
+
Field,
|
|
10
|
+
formatCount,
|
|
11
|
+
Icon,
|
|
12
|
+
IconButton,
|
|
13
|
+
Input,
|
|
14
|
+
type MenuItem,
|
|
15
|
+
navigation,
|
|
16
|
+
SectionLabel,
|
|
17
|
+
Select,
|
|
18
|
+
SettingsPage,
|
|
19
|
+
Skeleton,
|
|
20
|
+
session,
|
|
21
|
+
toast,
|
|
22
|
+
} from '@kernhq/ui'
|
|
23
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
24
|
+
import { getHrApi } from '../api-instance.js'
|
|
3
25
|
import { t } from '../i18n.js'
|
|
26
|
+
import type { LeaveType } from '../index.js'
|
|
27
|
+
import { canHr } from '../permissions.js'
|
|
28
|
+
import { hrKeys } from '../query.js'
|
|
4
29
|
|
|
5
30
|
/**
|
|
6
|
-
*
|
|
31
|
+
* The kinds of time off this workspace has.
|
|
7
32
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
33
|
+
* Everything downstream of leave reads this list: the request dialog's picker, the balance tiles,
|
|
34
|
+
* the ledger, the team calendar. Until this screen existed the list could only be seeded, so a
|
|
35
|
+
* workspace that switched leave on got a request dialog with an empty dropdown — the capability is
|
|
36
|
+
* `defaultEnabled`, so that was every workspace with HR.
|
|
37
|
+
*
|
|
38
|
+
* Two fields are deliberately not editable after creation, because the contract does not accept
|
|
39
|
+
* them on `update`: the **key**, which imports and the API name a type by, and the **unit**, which
|
|
40
|
+
* every ledger entry was already converted into. Both are stated in the form rather than silently
|
|
41
|
+
* ignored.
|
|
42
|
+
*
|
|
43
|
+
* Archiving is the only way out. There is no restore — `update` cannot clear `archivedAt` — so the
|
|
44
|
+
* confirmation says so alongside what archiving keeps.
|
|
45
|
+
*/
|
|
46
|
+
const api = getHrApi()
|
|
47
|
+
const queryClient = useQueryClient()
|
|
48
|
+
|
|
49
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
50
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
51
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
52
|
+
const canManage = $derived(canHr('leaveManage'))
|
|
53
|
+
|
|
54
|
+
/** The registry holds over a hundred icons; these are the ones that mean something on a leave type. */
|
|
55
|
+
const LEAVE_ICONS: { name: string; label: () => string }[] = [
|
|
56
|
+
{ name: 'tree-palm', label: () => t('leave_icon_holiday') },
|
|
57
|
+
{ name: 'activity', label: () => t('leave_icon_sick') },
|
|
58
|
+
{ name: 'house', label: () => t('leave_icon_home') },
|
|
59
|
+
{ name: 'briefcase', label: () => t('leave_icon_trip') },
|
|
60
|
+
{ name: 'user-plus', label: () => t('leave_icon_family') },
|
|
61
|
+
{ name: 'calendar-days', label: () => t('leave_icon_calendar') },
|
|
62
|
+
{ name: 'clock', label: () => t('leave_icon_hours') },
|
|
63
|
+
{ name: 'star', label: () => t('leave_icon_special') },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mid-tone hues, on purpose: the colour is drawn as an icon on the page's own background, and it
|
|
68
|
+
* has to stay visible against paper and against ink. A pale pastel disappears in light mode and a
|
|
69
|
+
* near-black one in dark.
|
|
12
70
|
*/
|
|
71
|
+
const LEAVE_COLORS: { value: string; label: () => string }[] = [
|
|
72
|
+
{ value: '#4c8bf5', label: () => t('leave_color_blue') },
|
|
73
|
+
{ value: '#3aa675', label: () => t('leave_color_green') },
|
|
74
|
+
{ value: '#d68b2c', label: () => t('leave_color_amber') },
|
|
75
|
+
{ value: '#dd5a5a', label: () => t('leave_color_red') },
|
|
76
|
+
{ value: '#8a6ee0', label: () => t('leave_color_purple') },
|
|
77
|
+
{ value: '#3ba1b5', label: () => t('leave_color_teal') },
|
|
78
|
+
{ value: '#cf5f8c', label: () => t('leave_color_pink') },
|
|
79
|
+
{ value: '#7a8794', label: () => t('leave_color_slate') },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const DEFAULT_COLOR = LEAVE_COLORS[0]!.value
|
|
83
|
+
const DEFAULT_ICON = LEAVE_ICONS[0]!.name
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the ledger means by a day. `services/ledger.ts` stores every movement in minutes and reads
|
|
87
|
+
* a day back as eight hours, so a limit typed in days has to be multiplied by the same number the
|
|
88
|
+
* server divides by — anything else shows one figure and enforces another.
|
|
89
|
+
*/
|
|
90
|
+
const MINUTES_PER_DAY = 8 * 60
|
|
91
|
+
const minutesPerUnit = (unit: LeaveType['unit']) => (unit === 'hour' ? 60 : MINUTES_PER_DAY)
|
|
92
|
+
|
|
93
|
+
const unitLabel = (unit: LeaveType['unit']): string =>
|
|
94
|
+
unit === 'hour'
|
|
95
|
+
? t('leave_unit_hour')
|
|
96
|
+
: unit === 'half_day'
|
|
97
|
+
? t('leave_unit_half_day')
|
|
98
|
+
: t('leave_unit_day')
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Its own cache key, even though the request dialog asks the same procedure.
|
|
102
|
+
*
|
|
103
|
+
* That one caches `hrKeys.leaveTypes(ws)` with `includeArchived: false`; serving it this answer
|
|
104
|
+
* would offer somebody an archived type to book. This screen always fetches both and splits them
|
|
105
|
+
* here, so showing the archived ones costs no round trip.
|
|
106
|
+
*/
|
|
107
|
+
const typesQuery = createQuery(() => ({
|
|
108
|
+
queryKey: [...hrKeys.leaveTypes(workspaceId), 'settings'] as const,
|
|
109
|
+
enabled: Boolean(workspaceId),
|
|
110
|
+
queryFn: () => api.leave.types.list({ workspaceId, includeArchived: true }),
|
|
111
|
+
}))
|
|
112
|
+
const types = $derived(typesQuery.data ?? [])
|
|
113
|
+
const active = $derived(types.filter((type) => !type.archivedAt))
|
|
114
|
+
const archived = $derived(types.filter((type) => type.archivedAt))
|
|
115
|
+
|
|
116
|
+
let showArchived = $state(false)
|
|
117
|
+
|
|
118
|
+
const invalidate = () => {
|
|
119
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-types'] })
|
|
120
|
+
// A balance row carries its type's name and unit, so a rename has to reach the tiles too.
|
|
121
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-balance'] })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------- the form
|
|
125
|
+
|
|
126
|
+
let dialogOpen = $state(false)
|
|
127
|
+
/** null while creating. It is also what decides whether `key` and `unit` may still be chosen. */
|
|
128
|
+
let editingId = $state<string | null>(null)
|
|
129
|
+
let name = $state('')
|
|
130
|
+
let key = $state('')
|
|
131
|
+
let paid = $state(true)
|
|
132
|
+
let unit = $state<LeaveType['unit']>('day')
|
|
133
|
+
let color = $state(DEFAULT_COLOR)
|
|
134
|
+
let icon = $state(DEFAULT_ICON)
|
|
135
|
+
let workingDaysOnly = $state(true)
|
|
136
|
+
let requireDocument = $state(false)
|
|
137
|
+
let documentDays = $state('3')
|
|
138
|
+
let allowNegative = $state(false)
|
|
139
|
+
let negativeLimit = $state('0')
|
|
140
|
+
/** Touched once, the key stops following the name — otherwise a rename would rewrite it. */
|
|
141
|
+
let keyEdited = $state(false)
|
|
142
|
+
/** Set in the same tick as the click: `isPending` only reaches the button on the next render. */
|
|
143
|
+
let saving = $state(false)
|
|
144
|
+
let reordering = $state(false)
|
|
145
|
+
|
|
146
|
+
const editing = $derived(editingId !== null)
|
|
147
|
+
|
|
148
|
+
/** `Annual leave` → `annual_leave`: the key is a machine name, not a sentence. */
|
|
149
|
+
const slugify = (value: string) =>
|
|
150
|
+
value
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
153
|
+
.replace(/^_+|_+$/g, '')
|
|
154
|
+
.slice(0, 48)
|
|
155
|
+
|
|
156
|
+
$effect(() => {
|
|
157
|
+
if (!keyEdited) key = slugify(name)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
function startNew() {
|
|
161
|
+
editingId = null
|
|
162
|
+
name = ''
|
|
163
|
+
key = ''
|
|
164
|
+
paid = true
|
|
165
|
+
unit = 'day'
|
|
166
|
+
color = DEFAULT_COLOR
|
|
167
|
+
icon = DEFAULT_ICON
|
|
168
|
+
workingDaysOnly = true
|
|
169
|
+
requireDocument = false
|
|
170
|
+
documentDays = '3'
|
|
171
|
+
allowNegative = false
|
|
172
|
+
negativeLimit = '0'
|
|
173
|
+
keyEdited = false
|
|
174
|
+
dialogOpen = true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Minutes back into the unit somebody typed them in, without a trailing `.00`. */
|
|
178
|
+
const fromMinutes = (minutes: number, forUnit: LeaveType['unit']) =>
|
|
179
|
+
String(Math.round((minutes / minutesPerUnit(forUnit)) * 100) / 100)
|
|
180
|
+
|
|
181
|
+
function startEdit(type: LeaveType) {
|
|
182
|
+
editingId = type.id
|
|
183
|
+
name = type.name
|
|
184
|
+
key = type.key
|
|
185
|
+
paid = type.paid
|
|
186
|
+
unit = type.unit
|
|
187
|
+
color = type.color ?? DEFAULT_COLOR
|
|
188
|
+
icon = type.icon ?? DEFAULT_ICON
|
|
189
|
+
workingDaysOnly = type.countsWorkingDaysOnly
|
|
190
|
+
requireDocument = type.requiresDocumentAfterDays !== null
|
|
191
|
+
documentDays = String(type.requiresDocumentAfterDays ?? 3)
|
|
192
|
+
allowNegative = type.allowNegative
|
|
193
|
+
negativeLimit = fromMinutes(type.maxNegativeMinutes, type.unit)
|
|
194
|
+
keyEdited = true
|
|
195
|
+
dialogOpen = true
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const keyValid = $derived(/^[a-z][a-z0-9_]*$/.test(key) && key.length <= 48)
|
|
199
|
+
const documentValid = $derived(!requireDocument || Number(documentDays) >= 1)
|
|
200
|
+
const negativeValid = $derived(!allowNegative || Number(negativeLimit) >= 0)
|
|
201
|
+
const canSave = $derived(
|
|
202
|
+
canManage && name.trim().length > 0 && (editing || keyValid) && documentValid && negativeValid,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const save = createMutation(() => ({
|
|
206
|
+
mutationFn: () => {
|
|
207
|
+
const common = {
|
|
208
|
+
workspaceId,
|
|
209
|
+
name: name.trim(),
|
|
210
|
+
paid,
|
|
211
|
+
color,
|
|
212
|
+
icon,
|
|
213
|
+
requiresDocumentAfterDays: requireDocument ? Math.max(1, Math.round(Number(documentDays))) : null,
|
|
214
|
+
countsWorkingDaysOnly: workingDaysOnly,
|
|
215
|
+
allowNegative,
|
|
216
|
+
maxNegativeMinutes: allowNegative
|
|
217
|
+
? Math.max(0, Math.round(Number(negativeLimit) * minutesPerUnit(unit)))
|
|
218
|
+
: 0,
|
|
219
|
+
}
|
|
220
|
+
return editingId
|
|
221
|
+
? api.leave.types.update({ ...common, leaveTypeId: editingId })
|
|
222
|
+
: api.leave.types.create({ ...common, key, unit })
|
|
223
|
+
},
|
|
224
|
+
onSuccess: (saved: LeaveType) => {
|
|
225
|
+
dialogOpen = false
|
|
226
|
+
invalidate()
|
|
227
|
+
toast.success(t('leave_type_saved', { name: saved.name }))
|
|
228
|
+
},
|
|
229
|
+
onError: (error: Error) => toast.error(error.message),
|
|
230
|
+
onSettled: () => {
|
|
231
|
+
saving = false
|
|
232
|
+
},
|
|
233
|
+
}))
|
|
234
|
+
|
|
235
|
+
function submit() {
|
|
236
|
+
if (!canSave || saving) return
|
|
237
|
+
saving = true
|
|
238
|
+
save.mutate()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------- rows
|
|
242
|
+
|
|
243
|
+
let archiving = $state<LeaveType | null>(null)
|
|
244
|
+
|
|
245
|
+
const archive = createMutation(() => ({
|
|
246
|
+
mutationFn: (type: LeaveType) => api.leave.types.archive({ workspaceId, leaveTypeId: type.id }),
|
|
247
|
+
onSuccess: (_result: unknown, type: LeaveType) => {
|
|
248
|
+
archiving = null
|
|
249
|
+
invalidate()
|
|
250
|
+
toast.success(t('leave_type_archived_toast', { name: type.name }))
|
|
251
|
+
},
|
|
252
|
+
onError: (error: Error) => toast.error(error.message),
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
const reorder = createMutation(() => ({
|
|
256
|
+
mutationFn: async (vars: { id: string; delta: number }) => {
|
|
257
|
+
const next = [...active]
|
|
258
|
+
const from = next.findIndex((type) => type.id === vars.id)
|
|
259
|
+
const to = from + vars.delta
|
|
260
|
+
if (from < 0 || to < 0 || to >= next.length) return
|
|
261
|
+
next.splice(to, 0, next.splice(from, 1)[0]!)
|
|
262
|
+
// Every row whose position no longer matches its stored `order` is written, not only the two
|
|
263
|
+
// that changed places: a seeded workspace has every type at order 0 and falls back to sorting
|
|
264
|
+
// by name, so exchanging two zeros would move nothing on screen. The list writes its own
|
|
265
|
+
// positions the first time anybody reorders it.
|
|
266
|
+
await Promise.all(
|
|
267
|
+
next.flatMap((type, index) =>
|
|
268
|
+
type.order === index
|
|
269
|
+
? []
|
|
270
|
+
: [api.leave.types.update({ workspaceId, leaveTypeId: type.id, order: index })],
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
},
|
|
274
|
+
onSuccess: invalidate,
|
|
275
|
+
onError: (error: Error) => toast.error(error.message),
|
|
276
|
+
onSettled: () => {
|
|
277
|
+
reordering = false
|
|
278
|
+
},
|
|
279
|
+
}))
|
|
280
|
+
|
|
281
|
+
function move(id: string, delta: number) {
|
|
282
|
+
if (reordering) return
|
|
283
|
+
reordering = true
|
|
284
|
+
reorder.mutate({ id, delta })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function actionsFor(type: LeaveType, index: number): MenuItem[] {
|
|
288
|
+
if (type.archivedAt) {
|
|
289
|
+
// Archived types keep an edit action — a typo in a name still shows up on old requests — but
|
|
290
|
+
// nothing here can bring one back, so no restore is offered.
|
|
291
|
+
return [{ label: t('common.edit'), icon: 'pencil', onSelect: () => startEdit(type) }]
|
|
292
|
+
}
|
|
293
|
+
const last = index === active.length - 1
|
|
294
|
+
return [
|
|
295
|
+
{ label: t('common.edit'), icon: 'pencil', onSelect: () => startEdit(type) },
|
|
296
|
+
{
|
|
297
|
+
label: t('leave_type_move_up'),
|
|
298
|
+
icon: 'chevron-up',
|
|
299
|
+
disabled: index === 0,
|
|
300
|
+
hint: index === 0 ? t('leave_type_first') : undefined,
|
|
301
|
+
onSelect: () => move(type.id, -1),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
label: t('leave_type_move_down'),
|
|
305
|
+
icon: 'chevron-down',
|
|
306
|
+
disabled: last,
|
|
307
|
+
hint: last ? t('leave_type_last') : undefined,
|
|
308
|
+
onSelect: () => move(type.id, 1),
|
|
309
|
+
},
|
|
310
|
+
{ type: 'separator' },
|
|
311
|
+
{
|
|
312
|
+
label: t('common.archive'),
|
|
313
|
+
icon: 'archive',
|
|
314
|
+
danger: true,
|
|
315
|
+
onSelect: () => {
|
|
316
|
+
archiving = type
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** What this type does beyond having a name — the column somebody scans to compare two of them. */
|
|
323
|
+
function rulesFor(type: LeaveType): string[] {
|
|
324
|
+
const rules: string[] = []
|
|
325
|
+
if (type.countsWorkingDaysOnly) rules.push(t('leave_type_working_days'))
|
|
326
|
+
if (type.requiresDocumentAfterDays !== null)
|
|
327
|
+
rules.push(t('leave_type_document_after', { count: type.requiresDocumentAfterDays }))
|
|
328
|
+
if (type.allowNegative) rules.push(t('leave_type_negative'))
|
|
329
|
+
return rules
|
|
330
|
+
}
|
|
13
331
|
</script>
|
|
14
332
|
|
|
15
|
-
<SettingsPage title={t('settings_leave')}>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
333
|
+
<SettingsPage title={t('settings_leave')} description={t('leave_types_desc')}>
|
|
334
|
+
{#snippet actions()}
|
|
335
|
+
{#if canManage}
|
|
336
|
+
<Button size="sm" icon="plus" onclick={startNew}>{t('leave_type_new')}</Button>
|
|
337
|
+
{/if}
|
|
338
|
+
{/snippet}
|
|
339
|
+
|
|
340
|
+
{#if typesQuery.isLoading}
|
|
341
|
+
<div class="rows">
|
|
342
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
|
|
343
|
+
</div>
|
|
344
|
+
{:else if typesQuery.isError}
|
|
345
|
+
<EmptyState icon="triangle-alert" title={t('leave_types_error')}>
|
|
346
|
+
{#snippet actions()}
|
|
347
|
+
<Button variant="secondary" onclick={() => void typesQuery.refetch()}>{t('retry')}</Button>
|
|
348
|
+
{/snippet}
|
|
349
|
+
</EmptyState>
|
|
350
|
+
{:else if types.length === 0}
|
|
351
|
+
<EmptyState icon="tree-palm" title={t('leave_types_none')} description={t('leave_types_none_desc')}>
|
|
352
|
+
{#snippet actions()}
|
|
353
|
+
{#if canManage}
|
|
354
|
+
<Button icon="plus" onclick={startNew}>{t('leave_type_new')}</Button>
|
|
355
|
+
{/if}
|
|
356
|
+
{/snippet}
|
|
357
|
+
</EmptyState>
|
|
358
|
+
{:else}
|
|
359
|
+
<div class="section">
|
|
360
|
+
<SectionLabel label={t('leave_types')} count={formatCount(active.length)}>
|
|
361
|
+
{#snippet trailing()}
|
|
362
|
+
{#if archived.length}
|
|
363
|
+
<Button
|
|
364
|
+
size="xs"
|
|
365
|
+
variant="ghost"
|
|
366
|
+
icon={showArchived ? 'eye-off' : 'eye'}
|
|
367
|
+
onclick={() => (showArchived = !showArchived)}
|
|
368
|
+
>
|
|
369
|
+
{showArchived ? t('leave_types_hide_archived') : t('leave_types_show_archived')}
|
|
370
|
+
</Button>
|
|
371
|
+
{/if}
|
|
372
|
+
{/snippet}
|
|
373
|
+
</SectionLabel>
|
|
374
|
+
|
|
375
|
+
{#if active.length === 0}
|
|
376
|
+
<EmptyState
|
|
377
|
+
bare
|
|
378
|
+
compact
|
|
379
|
+
icon="tree-palm"
|
|
380
|
+
title={t('leave_types_none')}
|
|
381
|
+
description={t('leave_types_none_desc')}
|
|
382
|
+
/>
|
|
383
|
+
{:else}
|
|
384
|
+
<div class="table" role="table" aria-label={t('leave_types')}>
|
|
385
|
+
<div class="thead" role="row">
|
|
386
|
+
<span role="columnheader">{t('leave_type')}</span>
|
|
387
|
+
<span role="columnheader">{t('leave_type_unit')}</span>
|
|
388
|
+
<span role="columnheader">{t('leave_type_paid')}</span>
|
|
389
|
+
<span role="columnheader">{t('leave_type_rules')}</span>
|
|
390
|
+
<span class="sr-only" role="columnheader">{t('leave_type_actions')}</span>
|
|
391
|
+
</div>
|
|
392
|
+
{#each active as type, index (type.id)}
|
|
393
|
+
{@const rules = rulesFor(type)}
|
|
394
|
+
<div class="trow" role="row">
|
|
395
|
+
<span class="cell name" role="cell">
|
|
396
|
+
<span class="chip" style:color={type.color ?? 'var(--kern-ink-400)'}>
|
|
397
|
+
<Icon name={type.icon ?? DEFAULT_ICON} size={15} strokeWidth={1.8} />
|
|
398
|
+
</span>
|
|
399
|
+
<span class="tname">{type.name}</span>
|
|
400
|
+
<span class="tkey">{type.key}</span>
|
|
401
|
+
</span>
|
|
402
|
+
<span class="cell muted" role="cell">{unitLabel(type.unit)}</span>
|
|
403
|
+
<span class="cell" role="cell">
|
|
404
|
+
<Badge tone={type.paid ? 'success' : 'grey'}>
|
|
405
|
+
{type.paid ? t('leave_type_paid') : t('leave_type_unpaid')}
|
|
406
|
+
</Badge>
|
|
407
|
+
</span>
|
|
408
|
+
<span class="cell rules" role="cell">
|
|
409
|
+
{#if rules.length}
|
|
410
|
+
{#each rules as rule (rule)}<span class="rule">{rule}</span>{/each}
|
|
411
|
+
{:else}
|
|
412
|
+
<span class="rule">—</span>
|
|
413
|
+
{/if}
|
|
414
|
+
</span>
|
|
415
|
+
<span class="cell actions" role="cell">
|
|
416
|
+
{#if canManage}
|
|
417
|
+
<DropdownMenu items={actionsFor(type, index)} align="end">
|
|
418
|
+
{#snippet trigger(props)}
|
|
419
|
+
<IconButton {...props} icon="ellipsis" size={28} label={t('leave_type_actions')} />
|
|
420
|
+
{/snippet}
|
|
421
|
+
</DropdownMenu>
|
|
422
|
+
{/if}
|
|
423
|
+
</span>
|
|
424
|
+
</div>
|
|
425
|
+
{/each}
|
|
426
|
+
</div>
|
|
427
|
+
{#if canManage && active.length > 1}
|
|
428
|
+
<p class="hint">{t('leave_type_order_hint')}</p>
|
|
429
|
+
{/if}
|
|
430
|
+
{/if}
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
{#if showArchived && archived.length}
|
|
434
|
+
<div class="section">
|
|
435
|
+
<SectionLabel label={t('leave_type_archived')} count={formatCount(archived.length)} />
|
|
436
|
+
<div class="table" role="table" aria-label={t('leave_type_archived')}>
|
|
437
|
+
{#each archived as type (type.id)}
|
|
438
|
+
<div class="trow" role="row">
|
|
439
|
+
<span class="cell name" role="cell">
|
|
440
|
+
<span class="chip" style:color="var(--kern-ink-400)">
|
|
441
|
+
<Icon name={type.icon ?? DEFAULT_ICON} size={15} strokeWidth={1.8} />
|
|
442
|
+
</span>
|
|
443
|
+
<span class="tname muted">{type.name}</span>
|
|
444
|
+
<span class="tkey">{type.key}</span>
|
|
445
|
+
<Badge tone="grey">{t('leave_type_archived')}</Badge>
|
|
446
|
+
</span>
|
|
447
|
+
<span class="cell muted" role="cell">{unitLabel(type.unit)}</span>
|
|
448
|
+
<span class="cell" role="cell"></span>
|
|
449
|
+
<span class="cell rules" role="cell"></span>
|
|
450
|
+
<span class="cell actions" role="cell">
|
|
451
|
+
{#if canManage}
|
|
452
|
+
<DropdownMenu items={actionsFor(type, -1)} align="end">
|
|
453
|
+
{#snippet trigger(props)}
|
|
454
|
+
<IconButton {...props} icon="ellipsis" size={28} label={t('leave_type_actions')} />
|
|
455
|
+
{/snippet}
|
|
456
|
+
</DropdownMenu>
|
|
457
|
+
{/if}
|
|
458
|
+
</span>
|
|
459
|
+
</div>
|
|
460
|
+
{/each}
|
|
461
|
+
</div>
|
|
462
|
+
<p class="hint">{t('leave_type_archive_permanent')}</p>
|
|
463
|
+
</div>
|
|
464
|
+
{/if}
|
|
465
|
+
{/if}
|
|
21
466
|
</SettingsPage>
|
|
467
|
+
|
|
468
|
+
<Dialog
|
|
469
|
+
bind:open={dialogOpen}
|
|
470
|
+
size="md"
|
|
471
|
+
title={editing ? t('leave_type_edit') : t('leave_type_new')}
|
|
472
|
+
onOpenChange={(next) => {
|
|
473
|
+
if (!next) dialogOpen = false
|
|
474
|
+
}}
|
|
475
|
+
>
|
|
476
|
+
<div class="form">
|
|
477
|
+
<!--
|
|
478
|
+
The picker preview. A leave type is chosen from a dropdown by its colour and icon as much as
|
|
479
|
+
by its name, so the form shows the thing being made rather than the fields it is made of.
|
|
480
|
+
-->
|
|
481
|
+
<div class="preview">
|
|
482
|
+
<span class="plabel">{t('leave_type_preview')}</span>
|
|
483
|
+
<span class="pchip">
|
|
484
|
+
<span class="chip" style:color={color}>
|
|
485
|
+
<Icon name={icon} size={15} strokeWidth={1.8} />
|
|
486
|
+
</span>
|
|
487
|
+
<span class="pname">{name.trim() || t('leave_type_name_placeholder')}</span>
|
|
488
|
+
</span>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<Field label={t('leave_type_name')} id="hr-leave-type-name" required>
|
|
492
|
+
{#snippet children(id)}
|
|
493
|
+
<Input {id} bind:value={name} placeholder={t('leave_type_name_placeholder')} />
|
|
494
|
+
{/snippet}
|
|
495
|
+
</Field>
|
|
496
|
+
|
|
497
|
+
<Field
|
|
498
|
+
label={t('leave_type_key')}
|
|
499
|
+
id="hr-leave-type-key"
|
|
500
|
+
hint={editing ? t('leave_type_key_fixed') : t('leave_type_key_hint')}
|
|
501
|
+
error={!editing && key.length > 0 && !keyValid ? t('leave_type_key_invalid') : null}
|
|
502
|
+
>
|
|
503
|
+
{#snippet children(id)}
|
|
504
|
+
<Input
|
|
505
|
+
{id}
|
|
506
|
+
mono
|
|
507
|
+
bind:value={key}
|
|
508
|
+
disabled={editing}
|
|
509
|
+
oninput={() => (keyEdited = true)}
|
|
510
|
+
placeholder="annual_leave"
|
|
511
|
+
/>
|
|
512
|
+
{/snippet}
|
|
513
|
+
</Field>
|
|
514
|
+
|
|
515
|
+
<Field
|
|
516
|
+
label={t('leave_type_unit')}
|
|
517
|
+
id="hr-leave-type-unit"
|
|
518
|
+
hint={editing ? t('leave_type_unit_fixed') : undefined}
|
|
519
|
+
>
|
|
520
|
+
{#snippet children(id)}
|
|
521
|
+
<Select
|
|
522
|
+
{id}
|
|
523
|
+
value={unit}
|
|
524
|
+
disabled={editing}
|
|
525
|
+
options={[
|
|
526
|
+
{ value: 'day', label: t('leave_unit_day') },
|
|
527
|
+
{ value: 'half_day', label: t('leave_unit_half_day') },
|
|
528
|
+
{ value: 'hour', label: t('leave_unit_hour') },
|
|
529
|
+
]}
|
|
530
|
+
onValueChange={(next) => (unit = next as LeaveType['unit'])}
|
|
531
|
+
/>
|
|
532
|
+
{/snippet}
|
|
533
|
+
</Field>
|
|
534
|
+
|
|
535
|
+
<div class="grid2">
|
|
536
|
+
<div class="pick">
|
|
537
|
+
<span class="lbl">{t('leave_type_color')}</span>
|
|
538
|
+
<div class="swatches">
|
|
539
|
+
{#each LEAVE_COLORS as choice (choice.value)}
|
|
540
|
+
<button
|
|
541
|
+
type="button"
|
|
542
|
+
class="swatch"
|
|
543
|
+
class:on={color === choice.value}
|
|
544
|
+
style="--swatch: {choice.value}"
|
|
545
|
+
aria-pressed={color === choice.value}
|
|
546
|
+
aria-label={choice.label()}
|
|
547
|
+
title={choice.label()}
|
|
548
|
+
onclick={() => (color = choice.value)}
|
|
549
|
+
></button>
|
|
550
|
+
{/each}
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<div class="pick">
|
|
555
|
+
<span class="lbl">{t('leave_type_icon')}</span>
|
|
556
|
+
<div class="swatches">
|
|
557
|
+
{#each LEAVE_ICONS as choice (choice.name)}
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
class="ico"
|
|
561
|
+
class:on={icon === choice.name}
|
|
562
|
+
aria-pressed={icon === choice.name}
|
|
563
|
+
aria-label={choice.label()}
|
|
564
|
+
title={choice.label()}
|
|
565
|
+
onclick={() => (icon = choice.name)}
|
|
566
|
+
>
|
|
567
|
+
<Icon name={choice.name} size={16} strokeWidth={1.8} />
|
|
568
|
+
</button>
|
|
569
|
+
{/each}
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<div class="switches">
|
|
575
|
+
<Checkbox
|
|
576
|
+
checked={paid}
|
|
577
|
+
label={t('leave_type_paid')}
|
|
578
|
+
description={t('leave_type_paid_hint')}
|
|
579
|
+
onCheckedChange={(on) => (paid = on)}
|
|
580
|
+
/>
|
|
581
|
+
<Checkbox
|
|
582
|
+
checked={workingDaysOnly}
|
|
583
|
+
label={t('leave_type_working_days')}
|
|
584
|
+
description={t('leave_type_working_days_hint')}
|
|
585
|
+
onCheckedChange={(on) => (workingDaysOnly = on)}
|
|
586
|
+
/>
|
|
587
|
+
<Checkbox
|
|
588
|
+
checked={requireDocument}
|
|
589
|
+
label={t('leave_type_document')}
|
|
590
|
+
description={t('leave_type_document_hint')}
|
|
591
|
+
onCheckedChange={(on) => (requireDocument = on)}
|
|
592
|
+
/>
|
|
593
|
+
{#if requireDocument}
|
|
594
|
+
<div class="nested">
|
|
595
|
+
<Field label={t('leave_type_document_days')} id="hr-leave-type-doc-days">
|
|
596
|
+
{#snippet children(id)}
|
|
597
|
+
<Input {id} type="number" min="1" step="1" bind:value={documentDays} />
|
|
598
|
+
{/snippet}
|
|
599
|
+
</Field>
|
|
600
|
+
</div>
|
|
601
|
+
{/if}
|
|
602
|
+
<Checkbox
|
|
603
|
+
checked={allowNegative}
|
|
604
|
+
label={t('leave_type_negative')}
|
|
605
|
+
description={t('leave_type_negative_hint')}
|
|
606
|
+
onCheckedChange={(on) => (allowNegative = on)}
|
|
607
|
+
/>
|
|
608
|
+
{#if allowNegative}
|
|
609
|
+
<div class="nested">
|
|
610
|
+
<Field
|
|
611
|
+
label={t('leave_type_negative_limit')}
|
|
612
|
+
id="hr-leave-type-negative"
|
|
613
|
+
hint={unit === 'hour' ? t('leave_type_negative_hours_hint') : t('leave_type_negative_days_hint')}
|
|
614
|
+
>
|
|
615
|
+
{#snippet children(id)}
|
|
616
|
+
<Input {id} type="number" min="0" step="0.5" bind:value={negativeLimit} />
|
|
617
|
+
{/snippet}
|
|
618
|
+
</Field>
|
|
619
|
+
</div>
|
|
620
|
+
{/if}
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
|
|
624
|
+
{#snippet footer()}
|
|
625
|
+
<Button variant="ghost" onclick={() => (dialogOpen = false)}>{t('common.cancel')}</Button>
|
|
626
|
+
<Button disabled={!canSave} loading={save.isPending} onclick={submit}>{t('common.save')}</Button>
|
|
627
|
+
{/snippet}
|
|
628
|
+
</Dialog>
|
|
629
|
+
|
|
630
|
+
<!--
|
|
631
|
+
Archiving is not deleting, and an HR admin has to know that before clicking: the ledger is
|
|
632
|
+
append-only, so every day already taken stays taken and every balance reads the same afterwards.
|
|
633
|
+
What does change is that nobody can pick it again — and nothing on this screen undoes it.
|
|
634
|
+
-->
|
|
635
|
+
<Dialog
|
|
636
|
+
open={archiving !== null}
|
|
637
|
+
size="sm"
|
|
638
|
+
title={archiving ? t('leave_type_archive_title', { name: archiving.name }) : ''}
|
|
639
|
+
onOpenChange={(next) => {
|
|
640
|
+
if (!next) archiving = null
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
<p class="body">{t('leave_type_archive_body')}</p>
|
|
644
|
+
<p class="body warn">{t('leave_type_archive_permanent')}</p>
|
|
645
|
+
|
|
646
|
+
{#snippet footer()}
|
|
647
|
+
<Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
|
|
648
|
+
<Button
|
|
649
|
+
variant="danger"
|
|
650
|
+
loading={archive.isPending}
|
|
651
|
+
onclick={() => {
|
|
652
|
+
if (archiving && !archive.isPending) archive.mutate(archiving)
|
|
653
|
+
}}
|
|
654
|
+
>
|
|
655
|
+
{t('common.archive')}
|
|
656
|
+
</Button>
|
|
657
|
+
{/snippet}
|
|
658
|
+
</Dialog>
|
|
659
|
+
|
|
660
|
+
<style>
|
|
661
|
+
.section {
|
|
662
|
+
display: grid;
|
|
663
|
+
gap: 4px;
|
|
664
|
+
}
|
|
665
|
+
.rows {
|
|
666
|
+
display: grid;
|
|
667
|
+
gap: 4px;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
671
|
+
.table {
|
|
672
|
+
--hr-leave-cols: minmax(180px, 1.6fr) 110px 88px minmax(140px, 1.2fr) max-content;
|
|
673
|
+
width: 100%;
|
|
674
|
+
}
|
|
675
|
+
.thead,
|
|
676
|
+
.trow {
|
|
677
|
+
display: grid;
|
|
678
|
+
grid-template-columns: var(--hr-leave-cols);
|
|
679
|
+
gap: 12px;
|
|
680
|
+
align-items: center;
|
|
681
|
+
padding-inline: 10px;
|
|
682
|
+
}
|
|
683
|
+
.thead {
|
|
684
|
+
height: 34px;
|
|
685
|
+
border-block-end: 1px solid var(--kern-border);
|
|
686
|
+
font-size: 11px;
|
|
687
|
+
font-weight: 600;
|
|
688
|
+
letter-spacing: 0.06em;
|
|
689
|
+
text-transform: uppercase;
|
|
690
|
+
color: var(--kern-ink-500);
|
|
691
|
+
}
|
|
692
|
+
.trow {
|
|
693
|
+
min-height: 48px;
|
|
694
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
695
|
+
border-radius: var(--kern-r-md);
|
|
696
|
+
}
|
|
697
|
+
.trow:hover {
|
|
698
|
+
background: var(--kern-surface-raised);
|
|
699
|
+
}
|
|
700
|
+
.cell {
|
|
701
|
+
min-width: 0;
|
|
702
|
+
overflow: hidden;
|
|
703
|
+
text-overflow: ellipsis;
|
|
704
|
+
white-space: nowrap;
|
|
705
|
+
}
|
|
706
|
+
.name {
|
|
707
|
+
display: flex;
|
|
708
|
+
align-items: center;
|
|
709
|
+
gap: 8px;
|
|
710
|
+
}
|
|
711
|
+
.chip {
|
|
712
|
+
display: inline-grid;
|
|
713
|
+
place-items: center;
|
|
714
|
+
flex: none;
|
|
715
|
+
width: 24px;
|
|
716
|
+
height: 24px;
|
|
717
|
+
border-radius: var(--kern-r-sm);
|
|
718
|
+
border: 1px solid var(--kern-border-hairline);
|
|
719
|
+
background: var(--kern-surface-chip);
|
|
720
|
+
}
|
|
721
|
+
.tname {
|
|
722
|
+
min-width: 0;
|
|
723
|
+
overflow: hidden;
|
|
724
|
+
text-overflow: ellipsis;
|
|
725
|
+
font-size: 13.5px;
|
|
726
|
+
font-weight: 500;
|
|
727
|
+
}
|
|
728
|
+
.tkey {
|
|
729
|
+
flex: none;
|
|
730
|
+
font-family: var(--kern-font-mono);
|
|
731
|
+
font-size: 11px;
|
|
732
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
733
|
+
color: var(--kern-ink-400);
|
|
734
|
+
}
|
|
735
|
+
.muted {
|
|
736
|
+
font-size: 13px;
|
|
737
|
+
color: var(--kern-ink-500);
|
|
738
|
+
}
|
|
739
|
+
.rules {
|
|
740
|
+
display: flex;
|
|
741
|
+
align-items: center;
|
|
742
|
+
font-size: 12.5px;
|
|
743
|
+
color: var(--kern-ink-500);
|
|
744
|
+
}
|
|
745
|
+
.rule {
|
|
746
|
+
min-width: 0;
|
|
747
|
+
overflow: hidden;
|
|
748
|
+
text-overflow: ellipsis;
|
|
749
|
+
}
|
|
750
|
+
.rule + .rule::before {
|
|
751
|
+
content: '·';
|
|
752
|
+
margin-inline: 6px;
|
|
753
|
+
color: var(--kern-ink-300);
|
|
754
|
+
}
|
|
755
|
+
.actions {
|
|
756
|
+
display: flex;
|
|
757
|
+
justify-content: flex-end;
|
|
758
|
+
overflow: visible;
|
|
759
|
+
}
|
|
760
|
+
.hint {
|
|
761
|
+
margin-block-start: 8px;
|
|
762
|
+
font-size: 12px;
|
|
763
|
+
color: var(--kern-ink-500);
|
|
764
|
+
}
|
|
765
|
+
.sr-only {
|
|
766
|
+
position: absolute;
|
|
767
|
+
width: 1px;
|
|
768
|
+
height: 1px;
|
|
769
|
+
overflow: hidden;
|
|
770
|
+
clip-path: inset(50%);
|
|
771
|
+
white-space: nowrap;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/* ---- dialog ---- */
|
|
775
|
+
.form {
|
|
776
|
+
display: grid;
|
|
777
|
+
gap: 14px;
|
|
778
|
+
}
|
|
779
|
+
.preview {
|
|
780
|
+
display: flex;
|
|
781
|
+
align-items: center;
|
|
782
|
+
justify-content: space-between;
|
|
783
|
+
gap: 12px;
|
|
784
|
+
padding: 10px 12px;
|
|
785
|
+
border: 1px solid var(--kern-border-hairline);
|
|
786
|
+
border-radius: var(--kern-r-md);
|
|
787
|
+
background: var(--kern-surface-chip);
|
|
788
|
+
}
|
|
789
|
+
.plabel {
|
|
790
|
+
font-size: 11px;
|
|
791
|
+
font-weight: 600;
|
|
792
|
+
letter-spacing: 0.06em;
|
|
793
|
+
text-transform: uppercase;
|
|
794
|
+
color: var(--kern-ink-500);
|
|
795
|
+
}
|
|
796
|
+
.pchip {
|
|
797
|
+
display: flex;
|
|
798
|
+
align-items: center;
|
|
799
|
+
gap: 8px;
|
|
800
|
+
min-width: 0;
|
|
801
|
+
}
|
|
802
|
+
.pname {
|
|
803
|
+
min-width: 0;
|
|
804
|
+
overflow: hidden;
|
|
805
|
+
text-overflow: ellipsis;
|
|
806
|
+
font-size: 13.5px;
|
|
807
|
+
font-weight: 500;
|
|
808
|
+
color: var(--kern-ink-900);
|
|
809
|
+
}
|
|
810
|
+
.grid2 {
|
|
811
|
+
display: grid;
|
|
812
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
813
|
+
gap: 14px;
|
|
814
|
+
}
|
|
815
|
+
.pick {
|
|
816
|
+
display: grid;
|
|
817
|
+
gap: 6px;
|
|
818
|
+
}
|
|
819
|
+
.lbl {
|
|
820
|
+
font-size: 12px;
|
|
821
|
+
font-weight: 500;
|
|
822
|
+
color: var(--kern-ink-600);
|
|
823
|
+
}
|
|
824
|
+
.swatches {
|
|
825
|
+
display: flex;
|
|
826
|
+
flex-wrap: wrap;
|
|
827
|
+
gap: 6px;
|
|
828
|
+
}
|
|
829
|
+
.swatch,
|
|
830
|
+
.ico {
|
|
831
|
+
display: grid;
|
|
832
|
+
place-items: center;
|
|
833
|
+
width: 30px;
|
|
834
|
+
height: 30px;
|
|
835
|
+
border: 1px solid var(--kern-border-hairline);
|
|
836
|
+
border-radius: var(--kern-r-sm);
|
|
837
|
+
background: none;
|
|
838
|
+
cursor: pointer;
|
|
839
|
+
}
|
|
840
|
+
.swatch {
|
|
841
|
+
background: var(--swatch);
|
|
842
|
+
}
|
|
843
|
+
.swatch.on,
|
|
844
|
+
.ico.on {
|
|
845
|
+
border-color: var(--kern-accent);
|
|
846
|
+
box-shadow: 0 0 0 1px var(--kern-accent);
|
|
847
|
+
}
|
|
848
|
+
/* A swatch carries no text, so the ring is the only thing telling a keyboard where it is. */
|
|
849
|
+
.swatch:focus-visible,
|
|
850
|
+
.ico:focus-visible {
|
|
851
|
+
outline: 2px solid var(--kern-accent);
|
|
852
|
+
outline-offset: 2px;
|
|
853
|
+
}
|
|
854
|
+
.ico {
|
|
855
|
+
color: var(--kern-ink-600);
|
|
856
|
+
}
|
|
857
|
+
.ico:hover {
|
|
858
|
+
background: var(--kern-surface-hover);
|
|
859
|
+
}
|
|
860
|
+
.ico.on {
|
|
861
|
+
color: var(--kern-accent);
|
|
862
|
+
}
|
|
863
|
+
.switches {
|
|
864
|
+
display: grid;
|
|
865
|
+
gap: 12px;
|
|
866
|
+
}
|
|
867
|
+
.nested {
|
|
868
|
+
max-width: 180px;
|
|
869
|
+
margin-inline-start: 26px;
|
|
870
|
+
}
|
|
871
|
+
.body {
|
|
872
|
+
font-size: 13.5px;
|
|
873
|
+
line-height: 1.55;
|
|
874
|
+
color: var(--kern-ink-700);
|
|
875
|
+
}
|
|
876
|
+
.warn {
|
|
877
|
+
margin-block-start: 8px;
|
|
878
|
+
color: var(--kern-ink-500);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
@media (max-width: 720px) {
|
|
882
|
+
.table {
|
|
883
|
+
--hr-leave-cols: minmax(140px, 1.6fr) 96px max-content;
|
|
884
|
+
}
|
|
885
|
+
/* Pay and the rules survive in the editor; the name and the unit are what a narrow list needs. */
|
|
886
|
+
.thead > :nth-child(3),
|
|
887
|
+
.trow > :nth-child(3),
|
|
888
|
+
.thead > :nth-child(4),
|
|
889
|
+
.trow > :nth-child(4) {
|
|
890
|
+
display: none;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
</style>
|