@kernhq/module-hr 0.13.0 → 0.13.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/server/index.d.ts.map +1 -1
- package/dist/server/index.js +30 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +276 -61
- package/dist/server/router.js.map +1 -1
- package/package.json +1 -1
- package/src/client/messages.ts +114 -60
- package/src/client/pages/AttendancePage.svelte +24 -1
- package/src/client/pages/DirectoryPage.svelte +240 -10
- package/src/client/pages/OfficesPage.svelte +6 -0
- package/src/client/pages/OrgPage.svelte +7 -0
- package/src/client/settings/AccrualSettings.svelte +119 -38
- package/src/client/settings/SchedulesSettings.svelte +31 -78
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
EmptyState,
|
|
9
9
|
formatCount,
|
|
10
10
|
formatDate,
|
|
11
|
+
Icon,
|
|
11
12
|
Input,
|
|
12
13
|
messageLocale,
|
|
13
14
|
navigation,
|
|
@@ -48,11 +49,101 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
|
48
49
|
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
49
50
|
const workspaceId = $derived(workspace?.id ?? '')
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A query parameter is whatever somebody pasted into the address bar, and the contract types both
|
|
54
|
+
* filters as uuids — so a truncated or hand-edited link would be refused by the server and this
|
|
55
|
+
* screen would draw a red error where a filter was meant. Anything that is not a uuid is no filter.
|
|
56
|
+
*/
|
|
57
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
58
|
+
const asId = (value: string | undefined): string | null => (value && UUID.test(value) ? value : null)
|
|
59
|
+
|
|
51
60
|
let search = $state('')
|
|
52
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The filter the directory was opened with.
|
|
63
|
+
*
|
|
64
|
+
* Both are links from somewhere else — an office row on the offices screen, "See the people" on a
|
|
65
|
+
* department — and until this read the query string they landed on the unfiltered company
|
|
66
|
+
* directory, which reads as the link being broken rather than as a filter that never applied.
|
|
67
|
+
*
|
|
68
|
+
* Seeded here rather than in the `$effect` below, and that is load-bearing: an effect runs after
|
|
69
|
+
* the first render, so `people.list` would fetch and draw the whole company for a beat before
|
|
70
|
+
* narrowing to the office somebody actually clicked.
|
|
71
|
+
*/
|
|
72
|
+
let officeTab = $state(asId(navigation.search.officeId) ?? 'all')
|
|
73
|
+
let orgUnitId = $state<string | null>(asId(navigation.search.orgUnit))
|
|
53
74
|
const selected = $derived(navigation.search.person)
|
|
54
75
|
const creating = $derived(navigation.search.new === '1')
|
|
55
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The URL keeps seeding the filter after that first render: arriving from the offices screen while
|
|
79
|
+
* the directory is already mounted changes the query string without remounting anything, and the
|
|
80
|
+
* back button is a filter change too. It only ever writes what it does not read, so there is no loop.
|
|
81
|
+
*/
|
|
82
|
+
$effect(() => {
|
|
83
|
+
officeTab = asId(navigation.search.officeId) ?? 'all'
|
|
84
|
+
orgUnitId = asId(navigation.search.orgUnit)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A filter change is written to the URL as well as to state, so the address bar always describes
|
|
89
|
+
* what is on screen: a reload, a shared link and the back button all land on the same directory.
|
|
90
|
+
* `person` and `new` are carried through rather than rebuilt, or switching office would close a
|
|
91
|
+
* panel somebody has open.
|
|
92
|
+
*
|
|
93
|
+
* `replaceState` because a filter is not a place — the way back from a filtered directory is the
|
|
94
|
+
* screen that linked into it, not one history entry per pill.
|
|
95
|
+
*/
|
|
96
|
+
function writeFilter(patch: { officeId?: string | null; orgUnit?: string | null }) {
|
|
97
|
+
const params = new URLSearchParams(navigation.search)
|
|
98
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
99
|
+
if (value) params.set(key, value)
|
|
100
|
+
else params.delete(key)
|
|
101
|
+
}
|
|
102
|
+
const query = params.toString()
|
|
103
|
+
navigation.go(`/${workspaceSlug}/hr${query ? `?${query}` : ''}`, {
|
|
104
|
+
replaceState: true,
|
|
105
|
+
keepFocus: true,
|
|
106
|
+
noScroll: true,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** State first, URL second: the shell's router is asynchronous and the pills must not lag a click. */
|
|
111
|
+
function chooseOffice(value: string) {
|
|
112
|
+
officeTab = value
|
|
113
|
+
writeFilter({ officeId: value === 'all' ? null : value })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Clears the search along with the office and the department.
|
|
118
|
+
*
|
|
119
|
+
* The empty state that offers this button is reached when a filter is on, which includes the case
|
|
120
|
+
* where a *search* inside that filter is what found nobody. Clearing only the filter there leaves
|
|
121
|
+
* the term in the box, so the table can still be empty after the click — a button that visibly does
|
|
122
|
+
* nothing, on the one screen where the reader has already failed to find someone.
|
|
123
|
+
*/
|
|
124
|
+
function clearFilters() {
|
|
125
|
+
officeTab = 'all'
|
|
126
|
+
orgUnitId = null
|
|
127
|
+
search = ''
|
|
128
|
+
debounced = ''
|
|
129
|
+
writeFilter({ officeId: null, orgUnit: null })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A row link carries the filter it was opened from.
|
|
134
|
+
*
|
|
135
|
+
* Now that the filter is in the query string, a bare `?person=…` is also a request to show the
|
|
136
|
+
* whole company — so opening somebody from an office directory would quietly re-fetch and redraw
|
|
137
|
+
* every person in the workspace behind the panel. `new` is dropped rather than kept: two dialogs
|
|
138
|
+
* over one directory is not a state anything should be able to link to.
|
|
139
|
+
*/
|
|
140
|
+
function personHref(personId: string): string {
|
|
141
|
+
const params = new URLSearchParams(navigation.search)
|
|
142
|
+
params.delete('new')
|
|
143
|
+
params.set('person', personId)
|
|
144
|
+
return `/${workspaceSlug}/hr?${params.toString()}`
|
|
145
|
+
}
|
|
146
|
+
|
|
56
147
|
const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
|
|
57
148
|
/**
|
|
58
149
|
* Leave is a capability, and a workspace that never switched it on has no balance to show.
|
|
@@ -79,6 +170,23 @@ $effect(() => {
|
|
|
79
170
|
return () => clearTimeout(handle)
|
|
80
171
|
})
|
|
81
172
|
|
|
173
|
+
/**
|
|
174
|
+
* What the filter asks the server for, shared by the table and the counts above it.
|
|
175
|
+
*
|
|
176
|
+
* `includeDescendants` is passed rather than left to the contract's default: the number on the
|
|
177
|
+
* "See the people" button that links here is the department's *subtree* total, and a directory
|
|
178
|
+
* holding fewer people than the link promised is the same defect one layer down.
|
|
179
|
+
*/
|
|
180
|
+
const scopeArgs = $derived({
|
|
181
|
+
...(officeTab !== 'all' ? { officeId: officeTab } : {}),
|
|
182
|
+
...(orgUnitId ? { orgUnitId, includeDescendants: true } : {}),
|
|
183
|
+
})
|
|
184
|
+
/**
|
|
185
|
+
* The same filter as a cache key. `'all'` is spelled out rather than left absent, so that no two
|
|
186
|
+
* scopes ever hash alike and the cache cannot answer a filtered directory with the whole company.
|
|
187
|
+
*/
|
|
188
|
+
const scopeKey = $derived({ officeId: officeTab, orgUnitId: orgUnitId ?? 'all' })
|
|
189
|
+
|
|
82
190
|
const officesQuery = createQuery(() => ({
|
|
83
191
|
queryKey: hrKeys.offices(workspaceId),
|
|
84
192
|
enabled: Boolean(workspaceId) && showOffices && canHr('officeView'),
|
|
@@ -87,24 +195,77 @@ const officesQuery = createQuery(() => ({
|
|
|
87
195
|
const offices = $derived(officesQuery.data ?? [])
|
|
88
196
|
|
|
89
197
|
const peopleQuery = createQuery(() => ({
|
|
90
|
-
queryKey: hrKeys.people(workspaceId, { q: debounced
|
|
198
|
+
queryKey: hrKeys.people(workspaceId, { ...scopeKey, q: debounced }),
|
|
91
199
|
enabled: Boolean(workspaceId),
|
|
92
200
|
queryFn: () =>
|
|
93
201
|
api.people.list({
|
|
94
202
|
workspaceId,
|
|
95
203
|
limit: 100,
|
|
96
204
|
...(debounced ? { q: debounced } : {}),
|
|
97
|
-
...
|
|
205
|
+
...scopeArgs,
|
|
98
206
|
}),
|
|
99
207
|
}))
|
|
100
208
|
const people = $derived(peopleQuery.data?.items ?? [])
|
|
101
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The tiles count the company, not the page.
|
|
212
|
+
*
|
|
213
|
+
* `people.list` returns a `total` for the whole filter, so both numbers are asked of the server
|
|
214
|
+
* with `limit: 1` rather than counted off the rows on screen. Counting the rows made "On leave"
|
|
215
|
+
* fall as somebody typed a name — the table narrows on every keystroke and stops at a hundred rows
|
|
216
|
+
* either way — which is a tile reporting the search box while wearing the label of a company fact.
|
|
217
|
+
*
|
|
218
|
+
* The search is deliberately not part of these: a tile describes the scope the directory is
|
|
219
|
+
* showing, and finding one person inside it does not change how many of them are away. The filter
|
|
220
|
+
* *is* part of them, so an office's directory shows that office's numbers.
|
|
221
|
+
*/
|
|
222
|
+
const headcountQuery = createQuery(() => ({
|
|
223
|
+
queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'headcount' }),
|
|
224
|
+
enabled: Boolean(workspaceId),
|
|
225
|
+
queryFn: () => api.people.list({ workspaceId, limit: 1, ...scopeArgs }),
|
|
226
|
+
}))
|
|
227
|
+
|
|
228
|
+
const onLeaveQuery = createQuery(() => ({
|
|
229
|
+
queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'on_leave' }),
|
|
230
|
+
enabled: Boolean(workspaceId),
|
|
231
|
+
queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['on_leave'], ...scopeArgs }),
|
|
232
|
+
}))
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Only to name the department in the filter chip, so it is asked for only when there is one to
|
|
236
|
+
* name. It shares `hrKeys.orgUnits` with the org chart, which is where "See the people" is clicked
|
|
237
|
+
* — so arriving from there costs no request at all.
|
|
238
|
+
*/
|
|
239
|
+
const orgUnitsQuery = createQuery(() => ({
|
|
240
|
+
queryKey: hrKeys.orgUnits(workspaceId),
|
|
241
|
+
enabled: Boolean(workspaceId) && orgUnitId !== null && canHr('orgView'),
|
|
242
|
+
queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
|
|
243
|
+
}))
|
|
244
|
+
const orgUnitName = $derived(orgUnitsQuery.data?.find((u) => u.id === orgUnitId)?.name ?? null)
|
|
245
|
+
|
|
102
246
|
/** Office tabs only once there is more than one place of work — otherwise they say nothing. */
|
|
103
247
|
const tabs = $derived([
|
|
104
248
|
{ value: 'all', label: t('title') },
|
|
105
249
|
...offices.map((o) => ({ value: o.id, label: o.name })),
|
|
106
250
|
])
|
|
107
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Whether the office filter has to be said in words.
|
|
254
|
+
*
|
|
255
|
+
* A selected pill already says it, so this is for what the tabs cannot cover: a workspace with one
|
|
256
|
+
* office (no tabs at all), a viewer without `hr.office.view`, and an id that is not in the list any
|
|
257
|
+
* more — an archived office whose link somebody kept. There is no name to give in any of those
|
|
258
|
+
* cases, which is why the chip's wording does not promise one.
|
|
259
|
+
*
|
|
260
|
+
* While the offices are still loading there is nothing to say yet and the table is drawing
|
|
261
|
+
* skeletons, so it waits rather than flashing a sentence a pill is about to replace.
|
|
262
|
+
*/
|
|
263
|
+
const officeNeedsChip = $derived(
|
|
264
|
+
officeTab !== 'all' && !officesQuery.isLoading && !tabs.some((tab) => tab.value === officeTab),
|
|
265
|
+
)
|
|
266
|
+
/** Any filter at all — what decides whether an empty table is "nobody yet" or "nobody here". */
|
|
267
|
+
const filtered = $derived(officeTab !== 'all' || orgUnitId !== null)
|
|
268
|
+
|
|
108
269
|
const balancesQuery = createQuery(() => ({
|
|
109
270
|
queryKey: hrKeys.leaveBalance(workspaceId, undefined),
|
|
110
271
|
enabled: Boolean(workspaceId) && showBalance,
|
|
@@ -205,9 +366,9 @@ const SUBJECT_LABELS: Record<string, () => string> = {
|
|
|
205
366
|
const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
|
|
206
367
|
|
|
207
368
|
const stats = $derived({
|
|
208
|
-
headcount:
|
|
369
|
+
headcount: headcountQuery.data?.total ?? null,
|
|
209
370
|
offices: offices.length,
|
|
210
|
-
away:
|
|
371
|
+
away: onLeaveQuery.data?.total ?? null,
|
|
211
372
|
balance: balancesQuery.data?.[0]?.available ?? 0,
|
|
212
373
|
})
|
|
213
374
|
|
|
@@ -267,6 +428,14 @@ const started = (iso: string | null) =>
|
|
|
267
428
|
|
|
268
429
|
/** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
|
|
269
430
|
const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A count nobody has yet is an em dash, never a zero.
|
|
434
|
+
*
|
|
435
|
+
* "0 on leave" is a fact about the company, and a tile stating it while the request is still in
|
|
436
|
+
* flight — or after it failed — is the same lie the tile was counting rows to tell.
|
|
437
|
+
*/
|
|
438
|
+
const tileCount = (n: number | null) => (n === null ? '—' : count(n))
|
|
270
439
|
</script>
|
|
271
440
|
|
|
272
441
|
<PageHeader
|
|
@@ -283,7 +452,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
283
452
|
|
|
284
453
|
<Page>
|
|
285
454
|
<div class="tiles">
|
|
286
|
-
<StatTile size="md" label={t('widget_headcount_title')} value={
|
|
455
|
+
<StatTile size="md" label={t('widget_headcount_title')} value={tileCount(stats.headcount)} />
|
|
287
456
|
<!--
|
|
288
457
|
Only where the workspace has offices. It rendered unconditionally and read "Offices 0" on a
|
|
289
458
|
single-site workspace — a tile counting a feature nobody switched on, sitting beside three
|
|
@@ -292,7 +461,11 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
292
461
|
{#if showOffices}
|
|
293
462
|
<StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
|
|
294
463
|
{/if}
|
|
295
|
-
|
|
464
|
+
<!--
|
|
465
|
+
The number comes from the server, and the note says which number it is: "On leave" over a
|
|
466
|
+
figure could as easily mean this week or this month, and it means neither.
|
|
467
|
+
-->
|
|
468
|
+
<StatTile size="md" label={t('status_on_leave')} value={tileCount(stats.away)} note={t('on_leave_note')} />
|
|
296
469
|
<!-- Same rule for leave: the balance tile is the surface of a capability, so it goes with it. -->
|
|
297
470
|
{#if showBalance}
|
|
298
471
|
<StatTile
|
|
@@ -310,7 +483,28 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
310
483
|
|
|
311
484
|
<div class="filters">
|
|
312
485
|
{#if tabs.length > 1}
|
|
313
|
-
<Tabs items={tabs} value={officeTab} variant="pill"
|
|
486
|
+
<Tabs items={tabs} value={officeTab} variant="pill" label={t('office')} onValueChange={chooseOffice} />
|
|
487
|
+
{/if}
|
|
488
|
+
<!--
|
|
489
|
+
What the pills cannot say. A directory that opened filtered and says nothing about it is
|
|
490
|
+
indistinguishable from a directory that lost half the company, so anything the tabs are not
|
|
491
|
+
already showing gets a chip — and one control puts every filter back at once.
|
|
492
|
+
-->
|
|
493
|
+
{#if officeNeedsChip || orgUnitId}
|
|
494
|
+
<div class="active">
|
|
495
|
+
{#if officeNeedsChip}
|
|
496
|
+
<span class="chip">
|
|
497
|
+
<Icon name="building" size={12} strokeWidth={1.8} />{t('filter_office_unnamed')}
|
|
498
|
+
</span>
|
|
499
|
+
{/if}
|
|
500
|
+
{#if orgUnitId}
|
|
501
|
+
<span class="chip">
|
|
502
|
+
<Icon name="git-branch" size={12} strokeWidth={1.8} />
|
|
503
|
+
{orgUnitName ? t('filter_department', { name: orgUnitName }) : t('filter_department_unnamed')}
|
|
504
|
+
</span>
|
|
505
|
+
{/if}
|
|
506
|
+
<Button size="xs" variant="ghost" onclick={clearFilters}>{t('filter_clear')}</Button>
|
|
507
|
+
</div>
|
|
314
508
|
{/if}
|
|
315
509
|
<div class="search">
|
|
316
510
|
<Input bind:value={search} placeholder={t('search_people')} type="search" size="sm" />
|
|
@@ -339,7 +533,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
339
533
|
</div>
|
|
340
534
|
{#each people as person (person.id)}
|
|
341
535
|
{@const time = localTime(person.timezone, tick)}
|
|
342
|
-
<a class="trow" role="row" href={
|
|
536
|
+
<a class="trow" role="row" href={personHref(person.id)}>
|
|
343
537
|
<span class="cell who" role="cell">
|
|
344
538
|
<Avatar name={person.displayName} id={person.id} size={28} />
|
|
345
539
|
<span class="stack">
|
|
@@ -363,6 +557,19 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
363
557
|
<Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
|
|
364
558
|
{/snippet}
|
|
365
559
|
</EmptyState>
|
|
560
|
+
<!--
|
|
561
|
+
"Nobody here yet — add the first person" is the right sentence for an empty company and the
|
|
562
|
+
wrong one for an office with nobody in it: it invites somebody to create a second record for
|
|
563
|
+
a person the filter is simply hiding. A filtered miss offers the way out instead.
|
|
564
|
+
-->
|
|
565
|
+
{:else if filtered}
|
|
566
|
+
<EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_filtered')}>
|
|
567
|
+
{#snippet actions()}
|
|
568
|
+
<Button variant="secondary" onclick={clearFilters}>{t('filter_clear')}</Button>
|
|
569
|
+
{/snippet}
|
|
570
|
+
</EmptyState>
|
|
571
|
+
{:else if debounced}
|
|
572
|
+
<EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_search')} />
|
|
366
573
|
{:else}
|
|
367
574
|
<EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
|
|
368
575
|
{/if}
|
|
@@ -458,11 +665,34 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
458
665
|
display: flex;
|
|
459
666
|
align-items: center;
|
|
460
667
|
gap: 12px;
|
|
668
|
+
/* A department name is as long as somebody named it, and Persian and German run longer than the
|
|
669
|
+
English it was laid out in. The row wraps rather than crushing the search box. */
|
|
670
|
+
flex-wrap: wrap;
|
|
461
671
|
margin-block: 4px 8px;
|
|
462
672
|
}
|
|
463
673
|
.search {
|
|
464
674
|
margin-inline-start: auto;
|
|
465
|
-
|
|
675
|
+
inline-size: min(260px, 40%);
|
|
676
|
+
}
|
|
677
|
+
.active {
|
|
678
|
+
display: flex;
|
|
679
|
+
align-items: center;
|
|
680
|
+
gap: 6px;
|
|
681
|
+
flex-wrap: wrap;
|
|
682
|
+
min-inline-size: 0;
|
|
683
|
+
}
|
|
684
|
+
.chip {
|
|
685
|
+
display: inline-flex;
|
|
686
|
+
align-items: center;
|
|
687
|
+
gap: 5px;
|
|
688
|
+
padding-inline: 9px;
|
|
689
|
+
block-size: 24px;
|
|
690
|
+
border-radius: var(--kern-r-full);
|
|
691
|
+
background: var(--kern-surface-chip);
|
|
692
|
+
font-size: 12px;
|
|
693
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
694
|
+
color: var(--kern-ink-700);
|
|
695
|
+
white-space: nowrap;
|
|
466
696
|
}
|
|
467
697
|
.rows {
|
|
468
698
|
display: grid;
|
|
@@ -160,6 +160,12 @@ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'gre
|
|
|
160
160
|
<span role="columnheader">{t('local_time')}</span>
|
|
161
161
|
</div>
|
|
162
162
|
{#each offices as office (office.id)}
|
|
163
|
+
<!--
|
|
164
|
+
`officeId` is read by the directory, which seeds its office filter from it and shows the
|
|
165
|
+
filter it landed with. The parameter name is the whole contract between the two screens:
|
|
166
|
+
renaming it here alone breaks nothing loudly — the link still resolves, and quietly lands
|
|
167
|
+
on the unfiltered company directory, which is exactly how it shipped.
|
|
168
|
+
-->
|
|
163
169
|
<a
|
|
164
170
|
class="trow"
|
|
165
171
|
role="row"
|
|
@@ -871,6 +871,13 @@ const tabs = $derived([
|
|
|
871
871
|
</div>
|
|
872
872
|
|
|
873
873
|
{#if selected.total > 0}
|
|
874
|
+
<!--
|
|
875
|
+
`orgUnit` is read by the directory, which seeds an org-unit filter from it and asks
|
|
876
|
+
for the whole subtree — which is why the count here is `total` and not `headcount`.
|
|
877
|
+
The parameter name is the whole contract between the two screens: renaming it here
|
|
878
|
+
alone breaks nothing loudly, it just lands on the unfiltered company directory,
|
|
879
|
+
which is exactly how it shipped.
|
|
880
|
+
-->
|
|
874
881
|
<Button
|
|
875
882
|
size="sm"
|
|
876
883
|
variant="secondary"
|
|
@@ -270,6 +270,19 @@ function failureText(error: unknown, fallbackKey: string): string {
|
|
|
270
270
|
|
|
271
271
|
// ---------------------------------------------------------------- the policy form
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* What an empty form starts a day at, and the only eight hours on this screen.
|
|
275
|
+
*
|
|
276
|
+
* A seed, never a conversion. `accrueForPeriod` multiplies entitlement days by the policy's own
|
|
277
|
+
* `minutesPerDay`, so anything here that turns time into days — or days into time — reads that
|
|
278
|
+
* figure instead of assuming one: five days of a seven-and-a-half-hour day is 2250 minutes, and at
|
|
279
|
+
* a hardcoded 8 × 60 the same five days read as 2400, which is a cap that never bites and a
|
|
280
|
+
* balance nobody can reconcile against the ledger.
|
|
281
|
+
*/
|
|
282
|
+
const DEFAULT_DAY_MINUTES = 8 * 60
|
|
283
|
+
/** The contract's own ceiling on `roundToMinutes`. A whole day exceeds it on any day past eight hours. */
|
|
284
|
+
const ROUND_CAP_MINUTES = 480
|
|
285
|
+
|
|
273
286
|
let policyDialog = $state<'create' | 'edit' | null>(null)
|
|
274
287
|
let policyId = $state('')
|
|
275
288
|
let policyName = $state('')
|
|
@@ -278,11 +291,21 @@ let policyTo = $state('')
|
|
|
278
291
|
let frequency = $state<Frequency>('monthly')
|
|
279
292
|
let leaveTypeKey = $state('')
|
|
280
293
|
let daysPerYear = $state('20')
|
|
281
|
-
let minutesPerDay = $state(
|
|
294
|
+
let minutesPerDay = $state(String(DEFAULT_DAY_MINUTES))
|
|
282
295
|
let waitingMonths = $state('0')
|
|
283
|
-
|
|
296
|
+
/** A token rather than a minute count — see `roundMinutes`. */
|
|
297
|
+
let roundChoice = $state('0')
|
|
284
298
|
let tiers = $state<Tier[]>([])
|
|
285
299
|
let policyError = $state<string | null>(null)
|
|
300
|
+
/**
|
|
301
|
+
* Whether the form was seeded from a config the schema could not read.
|
|
302
|
+
*
|
|
303
|
+
* `configOf` answering null means the stored jsonb is not an accrual config — an older release, or
|
|
304
|
+
* a row edited by hand. Every field below then holds a default, and saving writes those defaults
|
|
305
|
+
* over whatever the engine is actually using, so the dialog says so rather than presenting eight
|
|
306
|
+
* hours and twenty days as though they were the policy.
|
|
307
|
+
*/
|
|
308
|
+
let seededFromUnreadable = $state(false)
|
|
286
309
|
|
|
287
310
|
function openCreate() {
|
|
288
311
|
policyDialog = 'create'
|
|
@@ -293,23 +316,30 @@ function openCreate() {
|
|
|
293
316
|
frequency = 'monthly'
|
|
294
317
|
leaveTypeKey = leaveTypes[0]?.key ?? ''
|
|
295
318
|
daysPerYear = '20'
|
|
296
|
-
minutesPerDay =
|
|
319
|
+
minutesPerDay = String(DEFAULT_DAY_MINUTES)
|
|
297
320
|
waitingMonths = '0'
|
|
298
|
-
|
|
321
|
+
roundChoice = '0'
|
|
299
322
|
tiers = []
|
|
300
323
|
policyError = null
|
|
324
|
+
seededFromUnreadable = false
|
|
301
325
|
}
|
|
302
326
|
|
|
303
327
|
function fillFrom(policy: PolicyRow) {
|
|
304
328
|
const config = configOf(policy)
|
|
329
|
+
seededFromUnreadable = config === null
|
|
305
330
|
policyName = policy.name
|
|
306
331
|
policyTo = policy.effectiveTo ?? ''
|
|
307
332
|
frequency = config?.frequency ?? 'monthly'
|
|
308
333
|
leaveTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
|
|
309
334
|
daysPerYear = String(config?.daysPerYear ?? 20)
|
|
310
|
-
|
|
335
|
+
const day = config?.minutesPerDay ?? DEFAULT_DAY_MINUTES
|
|
336
|
+
minutesPerDay = String(day)
|
|
311
337
|
waitingMonths = String(config?.waitingPeriodMonths ?? 0)
|
|
312
|
-
|
|
338
|
+
// Back to what the step *meant* under the day it was saved with. Read as a bare number, a policy
|
|
339
|
+
// rounding to a whole 450-minute day would reopen as "450 minutes" and stop following the day the
|
|
340
|
+
// moment somebody lengthened it.
|
|
341
|
+
const step = config?.roundToMinutes ?? 0
|
|
342
|
+
roundChoice = step === 0 ? '0' : step === day ? 'day' : step === Math.round(day / 2) ? 'half' : String(step)
|
|
313
343
|
tiers = (config?.seniorityTiers ?? []).map((tier) => ({
|
|
314
344
|
afterYears: String(tier.afterYears),
|
|
315
345
|
daysPerYear: String(tier.daysPerYear),
|
|
@@ -346,33 +376,56 @@ const clampedDays = (value: string, min: number, max: number) => {
|
|
|
346
376
|
}
|
|
347
377
|
|
|
348
378
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
379
|
+
* The day this policy is being given, and the figure everything below converts against.
|
|
380
|
+
*
|
|
381
|
+
* The same number the server stores as `minutesPerDay` and `accrueForPeriod` multiplies by, so a
|
|
382
|
+
* length shown here is the length leave is actually earned in.
|
|
383
|
+
*/
|
|
384
|
+
const dayMinutes = $derived(clamped(minutesPerDay, 1, 1440))
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Rounding held as what the admin meant, resolved against the day beside it.
|
|
388
|
+
*
|
|
389
|
+
* "A whole day" is 450 minutes on a seven-and-a-half-hour day and 480 on an eight-hour one. Held as
|
|
390
|
+
* a bare number the step stops meaning a day the moment the day length changes — silently, because
|
|
391
|
+
* the select goes on reading "A whole day" while the engine rounds to something else. So the choice
|
|
392
|
+
* is a token and the minutes follow from it.
|
|
393
|
+
*/
|
|
394
|
+
const roundMinutes = $derived(
|
|
395
|
+
roundChoice === 'day'
|
|
396
|
+
? dayMinutes
|
|
397
|
+
: roundChoice === 'half'
|
|
398
|
+
? Math.round(dayMinutes / 2)
|
|
399
|
+
: Math.max(0, Math.round(Number(roundChoice) || 0)),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Rounding offered as the steps somebody actually means, half and whole days included.
|
|
404
|
+
*
|
|
405
|
+
* The two day steps are kept beside the fixed ones even where they land on the same number, because
|
|
406
|
+
* they are a different choice: one follows the day length, the other stays where it was typed. A
|
|
407
|
+
* whole day past the contract's cap is offered and refused by `formProblem` with the reason next to
|
|
408
|
+
* the button, rather than dropped from the list with nothing to explain the gap.
|
|
353
409
|
*/
|
|
354
410
|
const roundOptions = $derived.by(() => {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
{ minutes:
|
|
358
|
-
{ minutes:
|
|
359
|
-
{ minutes:
|
|
360
|
-
{
|
|
361
|
-
{ minutes:
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const seen = new Set<number>()
|
|
365
|
-
const options = [{ value: '0', label: t('accr_round_exact') }]
|
|
366
|
-
for (const step of steps) {
|
|
367
|
-
if (seen.has(step.minutes)) continue
|
|
368
|
-
seen.add(step.minutes)
|
|
369
|
-
options.push({ value: String(step.minutes), label: step.label })
|
|
370
|
-
}
|
|
411
|
+
const half = Math.round(dayMinutes / 2)
|
|
412
|
+
const options = [
|
|
413
|
+
{ value: '0', minutes: 0, label: t('accr_round_exact') },
|
|
414
|
+
{ value: '15', minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
|
|
415
|
+
{ value: '30', minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
|
|
416
|
+
{ value: '60', minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
|
|
417
|
+
{ value: 'half', minutes: half, label: t('accr_round_half_day', { length: duration(half) }) },
|
|
418
|
+
{ value: 'day', minutes: dayMinutes, label: t('accr_round_day', { length: duration(dayMinutes) }) },
|
|
419
|
+
]
|
|
371
420
|
// A stored step none of these produce would otherwise vanish the moment somebody opened the
|
|
372
421
|
// policy to change its name.
|
|
373
|
-
if (!options.some((option) => option.value ===
|
|
374
|
-
options.push({
|
|
375
|
-
|
|
422
|
+
if (!options.some((option) => option.value === roundChoice))
|
|
423
|
+
options.push({
|
|
424
|
+
value: roundChoice,
|
|
425
|
+
minutes: roundMinutes,
|
|
426
|
+
label: t('accr_round_minutes', { count: roundMinutes }),
|
|
427
|
+
})
|
|
428
|
+
return options.sort((a, b) => a.minutes - b.minutes).map(({ value, label }) => ({ value, label }))
|
|
376
429
|
})
|
|
377
430
|
|
|
378
431
|
const tierYears = $derived(tiers.map((tier) => clamped(tier.afterYears, 0, 60)))
|
|
@@ -384,19 +437,23 @@ const formProblem = $derived.by(() => {
|
|
|
384
437
|
if (!policyFrom) return t('accr_error_from')
|
|
385
438
|
if (policyTo && policyTo < policyFrom) return t('accr_error_to_before_from')
|
|
386
439
|
if (tiersClash) return t('accr_error_tier_clash')
|
|
440
|
+
// Said rather than clamped: a step quietly cut back to eight hours is a policy rounding to
|
|
441
|
+
// something other than the day it names.
|
|
442
|
+
if (roundMinutes > ROUND_CAP_MINUTES) return t('accr_error_round_cap', { max: duration(ROUND_CAP_MINUTES) })
|
|
387
443
|
return null
|
|
388
444
|
})
|
|
389
445
|
|
|
390
446
|
const configDraft = $derived({
|
|
391
447
|
frequency,
|
|
392
448
|
daysPerYear: clampedDays(daysPerYear, 0, 365),
|
|
393
|
-
minutesPerDay:
|
|
449
|
+
minutesPerDay: dayMinutes,
|
|
394
450
|
seniorityTiers: tiers.map((tier) => ({
|
|
395
451
|
afterYears: clamped(tier.afterYears, 0, 60),
|
|
396
452
|
daysPerYear: clampedDays(tier.daysPerYear, 0, 365),
|
|
397
453
|
})),
|
|
398
454
|
waitingPeriodMonths: clamped(waitingMonths, 0, 24),
|
|
399
|
-
|
|
455
|
+
// Save is blocked above the cap, so this never silently shortens a step somebody chose.
|
|
456
|
+
roundToMinutes: Math.min(roundMinutes, ROUND_CAP_MINUTES),
|
|
400
457
|
leaveTypeKey,
|
|
401
458
|
})
|
|
402
459
|
|
|
@@ -815,7 +872,11 @@ const HEAD = 10
|
|
|
815
872
|
{#if config}
|
|
816
873
|
<span class="num">{t('accr_days_per_year', { count: config.daysPerYear })}</span>
|
|
817
874
|
<span class="sub">
|
|
818
|
-
|
|
875
|
+
<!-- The day length is named rather than left as a bare duration beside a leave
|
|
876
|
+
type, because it is the number those days per year are earned in. -->
|
|
877
|
+
{leaveTypeName(config.leaveTypeKey)} · {t('accr_day_is', {
|
|
878
|
+
length: duration(config.minutesPerDay),
|
|
879
|
+
})}
|
|
819
880
|
{#if config.seniorityTiers.length > 0}
|
|
820
881
|
· {t('accr_tiers_count', { count: config.seniorityTiers.length })}
|
|
821
882
|
{/if}
|
|
@@ -1003,6 +1064,13 @@ const HEAD = 10
|
|
|
1003
1064
|
<p class="note">{t('accr_edit_retroactive')}</p>
|
|
1004
1065
|
{/if}
|
|
1005
1066
|
|
|
1067
|
+
{#if seededFromUnreadable}
|
|
1068
|
+
<!-- Every field below is a default, the day length included — and the day length is what the
|
|
1069
|
+
engine multiplies entitlement days by. Saying so is the difference between repairing a
|
|
1070
|
+
broken row and overwriting a working one with eight hours and twenty days. -->
|
|
1071
|
+
<p class="note warn">{t('accr_config_unreadable_form')}</p>
|
|
1072
|
+
{/if}
|
|
1073
|
+
|
|
1006
1074
|
<Field label={t('accr_name')} hint={t('accr_name_hint')} required>
|
|
1007
1075
|
{#snippet children(id)}
|
|
1008
1076
|
<Input {id} bind:value={policyName} maxlength={120} />
|
|
@@ -1054,10 +1122,7 @@ const HEAD = 10
|
|
|
1054
1122
|
<Input {id} type="number" min={0} max={365} step={0.5} bind:value={daysPerYear} />
|
|
1055
1123
|
{/snippet}
|
|
1056
1124
|
</Field>
|
|
1057
|
-
<Field
|
|
1058
|
-
label={t('accr_day_length')}
|
|
1059
|
-
hint={t('accr_day_length_hint', { length: duration(clamped(minutesPerDay, 1, 1440)) })}
|
|
1060
|
-
>
|
|
1125
|
+
<Field label={t('accr_day_length')} hint={t('accr_day_length_hint', { length: duration(dayMinutes) })}>
|
|
1061
1126
|
{#snippet children(id)}
|
|
1062
1127
|
<Input {id} type="number" min={1} max={1440} bind:value={minutesPerDay} />
|
|
1063
1128
|
{/snippet}
|
|
@@ -1072,7 +1137,12 @@ const HEAD = 10
|
|
|
1072
1137
|
</Field>
|
|
1073
1138
|
<Field label={t('accr_rounding')} hint={t('accr_rounding_hint')}>
|
|
1074
1139
|
{#snippet children(id)}
|
|
1075
|
-
<Select
|
|
1140
|
+
<Select
|
|
1141
|
+
{id}
|
|
1142
|
+
value={roundChoice}
|
|
1143
|
+
onValueChange={(v) => (roundChoice = v)}
|
|
1144
|
+
options={roundOptions}
|
|
1145
|
+
/>
|
|
1076
1146
|
{/snippet}
|
|
1077
1147
|
</Field>
|
|
1078
1148
|
</div>
|
|
@@ -1388,7 +1458,18 @@ const HEAD = 10
|
|
|
1388
1458
|
{:else if preview}
|
|
1389
1459
|
<div class="tiles">
|
|
1390
1460
|
<StatTile size="md" label={t('accr_run_people')} value={formatCount(creditable.length, 999)} />
|
|
1391
|
-
|
|
1461
|
+
<!--
|
|
1462
|
+
In time, not days, and deliberately so: one run covers everybody, and the people in it may
|
|
1463
|
+
sit on policies whose days are different lengths, so there is no single day this sum could
|
|
1464
|
+
honestly be divided by. Each row below *is* in days, because the server divided it by that
|
|
1465
|
+
person's own policy. The unit is on the tile rather than left to be inferred.
|
|
1466
|
+
-->
|
|
1467
|
+
<StatTile
|
|
1468
|
+
size="md"
|
|
1469
|
+
label={t('accr_run_total')}
|
|
1470
|
+
value={duration(preview.totalMinutes)}
|
|
1471
|
+
note={t('accr_run_total_note')}
|
|
1472
|
+
/>
|
|
1392
1473
|
<StatTile size="md" label={t('accr_run_skipped')} value={formatCount(preview.skipped.length, 999)} />
|
|
1393
1474
|
</div>
|
|
1394
1475
|
|