@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
@@ -4,7 +4,9 @@ import {
4
4
  Badge,
5
5
  Button,
6
6
  Dialog,
7
+ EmptyState,
7
8
  Field,
9
+ formatDate,
8
10
  Input,
9
11
  navigation,
10
12
  RightPanel,
@@ -68,6 +70,27 @@ const managerQuery = createQuery(() => ({
68
70
  const close = () =>
69
71
  void navigation.go(`/${workspaceSlug}/hr`, { replaceState: true, keepFocus: true, noScroll: true })
70
72
 
73
+ /**
74
+ * The sentence to put in front of somebody when a write here fails.
75
+ *
76
+ * A refusal arrives as two pieces: a machine-readable `reason` a module translates, and the
77
+ * English sentence the router wrote for a reader. Nothing under `people.*` sends a reason today —
78
+ * its refusals are a record that has gone and a field the server will not take — so this uses the
79
+ * second, and only for the codes that carry a sentence somebody wrote. Everything else is machine
80
+ * text in English: a network drop, a 500, a gateway, and `Forbidden` and `Unauthorized`, which are
81
+ * one word each. A toast is the last place to paste any of them, so they fall back to this
82
+ * module's own string.
83
+ *
84
+ * When a `people.*` refusal does grow a reason, it is read the way `ClockControls.svelte` reads a
85
+ * punch's — keyed by the code, never by the sentence.
86
+ */
87
+ const READABLE = new Set(['BAD_REQUEST', 'CONFLICT', 'NOT_FOUND'])
88
+ function explain(error: unknown, fallback: string): string {
89
+ const failure = error as { code?: unknown; message?: string }
90
+ const readable = typeof failure.code === 'string' && READABLE.has(failure.code)
91
+ return (readable ? failure.message : '') || fallback
92
+ }
93
+
71
94
  let editing = $state(false)
72
95
  let displayName = $state('')
73
96
  let workEmail = $state('')
@@ -83,6 +106,14 @@ $effect(() => {
83
106
  }
84
107
  })
85
108
 
109
+ /**
110
+ * `saving` and `ending` rather than `isPending`: the disabled attribute only reaches the button on
111
+ * the next render, so two quick clicks both fire — twice through here is two history rows for one
112
+ * decision. These are set in the same tick as the first click.
113
+ */
114
+ let saving = $state(false)
115
+ let ending = $state(false)
116
+
86
117
  const save = createMutation(() => ({
87
118
  mutationFn: () =>
88
119
  api.people.update({
@@ -98,9 +129,18 @@ const save = createMutation(() => ({
98
129
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
99
130
  editing = false
100
131
  },
101
- onError: (error: Error) => toast.error(error.message),
132
+ onError: (error) => toast.error(explain(error, t('person_save_error'))),
133
+ onSettled: () => {
134
+ saving = false
135
+ },
102
136
  }))
103
137
 
138
+ const submitSave = () => {
139
+ if (saving) return
140
+ saving = true
141
+ save.mutate()
142
+ }
143
+
104
144
  let offboarding = $state(false)
105
145
  let lastDay = $state(isoDate())
106
146
  let offboardReason = $state('')
@@ -118,9 +158,18 @@ const offboard = createMutation(() => ({
118
158
  void queryClient.invalidateQueries({ queryKey: ['hr'] })
119
159
  offboarding = false
120
160
  },
121
- onError: (error: Error) => toast.error(error.message),
161
+ onError: (error) => toast.error(explain(error, t('person_offboard_error'))),
162
+ onSettled: () => {
163
+ ending = false
164
+ },
122
165
  }))
123
166
 
167
+ const submitOffboard = () => {
168
+ if (ending) return
169
+ ending = true
170
+ offboard.mutate()
171
+ }
172
+
124
173
  /**
125
174
  * The employment types the server can send, as words.
126
175
  *
@@ -138,13 +187,55 @@ const EMPLOYMENT_KEYS: Record<string, string> = {
138
187
  }
139
188
  const typeLabel = (value: string) => (EMPLOYMENT_KEYS[value] ? t(EMPLOYMENT_KEYS[value]) : value)
140
189
 
190
+ /**
191
+ * The clock where this person works, in the reader's language.
192
+ *
193
+ * `formatDate` rather than a bare `Intl` call: an interface in Persian writes its own digits, and
194
+ * a time in Latin ones beside them is the one thing on the panel that did not get translated. The
195
+ * zone comes from the office record, so it is guarded — an unknown one must not take the panel
196
+ * down with it.
197
+ */
198
+ function officeTime(timezone: string): string {
199
+ try {
200
+ return formatDate(new Date().toISOString(), {
201
+ timeZone: timezone,
202
+ hour: 'numeric',
203
+ minute: '2-digit',
204
+ })
205
+ } catch {
206
+ return '—'
207
+ }
208
+ }
209
+
141
210
  const canManage = $derived(canHr('personManage'))
142
211
  const left = $derived(person?.status === 'terminated')
143
212
  </script>
144
213
 
145
- <RightPanel onClose={close} title={person?.displayName ?? ''}>
214
+ {#snippet footerActions()}
215
+ <div class="actions">
216
+ <!-- Disabled with the reason beside it, not hidden: the button was there a moment ago, and a
217
+ control that vanishes after an action reads as a bug rather than as a finished job. -->
218
+ {#if left}<span class="note">{t('offboard_already')}</span>{/if}
219
+ <Button size="sm" variant="secondary" onclick={() => (editing = true)}>{t('edit_person')}</Button>
220
+ <Button size="sm" variant="danger" disabled={left} onclick={() => (offboarding = true)}>
221
+ {t('offboard')}
222
+ </Button>
223
+ </div>
224
+ {/snippet}
225
+
226
+ <RightPanel
227
+ onClose={close}
228
+ title={person?.displayName ?? t('person_panel')}
229
+ footer={canManage && person ? footerActions : undefined}
230
+ >
146
231
  {#if personQuery.isLoading}
147
- <div class="pad"><Skeleton height="120px" /></div>
232
+ <div class="pad">
233
+ <div class="head">
234
+ <Skeleton width="56px" height="56px" radius="50%" />
235
+ <Skeleton width="150px" height="14px" />
236
+ </div>
237
+ <Skeleton lines={4} />
238
+ </div>
148
239
  {:else if person}
149
240
  <div class="pad">
150
241
  <div class="head">
@@ -163,11 +254,7 @@ const left = $derived(person?.status === 'terminated')
163
254
  {#if resolution?.timezone}
164
255
  <dt>{t('local_time')}</dt>
165
256
  <dd>
166
- {new Intl.DateTimeFormat(undefined, {
167
- timeZone: resolution.timezone,
168
- hour: 'numeric',
169
- minute: '2-digit',
170
- }).format(new Date())}
257
+ {officeTime(resolution.timezone)}
171
258
  <span class="meta">{resolution.timezone}</span>
172
259
  </dd>
173
260
  {/if}
@@ -177,11 +264,7 @@ const left = $derived(person?.status === 'terminated')
177
264
  {/if}
178
265
  {#if person.hiredOn}
179
266
  <dt>{t('started')}</dt>
180
- <dd>
181
- {new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(
182
- new Date(`${person.hiredOn}T00:00:00`),
183
- )}
184
- </dd>
267
+ <dd>{formatDate(`${person.hiredOn}T00:00:00`)}</dd>
185
268
  {/if}
186
269
  {#if person.phone}
187
270
  <dt>{t('phone')}</dt>
@@ -213,16 +296,34 @@ const left = $derived(person?.status === 'terminated')
213
296
  : t('status_terminated')}</Badge
214
297
  >
215
298
  </div>
216
- {/if}
217
-
218
- {#snippet footer()}
219
- {#if canManage && person && !left}
220
- <div class="actions">
221
- <Button size="sm" variant="secondary" onclick={() => (editing = true)}>{t('edit_person')}</Button>
222
- <Button size="sm" variant="danger" onclick={() => (offboarding = true)}>{t('offboard')}</Button>
299
+ {:else if personQuery.isError}
300
+ <!--
301
+ The panel used to draw nothing here: an untitled 440px column with a close button and no
302
+ word in it, which reads as a record that is empty rather than as one that failed to load.
303
+ The other two queries are details on the record, so they stay silent when they fail; this
304
+ one is the record.
305
+ -->
306
+ <div class="pad">
307
+ <EmptyState compact icon="triangle-alert" title={t('person_error')}>
308
+ {#snippet actions()}
309
+ <Button size="sm" variant="secondary" onclick={() => void personQuery.refetch()}>
310
+ {t('retry')}
311
+ </Button>
312
+ {/snippet}
313
+ </EmptyState>
314
+ </div>
315
+ {:else}
316
+ <!--
317
+ No workspace yet. The query is disabled until one arrives, and a disabled query is not
318
+ "loading" — so without this branch the panel is briefly a blank column on every first paint.
319
+ -->
320
+ <div class="pad">
321
+ <div class="head">
322
+ <Skeleton width="56px" height="56px" radius="50%" />
323
+ <Skeleton width="150px" height="14px" />
223
324
  </div>
224
- {/if}
225
- {/snippet}
325
+ </div>
326
+ {/if}
226
327
  </RightPanel>
227
328
 
228
329
  <Dialog bind:open={editing} title={t('edit_person')}>
@@ -251,8 +352,8 @@ const left = $derived(person?.status === 'terminated')
251
352
  {#snippet footer()}
252
353
  <Button variant="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</Button>
253
354
  <Button
254
- onclick={() => save.mutate()}
255
- disabled={displayName.trim().length === 0}
355
+ onclick={submitSave}
356
+ disabled={displayName.trim().length === 0 || saving}
256
357
  loading={save.isPending}>{t('common.save')}</Button
257
358
  >
258
359
  {/snippet}
@@ -278,7 +379,7 @@ const left = $derived(person?.status === 'terminated')
278
379
  </div>
279
380
  {#snippet footer()}
280
381
  <Button variant="ghost" onclick={() => (offboarding = false)}>{t('common.cancel')}</Button>
281
- <Button variant="danger" onclick={() => offboard.mutate()} loading={offboard.isPending}
382
+ <Button variant="danger" onclick={submitOffboard} disabled={!lastDay || ending} loading={offboard.isPending}
282
383
  >{t('offboard')}</Button
283
384
  >
284
385
  {/snippet}
@@ -319,8 +420,15 @@ dd {
319
420
  .actions {
320
421
  display: flex;
321
422
  gap: 8px;
423
+ align-items: center;
322
424
  justify-content: end;
323
425
  }
426
+ /* A colour, not opacity: opacity fades text against the panel whatever token it names. */
427
+ .note {
428
+ margin-inline-end: auto;
429
+ font-size: 12px;
430
+ color: var(--kern-ink-500);
431
+ }
324
432
  .form {
325
433
  display: grid;
326
434
  gap: 14px;