@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.
- package/dist/contract/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- 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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
{
|
|
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
|
-
{
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
{/
|
|
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={
|
|
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={
|
|
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;
|