@kernhq/module-hr 0.9.3 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +9 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +458 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1896 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +146 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +53 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +101 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +14 -0
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +14 -3
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -6
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -13
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/policies.d.ts +6 -0
  44. package/dist/server/services/policies.d.ts.map +1 -1
  45. package/dist/server/services/policies.js +6 -0
  46. package/dist/server/services/policies.js.map +1 -1
  47. package/migrations/0005_approval_requester.sql +30 -0
  48. package/migrations/0006_schedule_no_overlap.sql +85 -0
  49. package/migrations/0007_hot_path_indexes.sql +38 -0
  50. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  51. package/migrations/meta/0005_snapshot.json +4047 -0
  52. package/migrations/meta/0007_snapshot.json +4225 -0
  53. package/migrations/meta/0009_snapshot.json +4231 -0
  54. package/migrations/meta/_journal.json +28 -0
  55. package/package.json +7 -7
  56. package/src/client/capabilities.ts +34 -0
  57. package/src/client/components/ClockControls.svelte +127 -20
  58. package/src/client/components/ClockControls.test.ts +446 -0
  59. package/src/client/components/DecisionDialog.svelte +90 -0
  60. package/src/client/components/DelegationDialog.svelte +219 -0
  61. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  62. package/src/client/components/PersonFormDialog.svelte +53 -3
  63. package/src/client/components/PersonPanel.svelte +134 -26
  64. package/src/client/i18n.ts +5 -625
  65. package/src/client/index.ts +1 -20
  66. package/src/client/messages.test.ts +154 -0
  67. package/src/client/messages.ts +3388 -0
  68. package/src/client/mock.ts +1691 -162
  69. package/src/client/module.ts +17 -2
  70. package/src/client/pages/ApprovalsPage.svelte +332 -60
  71. package/src/client/pages/AttendancePage.svelte +106 -20
  72. package/src/client/pages/DirectoryPage.svelte +192 -55
  73. package/src/client/pages/LeavePage.svelte +280 -25
  74. package/src/client/pages/OfficesPage.svelte +181 -39
  75. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  76. package/src/client/permissions.ts +2 -1
  77. package/src/client/query.ts +22 -1
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +43 -0
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/approvals.ts +13 -1
  90. package/src/contract/capabilities.ts +17 -0
@@ -1,21 +1,1372 @@
1
1
  <script lang="ts">
2
- import { EmptyState, SettingsPage } from '@kernhq/ui'
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
- * calendars settings.
38
+ * The days this company does not work.
7
39
  *
8
- * The API behind this is complete — the module's server implements the full surface but the
9
- * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
- * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
- * because the capability is on.
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
- <EmptyState
17
- icon="calendar"
18
- title={t('settings_calendars')}
19
- description={t('settings_not_built')}
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
+ &nbsp;— {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>