@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,6 +1,22 @@
1
1
  <script lang="ts">
2
- import { Card, coreApi, keys, navigation, SettingsPage, Skeleton, Switch, session } from '@kernhq/ui'
2
+ import { type CapabilityDef, capabilityDependents } from '@kernhq/contracts'
3
+ import {
4
+ Button,
5
+ Card,
6
+ coreApi,
7
+ Dialog,
8
+ EmptyState,
9
+ keys,
10
+ messageLocale,
11
+ navigation,
12
+ SettingsPage,
13
+ Skeleton,
14
+ Switch,
15
+ session,
16
+ toast,
17
+ } from '@kernhq/ui'
3
18
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
19
+ import { capabilityDescriptionKey, capabilityLabelKey } from '../../contract/capabilities.js'
4
20
  import type { CoreApi } from '../core-api.js'
5
21
  import { t } from '../i18n.js'
6
22
 
@@ -19,6 +35,9 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
19
35
  const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
20
36
  const workspaceId = $derived(workspace?.id ?? '')
21
37
 
38
+ /** Core gates `modules.updateSettings` on this, and every control here is a write to it. */
39
+ const canManage = $derived(session.can('core.modules.manage'))
40
+
22
41
  const api = coreApi<CoreApi>()
23
42
  const queryClient = useQueryClient()
24
43
 
@@ -29,9 +48,45 @@ const modulesQuery = createQuery(() => ({
29
48
  }))
30
49
 
31
50
  const hr = $derived(modulesQuery.data?.find((entry) => entry.manifest.id === 'hr'))
32
- const definitions = $derived(hr?.manifest.capabilities ?? [])
51
+ const definitions = $derived<CapabilityDef[]>(hr?.manifest.capabilities ?? [])
33
52
  const enabled = $derived(new Set(hr?.state.capabilities ?? []))
34
53
 
