@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
@@ -0,0 +1,420 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ coreApi,
5
+ Dialog,
6
+ EmptyState,
7
+ Field,
8
+ Icon,
9
+ Input,
10
+ keys,
11
+ messageLocale,
12
+ navigation,
13
+ Select,
14
+ type SelectOption,
15
+ SettingsPage,
16
+ SettingsSection,
17
+ Skeleton,
18
+ session,
19
+ toast,
20
+ } from '@kernhq/ui'
21
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
22
+ import { HrSettings } from '../../contract/settings.js'
23
+ import type { CoreApi } from '../core-api.js'
24
+ import { t } from '../i18n.js'
25
+
26
+ /**
27
+ * The two workspace-wide facts HR keeps: where it was set up, and how employee numbers are issued.
28
+ *
29
+ * Settings live in core, not in this module's own schema, so they are read from
30
+ * `workspaces.modules.list` and written with `workspaces.modules.updateSettings` — the same
31
+ * mechanism the capabilities screen uses, and the reason the module never needs a settings table.
32
+ *
33
+ * **The write carries the whole `HrSettings` object.** Core merges a partial write now, but this
34
+ * page owns every field in that schema and sending it whole is what makes the round trip
35
+ * self-evident: nothing here can be a field somebody forgot to carry forward.
36
+ *
37
+ * `directoryVisibleToMembers` is in the schema and is deliberately *not* on this page. Nothing in
38
+ * `src/server` reads it, so a switch for it would promise a rule the API does not enforce. It is
39
+ * carried through the write unchanged until it is either enforced or removed.
40
+ */
41
+ const api = coreApi<CoreApi>()
42
+ const queryClient = useQueryClient()
43
+
44
+ const workspaceSlug = $derived(navigation.workspaceSlug)
45
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
46
+ const workspaceId = $derived(workspace?.id ?? '')
47
+
48
+ /** The permission core actually enforces on `workspaces.modules.updateSettings`. */
49
+ const canManage = $derived(session.can('core.modules.manage'))
50
+
51
+ const modulesQuery = createQuery(() => ({
52
+ queryKey: keys.modules(workspaceId),
53
+ enabled: Boolean(workspaceId),
54
+ queryFn: () => api.workspaces.modules.list({ workspaceId }),
55
+ }))
56
+
57
+ const hr = $derived(modulesQuery.data?.find((entry) => entry.manifest.id === 'hr'))
58
+
59
+ /**
60
+ * What is stored, with the schema's defaults applied and `$capabilities` stripped by the parse.
61
+ *
62
+ * `safeParse` rather than `parse`: a settings blob written before a field existed must not take the
63
+ * page down, and every field has a default, so the fallback is the same shape a new workspace has.
64
+ */
65
+ const stored = $derived.by(() => {
66
+ const parsed = HrSettings.safeParse(hr?.state.settings ?? {})
67
+ return parsed.success ? parsed.data : HrSettings.parse({})
68
+ })
69
+
70
+ let country = $state('')
71
+ let prefix = $state('')
72
+ /**
73
+ * The counter is held as text, not a number: an input somebody is half-way through clearing is
74
+ * empty, and `bind:value` on a number input turns that into `undefined` — which reads as "no
75
+ * change" and would save the old value behind their back.
76
+ */
77
+ let nextNumber = $state('')
78
+ let loaded = $state(false)
79
+
80
+ $effect(() => {
81
+ if (loaded || !hr) return
82
+ country = stored.country
83
+ prefix = stored.employeeNumberPrefix
84
+ nextNumber = String(stored.employeeNumberNext)
85
+ loaded = true
86
+ })
87
+
88
+ /** Discard and re-seed are the same act: drop the edits and let the effect above refill them. */
89
+ function discard() {
90
+ loaded = false
91
+ }
92
+
93
+ const prefixError = $derived(prefix.length > 8 ? t('general_prefix_too_long') : null)
94
+
95
+ /**
96
+ * A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither — so a person
97
+ * typing the digits of their own language into a counter would be told their input is not a whole
98
+ * number. They are folded to ASCII before parsing; the field keeps what was actually typed.
99
+ */
100
+ const toLatinDigits = (value: string) =>
101
+ value
102
+ .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
103
+ .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
104
+
105
+ /** Nine digits at most, so `Number` cannot silently round a pasted counter into a different one. */
106
+ const nextDigits = $derived(toLatinDigits(nextNumber.trim()))
107
+ const parsedNext = $derived(/^\d{1,9}$/.test(nextDigits) ? Number(nextDigits) : Number.NaN)
108
+ const nextError = $derived(parsedNext >= 1 ? null : t('general_next_invalid'))
109
+ const valid = $derived(!prefixError && !nextError)
110
+
111
+ const dirty = $derived(
112
+ loaded &&
113
+ (country !== stored.country ||
114
+ prefix !== stored.employeeNumberPrefix ||
115
+ parsedNext !== stored.employeeNumberNext),
116
+ )
117
+
118
+ /**
119
+ * No prefix and a counter of 1 is how the server spells "off" — `nextEmployeeNo` returns null and
120
+ * the person is created without a number. It is a real state, not an empty form, so the page says
121
+ * so rather than previewing a number nobody will be given.
122
+ */
123
+ const numberingOff = $derived(prefix === '' && parsedNext === 1)
124
+ const preview = $derived(numberingOff || Number.isNaN(parsedNext) ? null : `${prefix}${parsedNext}`)
125
+
126
+ /** Moving the counter backwards re-issues numbers that may already belong to somebody. */
127
+ const rewinding = $derived(!Number.isNaN(parsedNext) && parsedNext < stored.employeeNumberNext)
128
+
129
+ let saving = $state(false)
130
+ let saveError = $state<string | null>(null)
131
+ let confirmRewind = $state(false)
132
+
133
+ const save = createMutation(() => ({
134
+ mutationFn: () =>
135
+ api.workspaces.modules.updateSettings({
136
+ workspaceId,
137
+ moduleId: 'hr',
138
+ settings: {
139
+ country,
140
+ employeeNumberPrefix: prefix,
141
+ employeeNumberNext: parsedNext,
142
+ // Not editable here, and carried through rather than dropped: omitting it would leave the
143
+ // stored value to core's merge, which is a fact about core rather than about this page.
144
+ directoryVisibleToMembers: stored.directoryVisibleToMembers,
145
+ },
146
+ }),
147
+ onSuccess: () => {
148
+ confirmRewind = false
149
+ saveError = null
150
+ // Re-seed from whatever the server stored: the counter moves on its own every time somebody is
151
+ // hired, so what came back is the truth and what is in the form is only what was sent.
152
+ loaded = false
153
+ void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
154
+ toast.success(t('general_saved'))
155
+ },
156
+ onError: (err) => {
157
+ saveError = err instanceof Error && err.message ? err.message : t('general_save_error')
158
+ },
159
+ onSettled: () => {
160
+ saving = false
161
+ },
162
+ }))
163
+
164
+ /**
165
+ * `disabled={save.isPending}` reaches the button a render too late, and two quick clicks are one
166
+ * render apart — so the guard is a plain flag set in the same tick as the click.
167
+ */
168
+ function runSave() {
169
+ if (saving) return
170
+ saving = true
171
+ saveError = null
172
+ save.mutate()
173
+ }
174
+
175
+ function requestSave() {
176
+ if (saving || !dirty || !valid) return
177
+ if (rewinding) {
178
+ confirmRewind = true
179
+ return
180
+ }
181
+ runSave()
182
+ }
183
+
184
+ /**
185
+ * Every country the runtime can name, in the reader's own language.
186
+ *
187
+ * The same technique the offices screen uses, and deliberately the same: there is no
188
+ * `Intl.supportedValuesOf('region')`, so the set comes from asking `DisplayNames` about all 676
189
+ * two-letter codes and keeping the ones it answers with a name. Two country lists in one module
190
+ * would eventually disagree with each other, and a table of codes in this file goes stale the next
191
+ * time a country changes its name.
192
+ */
193
+ const COUNTRY_CACHE = new Map<string, SelectOption[]>()
194
+ /** Codes `DisplayNames` names that ISO 3166-1 does not: unions, outlying areas, reserved codes. */
195
+ const NOT_COUNTRIES = new Set(['AC', 'CP', 'DG', 'EA', 'EU', 'EZ', 'IC', 'QO', 'TA', 'UN', 'XA', 'XB', 'ZZ'])
196
+
197
+ function countryOptions(locale: string): SelectOption[] {
198
+ const cached = COUNTRY_CACHE.get(locale)
199
+ if (cached) return cached
200
+ const options: SelectOption[] = []
201
+ try {
202
+ const names = new Intl.DisplayNames(locale, { type: 'region' })
203
+ for (let first = 65; first <= 90; first++) {
204
+ for (let second = 65; second <= 90; second++) {
205
+ const code = String.fromCharCode(first, second)
206
+ if (NOT_COUNTRIES.has(code)) continue
207
+ const label = names.of(code)
208
+ if (!label || label === code) continue
209
+ options.push({ value: code, label })
210
+ }
211
+ }
212
+ options.sort((a, b) => a.label.localeCompare(b.label, locale))
213
+ } catch {
214
+ // A runtime without region display names must not take the form down with it; the stored code
215
+ // is still a valid answer, and the branch below keeps it selectable.
216
+ }
217
+ COUNTRY_CACHE.set(locale, options)
218
+ return options
219
+ }
220
+
221
+ const countries = $derived.by((): SelectOption[] => {
222
+ const list = countryOptions(messageLocale())
223
+ // A stored country the runtime cannot name has to stay selectable, or opening this page and
224
+ // saving anything else would quietly change it to whatever happened to be first.
225
+ if (country && !list.some((o) => o.value === country)) return [{ value: country, label: country }, ...list]
226
+ return list
227
+ })
228
+ </script>
229
+
230
+ {#snippet saveBar()}
231
+ <Button size="sm" variant="secondary" onclick={discard} disabled={!dirty || saving}>
232
+ {t('common.discard')}
233
+ </Button>
234
+ <Button size="sm" onclick={requestSave} disabled={!dirty || !valid} loading={saving}>
235
+ {t('common.save')}
236
+ </Button>
237
+ {/snippet}
238
+
239
+ <SettingsPage title={t('settings_general')} description={t('settings_general_desc')}>
240
+ {#if modulesQuery.isLoading}
241
+ <Skeleton height="150px" radius="10px" />
242
+ <Skeleton height="260px" radius="10px" />
243
+ {:else if modulesQuery.isError}
244
+ <EmptyState icon="triangle-alert" title={t('general_error')} description={t('general_error_desc')}>
245
+ {#snippet actions()}
246
+ <Button variant="secondary" onclick={() => void modulesQuery.refetch()}>{t('retry')}</Button>
247
+ {/snippet}
248
+ </EmptyState>
249
+ {:else if !hr}
250
+ <EmptyState icon="users" title={t('general_not_enabled')} description={t('general_not_enabled_desc')} />
251
+ {:else}
252
+ {#if !canManage}
253
+ <p class="readonly">{t('general_readonly')}</p>
254
+ {/if}
255
+
256
+ <SettingsSection
257
+ title={t('office_country')}
258
+ description={t('general_country_desc')}
259
+ footer={canManage ? saveBar : undefined}
260
+ >
261
+ <Field inline label={t('office_country')} hint={t('general_country_hint')}>
262
+ {#snippet children(id)}
263
+ <Select
264
+ {id}
265
+ bind:value={country}
266
+ options={countries}
267
+ placeholder={t('office_country')}
268
+ disabled={!canManage}
269
+ width="280px"
270
+ />
271
+ {/snippet}
272
+ </Field>
273
+ </SettingsSection>
274
+
275
+ <SettingsSection
276
+ title={t('general_numbering')}
277
+ description={t('general_numbering_desc')}
278
+ footer={canManage ? saveBar : undefined}
279
+ >
280
+ <div class="rows">
281
+ <Field inline label={t('general_prefix')} hint={t('general_prefix_hint')} error={prefixError}>
282
+ {#snippet children(id)}
283
+ <div class="narrow">
284
+ <Input {id} bind:value={prefix} maxlength={8} mono disabled={!canManage} />
285
+ </div>
286
+ {/snippet}
287
+ </Field>
288
+
289
+ <Field inline label={t('general_next')} hint={t('general_next_hint')} error={nextError}>
290
+ {#snippet children(id)}
291
+ <div class="narrow">
292
+ <Input
293
+ {id}
294
+ bind:value={nextNumber}
295
+ inputmode="numeric"
296
+ maxlength={9}
297
+ mono
298
+ disabled={!canManage}
299
+ />
300
+ </div>
301
+ {/snippet}
302
+ </Field>
303
+
304
+ <div class="preview">
305
+ <span class="preview-label">{t('general_preview')}</span>
306
+ <!--
307
+ The number is interpolated as text on purpose. It is an identifier the server builds by
308
+ concatenation, so a Persian reader has to see the same characters their colleague's
309
+ badge carries — this is the one place on the page where localised digits would be wrong.
310
+ -->
311
+ <span class="preview-value">{preview ?? '—'}</span>
312
+ </div>
313
+
314
+ {#if numberingOff}
315
+ <p class="note">{t('general_numbering_off')}</p>
316
+ {/if}
317
+ {#if rewinding}
318
+ <p class="note warn">
319
+ <Icon name="triangle-alert" size={14} strokeWidth={1.7} />
320
+ <span>{t('general_rewind_warning', { value: String(parsedNext) })}</span>
321
+ </p>
322
+ {/if}
323
+ </div>
324
+ </SettingsSection>
325
+
326
+ {#if saveError}
327
+ <p class="save-error" role="alert">{saveError}</p>
328
+ {/if}
329
+ {/if}
330
+ </SettingsPage>
331
+
332
+ <Dialog
333
+ open={confirmRewind}
334
+ size="sm"
335
+ title={t('general_rewind_title')}
336
+ onOpenChange={(open) => {
337
+ if (!open) confirmRewind = false
338
+ }}
339
+ >
340
+ <p class="dialog-body">{t('general_rewind_body', { value: String(parsedNext) })}</p>
341
+ {#snippet footer()}
342
+ <Button size="sm" variant="secondary" onclick={() => (confirmRewind = false)} disabled={saving}>
343
+ {t('common.cancel')}
344
+ </Button>
345
+ <Button size="sm" variant="danger" onclick={runSave} loading={saving}>
346
+ {t('general_rewind_confirm')}
347
+ </Button>
348
+ {/snippet}
349
+ </Dialog>
350
+
351
+ <style>
352
+ .readonly {
353
+ margin: 0;
354
+ font-size: 12.5px;
355
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
356
+ color: var(--kern-ink-500);
357
+ }
358
+ .rows {
359
+ display: grid;
360
+ gap: 14px;
361
+ }
362
+ .narrow {
363
+ max-inline-size: 180px;
364
+ }
365
+ .preview {
366
+ display: flex;
367
+ align-items: baseline;
368
+ gap: 10px;
369
+ padding: 10px 12px;
370
+ border-radius: var(--kern-r-md);
371
+ background: var(--kern-surface-chip);
372
+ }
373
+ .preview-label {
374
+ font-size: 12.5px;
375
+ color: var(--kern-ink-500);
376
+ }
377
+ .preview-value {
378
+ font-family: var(--kern-font-mono);
379
+ font-size: 14px;
380
+ font-weight: 500;
381
+ color: var(--kern-ink-900);
382
+ /* An employee number is read digit by digit, so the glyphs line up rather than reflow. */
383
+ font-variant-numeric: tabular-nums;
384
+ /* The identifier stays left-to-right inside a Persian or Arabic sentence. */
385
+ direction: ltr;
386
+ unicode-bidi: isolate;
387
+ }
388
+ .note {
389
+ margin: 0;
390
+ font-size: 12.5px;
391
+ line-height: 1.5;
392
+ color: var(--kern-ink-500);
393
+ }
394
+ .note.warn {
395
+ display: flex;
396
+ align-items: flex-start;
397
+ gap: 8px;
398
+ padding: 10px 12px;
399
+ border-radius: var(--kern-r-md);
400
+ /* Measured: #925e22 on #f7e9d8 is 4.58:1, #d59548 on #3a2c1c is 5.28:1. */
401
+ background: var(--kern-warning-tint);
402
+ color: var(--kern-warning);
403
+ }
404
+ /* The icon comes from a component, so the scoped selector cannot reach its svg without this. */
405
+ .note.warn :global(svg) {
406
+ flex: none;
407
+ margin-block-start: 1px;
408
+ }
409
+ .save-error {
410
+ margin: 0;
411
+ font-size: 12.5px;
412
+ color: var(--kern-danger);
413
+ }
414
+ .dialog-body {
415
+ margin: 0;
416
+ font-size: 13.5px;
417
+ line-height: 1.55;
418
+ color: var(--kern-ink-700);
419
+ }
420
+ </style>