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