54
+ /**
55
+ * The switch that is waiting for an answer, and the last refused write.
56
+ *
57
+ * `busy` rather than `setCapability.isPending`: a disabled attribute only reaches the control on
58
+ * the next render, and two quick clicks are one render apart — so the guard has to be set in the
59
+ * same tick as the first one.
60
+ */
61
+ let confirming = $state<CapabilityDef | null>(null)
62
+ let writeError = $state<string | null>(null)
63
+ let busy = $state(false)
64
+ /**
65
+ * Which switch the write in flight belongs to, so that one alone stays live.
66
+ *
67
+ * `disabled` on all of them was the obvious way to hold the screen still, and it takes the focus
68
+ * off the control the person is standing on: Chrome blurs a focused element the moment it is
69
+ * disabled and gives it back to `<body>`, which nothing returns. Measured — `document.activeElement`
70
+ * is `BODY` for the whole write and after it. So the others go inert and this one does not; the
71
+ * second click on *this* one is caught by the guard in `toggle`, which is what was really stopping
72
+ * it anyway.
73
+ */
74
+ let pending = $state<string | null>(null)
75
+ /**
76
+ * Bumped to put a switch back where the server has it.
77
+ *
78
+ * The track carries its own state — it moves the moment it is clicked, before anything is written
79
+ * — and `checked` here is derived from the query, which a refused write leaves untouched. Nothing
80
+ * would then re-assert it, so the switch would sit in a position the workspace is not in. Keying
81
+ * the control on this remounts it against the truth.
82
+ *
83
+ * On this alone, and not on the enabled set beside it. A remount takes the focus with it: keying on
84
+ * the value as well sent a keyboard user to the top of the page every time a switch answered them,
85
+ * measured as `document.activeElement` landing on `<body>`. A value that really changed reaches the
86
+ * track as a prop without a remount; only a write the server refused needs one, and that is this.
87
+ */
88
+ let snapback = $state(0)
89
+
35
90
  const setCapability = createMutation(() => ({
36
91
  mutationFn: async (vars: { id: string; on: boolean }) => {
37
92
  const stored = ((hr?.state.settings as Record<string, unknown>)?.$capabilities ?? {}) as Record<
@@ -47,55 +102,227 @@ const setCapability = createMutation(() => ({
47
102
  })
48
103
  },
49
104
  onSuccess: () => {
105
+ confirming = null
106
+ writeError = null
50
107
  // Navigation, widgets and routes are all derived from this, so the whole shell needs to re-read.
51
108
  void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
52
109
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
53
110
  },
111
+ onError: () => {
112
+ snapback++
113
+ writeError = t('capability_write_error')
114
+ // A refusal inside the dialog is answered in the dialog, where the button that failed still is.
115
+ // Turning one *on* has no dialog to carry the message, so that one gets a toast.
116
+ if (!confirming) toast.error(t('capability_write_error'))
117
+ },
118
+ onSettled: () => {
119
+ busy = false
120
+ pending = null
121
+ },
54
122
  }))
55
123
 
56
- const nameOf = (id: string): string =>
57
- definitions.find((d: { id: string; label: string }) => d.id === id)?.label ?? id
124
+ /**
125
+ * The label and description a person reads.
126
+ *
127
+ * The manifest carries English literals, because core's module admin and the shell's mock parse it
128
+ * with no HR bundle merged. The translation is looked up by convention from the capability's id,
129
+ * and `t()` answering with the key itself is what says there is no string for this reader yet — in
130
+ * which case the literal is what the manifest is for.
131
+ */
132
+ function resolve(key: string, fallback: string): string {
133
+ const translated = t(key)
134
+ return translated === key ? fallback : translated
135
+ }
136
+ const labelOf = (def: CapabilityDef): string => resolve(capabilityLabelKey(def.id), def.label)
137
+ const describe = (def: CapabilityDef): string | undefined =>
138
+ def.description ? resolve(capabilityDescriptionKey(def.id), def.description) : undefined
139
+
140
+ const nameOf = (id: string): string => {
141
+ const def = definitions.find((d) => d.id === id)
142
+ return def ? labelOf(def) : id
143
+ }
144
+
145
+ /** Arabic separates a list with '،' and German with 'und'; `join(', ')` gives every reader a comma. */
146
+ function listOf(parts: string[]): string {
147
+ try {
148
+ return new Intl.ListFormat(messageLocale(), { type: 'conjunction' }).format(parts)
149
+ } catch {
150
+ return parts.join(', ')
151
+ }
152
+ }
58
153
 
59
154
  /** A capability whose dependency is off cannot be switched on — the server would prune it anyway. */
60
155
  const blockedBy = (deps: string[]) => deps.filter((d) => !enabled.has(d))
156
+
157
+ /**
158
+ * What else goes out with the one being switched off.
159
+ *
160
+ * Only the dependants that are actually on: `calendars` carries leave, accrual and attendance in a
161
+ * workspace that uses them and carries nothing in one that does not, and naming a feature the
162
+ * workspace never had would read as a threat rather than a warning.
163
+ */
164
+ const dependentsOf = (def: CapabilityDef): string[] =>
165
+ capabilityDependents(definitions, def.id)
166
+ .filter((id) => id !== def.id && enabled.has(id))
167
+ .map(nameOf)
168
+
169
+ /**
170
+ * The question the dialog asked, kept as it was asked.
171
+ *
172
+ * Everything in the panel used to be derived from `confirming` and `enabled`, and a successful
173
+ * write clears the one and empties the other — a frame before the dialog has finished fading out,
174
+ * so the heading and the list of what goes with it blanked while the box was still on screen. It is
175
+ * only ever replaced by the next question, which is what carries it through the close.
176
+ */
177
+ let asked = $state<{ name: string; losing: string[] } | null>(null)
178
+
179
+ function toggle(def: CapabilityDef, on: boolean) {
180
+ if (busy) {
181
+ // Swallowed — and the track moved itself on the way in, so it is now showing a position nobody
182
+ // is going to write. This is the same repair a refused write needs.
183
+ snapback++
184
+ return
185
+ }
186
+ if (!on) {
187
+ // Off is the direction that takes other features with it, so it is asked rather than written.
188
+ // The track has already moved itself; put it back until the dialog has an answer.
189
+ confirming = def
190
+ asked = { name: labelOf(def), losing: dependentsOf(def) }
191
+ writeError = null
192
+ snapback++
193
+ return
194
+ }
195
+ busy = true
196
+ pending = def.id
197
+ writeError = null
198
+ setCapability.mutate({ id: def.id, on: true })
199
+ }
200
+
201
+ function confirmOff() {
202
+ if (!confirming || busy) return
203
+ busy = true
204
+ pending = confirming.id
205
+ writeError = null
206
+ setCapability.mutate({ id: confirming.id, on: false })
207
+ }
208
+
209
+ /**
210
+ * Closes the dialog; it does not stop a write already on its way, which is why the Cancel button
211
+ * is disabled while one is. Escape is not, and cannot be — so this has to be safe to run mid-write:
212
+ * clearing `confirming` is what sends the answer to a toast instead of to a dialog nobody can see.
213
+ */
214
+ function cancelOff() {
215
+ confirming = null
216
+ writeError = null
217
+ }
61
218
  </script>
62
219
 
63
220
  <SettingsPage title={t('settings_capabilities')} description={t('capabilities_desc')}>
64
221
 
222
+ <!--
223
+ Held definitions outrank the error. A background refetch fails on every core restart, and an error
224
+ branch above the list would answer that by blanking the switchboard — telling an administrator
225
+ that HR has no features, on a screen that had them a second ago. The error is only the whole frame
226
+ when there is nothing else to draw; when there is, it is the line above the list.
227
+ -->
65
228
  {#if modulesQuery.isLoading}
66
- <Skeleton height="220px" />
67
- {:else}
229
+ <div class="list">
230
+ {#each [1, 2, 3, 4] as n (n)}<Skeleton height="64px" radius="10px" />{/each}
231
+ </div>
232
+ {:else if definitions.length > 0}
233
+ {#if !canManage}
234
+ <p class="readonly">{t('general_readonly')}</p>
235
+ {/if}
236
+
237
+ {#if modulesQuery.isError}
238
+ <div class="stale" role="status">
239
+ <span>{t('capabilities_stale')}</span>
240
+ <Button size="sm" variant="secondary" onclick={() => void modulesQuery.refetch()}>
241
+ {t('retry')}
242
+ </Button>
243
+ </div>
244
+ {/if}
245
+
68
246
  <div class="list">
69
247
  {#each definitions as capability (capability.id)}
70
248
  {@const missing = blockedBy(capability.dependsOn)}
249
+ {@const description = describe(capability)}
71
250
  <Card>
72
251
  <div class="row">
73
252
  <div class="what">
74
- <span class="name">{capability.label}</span>
75
- {#if capability.description}
76
- <span class="meta">{capability.description}</span>
253
+ <span class="name">{labelOf(capability)}</span>
254
+ {#if description}
255
+ <span class="meta">{description}</span>
77
256
  {/if}
78
- {#if missing.length}
257
+ <!-- Both reasons a switch is dead are written down. A control that cannot be moved and
258
+ does not say why reads as a broken screen. -->
259
+ {#if capability.required}
260
+ <span class="meta">{t('capability_always_on')}</span>
261
+ {:else if missing.length}
79
262
  <span class="meta">
80
- {t('capability_requires', { name: missing.map(nameOf).join(', ') })}
263
+ {t('capability_requires', { name: listOf(missing.map(nameOf)) })}
81
264
  </span>
82
265
  {/if}
83
266
  </div>
84
- <!-- The module's own foundation is always on, and a capability whose dependency is off
85
- cannot be switched on: the server prunes it anyway, so the control says so. -->
86
- <Switch
87
- checked={enabled.has(capability.id)}
88
- disabled={capability.required || missing.length > 0 || setCapability.isPending}
89
- onCheckedChange={(on) => setCapability.mutate({ id: capability.id, on })}
90
- label={capability.label}
91
- />
267
+ <!-- `ariaLabel`, not `label`: `Switch` renders a visible `label` beside its track, and
268
+ the name is already the first line of this row — so it was printed twice. A screen
269
+ reader still needs it, and this is the way to give it one without a second copy. -->
270
+ {#key snapback}
271
+ <Switch
272
+ checked={enabled.has(capability.id)}
273
+ disabled={!canManage ||
274
+ capability.required ||
275
+ missing.length > 0 ||
276
+ (busy && pending !== capability.id)}
277
+ onCheckedChange={(on) => toggle(capability, on)}
278
+ ariaLabel={labelOf(capability)}
279
+ />
280
+ {/key}
92
281
  </div>
93
282
  </Card>
94
283
  {/each}
95
284
  </div>
285
+ {:else if modulesQuery.isError}
286
+ <EmptyState icon="triangle-alert" title={t('general_error')} description={t('general_error_desc')}>
287
+ {#snippet actions()}
288
+ <Button variant="secondary" onclick={() => void modulesQuery.refetch()}>{t('retry')}</Button>
289
+ {/snippet}
290
+ </EmptyState>
291
+ {:else if !hr}
292
+ <EmptyState icon="users" title={t('general_not_enabled')} description={t('general_not_enabled_desc')} />
293
+ {:else}
294
+ <EmptyState icon="toggle-left" title={t('capabilities_none')} description={t('capabilities_none_desc')} />
96
295
  {/if}
97
296
  </SettingsPage>
98
297
 
298
+ <!--
299
+ What turning one off actually does, before it is done.
300
+ The dependants are the half nobody expects: switching `calendars` off in a workspace that uses
301
+ leave takes leave, accrual and attendance with it, in one click, and the only clue afterwards is
302
+ three navigation rows that are gone.
303
+ -->
304
+ <Dialog
305
+ open={Boolean(confirming)}
306
+ size="sm"
307
+ title={asked ? t('capability_off_title', { name: asked.name }) : ''}
308
+ description={t('capability_off_body')}
309
+ onOpenChange={(open) => {
310
+ if (!open) cancelOff()
311
+ }}
312
+ >
313
+ {#if asked?.losing.length}
314
+ <p class="also">{t('capability_off_also', { names: listOf(asked.losing) })}</p>
315
+ {/if}
316
+ {#if writeError}
317
+ <p class="failed" role="alert">{writeError}</p>
318
+ {/if}
319
+
320
+ {#snippet footer()}
321
+ <Button variant="secondary" onclick={cancelOff} disabled={busy}>{t('cancel')}</Button>
322
+ <Button variant="danger" loading={busy} onclick={confirmOff}>{t('capability_off_confirm')}</Button>
323
+ {/snippet}
324
+ </Dialog>
325
+
99
326
  <style>
100
327
  .list {
101
328
  display: grid;
@@ -119,4 +346,34 @@ const blockedBy = (deps: string[]) => deps.filter((d) => !enabled.has(d))
119
346
  color: var(--kern-ink-500);
120
347
  font-size: 12px;
121
348
  }
349
+ .readonly {
350
+ margin: 0 0 12px;
351
+ font-size: 13px;
352
+ color: var(--kern-ink-500);
353
+ }
354
+ .stale {
355
+ display: flex;
356
+ align-items: center;
357
+ justify-content: space-between;
358
+ gap: 12px;
359
+ flex-wrap: wrap;
360
+ margin-block-end: 8px;
361
+ padding: 8px 12px;
362
+ border: 1px solid var(--kern-border);
363
+ border-radius: var(--kern-r-md);
364
+ font-size: 12.5px;
365
+ /* 5.23:1 on --kern-surface in light, 6.80:1 in dark — small text, so it has to clear 4.5. */
366
+ color: var(--kern-warning);
367
+ }
368
+ .also {
369
+ margin: 0 0 8px;
370
+ font-size: 13.5px;
371
+ font-weight: 500;
372
+ }
373
+ .failed {
374
+ margin: 0;
375
+ font-size: 13px;
376
+ /* 6.33:1 on the dialog surface in light, 5.04:1 in dark. */
377
+ color: var(--kern-danger);
378
+ }
122
379
  </style>