@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.
- package/dist/contract/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.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 +9 -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 +458 -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 +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- 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 +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- 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 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- 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 +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- 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 +43 -0
- 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/approvals.ts +13 -1
- package/src/contract/capabilities.ts +17 -0
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
EmptyState,
|
|
7
|
+
formatCount,
|
|
8
|
+
messageLocale,
|
|
9
|
+
navigation,
|
|
10
|
+
Page,
|
|
11
|
+
PageHeader,
|
|
12
|
+
SectionLabel,
|
|
13
|
+
Skeleton,
|
|
14
|
+
StatTile,
|
|
15
|
+
session,
|
|
16
|
+
} from '@kernhq/ui'
|
|
3
17
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
18
|
import { getHrApi } from '../api-instance.js'
|
|
5
19
|
import { t } from '../i18n.js'
|
|
20
|
+
import { canHr } from '../permissions.js'
|
|
6
21
|
import { hrKeys } from '../query.js'
|
|
7
22
|
|
|
8
23
|
/**
|
|
9
24
|
* Where the company works.
|
|
10
25
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
26
|
+
* A table rather than cards: an office list is read down a column — which of these is in which
|
|
27
|
+
* country, how many people, what time is it there — and cards make every one of those a scan across
|
|
28
|
+
* the page instead of down it.
|
|
29
|
+
*
|
|
30
|
+
* The **current local time** is the reason this screen earns its place once there is more than one
|
|
31
|
+
* office: it is the answer to "can I call Amsterdam now". The default office is marked because it is
|
|
32
|
+
* where somebody with no assignment lands and where the resolution ladder bottoms out.
|
|
15
33
|
*/
|
|
16
34
|
const api = getHrApi()
|
|
17
35
|
|
|
@@ -26,6 +44,14 @@ const officesQuery = createQuery(() => ({
|
|
|
26
44
|
}))
|
|
27
45
|
const offices = $derived(officesQuery.data ?? [])
|
|
28
46
|
|
|
47
|
+
const stats = $derived({
|
|
48
|
+
countries: new Set(offices.map((o) => o.country)).size,
|
|
49
|
+
people: offices.reduce((sum, o) => sum + o.headcount, 0),
|
|
50
|
+
/** Two offices in one zone is one clock to think about; two zones is a scheduling problem. */
|
|
51
|
+
timezones: new Set(offices.map((o) => o.timezone)).size,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** Re-renders the clocks once a minute; an office list showing a stale time is worse than none. */
|
|
29
55
|
let tick = $state(0)
|
|
30
56
|
$effect(() => {
|
|
31
57
|
const handle = setInterval(() => {
|
|
@@ -37,83 +63,199 @@ $effect(() => {
|
|
|
37
63
|
function localTime(timezone: string, _tick: number): string {
|
|
38
64
|
void _tick
|
|
39
65
|
try {
|
|
40
|
-
|
|
66
|
+
// The office's zone, the reader's locale: `localTime` from @kernhq/ui cannot help here because
|
|
67
|
+
// it formats in the reader's own zone, and the whole point of this column is somebody else's.
|
|
68
|
+
// `messageLocale()` is what stops the clocks reading in Latin digits on a Persian screen.
|
|
69
|
+
return new Intl.DateTimeFormat(messageLocale(), {
|
|
41
70
|
timeZone: timezone,
|
|
42
71
|
hour: 'numeric',
|
|
43
72
|
minute: '2-digit',
|
|
44
73
|
}).format(new Date())
|
|
45
74
|
} catch {
|
|
46
|
-
|
|
75
|
+
// An unknown zone must not take the page down with it.
|
|
76
|
+
return '—'
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
/** The country's own name
|
|
80
|
+
/** The country's own name, rather than a two-letter code nobody reads. */
|
|
51
81
|
function countryName(code: string): string {
|
|
52
82
|
try {
|
|
53
|
-
return new Intl.DisplayNames(
|
|
83
|
+
return new Intl.DisplayNames(messageLocale(), { type: 'region' }).of(code) ?? code
|
|
54
84
|
} catch {
|
|
55
85
|
return code
|
|
56
86
|
}
|
|
57
87
|
}
|
|
88
|
+
|
|
89
|
+
const kindLabel = (kind: string): string =>
|
|
90
|
+
kind === 'head_office'
|
|
91
|
+
? t('office_kind_head_office')
|
|
92
|
+
: kind === 'branch'
|
|
93
|
+
? t('office_kind_branch')
|
|
94
|
+
: kind === 'site'
|
|
95
|
+
? t('office_kind_site')
|
|
96
|
+
: kind === 'warehouse'
|
|
97
|
+
? t('office_kind_warehouse')
|
|
98
|
+
: kind === 'store'
|
|
99
|
+
? t('office_kind_store')
|
|
100
|
+
: t('office_kind_remote')
|
|
101
|
+
|
|
102
|
+
const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'grey')
|
|
58
103
|
</script>
|
|
59
104
|
|
|
60
105
|
<PageHeader
|
|
61
106
|
crumbs={[{ label: workspace?.name ?? '' }, { label: t('offices_title') }]}
|
|
62
107
|
title={t('offices_title')}
|
|
63
|
-
|
|
108
|
+
>
|
|
109
|
+
{#snippet actions()}
|
|
110
|
+
{#if canHr('officeManage')}
|
|
111
|
+
<Button size="sm" variant="secondary" icon="settings" href={`/${workspaceSlug}/settings/hr/offices`}>
|
|
112
|
+
{t('offices_manage')}
|
|
113
|
+
</Button>
|
|
114
|
+
{/if}
|
|
115
|
+
{/snippet}
|
|
116
|
+
</PageHeader>
|
|
64
117
|
|
|
65
118
|
<Page>
|
|
119
|
+
<!--
|
|
120
|
+
No "Offices" tile. It said the same word and the same number as the section label directly
|
|
121
|
+
below it, so the page opened with "Offices / Offices / Offices 2 / OFFICES 2" down the left
|
|
122
|
+
edge. A tile has to add a fact the list does not already state: how many countries the company
|
|
123
|
+
operates in, how many people those offices hold, and how many clocks you are working across.
|
|
124
|
+
-->
|
|
125
|
+
<div class="tiles">
|
|
126
|
+
<StatTile
|
|
127
|
+
size="md"
|
|
128
|
+
label={t('offices_countries')}
|
|
129
|
+
value={formatCount(stats.countries, Number.MAX_SAFE_INTEGER)}
|
|
130
|
+
/>
|
|
131
|
+
<StatTile size="md" label={t('office_people')} value={formatCount(stats.people, Number.MAX_SAFE_INTEGER)} />
|
|
132
|
+
<StatTile
|
|
133
|
+
size="md"
|
|
134
|
+
label={t('offices_timezones')}
|
|
135
|
+
value={formatCount(stats.timezones, Number.MAX_SAFE_INTEGER)}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<SectionLabel label={t('offices_title')} count={offices.length} />
|
|
140
|
+
|
|
66
141
|
{#if officesQuery.isLoading}
|
|
67
|
-
<div class="
|
|
142
|
+
<div class="rows">
|
|
143
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
|
|
144
|
+
</div>
|
|
145
|
+
{:else if officesQuery.isError}
|
|
146
|
+
<EmptyState icon="triangle-alert" title={t('offices_error')}>
|
|
147
|
+
{#snippet actions()}
|
|
148
|
+
<Button variant="secondary" onclick={() => void officesQuery.refetch()}>{t('retry')}</Button>
|
|
149
|
+
{/snippet}
|
|
150
|
+
</EmptyState>
|
|
68
151
|
{:else if offices.length === 0}
|
|
69
152
|
<EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')} />
|
|
70
153
|
{:else}
|
|
71
|
-
<div class="
|
|
154
|
+
<div class="table" role="table" aria-label={t('offices_title')}>
|
|
155
|
+
<div class="thead" role="row">
|
|
156
|
+
<span role="columnheader">{t('office')}</span>
|
|
157
|
+
<span role="columnheader">{t('office_kind')}</span>
|
|
158
|
+
<span role="columnheader">{t('office_country')}</span>
|
|
159
|
+
<span role="columnheader">{t('office_people')}</span>
|
|
160
|
+
<span role="columnheader">{t('local_time')}</span>
|
|
161
|
+
</div>
|
|
72
162
|
{#each offices as office (office.id)}
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
163
|
+
<a
|
|
164
|
+
class="trow"
|
|
165
|
+
role="row"
|
|
166
|
+
href={`/${workspaceSlug}/hr?officeId=${office.id}`}
|
|
167
|
+
aria-label={`${office.name} — ${t('office_view_people')}`}
|
|
168
|
+
>
|
|
169
|
+
<span class="cell name" role="cell">
|
|
170
|
+
{office.name}
|
|
76
171
|
{#if office.isDefault}<Badge tone="accent">{t('office_default')}</Badge>{/if}
|
|
77
|
-
</
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
</Card>
|
|
172
|
+
</span>
|
|
173
|
+
<span class="cell" role="cell"><Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge></span>
|
|
174
|
+
<span class="cell muted" role="cell">{countryName(office.country)}</span>
|
|
175
|
+
<span class="cell num" role="cell">{formatCount(office.headcount, Number.MAX_SAFE_INTEGER)}</span>
|
|
176
|
+
<span class="cell num" role="cell" title={office.timezone}>{localTime(office.timezone, tick)}</span>
|
|
177
|
+
</a>
|
|
84
178
|
{/each}
|
|
85
179
|
</div>
|
|
86
180
|
{/if}
|
|
87
181
|
</Page>
|
|
88
182
|
|
|
89
183
|
<style>
|
|
90
|
-
.
|
|
184
|
+
.tiles {
|
|
91
185
|
display: grid;
|
|
92
|
-
grid-template-columns: repeat(auto-
|
|
186
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
93
187
|
gap: 12px;
|
|
188
|
+
margin-block-end: 20px;
|
|
94
189
|
}
|
|
95
|
-
.
|
|
96
|
-
display:
|
|
190
|
+
.rows {
|
|
191
|
+
display: grid;
|
|
192
|
+
gap: 4px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
196
|
+
.table {
|
|
197
|
+
--hr-office-cols: minmax(200px, 1.4fr) 130px minmax(120px, 0.8fr) 96px 104px;
|
|
198
|
+
width: 100%;
|
|
199
|
+
}
|
|
200
|
+
.thead,
|
|
201
|
+
.trow {
|
|
202
|
+
display: grid;
|
|
203
|
+
grid-template-columns: var(--hr-office-cols);
|
|
204
|
+
gap: 12px;
|
|
97
205
|
align-items: center;
|
|
98
|
-
|
|
99
|
-
|
|
206
|
+
padding-inline: 12px;
|
|
207
|
+
}
|
|
208
|
+
.thead {
|
|
209
|
+
height: 34px;
|
|
210
|
+
border-block-end: 1px solid var(--kern-border);
|
|
211
|
+
font-size: 11px;
|
|
212
|
+
font-weight: 600;
|
|
213
|
+
letter-spacing: 0.06em;
|
|
214
|
+
text-transform: uppercase;
|
|
215
|
+
color: var(--kern-ink-500);
|
|
216
|
+
}
|
|
217
|
+
.trow {
|
|
218
|
+
height: 48px;
|
|
219
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
220
|
+
text-decoration: none;
|
|
221
|
+
color: inherit;
|
|
222
|
+
border-radius: var(--kern-r-md);
|
|
223
|
+
}
|
|
224
|
+
.trow:hover {
|
|
225
|
+
background: var(--kern-surface-raised);
|
|
226
|
+
}
|
|
227
|
+
.cell {
|
|
228
|
+
min-width: 0;
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
text-overflow: ellipsis;
|
|
231
|
+
white-space: nowrap;
|
|
100
232
|
}
|
|
101
233
|
.name {
|
|
234
|
+
display: flex;
|
|
235
|
+
align-items: center;
|
|
236
|
+
gap: 8px;
|
|
237
|
+
font-size: 13.5px;
|
|
102
238
|
font-weight: 500;
|
|
103
239
|
}
|
|
104
|
-
.
|
|
240
|
+
.muted {
|
|
241
|
+
font-size: 13px;
|
|
242
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
105
243
|
color: var(--kern-ink-500);
|
|
106
|
-
font-size: 12px;
|
|
107
|
-
margin: 4px 0 0;
|
|
108
244
|
}
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
justify-content: space-between;
|
|
113
|
-
margin-block-start: 12px;
|
|
114
|
-
}
|
|
115
|
-
.time {
|
|
116
|
-
font-size: 15px;
|
|
245
|
+
.num {
|
|
246
|
+
font-size: 13px;
|
|
247
|
+
color: var(--kern-ink-500);
|
|
117
248
|
font-variant-numeric: tabular-nums;
|
|
118
249
|
}
|
|
250
|
+
|
|
251
|
+
@media (max-width: 900px) {
|
|
252
|
+
.table {
|
|
253
|
+
--hr-office-cols: minmax(140px, 1.4fr) 110px 96px 88px;
|
|
254
|
+
}
|
|
255
|
+
/* The country column is the one a narrow screen can lose: the office name implies it. */
|
|
256
|
+
.thead > :nth-child(3),
|
|
257
|
+
.trow > :nth-child(3) {
|
|
258
|
+
display: none;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
119
261
|
</style>
|