@kernhq/module-hr 0.9.3 → 0.10.0
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/router.d.ts +9 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/server/router.d.ts +9 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +17 -0
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +36 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +20 -0
- 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 +2 -0
- package/dist/server/services/approvals.js.map +1 -1
- package/migrations/0005_approval_requester.sql +20 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +210 -0
- package/src/client/i18n.ts +302 -0
- package/src/client/index.ts +1 -20
- package/src/client/mock.ts +133 -16
- package/src/client/module.ts +2 -1
- package/src/client/pages/ApprovalsPage.svelte +252 -54
- package/src/client/pages/OfficesPage.svelte +161 -37
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +10 -1
- package/src/client/summary.ts +36 -0
- package/src/contract/approvals.ts +13 -1
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
EmptyState,
|
|
7
|
+
navigation,
|
|
8
|
+
Page,
|
|
9
|
+
PageHeader,
|
|
10
|
+
SectionLabel,
|
|
11
|
+
Skeleton,
|
|
12
|
+
StatTile,
|
|
13
|
+
session,
|
|
14
|
+
} from '@kernhq/ui'
|
|
3
15
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
16
|
import { getHrApi } from '../api-instance.js'
|
|
5
17
|
import { t } from '../i18n.js'
|
|
18
|
+
import { canHr } from '../permissions.js'
|
|
6
19
|
import { hrKeys } from '../query.js'
|
|
7
20
|
|
|
8
21
|
/**
|
|
9
22
|
* Where the company works.
|
|
10
23
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
24
|
+
* A table rather than cards: an office list is read down a column — which of these is in which
|
|
25
|
+
* country, how many people, what time is it there — and cards make every one of those a scan across
|
|
26
|
+
* the page instead of down it.
|
|
27
|
+
*
|
|
28
|
+
* The **current local time** is the reason this screen earns its place once there is more than one
|
|
29
|
+
* office: it is the answer to "can I call Amsterdam now". The default office is marked because it is
|
|
30
|
+
* where somebody with no assignment lands and where the resolution ladder bottoms out.
|
|
15
31
|
*/
|
|
16
32
|
const api = getHrApi()
|
|
17
33
|
|
|
@@ -26,6 +42,13 @@ const officesQuery = createQuery(() => ({
|
|
|
26
42
|
}))
|
|
27
43
|
const offices = $derived(officesQuery.data ?? [])
|
|
28
44
|
|
|
45
|
+
const stats = $derived({
|
|
46
|
+
offices: offices.length,
|
|
47
|
+
countries: new Set(offices.map((o) => o.country)).size,
|
|
48
|
+
people: offices.reduce((sum, o) => sum + o.headcount, 0),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** Re-renders the clocks once a minute; an office list showing a stale time is worse than none. */
|
|
29
52
|
let tick = $state(0)
|
|
30
53
|
$effect(() => {
|
|
31
54
|
const handle = setInterval(() => {
|
|
@@ -43,11 +66,12 @@ function localTime(timezone: string, _tick: number): string {
|
|
|
43
66
|
minute: '2-digit',
|
|
44
67
|
}).format(new Date())
|
|
45
68
|
} catch {
|
|
46
|
-
|
|
69
|
+
// An unknown zone must not take the page down with it.
|
|
70
|
+
return '—'
|
|
47
71
|
}
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
/** The country's own name
|
|
74
|
+
/** The country's own name, rather than a two-letter code nobody reads. */
|
|
51
75
|
function countryName(code: string): string {
|
|
52
76
|
try {
|
|
53
77
|
return new Intl.DisplayNames(undefined, { type: 'region' }).of(code) ?? code
|
|
@@ -55,65 +79,165 @@ function countryName(code: string): string {
|
|
|
55
79
|
return code
|
|
56
80
|
}
|
|
57
81
|
}
|
|
82
|
+
|
|
83
|
+
const kindLabel = (kind: string): string =>
|
|
84
|
+
kind === 'head_office'
|
|
85
|
+
? t('office_kind_head_office')
|
|
86
|
+
: kind === 'branch'
|
|
87
|
+
? t('office_kind_branch')
|
|
88
|
+
: kind === 'site'
|
|
89
|
+
? t('office_kind_site')
|
|
90
|
+
: kind === 'warehouse'
|
|
91
|
+
? t('office_kind_warehouse')
|
|
92
|
+
: kind === 'store'
|
|
93
|
+
? t('office_kind_store')
|
|
94
|
+
: t('office_kind_remote')
|
|
95
|
+
|
|
96
|
+
const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'grey')
|
|
58
97
|
</script>
|
|
59
98
|
|
|
60
99
|
<PageHeader
|
|
61
100
|
crumbs={[{ label: workspace?.name ?? '' }, { label: t('offices_title') }]}
|
|
62
101
|
title={t('offices_title')}
|
|
63
|
-
|
|
102
|
+
>
|
|
103
|
+
{#snippet actions()}
|
|
104
|
+
{#if canHr('officeManage')}
|
|
105
|
+
<Button size="sm" href={`/${workspaceSlug}/settings/hr/offices`}>{t('settings_offices')}</Button>
|
|
106
|
+
{/if}
|
|
107
|
+
{/snippet}
|
|
108
|
+
</PageHeader>
|
|
64
109
|
|
|
65
110
|
<Page>
|
|
111
|
+
<div class="tiles">
|
|
112
|
+
<StatTile size="md" label={t('offices_total')} value={new Intl.NumberFormat().format(stats.offices)} />
|
|
113
|
+
<StatTile
|
|
114
|
+
size="md"
|
|
115
|
+
label={t('offices_countries')}
|
|
116
|
+
value={new Intl.NumberFormat().format(stats.countries)}
|
|
117
|
+
/>
|
|
118
|
+
<StatTile size="md" label={t('office_people')} value={new Intl.NumberFormat().format(stats.people)} />
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<SectionLabel label={t('offices_title')} count={offices.length} />
|
|
122
|
+
|
|
66
123
|
{#if officesQuery.isLoading}
|
|
67
|
-
<div class="
|
|
124
|
+
<div class="rows">
|
|
125
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
|
|
126
|
+
</div>
|
|
127
|
+
{:else if officesQuery.isError}
|
|
128
|
+
<EmptyState icon="triangle-alert" title={t('offices_error')}>
|
|
129
|
+
{#snippet actions()}
|
|
130
|
+
<Button variant="secondary" onclick={() => void officesQuery.refetch()}>{t('retry')}</Button>
|
|
131
|
+
{/snippet}
|
|
132
|
+
</EmptyState>
|
|
68
133
|
{:else if offices.length === 0}
|
|
69
134
|
<EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')} />
|
|
70
135
|
{:else}
|
|
71
|
-
<div class="
|
|
136
|
+
<div class="table" role="table" aria-label={t('offices_title')}>
|
|
137
|
+
<div class="thead" role="row">
|
|
138
|
+
<span role="columnheader">{t('office')}</span>
|
|
139
|
+
<span role="columnheader">{t('office_kind')}</span>
|
|
140
|
+
<span role="columnheader">{t('office_country')}</span>
|
|
141
|
+
<span role="columnheader">{t('office_people')}</span>
|
|
142
|
+
<span role="columnheader">{t('local_time')}</span>
|
|
143
|
+
</div>
|
|
72
144
|
{#each offices as office (office.id)}
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
145
|
+
<a
|
|
146
|
+
class="trow"
|
|
147
|
+
role="row"
|
|
148
|
+
href={`/${workspaceSlug}/hr?officeId=${office.id}`}
|
|
149
|
+
aria-label={`${office.name} — ${t('office_view_people')}`}
|
|
150
|
+
>
|
|
151
|
+
<span class="cell name" role="cell">
|
|
152
|
+
{office.name}
|
|
76
153
|
{#if office.isDefault}<Badge tone="accent">{t('office_default')}</Badge>{/if}
|
|
77
|
-
</
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
</Card>
|
|
154
|
+
</span>
|
|
155
|
+
<span class="cell" role="cell"><Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge></span>
|
|
156
|
+
<span class="cell muted" role="cell">{countryName(office.country)}</span>
|
|
157
|
+
<span class="cell num" role="cell">{new Intl.NumberFormat().format(office.headcount)}</span>
|
|
158
|
+
<span class="cell num" role="cell" title={office.timezone}>{localTime(office.timezone, tick)}</span>
|
|
159
|
+
</a>
|
|
84
160
|
{/each}
|
|
85
161
|
</div>
|
|
86
162
|
{/if}
|
|
87
163
|
</Page>
|
|
88
164
|
|
|
89
165
|
<style>
|
|
90
|
-
.
|
|
166
|
+
.tiles {
|
|
91
167
|
display: grid;
|
|
92
|
-
grid-template-columns: repeat(auto-
|
|
168
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
93
169
|
gap: 12px;
|
|
170
|
+
margin-block-end: 20px;
|
|
94
171
|
}
|
|
95
|
-
.
|
|
96
|
-
display:
|
|
172
|
+
.rows {
|
|
173
|
+
display: grid;
|
|
174
|
+
gap: 4px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
178
|
+
.table {
|
|
179
|
+
--hr-office-cols: minmax(200px, 1.4fr) 130px minmax(120px, 0.8fr) 96px 104px;
|
|
180
|
+
width: 100%;
|
|
181
|
+
}
|
|
182
|
+
.thead,
|
|
183
|
+
.trow {
|
|
184
|
+
display: grid;
|
|
185
|
+
grid-template-columns: var(--hr-office-cols);
|
|
186
|
+
gap: 12px;
|
|
97
187
|
align-items: center;
|
|
98
|
-
|
|
99
|
-
|
|
188
|
+
padding-inline: 12px;
|
|
189
|
+
}
|
|
190
|
+
.thead {
|
|
191
|
+
height: 34px;
|
|
192
|
+
border-block-end: 1px solid var(--kern-border);
|
|
193
|
+
font-size: 11px;
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
letter-spacing: 0.06em;
|
|
196
|
+
text-transform: uppercase;
|
|
197
|
+
color: var(--kern-ink-500);
|
|
198
|
+
}
|
|
199
|
+
.trow {
|
|
200
|
+
height: 48px;
|
|
201
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
202
|
+
text-decoration: none;
|
|
203
|
+
color: inherit;
|
|
204
|
+
border-radius: var(--kern-r-md);
|
|
205
|
+
}
|
|
206
|
+
.trow:hover {
|
|
207
|
+
background: var(--kern-surface-raised);
|
|
208
|
+
}
|
|
209
|
+
.cell {
|
|
210
|
+
min-width: 0;
|
|
211
|
+
overflow: hidden;
|
|
212
|
+
text-overflow: ellipsis;
|
|
213
|
+
white-space: nowrap;
|
|
100
214
|
}
|
|
101
215
|
.name {
|
|
216
|
+
display: flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 8px;
|
|
219
|
+
font-size: 13.5px;
|
|
102
220
|
font-weight: 500;
|
|
103
221
|
}
|
|
104
|
-
.
|
|
222
|
+
.muted {
|
|
223
|
+
font-size: 13px;
|
|
224
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
105
225
|
color: var(--kern-ink-500);
|
|
106
|
-
font-size: 12px;
|
|
107
|
-
margin: 4px 0 0;
|
|
108
226
|
}
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
justify-content: space-between;
|
|
113
|
-
margin-block-start: 12px;
|
|
114
|
-
}
|
|
115
|
-
.time {
|
|
116
|
-
font-size: 15px;
|
|
227
|
+
.num {
|
|
228
|
+
font-size: 13px;
|
|
229
|
+
color: var(--kern-ink-500);
|
|
117
230
|
font-variant-numeric: tabular-nums;
|
|
118
231
|
}
|
|
232
|
+
|
|
233
|
+
@media (max-width: 900px) {
|
|
234
|
+
.table {
|
|
235
|
+
--hr-office-cols: minmax(140px, 1.4fr) 110px 96px 88px;
|
|
236
|
+
}
|
|
237
|
+
/* The country column is the one a narrow screen can lose: the office name implies it. */
|
|
238
|
+
.thead > :nth-child(3),
|
|
239
|
+
.trow > :nth-child(3) {
|
|
240
|
+
display: none;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
119
243
|
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { session } from '@kernhq/ui'
|
|
2
|
-
import {
|
|
2
|
+
import { HR_PERMISSIONS } from '../contract/permissions.js'
|
|
3
|
+
import { HR_CAPABILITIES } from './capabilities.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* What this module lets somebody do, and what this workspace has switched on.
|
package/src/client/query.ts
CHANGED
|
@@ -28,7 +28,16 @@ export const hrKeys = {
|
|
|
28
28
|
attendanceDays: (ws: string, personId: string | undefined, from: string, to: string) =>
|
|
29
29
|
['hr', 'attendance-days', ws, personId ?? 'me', from, to] as const,
|
|
30
30
|
schedules: (ws: string) => ['hr', 'schedules', ws] as const,
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `includeDecided` is part of the key, not a filter over one cached list.
|
|
33
|
+
*
|
|
34
|
+
* The two answers are different rows from the server — the waiting list is what a step is
|
|
35
|
+
* pending on, the decided one is history — so sharing a key would show one tab the other's
|
|
36
|
+
* contents for as long as the refetch takes.
|
|
37
|
+
*/
|
|
38
|
+
approvalInbox: (ws: string, includeDecided = false) =>
|
|
39
|
+
['hr', 'approvals', ws, includeDecided ? 'decided' : 'waiting'] as const,
|
|
40
|
+
delegations: (ws: string) => ['hr', 'delegations', ws] as const,
|
|
32
41
|
} as const
|
|
33
42
|
|
|
34
43
|
/** `YYYY-MM-DD` for a date, in the viewer's own zone rather than UTC. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ApprovalRequest } from '../contract/index.js'
|
|
2
|
+
import { t } from './i18n.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The request line, in the reader's language.
|
|
6
|
+
*
|
|
7
|
+
* `summaryParams` is the sentence as data; `summary` is the English the server composed before that
|
|
8
|
+
* existed and is the fallback for those rows. Rendering the fallback is better than rendering
|
|
9
|
+
* nothing — an inbox row with no subject cannot be decided at all.
|
|
10
|
+
*
|
|
11
|
+
* Lives beside neither page that uses it: an inbox row and its confirmation dialog must say the
|
|
12
|
+
* same sentence, or approving looks like an action on something else.
|
|
13
|
+
*/
|
|
14
|
+
export function summarise(request: ApprovalRequest): string {
|
|
15
|
+
const p = request.summaryParams
|
|
16
|
+
if (!p) return request.summary
|
|
17
|
+
if (request.subjectType === 'leave' && p.from && p.to)
|
|
18
|
+
return t('approval_summary_leave', {
|
|
19
|
+
days: String(p.days ?? ''),
|
|
20
|
+
range: dateRange(String(p.from), String(p.to)),
|
|
21
|
+
})
|
|
22
|
+
if (request.subjectType === 'regularization' && p.date)
|
|
23
|
+
return t('approval_summary_regularization', { date: day(String(p.date)) })
|
|
24
|
+
if (request.subjectType === 'overtime' && p.date)
|
|
25
|
+
return t('approval_summary_overtime', { date: day(String(p.date)) })
|
|
26
|
+
return request.summary
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const day = (iso: string) =>
|
|
30
|
+
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(iso))
|
|
31
|
+
|
|
32
|
+
/** One range, not two dates and a dash — the separator is the locale's, and RTL needs its own. */
|
|
33
|
+
export const dateRange = (from: string, to: string) =>
|
|
34
|
+
from === to
|
|
35
|
+
? day(from)
|
|
36
|
+
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).formatRange(new Date(from), new Date(to))
|
|
@@ -117,11 +117,23 @@ export const ApprovalRequest = z.object({
|
|
|
117
117
|
...ws,
|
|
118
118
|
subjectType: ApprovalSubjectType,
|
|
119
119
|
subjectId: z.uuid(),
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* The English one-liner a request was raised with, kept as the fallback.
|
|
122
|
+
*
|
|
123
|
+
* Prefer `summaryParams`: a sentence composed on the server is English for every reader, which
|
|
124
|
+
* is how half a screen ends up untranslated while every label around it is not.
|
|
125
|
+
*/
|
|
121
126
|
summary: z.string().max(200),
|
|
127
|
+
/** The same sentence as data — `{ days, from, to }` for leave, `{ date }` for a correction. */
|
|
128
|
+
summaryParams: z.record(z.string(), z.union([z.string(), z.number()])).nullable(),
|
|
122
129
|
status: ApprovalStatus,
|
|
123
130
|
currentStep: z.number().int(),
|
|
131
|
+
/** The account that submitted it, which may be nobody: an import has no user behind it. */
|
|
124
132
|
requestedBy: z.uuid().nullable(),
|
|
133
|
+
/** Whose request it is, as an employee. Not the same as `requestedBy` — see the schema. */
|
|
134
|
+
requesterPersonId: z.uuid().nullable(),
|
|
135
|
+
/** Resolved on read so an inbox needs one call, not one call plus a directory lookup. */
|
|
136
|
+
requesterName: z.string().nullable(),
|
|
125
137
|
requestedAt: Timestamp,
|
|
126
138
|
decidedAt: Timestamp.nullable(),
|
|
127
139
|
steps: z.array(ApprovalStep),
|