@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,1372 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
Dialog,
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
EmptyState,
|
|
9
|
+
Field,
|
|
10
|
+
formatCount,
|
|
11
|
+
formatDate,
|
|
12
|
+
IconButton,
|
|
13
|
+
Input,
|
|
14
|
+
type MenuItem,
|
|
15
|
+
navigation,
|
|
16
|
+
SectionLabel,
|
|
17
|
+
Select,
|
|
18
|
+
SettingsPage,
|
|
19
|
+
SettingsSection,
|
|
20
|
+
Skeleton,
|
|
21
|
+
StatTile,
|
|
22
|
+
Switch,
|
|
23
|
+
session,
|
|
24
|
+
Textarea,
|
|
25
|
+
toast,
|
|
26
|
+
} from '@kernhq/ui'
|
|
27
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
28
|
+
// The client barrel re-exports the models it has needed so far, and the day *kind* was not one of
|
|
29
|
+
// them. Straight from the contract rather than widening a barrel another screen shares.
|
|
30
|
+
import type { CalendarDayKind } from '../../contract/models.js'
|
|
31
|
+
import { getHrApi } from '../api-instance.js'
|
|
3
32
|
import { t } from '../i18n.js'
|
|
33
|
+
import type { Calendar, ResolvedCalendarDay, WorkingWeek } from '../index.js'
|
|
34
|
+
import { canHr } from '../permissions.js'
|
|
35
|
+
import { hrKeys, isoDate } from '../query.js'
|
|
4
36
|
|
|
5
37
|
/**
|
|
6
|
-
*
|
|
38
|
+
* The days this company does not work.
|
|
7
39
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
40
|
+
* Two things justify the screen, and everything on it serves one of them.
|
|
41
|
+
*
|
|
42
|
+
* **HR keeps their own days.** A country pack knows about national holidays and nothing else — it
|
|
43
|
+
* has never heard of the week the factory shuts, the bridge Friday, or the national holiday this
|
|
44
|
+
* company works through. So the composed list labels every day with where it came from: a day from
|
|
45
|
+
* the pack is somebody else's decision, a day added here is this company's, and the difference is
|
|
46
|
+
* the difference between "I can change this" and "I daren't touch it".
|
|
47
|
+
*
|
|
48
|
+
* **A pack upgrade must not eat those days.** Removing a pack day does not delete it — the server
|
|
49
|
+
* writes a suppressing row over it, because deleting it would only bring it back next year — so
|
|
50
|
+
* such a day is drawn struck through and still present rather than gone, and the menu on it offers
|
|
51
|
+
* to restore the pack's version instead of a second delete. `pack.apply` is never reachable
|
|
52
|
+
* without `pack.preview`: the dialog names what would be added, changed and dropped, and says out
|
|
53
|
+
* loud that the days added here are untouched, because that is the sentence somebody needs before
|
|
54
|
+
* they press a button that rewrites a year.
|
|
55
|
+
*
|
|
56
|
+
* The year is the unit throughout — packs are published per year, and a holiday list read a month
|
|
57
|
+
* at a time answers nothing.
|
|
58
|
+
*/
|
|
59
|
+
const api = getHrApi()
|
|
60
|
+
const queryClient = useQueryClient()
|
|
61
|
+
|
|
62
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
63
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
64
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
65
|
+
|
|
66
|
+
/** Viewing is granted to everyone by default; changing anything is an administrator's. */
|
|
67
|
+
const manage = $derived(canHr('calendarManage'))
|
|
68
|
+
|
|
69
|
+
type CalendarRow = Calendar & { officeIds: string[] }
|
|
70
|
+
type WeekdayKey = keyof WorkingWeek
|
|
71
|
+
|
|
72
|
+
/** 2024-01-01 was a Monday, so these seven dates name the weekdays in the reader's own language. */
|
|
73
|
+
const WEEKDAYS: { key: WeekdayKey; iso: string }[] = [
|
|
74
|
+
{ key: 'mon', iso: '2024-01-01' },
|
|
75
|
+
{ key: 'tue', iso: '2024-01-02' },
|
|
76
|
+
{ key: 'wed', iso: '2024-01-03' },
|
|
77
|
+
{ key: 'thu', iso: '2024-01-04' },
|
|
78
|
+
{ key: 'fri', iso: '2024-01-05' },
|
|
79
|
+
{ key: 'sat', iso: '2024-01-06' },
|
|
80
|
+
{ key: 'sun', iso: '2024-01-07' },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const DEFAULT_WEEK: WorkingWeek = { mon: 1, tue: 1, wed: 1, thu: 1, fri: 1, sat: 0, sun: 0 }
|
|
84
|
+
|
|
85
|
+
const DAY_KINDS: CalendarDayKind[] = [
|
|
86
|
+
'public_holiday',
|
|
87
|
+
'religious',
|
|
88
|
+
'company_closure',
|
|
89
|
+
'half_day',
|
|
90
|
+
'working_override',
|
|
91
|
+
'bridge',
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What each kind normally costs, applied when the kind changes.
|
|
96
|
+
*
|
|
97
|
+
* A half day that counts as a whole one is a data-entry mistake nobody would notice until a leave
|
|
98
|
+
* balance came out wrong, so picking the kind fills in the obvious answer — and the field stays
|
|
99
|
+
* editable, because a country's public holiday genuinely is a half day in some companies.
|
|
100
|
+
*/
|
|
101
|
+
const KIND_FRACTION: Record<CalendarDayKind, number> = {
|
|
102
|
+
public_holiday: 0,
|
|
103
|
+
religious: 0,
|
|
104
|
+
company_closure: 0,
|
|
105
|
+
half_day: 0.5,
|
|
106
|
+
working_override: 1,
|
|
107
|
+
bridge: 0,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const kindLabel = (kind: CalendarDayKind): string =>
|
|
111
|
+
kind === 'public_holiday'
|
|
112
|
+
? t('cal_kind_public_holiday')
|
|
113
|
+
: kind === 'religious'
|
|
114
|
+
? t('cal_kind_religious')
|
|
115
|
+
: kind === 'company_closure'
|
|
116
|
+
? t('cal_kind_company_closure')
|
|
117
|
+
: kind === 'half_day'
|
|
118
|
+
? t('cal_kind_half_day')
|
|
119
|
+
: kind === 'working_override'
|
|
120
|
+
? t('cal_kind_working_override')
|
|
121
|
+
: t('cal_kind_bridge')
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- what is on screen
|
|
124
|
+
|
|
125
|
+
let picked = $state('')
|
|
126
|
+
let year = $state(new Date().getFullYear())
|
|
127
|
+
|
|
128
|
+
const calendarsQuery = createQuery(() => ({
|
|
129
|
+
queryKey: hrKeys.calendars(workspaceId),
|
|
130
|
+
enabled: Boolean(workspaceId),
|
|
131
|
+
queryFn: () => api.calendars.list({ workspaceId, includeArchived: false }),
|
|
132
|
+
}))
|
|
133
|
+
const calendars = $derived((calendarsQuery.data ?? []) as CalendarRow[])
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Falling back to the first calendar rather than storing a default keeps the selection honest after
|
|
137
|
+
* an archive: the id somebody picked can stop existing, and a screen pointing at it would show an
|
|
138
|
+
* empty year instead of a calendar.
|
|
139
|
+
*/
|
|
140
|
+
const selectedId = $derived(calendars.some((c) => c.id === picked) ? picked : (calendars[0]?.id ?? ''))
|
|
141
|
+
const selected = $derived(calendars.find((c) => c.id === selectedId) ?? null)
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The calendar this one extends, fetched rather than looked up in the list.
|
|
145
|
+
*
|
|
146
|
+
* A country pack is not one of the workspace's own calendars — it is the shared thing they build
|
|
147
|
+
* on — so `calendars.list` need not contain it, and its name and pack key are only available by
|
|
148
|
+
* asking for it.
|
|
149
|
+
*/
|
|
150
|
+
const extendsId = $derived(selected?.extendsId ?? '')
|
|
151
|
+
const baseQuery = createQuery(() => ({
|
|
152
|
+
queryKey: ['hr', 'calendar', workspaceId, extendsId] as const,
|
|
153
|
+
enabled: Boolean(workspaceId && extendsId),
|
|
154
|
+
queryFn: () => api.calendars.get({ workspaceId, calendarId: extendsId }),
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
const from = $derived(`${year}-01-01`)
|
|
158
|
+
const to = $derived(`${year}-12-31`)
|
|
159
|
+
/** The Gregorian year, in the reader's digits: a Persian interface must not print 2026 in Latin. */
|
|
160
|
+
const yearLabel = $derived(formatDate(from, { year: 'numeric', calendar: 'gregory' }))
|
|
161
|
+
|
|
162
|
+
const daysQuery = createQuery(() => ({
|
|
163
|
+
queryKey: hrKeys.calendarDays(workspaceId, selectedId, from, to),
|
|
164
|
+
enabled: Boolean(workspaceId && selectedId),
|
|
165
|
+
queryFn: () => api.calendars.days.list({ workspaceId, calendarId: selectedId, from, to }),
|
|
166
|
+
}))
|
|
167
|
+
const days = $derived(daysQuery.data ?? [])
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The number the whole calendar exists to produce. Leave, attendance and reporting all count
|
|
171
|
+
* against it, so showing it here is what makes a working-week or holiday edit verifiable.
|
|
172
|
+
*/
|
|
173
|
+
const workingDaysQuery = createQuery(() => ({
|
|
174
|
+
queryKey: ['hr', 'calendar-working-days', workspaceId, selectedId, from, to] as const,
|
|
175
|
+
enabled: Boolean(workspaceId && selectedId),
|
|
176
|
+
queryFn: () => api.calendars.workingDays({ workspaceId, calendarId: selectedId, from, to }),
|
|
177
|
+
}))
|
|
178
|
+
|
|
179
|
+
const stats = $derived({
|
|
180
|
+
off: days.filter((d) => d.workingFraction < 1).length,
|
|
181
|
+
pack: days.filter((d) => d.source === 'pack').length,
|
|
182
|
+
own: days.filter((d) => d.source === 'custom').length,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
/** Where the pack days would come from: this calendar's own, its base's, or the country code. */
|
|
186
|
+
/**
|
|
187
|
+
* The pack this calendar would update from.
|
|
188
|
+
*
|
|
189
|
+
* Upper-cased, because `COUNTRY_PACKS` is keyed by the ISO code as ISO writes it — `TR`, not `tr`.
|
|
190
|
+
* Lower-casing it here meant a calendar with a country and no pack key of its own proposed a key
|
|
191
|
+
* nobody publishes, and the server answered that with a diff reading "remove every holiday". The
|
|
192
|
+
* server refuses an unknown key now, so the worst case is a readable error rather than an empty
|
|
193
|
+
* year; this keeps the common case from producing one at all.
|
|
194
|
+
*/
|
|
195
|
+
const packKey = $derived(
|
|
196
|
+
selected?.packKey ?? baseQuery.data?.packKey ?? (selected?.country ?? '').toUpperCase(),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const baseLine = $derived(
|
|
200
|
+
extendsId
|
|
201
|
+
? baseQuery.data
|
|
202
|
+
? t('cal_based_on_name', { name: baseQuery.data.name })
|
|
203
|
+
: ''
|
|
204
|
+
: t('cal_based_on_none'),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const shortDay = (iso: string) => formatDate(iso, { weekday: 'short' })
|
|
208
|
+
|
|
209
|
+
/** "Mon · Tue · Wed · Thu · Fri", with a half day marked rather than hidden. */
|
|
210
|
+
function weekSummary(week: WorkingWeek): string {
|
|
211
|
+
const worked = WEEKDAYS.filter((d) => week[d.key] > 0)
|
|
212
|
+
if (worked.length === 0) return t('cal_week_none')
|
|
213
|
+
return worked.map((d) => `${shortDay(d.iso)}${week[d.key] < 1 ? ' ½' : ''}`).join(' · ')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Where a composed day came from, as the reader needs it.
|
|
218
|
+
*
|
|
219
|
+
* The model says `source` and `overrides`; those two answer four different questions, and the one
|
|
220
|
+
* that matters most is the fourth: a pack day this company masked is still in the list, and drawing
|
|
221
|
+
* it as if it were simply gone would hide the mechanism that protects it from the next upgrade.
|
|
222
|
+
*/
|
|
223
|
+
type Origin = 'pack' | 'added' | 'changed' | 'masked'
|
|
224
|
+
function originOf(day: ResolvedCalendarDay): Origin {
|
|
225
|
+
if (day.source === 'pack') return 'pack'
|
|
226
|
+
if (!day.overrides) return 'added'
|
|
227
|
+
return day.workingFraction >= 1 ? 'masked' : 'changed'
|
|
228
|
+
}
|
|
229
|
+
const ORIGIN_TONES: Record<Origin, BadgeTone> = {
|
|
230
|
+
pack: 'grey',
|
|
231
|
+
added: 'accent',
|
|
232
|
+
changed: 'info',
|
|
233
|
+
masked: 'warning',
|
|
234
|
+
}
|
|
235
|
+
const originLabel = (origin: Origin): string =>
|
|
236
|
+
origin === 'pack'
|
|
237
|
+
? t('cal_origin_pack')
|
|
238
|
+
: origin === 'added'
|
|
239
|
+
? t('cal_origin_added')
|
|
240
|
+
: origin === 'changed'
|
|
241
|
+
? t('cal_origin_changed')
|
|
242
|
+
: t('cal_origin_masked')
|
|
243
|
+
|
|
244
|
+
const effectLabel = (fraction: number): string =>
|
|
245
|
+
fraction <= 0
|
|
246
|
+
? t('cal_effect_off')
|
|
247
|
+
: fraction >= 1
|
|
248
|
+
? t('cal_effect_worked')
|
|
249
|
+
: fraction === 0.5
|
|
250
|
+
? t('cal_effect_half')
|
|
251
|
+
: t('cal_effect_part', { fraction })
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A day change moves more than this screen: working-day counts, leave balances and the attendance
|
|
255
|
+
* day sheet are all computed from these rows, so the module's cache is dropped whole rather than
|
|
256
|
+
* guessing which keys a recomputation touched.
|
|
257
|
+
*/
|
|
258
|
+
const refresh = () => {
|
|
259
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One click, one write.
|
|
264
|
+
*
|
|
265
|
+
* `disabled={mutation.isPending}` reaches the button on the next render, and two quick clicks are
|
|
266
|
+
* one render apart — which on this screen means two calendars, two holidays, or a pack applied
|
|
267
|
+
* twice. The flag is set in the same tick as the click and cleared when the call settles.
|
|
268
|
+
*/
|
|
269
|
+
let firing = $state(false)
|
|
270
|
+
function once(run: () => void) {
|
|
271
|
+
if (firing) return
|
|
272
|
+
firing = true
|
|
273
|
+
run()
|
|
274
|
+
}
|
|
275
|
+
const settled = () => {
|
|
276
|
+
firing = false
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------- the calendar form
|
|
280
|
+
|
|
281
|
+
let calDialog = $state<'create' | 'edit' | null>(null)
|
|
282
|
+
let calId = $state('')
|
|
283
|
+
let calName = $state('')
|
|
284
|
+
let calExtends = $state('')
|
|
285
|
+
let calCountry = $state('')
|
|
286
|
+
let calRegion = $state('')
|
|
287
|
+
let calWeek = $state<WorkingWeek>({ ...DEFAULT_WEEK })
|
|
288
|
+
|
|
289
|
+
const countryValid = $derived(calCountry === '' || /^[A-Z]{2}$/.test(calCountry))
|
|
290
|
+
|
|
291
|
+
function openCreate() {
|
|
292
|
+
calDialog = 'create'
|
|
293
|
+
calId = ''
|
|
294
|
+
calName = ''
|
|
295
|
+
calExtends = ''
|
|
296
|
+
calCountry = ''
|
|
297
|
+
calRegion = ''
|
|
298
|
+
calWeek = { ...DEFAULT_WEEK }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function openEdit(cal: CalendarRow) {
|
|
302
|
+
calDialog = 'edit'
|
|
303
|
+
calId = cal.id
|
|
304
|
+
calName = cal.name
|
|
305
|
+
calExtends = cal.extendsId ?? ''
|
|
306
|
+
calCountry = cal.country ?? ''
|
|
307
|
+
calRegion = cal.region ?? ''
|
|
308
|
+
calWeek = { ...cal.workingWeek }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function setWeekday(key: WeekdayKey, value: number) {
|
|
312
|
+
calWeek = { ...calWeek, [key]: value }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const saveCalendar = createMutation(() => ({
|
|
316
|
+
// `$state.snapshot` because the working week is a state proxy, and a proxy cannot be cloned on
|
|
317
|
+
// its way into the request — the call throws instead of saving.
|
|
318
|
+
mutationFn: () =>
|
|
319
|
+
calDialog === 'edit'
|
|
320
|
+
? api.calendars.update({
|
|
321
|
+
workspaceId,
|
|
322
|
+
calendarId: calId,
|
|
323
|
+
name: calName.trim(),
|
|
324
|
+
extendsId: calExtends || null,
|
|
325
|
+
workingWeek: $state.snapshot(calWeek),
|
|
326
|
+
})
|
|
327
|
+
: api.calendars.create({
|
|
328
|
+
workspaceId,
|
|
329
|
+
name: calName.trim(),
|
|
330
|
+
extendsId: calExtends || null,
|
|
331
|
+
country: calCountry || null,
|
|
332
|
+
region: calRegion.trim() || null,
|
|
333
|
+
workingWeek: $state.snapshot(calWeek),
|
|
334
|
+
}),
|
|
335
|
+
onSuccess: (cal: Calendar) => {
|
|
336
|
+
toast.success(calDialog === 'edit' ? t('cal_saved') : t('cal_created', { name: cal.name }))
|
|
337
|
+
// A new calendar is the one somebody wants to fill in, so the day list below switches to it.
|
|
338
|
+
picked = cal.id
|
|
339
|
+
calDialog = null
|
|
340
|
+
refresh()
|
|
341
|
+
},
|
|
342
|
+
onError: (error: Error) => toast.error(error.message),
|
|
343
|
+
onSettled: settled,
|
|
344
|
+
}))
|
|
345
|
+
|
|
346
|
+
let archiving = $state<CalendarRow | null>(null)
|
|
347
|
+
|
|
348
|
+
const archiveCalendar = createMutation(() => ({
|
|
349
|
+
mutationFn: (cal: CalendarRow) => api.calendars.archive({ workspaceId, calendarId: cal.id }),
|
|
350
|
+
onSuccess: (_ok, cal: CalendarRow) => {
|
|
351
|
+
toast.success(t('cal_archived', { name: cal.name }))
|
|
352
|
+
archiving = null
|
|
353
|
+
refresh()
|
|
354
|
+
},
|
|
355
|
+
onError: (error: Error) => toast.error(error.message),
|
|
356
|
+
onSettled: settled,
|
|
357
|
+
}))
|
|
358
|
+
|
|
359
|
+
function calendarMenu(cal: CalendarRow): MenuItem[] {
|
|
360
|
+
const inUse = cal.officeIds.length > 0
|
|
361
|
+
return [
|
|
362
|
+
{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(cal) },
|
|
363
|
+
{ type: 'separator' },
|
|
364
|
+
{
|
|
365
|
+
label: t('common.archive'),
|
|
366
|
+
icon: 'archive',
|
|
367
|
+
danger: true,
|
|
368
|
+
// An office resolves its holidays through this calendar; archiving it under their feet would
|
|
369
|
+
// change everyone's working days silently. The office has to be moved first.
|
|
370
|
+
disabled: inUse,
|
|
371
|
+
hint: inUse ? t('cal_in_use', { count: cal.officeIds.length }) : undefined,
|
|
372
|
+
onSelect: () => {
|
|
373
|
+
archiving = cal
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------- the day form
|
|
380
|
+
|
|
381
|
+
let dayDialog = $state<'add' | 'edit' | null>(null)
|
|
382
|
+
let dayId = $state('')
|
|
383
|
+
let dayDate = $state('')
|
|
384
|
+
let dayName = $state('')
|
|
385
|
+
let dayKind = $state<CalendarDayKind>('company_closure')
|
|
386
|
+
let dayEffect = $state('0')
|
|
387
|
+
let dayPaid = $state(true)
|
|
388
|
+
let dayNote = $state('')
|
|
389
|
+
let dayFromPack = $state(false)
|
|
390
|
+
|
|
391
|
+
const effectOptions = $derived(
|
|
392
|
+
[
|
|
393
|
+
{ value: '0', label: t('cal_effect_off') },
|
|
394
|
+
{ value: '0.5', label: t('cal_effect_half') },
|
|
395
|
+
{ value: '1', label: t('cal_effect_worked') },
|
|
396
|
+
// A pack can carry any fraction; without this its own value would vanish the moment somebody
|
|
397
|
+
// opened the day to change its name.
|
|
398
|
+
...(['0', '0.5', '1'].includes(dayEffect)
|
|
399
|
+
? []
|
|
400
|
+
: [{ value: dayEffect, label: t('cal_effect_part', { fraction: Number(dayEffect) }) }]),
|
|
401
|
+
].sort((a, b) => Number(a.value) - Number(b.value)),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
function openAddDay() {
|
|
405
|
+
dayDialog = 'add'
|
|
406
|
+
dayId = ''
|
|
407
|
+
// Today when today is in the year on screen; otherwise that year's first day, which is at least
|
|
408
|
+
// inside the list the dialog was opened from.
|
|
409
|
+
dayDate = year === new Date().getFullYear() ? isoDate() : `${year}-01-01`
|
|
410
|
+
dayName = ''
|
|
411
|
+
dayKind = 'company_closure'
|
|
412
|
+
dayEffect = '0'
|
|
413
|
+
dayPaid = true
|
|
414
|
+
dayNote = ''
|
|
415
|
+
dayFromPack = false
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function openEditDay(day: ResolvedCalendarDay) {
|
|
419
|
+
dayDialog = 'edit'
|
|
420
|
+
dayId = day.id
|
|
421
|
+
dayDate = day.date
|
|
422
|
+
dayName = day.name
|
|
423
|
+
dayKind = day.kind
|
|
424
|
+
dayEffect = String(day.workingFraction)
|
|
425
|
+
dayPaid = day.paid
|
|
426
|
+
dayNote = day.note ?? ''
|
|
427
|
+
dayFromPack = day.source === 'pack'
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function pickKind(kind: string) {
|
|
431
|
+
dayKind = kind as CalendarDayKind
|
|
432
|
+
dayEffect = String(KIND_FRACTION[dayKind])
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const canSaveDay = $derived(Boolean(dayDate && dayName.trim()) && manage)
|
|
436
|
+
|
|
437
|
+
const saveDay = createMutation(() => ({
|
|
438
|
+
mutationFn: () =>
|
|
439
|
+
dayDialog === 'edit'
|
|
440
|
+
? api.calendars.days.update({
|
|
441
|
+
workspaceId,
|
|
442
|
+
calendarId: selectedId,
|
|
443
|
+
dayId,
|
|
444
|
+
name: dayName.trim(),
|
|
445
|
+
kind: dayKind,
|
|
446
|
+
workingFraction: Number(dayEffect),
|
|
447
|
+
paid: dayPaid,
|
|
448
|
+
note: dayNote.trim() || null,
|
|
449
|
+
})
|
|
450
|
+
: api.calendars.days.add({
|
|
451
|
+
workspaceId,
|
|
452
|
+
calendarId: selectedId,
|
|
453
|
+
date: dayDate,
|
|
454
|
+
name: dayName.trim(),
|
|
455
|
+
kind: dayKind,
|
|
456
|
+
workingFraction: Number(dayEffect),
|
|
457
|
+
paid: dayPaid,
|
|
458
|
+
note: dayNote.trim() || null,
|
|
459
|
+
}),
|
|
460
|
+
onSuccess: (day: ResolvedCalendarDay) => {
|
|
461
|
+
toast.success(dayDialog === 'edit' ? t('cal_saved') : t('cal_day_added', { name: day.name }))
|
|
462
|
+
dayDialog = null
|
|
463
|
+
// The year on screen need not contain the day just added.
|
|
464
|
+
year = Number(day.date.slice(0, 4))
|
|
465
|
+
refresh()
|
|
466
|
+
},
|
|
467
|
+
onError: (error: Error) => toast.error(error.message),
|
|
468
|
+
onSettled: settled,
|
|
469
|
+
}))
|
|
470
|
+
|
|
471
|
+
let removing = $state<ResolvedCalendarDay | null>(null)
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Three different things wear the same button.
|
|
475
|
+
*
|
|
476
|
+
* Deleting a day this company added removes it. "Removing" a pack day cannot delete anything — the
|
|
477
|
+
* server masks it — and removing a row that already masks or changes a pack day gives the pack's
|
|
478
|
+
* own version back. Each says what it does, because the outcomes are not the same and the middle
|
|
479
|
+
* one is the whole reason this screen is safe to use.
|
|
12
480
|
*/
|
|
481
|
+
const removeMode = $derived(
|
|
482
|
+
!removing ? 'delete' : removing.source === 'pack' ? 'mask' : removing.overrides ? 'restore' : 'delete',
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
const removeDay = createMutation(() => ({
|
|
486
|
+
mutationFn: (day: ResolvedCalendarDay) =>
|
|
487
|
+
api.calendars.days.remove({ workspaceId, calendarId: selectedId, dayId: day.id }),
|
|
488
|
+
onSuccess: (result: { ok: true; suppressed: boolean }, day: ResolvedCalendarDay) => {
|
|
489
|
+
toast.success(
|
|
490
|
+
result.suppressed
|
|
491
|
+
? t('cal_masked', { name: day.name })
|
|
492
|
+
: removeMode === 'restore'
|
|
493
|
+
? t('cal_restored', { name: day.name })
|
|
494
|
+
: t('cal_removed', { name: day.name }),
|
|
495
|
+
)
|
|
496
|
+
removing = null
|
|
497
|
+
refresh()
|
|
498
|
+
},
|
|
499
|
+
onError: (error: Error) => toast.error(error.message),
|
|
500
|
+
onSettled: settled,
|
|
501
|
+
}))
|
|
502
|
+
|
|
503
|
+
function dayMenu(day: ResolvedCalendarDay): MenuItem[] {
|
|
504
|
+
const origin = originOf(day)
|
|
505
|
+
const restores = origin === 'masked' || origin === 'changed'
|
|
506
|
+
return [
|
|
507
|
+
{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEditDay(day) },
|
|
508
|
+
{ type: 'separator' },
|
|
509
|
+
{
|
|
510
|
+
label: restores ? t('cal_restore') : t('common.remove'),
|
|
511
|
+
icon: restores ? 'rotate-ccw' : 'trash-2',
|
|
512
|
+
// Giving the pack's day back is not destructive — it puts a day off back on the calendar.
|
|
513
|
+
danger: !restores,
|
|
514
|
+
onSelect: () => {
|
|
515
|
+
removing = day
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------- the pack
|
|
522
|
+
|
|
523
|
+
let packOpen = $state(false)
|
|
524
|
+
let packInput = $state('')
|
|
525
|
+
|
|
526
|
+
function openPack() {
|
|
527
|
+
packInput = packKey
|
|
528
|
+
packOpen = true
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const previewQuery = createQuery(() => ({
|
|
532
|
+
queryKey: ['hr', 'calendar-pack-preview', workspaceId, selectedId, packInput, year] as const,
|
|
533
|
+
enabled: Boolean(packOpen && workspaceId && selectedId && packInput),
|
|
534
|
+
// A pack key that does not exist is a typo, not a network blip: answer at once instead of
|
|
535
|
+
// retrying three times behind a spinner.
|
|
536
|
+
retry: false,
|
|
537
|
+
queryFn: () =>
|
|
538
|
+
api.calendars.pack.preview({ workspaceId, calendarId: selectedId, packKey: packInput, year }),
|
|
539
|
+
}))
|
|
540
|
+
const preview = $derived(previewQuery.data)
|
|
541
|
+
const packChanges = $derived(
|
|
542
|
+
preview ? preview.added.length + preview.changed.length + preview.removed.length : 0,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
const applyPack = createMutation(() => ({
|
|
546
|
+
mutationFn: () =>
|
|
547
|
+
api.calendars.pack.apply({ workspaceId, calendarId: selectedId, packKey: packInput, year }),
|
|
548
|
+
onSuccess: (result: { added: number; changed: number; removed: number }) => {
|
|
549
|
+
toast.success(
|
|
550
|
+
t('cal_pack_applied', {
|
|
551
|
+
added: result.added,
|
|
552
|
+
changed: result.changed,
|
|
553
|
+
removed: result.removed,
|
|
554
|
+
}),
|
|
555
|
+
)
|
|
556
|
+
packOpen = false
|
|
557
|
+
refresh()
|
|
558
|
+
},
|
|
559
|
+
onError: (error: Error) => toast.error(error.message),
|
|
560
|
+
onSettled: settled,
|
|
561
|
+
}))
|
|
562
|
+
|
|
563
|
+
/** Long lists are for reading, not for scrolling past — the tail is counted instead. */
|
|
564
|
+
const HEAD = 8
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The four things a pack update would do, in the order somebody worries about them — and the fourth
|
|
568
|
+
* is the one that matters: the days added here are listed precisely so the dialog can say they are
|
|
569
|
+
* left alone.
|
|
570
|
+
*/
|
|
571
|
+
type PackRow = { date: string; name: string; was?: string }
|
|
572
|
+
const packGroups = $derived<{ key: string; label: string; rows: PackRow[] }[]>(
|
|
573
|
+
preview
|
|
574
|
+
? [
|
|
575
|
+
{ key: 'added', label: t('cal_pack_added'), rows: preview.added },
|
|
576
|
+
{ key: 'changed', label: t('cal_pack_changed'), rows: preview.changed },
|
|
577
|
+
{ key: 'removed', label: t('cal_pack_removed'), rows: preview.removed },
|
|
578
|
+
{ key: 'kept', label: t('cal_pack_kept'), rows: preview.keptCustom },
|
|
579
|
+
]
|
|
580
|
+
: [],
|
|
581
|
+
)
|
|
13
582
|
</script>
|
|
14
583
|
|
|
15
|
-
<SettingsPage title={t('settings_calendars')}>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
584
|
+
<SettingsPage title={t('settings_calendars')} description={t('cal_desc')}>
|
|
585
|
+
{#snippet actions()}
|
|
586
|
+
{#if manage}
|
|
587
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('cal_new')}</Button>
|
|
588
|
+
{/if}
|
|
589
|
+
{/snippet}
|
|
590
|
+
|
|
591
|
+
<SettingsSection title={t('cal_section')} description={t('cal_section_desc')}>
|
|
592
|
+
{#if calendarsQuery.isLoading}
|
|
593
|
+
<div class="rows">
|
|
594
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="44px" />{/each}
|
|
595
|
+
</div>
|
|
596
|
+
{:else if calendarsQuery.isError}
|
|
597
|
+
<EmptyState icon="triangle-alert" title={t('cal_error')}>
|
|
598
|
+
{#snippet actions()}
|
|
599
|
+
<Button variant="secondary" onclick={() => void calendarsQuery.refetch()}>{t('retry')}</Button>
|
|
600
|
+
{/snippet}
|
|
601
|
+
</EmptyState>
|
|
602
|
+
{:else if calendars.length === 0}
|
|
603
|
+
<EmptyState icon="calendar" title={t('cal_none')} description={t('cal_none_desc')}>
|
|
604
|
+
{#snippet actions()}
|
|
605
|
+
{#if manage}<Button icon="plus" onclick={openCreate}>{t('cal_new')}</Button>{/if}
|
|
606
|
+
{/snippet}
|
|
607
|
+
</EmptyState>
|
|
608
|
+
{:else}
|
|
609
|
+
<div class="table cals" role="table" aria-label={t('cal_section')}>
|
|
610
|
+
<div class="thead" role="row">
|
|
611
|
+
<span role="columnheader">{t('cal_name')}</span>
|
|
612
|
+
<span role="columnheader">{t('offices_title')}</span>
|
|
613
|
+
<span role="columnheader">{t('cal_working_week')}</span>
|
|
614
|
+
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
615
|
+
</div>
|
|
616
|
+
{#each calendars as cal (cal.id)}
|
|
617
|
+
<div
|
|
618
|
+
class="trow"
|
|
619
|
+
class:on={cal.id === selectedId}
|
|
620
|
+
role="row"
|
|
621
|
+
aria-current={cal.id === selectedId ? 'true' : undefined}
|
|
622
|
+
>
|
|
623
|
+
<span class="cell" role="cell">
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
class="pick"
|
|
627
|
+
onclick={() => (picked = cal.id)}
|
|
628
|
+
aria-label={t('cal_show', { name: cal.name })}
|
|
629
|
+
>
|
|
630
|
+
<span class="strong">{cal.name}</span>
|
|
631
|
+
{#if cal.source === 'pack'}
|
|
632
|
+
<Badge tone="grey">{t('cal_origin_pack')}</Badge>
|
|
633
|
+
{/if}
|
|
634
|
+
</button>
|
|
635
|
+
</span>
|
|
636
|
+
<span class="cell num" role="cell">{formatCount(cal.officeIds.length, 999)}</span>
|
|
637
|
+
<span class="cell muted" role="cell">{weekSummary(cal.workingWeek)}</span>
|
|
638
|
+
<span class="cell actions" role="cell">
|
|
639
|
+
{#if manage}
|
|
640
|
+
<DropdownMenu items={calendarMenu(cal)}>
|
|
641
|
+
{#snippet trigger(props)}
|
|
642
|
+
<IconButton
|
|
643
|
+
icon="ellipsis"
|
|
644
|
+
label={t('cal_actions_for', { name: cal.name })}
|
|
645
|
+
size={28}
|
|
646
|
+
{...props}
|
|
647
|
+
/>
|
|
648
|
+
{/snippet}
|
|
649
|
+
</DropdownMenu>
|
|
650
|
+
{/if}
|
|
651
|
+
</span>
|
|
652
|
+
</div>
|
|
653
|
+
{/each}
|
|
654
|
+
</div>
|
|
655
|
+
{/if}
|
|
656
|
+
</SettingsSection>
|
|
657
|
+
|
|
658
|
+
{#if selected}
|
|
659
|
+
<SettingsSection title={selected.name} description={baseLine}>
|
|
660
|
+
{#snippet action()}
|
|
661
|
+
<div class="years" role="group" aria-label={t('cal_year')}>
|
|
662
|
+
<IconButton
|
|
663
|
+
icon="chevron-left"
|
|
664
|
+
class="flip"
|
|
665
|
+
label={t('cal_year_prev')}
|
|
666
|
+
size={28}
|
|
667
|
+
variant="outline"
|
|
668
|
+
onclick={() => year--}
|
|
669
|
+
/>
|
|
670
|
+
<span class="year">{yearLabel}</span>
|
|
671
|
+
<IconButton
|
|
672
|
+
icon="chevron-right"
|
|
673
|
+
class="flip"
|
|
674
|
+
label={t('cal_year_next')}
|
|
675
|
+
size={28}
|
|
676
|
+
variant="outline"
|
|
677
|
+
onclick={() => year++}
|
|
678
|
+
/>
|
|
679
|
+
</div>
|
|
680
|
+
{/snippet}
|
|
681
|
+
|
|
682
|
+
{#if daysQuery.isLoading}
|
|
683
|
+
<div class="tiles">
|
|
684
|
+
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="76px" />{/each}
|
|
685
|
+
</div>
|
|
686
|
+
{:else if !daysQuery.isError}
|
|
687
|
+
<div class="tiles">
|
|
688
|
+
<StatTile
|
|
689
|
+
size="md"
|
|
690
|
+
label={t('cal_working_days')}
|
|
691
|
+
value={workingDaysQuery.data ? formatCount(workingDaysQuery.data.days, 9999) : '—'}
|
|
692
|
+
/>
|
|
693
|
+
<StatTile size="md" label={t('cal_days_off')} value={formatCount(stats.off, 999)} />
|
|
694
|
+
<StatTile size="md" label={t('cal_from_pack')} value={formatCount(stats.pack, 999)} />
|
|
695
|
+
<StatTile size="md" label={t('cal_added_here')} value={formatCount(stats.own, 999)} />
|
|
696
|
+
</div>
|
|
697
|
+
{/if}
|
|
698
|
+
|
|
699
|
+
<SectionLabel label={t('cal_days_title')} count={days.length}>
|
|
700
|
+
{#snippet trailing()}
|
|
701
|
+
{#if manage}
|
|
702
|
+
<Button
|
|
703
|
+
size="sm"
|
|
704
|
+
variant="secondary"
|
|
705
|
+
icon="download"
|
|
706
|
+
disabled={!packKey}
|
|
707
|
+
onclick={openPack}
|
|
708
|
+
>
|
|
709
|
+
{t('cal_pack_update')}
|
|
710
|
+
</Button>
|
|
711
|
+
<Button size="sm" icon="plus" onclick={openAddDay}>{t('cal_add_day')}</Button>
|
|
712
|
+
{/if}
|
|
713
|
+
{/snippet}
|
|
714
|
+
</SectionLabel>
|
|
715
|
+
|
|
716
|
+
{#if manage && !packKey}
|
|
717
|
+
<!-- Disabled with the reason beside it: the pack is found by country, and this one has none. -->
|
|
718
|
+
<p class="hint">{t('cal_pack_needs_country')}</p>
|
|
719
|
+
{/if}
|
|
720
|
+
|
|
721
|
+
{#if daysQuery.isLoading}
|
|
722
|
+
<div class="rows">
|
|
723
|
+
{#each [1, 2, 3, 4, 5] as n (n)}<Skeleton height="44px" />{/each}
|
|
724
|
+
</div>
|
|
725
|
+
{:else if daysQuery.isError}
|
|
726
|
+
<EmptyState icon="triangle-alert" title={t('cal_days_error')}>
|
|
727
|
+
{#snippet actions()}
|
|
728
|
+
<Button variant="secondary" onclick={() => void daysQuery.refetch()}>{t('retry')}</Button>
|
|
729
|
+
{/snippet}
|
|
730
|
+
</EmptyState>
|
|
731
|
+
{:else if days.length === 0}
|
|
732
|
+
<EmptyState
|
|
733
|
+
icon="calendar-days"
|
|
734
|
+
title={t('cal_days_none', { year: yearLabel })}
|
|
735
|
+
description={t('cal_days_none_desc')}
|
|
736
|
+
>
|
|
737
|
+
{#snippet actions()}
|
|
738
|
+
{#if manage}
|
|
739
|
+
<Button icon="plus" onclick={openAddDay}>{t('cal_add_day')}</Button>
|
|
740
|
+
{#if packKey}
|
|
741
|
+
<Button variant="secondary" icon="download" onclick={openPack}>
|
|
742
|
+
{t('cal_pack_update')}
|
|
743
|
+
</Button>
|
|
744
|
+
{/if}
|
|
745
|
+
{/if}
|
|
746
|
+
{/snippet}
|
|
747
|
+
</EmptyState>
|
|
748
|
+
{:else}
|
|
749
|
+
<div class="table days" role="table" aria-label={t('cal_days_title')}>
|
|
750
|
+
<div class="thead" role="row">
|
|
751
|
+
<span role="columnheader">{t('cal_date')}</span>
|
|
752
|
+
<span role="columnheader">{t('cal_name')}</span>
|
|
753
|
+
<span role="columnheader">{t('cal_effect')}</span>
|
|
754
|
+
<span role="columnheader">{t('cal_origin')}</span>
|
|
755
|
+
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
756
|
+
</div>
|
|
757
|
+
{#each days as day (day.id)}
|
|
758
|
+
{@const origin = originOf(day)}
|
|
759
|
+
<div class="trow" role="row">
|
|
760
|
+
<span class="cell muted num" role="cell">
|
|
761
|
+
{formatDate(day.date, { weekday: 'short', day: 'numeric', month: 'short' })}
|
|
762
|
+
</span>
|
|
763
|
+
<span class="cell what" role="cell">
|
|
764
|
+
<!-- A masked pack day is struck through rather than removed: it is still there, and
|
|
765
|
+
that is exactly what stops the next upgrade bringing it back. -->
|
|
766
|
+
<span class="strong" class:struck={origin === 'masked'}>{day.name}</span>
|
|
767
|
+
<span class="chips">
|
|
768
|
+
<Badge tone="grey">{kindLabel(day.kind)}</Badge>
|
|
769
|
+
<span class="only-narrow">
|
|
770
|
+
<Badge tone={ORIGIN_TONES[origin]}>{originLabel(origin)}</Badge>
|
|
771
|
+
</span>
|
|
772
|
+
</span>
|
|
773
|
+
</span>
|
|
774
|
+
<span class="cell muted" role="cell">{effectLabel(day.workingFraction)}</span>
|
|
775
|
+
<span class="cell" role="cell">
|
|
776
|
+
<Badge
|
|
777
|
+
tone={ORIGIN_TONES[origin]}
|
|
778
|
+
title={origin === 'pack' ? t('cal_origin_from', { name: day.fromCalendarName }) : undefined}
|
|
779
|
+
>
|
|
780
|
+
{originLabel(origin)}
|
|
781
|
+
</Badge>
|
|
782
|
+
</span>
|
|
783
|
+
<span class="cell actions" role="cell">
|
|
784
|
+
{#if manage}
|
|
785
|
+
<DropdownMenu items={dayMenu(day)}>
|
|
786
|
+
{#snippet trigger(props)}
|
|
787
|
+
<IconButton
|
|
788
|
+
icon="ellipsis"
|
|
789
|
+
label={t('cal_actions_for', { name: day.name })}
|
|
790
|
+
size={28}
|
|
791
|
+
{...props}
|
|
792
|
+
/>
|
|
793
|
+
{/snippet}
|
|
794
|
+
</DropdownMenu>
|
|
795
|
+
{/if}
|
|
796
|
+
</span>
|
|
797
|
+
</div>
|
|
798
|
+
{/each}
|
|
799
|
+
</div>
|
|
800
|
+
{/if}
|
|
801
|
+
</SettingsSection>
|
|
802
|
+
{/if}
|
|
21
803
|
</SettingsPage>
|
|
804
|
+
|
|
805
|
+
<!-- ---------------------------------------------------------------- calendar form -->
|
|
806
|
+
<Dialog
|
|
807
|
+
open={calDialog !== null}
|
|
808
|
+
title={calDialog === 'edit' ? t('cal_edit_title') : t('cal_create_title')}
|
|
809
|
+
onOpenChange={(o) => {
|
|
810
|
+
if (!o) calDialog = null
|
|
811
|
+
}}
|
|
812
|
+
>
|
|
813
|
+
<div class="form">
|
|
814
|
+
<Field label={t('cal_name')} hint={t('cal_name_hint')} required>
|
|
815
|
+
{#snippet children(id)}
|
|
816
|
+
<Input {id} bind:value={calName} maxlength={160} />
|
|
817
|
+
{/snippet}
|
|
818
|
+
</Field>
|
|
819
|
+
|
|
820
|
+
<Field label={t('cal_base')} hint={t('cal_base_hint')}>
|
|
821
|
+
{#snippet children(id)}
|
|
822
|
+
<Select
|
|
823
|
+
{id}
|
|
824
|
+
value={calExtends}
|
|
825
|
+
onValueChange={(v) => (calExtends = v)}
|
|
826
|
+
placeholder={t('cal_base_none')}
|
|
827
|
+
options={[
|
|
828
|
+
{ value: '', label: t('cal_base_none') },
|
|
829
|
+
...calendars
|
|
830
|
+
.filter((c) => c.id !== calId)
|
|
831
|
+
.map((c) => ({ value: c.id, label: c.name })),
|
|
832
|
+
]}
|
|
833
|
+
/>
|
|
834
|
+
{/snippet}
|
|
835
|
+
</Field>
|
|
836
|
+
|
|
837
|
+
{#if calDialog === 'create'}
|
|
838
|
+
<div class="pair">
|
|
839
|
+
<Field
|
|
840
|
+
label={t('cal_country')}
|
|
841
|
+
hint={t('cal_country_hint')}
|
|
842
|
+
error={countryValid ? null : t('cal_country_invalid')}
|
|
843
|
+
>
|
|
844
|
+
{#snippet children(id)}
|
|
845
|
+
<Input
|
|
846
|
+
{id}
|
|
847
|
+
mono
|
|
848
|
+
value={calCountry}
|
|
849
|
+
maxlength={2}
|
|
850
|
+
oninput={(e) => (calCountry = e.currentTarget.value.toUpperCase())}
|
|
851
|
+
/>
|
|
852
|
+
{/snippet}
|
|
853
|
+
</Field>
|
|
854
|
+
<Field label={t('cal_region')} hint={t('cal_region_hint')}>
|
|
855
|
+
{#snippet children(id)}
|
|
856
|
+
<Input {id} mono bind:value={calRegion} maxlength={8} />
|
|
857
|
+
{/snippet}
|
|
858
|
+
</Field>
|
|
859
|
+
</div>
|
|
860
|
+
{/if}
|
|
861
|
+
|
|
862
|
+
<div class="week">
|
|
863
|
+
<span class="week-label">{t('cal_working_week')}</span>
|
|
864
|
+
<p class="hint">{t('cal_week_hint')}</p>
|
|
865
|
+
<div class="week-grid">
|
|
866
|
+
{#each WEEKDAYS as d (d.key)}
|
|
867
|
+
<Field label={formatDate(d.iso, { weekday: 'long' })}>
|
|
868
|
+
{#snippet children(id)}
|
|
869
|
+
<Select
|
|
870
|
+
{id}
|
|
871
|
+
size="sm"
|
|
872
|
+
value={String(calWeek[d.key])}
|
|
873
|
+
onValueChange={(v) => setWeekday(d.key, Number(v))}
|
|
874
|
+
options={[
|
|
875
|
+
{ value: '1', label: t('cal_day_full') },
|
|
876
|
+
{ value: '0.5', label: t('cal_day_half') },
|
|
877
|
+
{ value: '0', label: t('cal_day_off') },
|
|
878
|
+
]}
|
|
879
|
+
/>
|
|
880
|
+
{/snippet}
|
|
881
|
+
</Field>
|
|
882
|
+
{/each}
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
|
|
887
|
+
{#snippet footer()}
|
|
888
|
+
<Button variant="secondary" onclick={() => (calDialog = null)} disabled={saveCalendar.isPending}>
|
|
889
|
+
{t('cancel')}
|
|
890
|
+
</Button>
|
|
891
|
+
<Button
|
|
892
|
+
loading={saveCalendar.isPending}
|
|
893
|
+
disabled={!calName.trim() || !countryValid || !manage}
|
|
894
|
+
onclick={() => once(() => saveCalendar.mutate())}
|
|
895
|
+
>
|
|
896
|
+
{calDialog === 'edit' ? t('common.save') : t('common.create')}
|
|
897
|
+
</Button>
|
|
898
|
+
{/snippet}
|
|
899
|
+
</Dialog>
|
|
900
|
+
|
|
901
|
+
<!-- ---------------------------------------------------------------- archive a calendar -->
|
|
902
|
+
<Dialog
|
|
903
|
+
open={archiving !== null}
|
|
904
|
+
size="sm"
|
|
905
|
+
title={archiving ? t('cal_archive_title', { name: archiving.name }) : ''}
|
|
906
|
+
description={t('cal_archive_body')}
|
|
907
|
+
onOpenChange={(o) => {
|
|
908
|
+
if (!o) archiving = null
|
|
909
|
+
}}
|
|
910
|
+
>
|
|
911
|
+
<p class="body">{t('cal_archive_keeps')}</p>
|
|
912
|
+
|
|
913
|
+
{#snippet footer()}
|
|
914
|
+
<Button variant="secondary" onclick={() => (archiving = null)} disabled={archiveCalendar.isPending}>
|
|
915
|
+
{t('cancel')}
|
|
916
|
+
</Button>
|
|
917
|
+
<Button
|
|
918
|
+
variant="danger"
|
|
919
|
+
loading={archiveCalendar.isPending}
|
|
920
|
+
onclick={() => {
|
|
921
|
+
if (archiving) once(() => archiving && archiveCalendar.mutate(archiving))
|
|
922
|
+
}}
|
|
923
|
+
>
|
|
924
|
+
{t('common.archive')}
|
|
925
|
+
</Button>
|
|
926
|
+
{/snippet}
|
|
927
|
+
</Dialog>
|
|
928
|
+
|
|
929
|
+
<!-- ---------------------------------------------------------------- day form -->
|
|
930
|
+
<Dialog
|
|
931
|
+
open={dayDialog !== null}
|
|
932
|
+
title={dayDialog === 'edit' ? t('cal_day_edit_title') : t('cal_day_add_title')}
|
|
933
|
+
onOpenChange={(o) => {
|
|
934
|
+
if (!o) dayDialog = null
|
|
935
|
+
}}
|
|
936
|
+
>
|
|
937
|
+
<div class="form">
|
|
938
|
+
{#if dayFromPack}
|
|
939
|
+
<!-- Editing a pack day does not touch the pack: the change is kept as this company's own row
|
|
940
|
+
on top of it, which is why the next upgrade cannot undo it. -->
|
|
941
|
+
<p class="note">{t('cal_edit_pack_day')}</p>
|
|
942
|
+
{/if}
|
|
943
|
+
|
|
944
|
+
<div class="pair">
|
|
945
|
+
<Field label={t('cal_date')} required>
|
|
946
|
+
{#snippet children(id)}
|
|
947
|
+
<Input {id} type="date" bind:value={dayDate} disabled={dayDialog === 'edit'} />
|
|
948
|
+
{/snippet}
|
|
949
|
+
</Field>
|
|
950
|
+
<Field label={t('cal_kind')}>
|
|
951
|
+
{#snippet children(id)}
|
|
952
|
+
<Select
|
|
953
|
+
{id}
|
|
954
|
+
value={dayKind}
|
|
955
|
+
onValueChange={pickKind}
|
|
956
|
+
options={DAY_KINDS.map((k) => ({ value: k, label: kindLabel(k) }))}
|
|
957
|
+
/>
|
|
958
|
+
{/snippet}
|
|
959
|
+
</Field>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<Field label={t('cal_name')} hint={t('cal_day_name_hint')} required>
|
|
963
|
+
{#snippet children(id)}
|
|
964
|
+
<Input {id} bind:value={dayName} maxlength={160} />
|
|
965
|
+
{/snippet}
|
|
966
|
+
</Field>
|
|
967
|
+
|
|
968
|
+
<div class="pair">
|
|
969
|
+
<Field label={t('cal_effect')} hint={t('cal_effect_hint')}>
|
|
970
|
+
{#snippet children(id)}
|
|
971
|
+
<Select {id} value={dayEffect} onValueChange={(v) => (dayEffect = v)} options={effectOptions} />
|
|
972
|
+
{/snippet}
|
|
973
|
+
</Field>
|
|
974
|
+
<div class="paid">
|
|
975
|
+
<Switch
|
|
976
|
+
checked={dayPaid}
|
|
977
|
+
onCheckedChange={(v) => (dayPaid = v)}
|
|
978
|
+
label={t('cal_paid')}
|
|
979
|
+
description={t('cal_paid_hint')}
|
|
980
|
+
/>
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
|
|
984
|
+
<Field label={t('cal_note')} hint={t('cal_note_hint')}>
|
|
985
|
+
{#snippet children(id)}
|
|
986
|
+
<Textarea {id} bind:value={dayNote} rows={2} maxlength={500} />
|
|
987
|
+
{/snippet}
|
|
988
|
+
</Field>
|
|
989
|
+
</div>
|
|
990
|
+
|
|
991
|
+
{#snippet footer()}
|
|
992
|
+
<Button variant="secondary" onclick={() => (dayDialog = null)} disabled={saveDay.isPending}>
|
|
993
|
+
{t('cancel')}
|
|
994
|
+
</Button>
|
|
995
|
+
<Button loading={saveDay.isPending} disabled={!canSaveDay} onclick={() => once(() => saveDay.mutate())}>
|
|
996
|
+
{dayDialog === 'edit' ? t('common.save') : t('common.add')}
|
|
997
|
+
</Button>
|
|
998
|
+
{/snippet}
|
|
999
|
+
</Dialog>
|
|
1000
|
+
|
|
1001
|
+
<!-- ---------------------------------------------------------------- remove, mask or restore -->
|
|
1002
|
+
<Dialog
|
|
1003
|
+
open={removing !== null}
|
|
1004
|
+
size="sm"
|
|
1005
|
+
title={removing
|
|
1006
|
+
? removeMode === 'restore'
|
|
1007
|
+
? t('cal_restore_title', { name: removing.name })
|
|
1008
|
+
: t('cal_remove_title', { name: removing.name })
|
|
1009
|
+
: ''}
|
|
1010
|
+
description={removeMode === 'mask'
|
|
1011
|
+
? t('cal_remove_pack_body')
|
|
1012
|
+
: removeMode === 'restore'
|
|
1013
|
+
? t('cal_restore_body')
|
|
1014
|
+
: t('cal_remove_custom_body')}
|
|
1015
|
+
onOpenChange={(o) => {
|
|
1016
|
+
if (!o) removing = null
|
|
1017
|
+
}}
|
|
1018
|
+
>
|
|
1019
|
+
{#if removing}
|
|
1020
|
+
<p class="body">
|
|
1021
|
+
<span class="strong">{removing.name}</span>
|
|
1022
|
+
<span class="muted">
|
|
1023
|
+
— {formatDate(removing.date, { dateStyle: 'medium' })}
|
|
1024
|
+
</span>
|
|
1025
|
+
</p>
|
|
1026
|
+
{/if}
|
|
1027
|
+
|
|
1028
|
+
{#snippet footer()}
|
|
1029
|
+
<Button variant="secondary" onclick={() => (removing = null)} disabled={removeDay.isPending}>
|
|
1030
|
+
{t('cancel')}
|
|
1031
|
+
</Button>
|
|
1032
|
+
<Button
|
|
1033
|
+
variant={removeMode === 'restore' ? 'primary' : 'danger'}
|
|
1034
|
+
loading={removeDay.isPending}
|
|
1035
|
+
onclick={() => {
|
|
1036
|
+
if (removing) once(() => removing && removeDay.mutate(removing))
|
|
1037
|
+
}}
|
|
1038
|
+
>
|
|
1039
|
+
{removeMode === 'restore'
|
|
1040
|
+
? t('cal_restore')
|
|
1041
|
+
: removeMode === 'mask'
|
|
1042
|
+
? t('cal_mask')
|
|
1043
|
+
: t('common.remove')}
|
|
1044
|
+
</Button>
|
|
1045
|
+
{/snippet}
|
|
1046
|
+
</Dialog>
|
|
1047
|
+
|
|
1048
|
+
<!-- ---------------------------------------------------------------- pack preview, then apply -->
|
|
1049
|
+
<Dialog
|
|
1050
|
+
bind:open={packOpen}
|
|
1051
|
+
size="lg"
|
|
1052
|
+
title={t('cal_pack_title')}
|
|
1053
|
+
description={t('cal_pack_desc', { year: yearLabel })}
|
|
1054
|
+
onOpenChange={(o) => {
|
|
1055
|
+
if (!o) packOpen = false
|
|
1056
|
+
}}
|
|
1057
|
+
>
|
|
1058
|
+
<div class="form">
|
|
1059
|
+
<Field label={t('cal_pack_key')} hint={t('cal_pack_key_hint')}>
|
|
1060
|
+
{#snippet children(id)}
|
|
1061
|
+
<Input {id} mono bind:value={packInput} maxlength={32} />
|
|
1062
|
+
{/snippet}
|
|
1063
|
+
</Field>
|
|
1064
|
+
|
|
1065
|
+
{#if previewQuery.isLoading || previewQuery.isFetching}
|
|
1066
|
+
<div class="rows">
|
|
1067
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="40px" />{/each}
|
|
1068
|
+
</div>
|
|
1069
|
+
{:else if previewQuery.isError}
|
|
1070
|
+
<EmptyState icon="triangle-alert" title={t('cal_pack_error')} description={t('cal_pack_error_desc')}>
|
|
1071
|
+
{#snippet actions()}
|
|
1072
|
+
<Button variant="secondary" onclick={() => void previewQuery.refetch()}>{t('retry')}</Button>
|
|
1073
|
+
{/snippet}
|
|
1074
|
+
</EmptyState>
|
|
1075
|
+
{:else if preview}
|
|
1076
|
+
{#if packChanges === 0}
|
|
1077
|
+
<EmptyState
|
|
1078
|
+
icon="check-check"
|
|
1079
|
+
title={t('cal_pack_current')}
|
|
1080
|
+
description={t('cal_pack_current_desc', { pack: preview.packKey, year: yearLabel })}
|
|
1081
|
+
/>
|
|
1082
|
+
{:else}
|
|
1083
|
+
<p class="note">{t('cal_pack_preview_desc')}</p>
|
|
1084
|
+
{/if}
|
|
1085
|
+
|
|
1086
|
+
<div class="groups">
|
|
1087
|
+
{#each packGroups as group (group.key)}
|
|
1088
|
+
{#if group.rows.length > 0}
|
|
1089
|
+
<div class="group">
|
|
1090
|
+
<SectionLabel label={group.label} count={group.rows.length} sub />
|
|
1091
|
+
<ul class="plist">
|
|
1092
|
+
{#each group.rows.slice(0, HEAD) as row (row.date + row.name)}
|
|
1093
|
+
<li>
|
|
1094
|
+
<span class="pdate">{formatDate(row.date, { day: 'numeric', month: 'short' })}</span>
|
|
1095
|
+
<span class="pname">{row.name}</span>
|
|
1096
|
+
{#if row.was}
|
|
1097
|
+
<span class="muted">{t('cal_pack_was', { name: row.was })}</span>
|
|
1098
|
+
{/if}
|
|
1099
|
+
</li>
|
|
1100
|
+
{/each}
|
|
1101
|
+
{#if group.rows.length > HEAD}
|
|
1102
|
+
<li class="muted">{t('cal_pack_more', { count: group.rows.length - HEAD })}</li>
|
|
1103
|
+
{/if}
|
|
1104
|
+
</ul>
|
|
1105
|
+
{#if group.key === 'kept'}
|
|
1106
|
+
<p class="hint">{t('cal_pack_kept_desc')}</p>
|
|
1107
|
+
{/if}
|
|
1108
|
+
</div>
|
|
1109
|
+
{/if}
|
|
1110
|
+
{/each}
|
|
1111
|
+
</div>
|
|
1112
|
+
{/if}
|
|
1113
|
+
</div>
|
|
1114
|
+
|
|
1115
|
+
{#snippet footer()}
|
|
1116
|
+
<Button variant="secondary" onclick={() => (packOpen = false)} disabled={applyPack.isPending}>
|
|
1117
|
+
{t('cancel')}
|
|
1118
|
+
</Button>
|
|
1119
|
+
<Button
|
|
1120
|
+
loading={applyPack.isPending}
|
|
1121
|
+
disabled={!manage || !preview || packChanges === 0}
|
|
1122
|
+
onclick={() => once(() => applyPack.mutate())}
|
|
1123
|
+
>
|
|
1124
|
+
{t('cal_pack_apply')}
|
|
1125
|
+
</Button>
|
|
1126
|
+
{/snippet}
|
|
1127
|
+
</Dialog>
|
|
1128
|
+
|
|
1129
|
+
<style>
|
|
1130
|
+
.rows {
|
|
1131
|
+
display: grid;
|
|
1132
|
+
gap: 4px;
|
|
1133
|
+
}
|
|
1134
|
+
.tiles {
|
|
1135
|
+
display: grid;
|
|
1136
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
1137
|
+
gap: 10px;
|
|
1138
|
+
margin-block-end: 18px;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
1142
|
+
.table {
|
|
1143
|
+
width: 100%;
|
|
1144
|
+
}
|
|
1145
|
+
.cals {
|
|
1146
|
+
--hr-cal-cols: minmax(150px, 1fr) 72px minmax(130px, 1.1fr) 32px;
|
|
1147
|
+
}
|
|
1148
|
+
.days {
|
|
1149
|
+
--hr-cal-cols: 132px minmax(140px, 1fr) 92px 128px 32px;
|
|
1150
|
+
}
|
|
1151
|
+
.thead,
|
|
1152
|
+
.trow {
|
|
1153
|
+
display: grid;
|
|
1154
|
+
grid-template-columns: var(--hr-cal-cols);
|
|
1155
|
+
gap: 10px;
|
|
1156
|
+
align-items: center;
|
|
1157
|
+
padding-inline: 10px;
|
|
1158
|
+
border-inline-start: 2px solid transparent;
|
|
1159
|
+
}
|
|
1160
|
+
.thead {
|
|
1161
|
+
height: 32px;
|
|
1162
|
+
border-block-end: 1px solid var(--kern-border);
|
|
1163
|
+
font-size: 11px;
|
|
1164
|
+
font-weight: 600;
|
|
1165
|
+
letter-spacing: 0.06em;
|
|
1166
|
+
text-transform: uppercase;
|
|
1167
|
+
color: var(--kern-ink-500);
|
|
1168
|
+
}
|
|
1169
|
+
.trow {
|
|
1170
|
+
min-height: 44px;
|
|
1171
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
1172
|
+
border-radius: var(--kern-r-md);
|
|
1173
|
+
}
|
|
1174
|
+
.trow:hover {
|
|
1175
|
+
background: var(--kern-surface-raised);
|
|
1176
|
+
}
|
|
1177
|
+
/* The calendar the year below belongs to. A border rather than a colour alone, so it survives a
|
|
1178
|
+
theme where the tint is nearly the surface it sits on. */
|
|
1179
|
+
.cals .trow.on {
|
|
1180
|
+
border-inline-start-color: var(--kern-accent);
|
|
1181
|
+
background: var(--kern-surface-active);
|
|
1182
|
+
}
|
|
1183
|
+
.cell {
|
|
1184
|
+
min-width: 0;
|
|
1185
|
+
overflow: hidden;
|
|
1186
|
+
text-overflow: ellipsis;
|
|
1187
|
+
white-space: nowrap;
|
|
1188
|
+
}
|
|
1189
|
+
.pick {
|
|
1190
|
+
display: flex;
|
|
1191
|
+
align-items: center;
|
|
1192
|
+
gap: 8px;
|
|
1193
|
+
min-width: 0;
|
|
1194
|
+
width: 100%;
|
|
1195
|
+
padding-block: 6px;
|
|
1196
|
+
text-align: start;
|
|
1197
|
+
}
|
|
1198
|
+
.strong {
|
|
1199
|
+
min-width: 0;
|
|
1200
|
+
overflow: hidden;
|
|
1201
|
+
text-overflow: ellipsis;
|
|
1202
|
+
font-size: 13.5px;
|
|
1203
|
+
font-weight: 500;
|
|
1204
|
+
}
|
|
1205
|
+
.struck {
|
|
1206
|
+
text-decoration: line-through;
|
|
1207
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
1208
|
+
color: var(--kern-ink-500);
|
|
1209
|
+
}
|
|
1210
|
+
.what {
|
|
1211
|
+
display: flex;
|
|
1212
|
+
align-items: center;
|
|
1213
|
+
gap: 8px;
|
|
1214
|
+
}
|
|
1215
|
+
.chips {
|
|
1216
|
+
display: inline-flex;
|
|
1217
|
+
align-items: center;
|
|
1218
|
+
gap: 6px;
|
|
1219
|
+
}
|
|
1220
|
+
.muted {
|
|
1221
|
+
font-size: 13px;
|
|
1222
|
+
color: var(--kern-ink-500);
|
|
1223
|
+
}
|
|
1224
|
+
.num {
|
|
1225
|
+
font-variant-numeric: tabular-nums;
|
|
1226
|
+
}
|
|
1227
|
+
.actions {
|
|
1228
|
+
display: flex;
|
|
1229
|
+
justify-content: flex-end;
|
|
1230
|
+
overflow: visible;
|
|
1231
|
+
}
|
|
1232
|
+
.only-narrow {
|
|
1233
|
+
display: none;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
.years {
|
|
1237
|
+
display: flex;
|
|
1238
|
+
align-items: center;
|
|
1239
|
+
gap: 6px;
|
|
1240
|
+
}
|
|
1241
|
+
.year {
|
|
1242
|
+
min-width: 52px;
|
|
1243
|
+
text-align: center;
|
|
1244
|
+
font-size: 13.5px;
|
|
1245
|
+
font-weight: 500;
|
|
1246
|
+
font-variant-numeric: tabular-nums;
|
|
1247
|
+
}
|
|
1248
|
+
/* "Previous" points the other way in Persian and Arabic. */
|
|
1249
|
+
:global([dir='rtl']) .years :global(.flip) {
|
|
1250
|
+
transform: scaleX(-1);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.hint {
|
|
1254
|
+
margin: 0;
|
|
1255
|
+
font-size: 12px;
|
|
1256
|
+
color: var(--kern-ink-500);
|
|
1257
|
+
}
|
|
1258
|
+
.note {
|
|
1259
|
+
margin: 0;
|
|
1260
|
+
padding: 10px 12px;
|
|
1261
|
+
border-radius: var(--kern-r-md2);
|
|
1262
|
+
background: var(--kern-info-tint);
|
|
1263
|
+
color: var(--kern-ink-700);
|
|
1264
|
+
font-size: 12.5px;
|
|
1265
|
+
line-height: 1.5;
|
|
1266
|
+
}
|
|
1267
|
+
.body {
|
|
1268
|
+
margin: 0 0 4px;
|
|
1269
|
+
font-size: 13.5px;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
.form {
|
|
1273
|
+
display: grid;
|
|
1274
|
+
gap: 14px;
|
|
1275
|
+
}
|
|
1276
|
+
.pair {
|
|
1277
|
+
display: grid;
|
|
1278
|
+
grid-template-columns: 1fr 1fr;
|
|
1279
|
+
gap: 12px;
|
|
1280
|
+
align-items: start;
|
|
1281
|
+
}
|
|
1282
|
+
.paid {
|
|
1283
|
+
padding-block-start: 22px;
|
|
1284
|
+
}
|
|
1285
|
+
.week {
|
|
1286
|
+
display: grid;
|
|
1287
|
+
gap: 6px;
|
|
1288
|
+
}
|
|
1289
|
+
.week-label {
|
|
1290
|
+
font-size: 13px;
|
|
1291
|
+
font-weight: 500;
|
|
1292
|
+
color: var(--kern-ink-800);
|
|
1293
|
+
}
|
|
1294
|
+
.week-grid {
|
|
1295
|
+
display: grid;
|
|
1296
|
+
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
1297
|
+
gap: 10px;
|
|
1298
|
+
margin-block-start: 4px;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
.groups {
|
|
1302
|
+
display: grid;
|
|
1303
|
+
gap: 14px;
|
|
1304
|
+
}
|
|
1305
|
+
.group {
|
|
1306
|
+
display: grid;
|
|
1307
|
+
gap: 2px;
|
|
1308
|
+
}
|
|
1309
|
+
.plist {
|
|
1310
|
+
display: grid;
|
|
1311
|
+
gap: 2px;
|
|
1312
|
+
margin: 0;
|
|
1313
|
+
padding: 0;
|
|
1314
|
+
list-style: none;
|
|
1315
|
+
}
|
|
1316
|
+
.plist li {
|
|
1317
|
+
display: flex;
|
|
1318
|
+
align-items: baseline;
|
|
1319
|
+
gap: 10px;
|
|
1320
|
+
min-width: 0;
|
|
1321
|
+
font-size: 13px;
|
|
1322
|
+
}
|
|
1323
|
+
.pdate {
|
|
1324
|
+
min-width: 76px;
|
|
1325
|
+
color: var(--kern-ink-500);
|
|
1326
|
+
font-variant-numeric: tabular-nums;
|
|
1327
|
+
}
|
|
1328
|
+
.pname {
|
|
1329
|
+
min-width: 0;
|
|
1330
|
+
overflow: hidden;
|
|
1331
|
+
text-overflow: ellipsis;
|
|
1332
|
+
white-space: nowrap;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
.sr-only {
|
|
1336
|
+
position: absolute;
|
|
1337
|
+
width: 1px;
|
|
1338
|
+
height: 1px;
|
|
1339
|
+
overflow: hidden;
|
|
1340
|
+
clip-path: inset(50%);
|
|
1341
|
+
white-space: nowrap;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
@media (max-width: 640px) {
|
|
1345
|
+
.cals {
|
|
1346
|
+
--hr-cal-cols: minmax(130px, 1fr) minmax(110px, 1fr) 32px;
|
|
1347
|
+
}
|
|
1348
|
+
.days {
|
|
1349
|
+
--hr-cal-cols: 108px minmax(120px, 1fr) 32px;
|
|
1350
|
+
}
|
|
1351
|
+
/* The office count and the two right-hand day columns go; provenance is the one thing that
|
|
1352
|
+
cannot, so it moves into the name cell rather than disappearing with them. */
|
|
1353
|
+
.cals .thead > :nth-child(2),
|
|
1354
|
+
.cals .trow > :nth-child(2),
|
|
1355
|
+
.days .thead > :nth-child(3),
|
|
1356
|
+
.days .trow > :nth-child(3),
|
|
1357
|
+
.days .thead > :nth-child(4),
|
|
1358
|
+
.days .trow > :nth-child(4) {
|
|
1359
|
+
display: none;
|
|
1360
|
+
}
|
|
1361
|
+
.only-narrow {
|
|
1362
|
+
display: inline-flex;
|
|
1363
|
+
}
|
|
1364
|
+
.pair,
|
|
1365
|
+
.week-grid {
|
|
1366
|
+
grid-template-columns: 1fr;
|
|
1367
|
+
}
|
|
1368
|
+
.paid {
|
|
1369
|
+
padding-block-start: 0;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
</style>
|