@kernhq/module-hr 0.8.0 → 0.9.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/package.json +22 -2
- package/src/client/api-instance.ts +29 -0
- package/src/client/components/ClockControls.svelte +127 -0
- package/src/client/components/HrSidebar.svelte +73 -0
- package/src/client/components/LeaveRequestDialog.svelte +169 -0
- package/src/client/components/PersonFormDialog.svelte +184 -0
- package/src/client/components/PersonInline.svelte +49 -0
- package/src/client/components/PersonPanel.svelte +328 -0
- package/src/client/core-api.ts +34 -0
- package/src/client/i18n.ts +640 -0
- package/src/client/index.ts +3 -0
- package/src/client/mock.ts +447 -0
- package/src/client/module.ts +295 -0
- package/src/client/pages/ApprovalsPage.svelte +129 -0
- package/src/client/pages/AttendancePage.svelte +147 -0
- package/src/client/pages/DirectoryPage.svelte +428 -0
- package/src/client/pages/LeavePage.svelte +180 -0
- package/src/client/pages/OfficesPage.svelte +119 -0
- package/src/client/permissions.ts +31 -0
- package/src/client/query.test.ts +70 -0
- package/src/client/query.ts +69 -0
- package/src/client/settings/CalendarsSettings.svelte +21 -0
- package/src/client/settings/CapabilitiesSettings.svelte +122 -0
- package/src/client/settings/LeaveSettings.svelte +21 -0
- package/src/client/settings/OfficesSettings.svelte +21 -0
- package/src/client/settings/SchedulesSettings.svelte +21 -0
- package/src/client/widgets/ApprovalsWidget.svelte +86 -0
- package/src/client/widgets/ClockWidget.svelte +9 -0
- package/src/client/widgets/HeadcountWidget.svelte +29 -0
- package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
- package/src/client/widgets/WhosOutWidget.svelte +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-hr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Kern HR module: people, offices, org chart, calendars",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -44,17 +44,37 @@
|
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@kernhq/tsconfig": "^0.1.0",
|
|
47
|
+
"@kernhq/ui": "^0.7.0",
|
|
48
|
+
"@tanstack/svelte-query": "^6.1.0",
|
|
47
49
|
"@types/node": "^24.0.0",
|
|
48
50
|
"@types/pg": "^8.15.0",
|
|
49
51
|
"drizzle-kit": "^0.31.0",
|
|
50
52
|
"pg": "^8.16.0",
|
|
53
|
+
"svelte": "^5.46.0",
|
|
54
|
+
"svelte-check": "^4.0.0",
|
|
51
55
|
"typescript": "~5.9.3",
|
|
52
56
|
"vitest": "^4.0.0"
|
|
53
57
|
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@kernhq/ui": "^0.7.0",
|
|
60
|
+
"@tanstack/svelte-query": "^6.1.0",
|
|
61
|
+
"svelte": "^5.46.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@kernhq/ui": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@tanstack/svelte-query": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"svelte": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
54
74
|
"scripts": {
|
|
55
75
|
"build": "tsc -p tsconfig.json",
|
|
56
76
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
57
|
-
"typecheck": "tsc -p tsconfig.json --noEmit &&
|
|
77
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
|
|
58
78
|
"test": "vitest run",
|
|
59
79
|
"db:generate": "drizzle-kit generate"
|
|
60
80
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getHost } from '@kernhq/ui'
|
|
2
|
+
import { createHrClient, type HrApi } from './index.js'
|
|
3
|
+
import { createMockHrApi } from './mock.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This module's API client.
|
|
7
|
+
*
|
|
8
|
+
* An empty base URL keeps requests same-origin, so the dev proxy and the reverse proxy both work
|
|
9
|
+
* without CORS. `PUBLIC_API_MOCK=1` swaps in the in-memory implementation, which satisfies the same
|
|
10
|
+
* contract types — so no screen has a second code path for demos and end-to-end tests.
|
|
11
|
+
*/
|
|
12
|
+
export type { HrApi }
|
|
13
|
+
|
|
14
|
+
let cached: HrApi | null = null
|
|
15
|
+
|
|
16
|
+
export function getHrApi(): HrApi {
|
|
17
|
+
if (cached) return cached
|
|
18
|
+
cached = getHost().isMock
|
|
19
|
+
? (createMockHrApi() as unknown as HrApi)
|
|
20
|
+
: createHrClient({
|
|
21
|
+
baseUrl: getHost().apiBaseUrl,
|
|
22
|
+
})
|
|
23
|
+
return cached
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Test seam. */
|
|
27
|
+
export function __setHrApi(api: HrApi | null) {
|
|
28
|
+
cached = api
|
|
29
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Skeleton } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { formatDuration, hrKeys } from '../query.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Clock in, out and break — the whole of attendance for most people.
|
|
10
|
+
*
|
|
11
|
+
* Only the transitions that are currently legal are offered, because the server refuses the others
|
|
12
|
+
* anyway and a button that always errors is worse than no button. Clocked out: one action. Clocked
|
|
13
|
+
* in: clock out, and start a break. On a break: end it.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
workspaceId: string
|
|
17
|
+
}
|
|
18
|
+
const { workspaceId }: Props = $props()
|
|
19
|
+
|
|
20
|
+
const api = getHrApi()
|
|
21
|
+
const queryClient = useQueryClient()
|
|
22
|
+
|
|
23
|
+
const stateQuery = createQuery(() => ({
|
|
24
|
+
queryKey: hrKeys.clockState(workspaceId),
|
|
25
|
+
enabled: Boolean(workspaceId),
|
|
26
|
+
queryFn: () => api.attendance.state({ workspaceId }),
|
|
27
|
+
// The elapsed total is computed server-side from an open span, so it goes stale on its own.
|
|
28
|
+
refetchInterval: 60_000,
|
|
29
|
+
}))
|
|
30
|
+
const state = $derived(stateQuery.data)
|
|
31
|
+
|
|
32
|
+
const words = {
|
|
33
|
+
hours: (n: string) => t('hours_short', { n }),
|
|
34
|
+
minutes: (n: string) => t('minutes_short', { n }),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const act = createMutation(() => ({
|
|
38
|
+
mutationFn: async (action: 'in' | 'out' | 'break_start' | 'break_end') => {
|
|
39
|
+
if (action === 'in') return api.attendance.clockIn({ workspaceId })
|
|
40
|
+
if (action === 'out') return api.attendance.clockOut({ workspaceId })
|
|
41
|
+
if (action === 'break_start') return api.attendance.breakStart({ workspaceId })
|
|
42
|
+
return api.attendance.breakEnd({ workspaceId })
|
|
43
|
+
},
|
|
44
|
+
onSuccess: () => {
|
|
45
|
+
// A punch changes the day sheet as well as the clock, and both are on screen.
|
|
46
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
47
|
+
},
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
const since = $derived(
|
|
51
|
+
state?.since
|
|
52
|
+
? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date(state.since))
|
|
53
|
+
: null,
|
|
54
|
+
)
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
{#if stateQuery.isLoading}
|
|
58
|
+
<Skeleton height="72px" />
|
|
59
|
+
{:else if state}
|
|
60
|
+
<div class="clock">
|
|
61
|
+
<div class="status">
|
|
62
|
+
<span class="line">
|
|
63
|
+
{#if state.onBreak && since}
|
|
64
|
+
{t('on_break_since', { time: since })}
|
|
65
|
+
{:else if state.clockedIn && since}
|
|
66
|
+
{t('clocked_in_since', { time: since })}
|
|
67
|
+
{:else}
|
|
68
|
+
{t('not_clocked_in')}
|
|
69
|
+
{/if}
|
|
70
|
+
</span>
|
|
71
|
+
<span class="total">
|
|
72
|
+
{t('worked_today')}: {formatDuration(state.workedMinutesToday, words)}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="actions">
|
|
77
|
+
{#if !state.clockedIn}
|
|
78
|
+
<Button size="sm" disabled={act.isPending} onclick={() => act.mutate('in')}>
|
|
79
|
+
{t('clock_in')}
|
|
80
|
+
</Button>
|
|
81
|
+
{:else}
|
|
82
|
+
{#if state.onBreak}
|
|
83
|
+
<Button size="sm" variant="secondary" disabled={act.isPending} onclick={() => act.mutate('break_end')}>
|
|
84
|
+
{t('break_end')}
|
|
85
|
+
</Button>
|
|
86
|
+
{:else}
|
|
87
|
+
<Button size="sm" variant="secondary" disabled={act.isPending} onclick={() => act.mutate('break_start')}>
|
|
88
|
+
{t('break_start')}
|
|
89
|
+
</Button>
|
|
90
|
+
{/if}
|
|
91
|
+
<Button size="sm" disabled={act.isPending} onclick={() => act.mutate('out')}>
|
|
92
|
+
{t('clock_out')}
|
|
93
|
+
</Button>
|
|
94
|
+
{/if}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
{/if}
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
.clock {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: space-between;
|
|
104
|
+
gap: 12px;
|
|
105
|
+
flex-wrap: wrap;
|
|
106
|
+
padding: 12px;
|
|
107
|
+
border: 1px solid var(--kern-border);
|
|
108
|
+
border-radius: var(--kern-r-md);
|
|
109
|
+
background: var(--kern-surface);
|
|
110
|
+
}
|
|
111
|
+
.status {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
}
|
|
115
|
+
.line {
|
|
116
|
+
font-weight: 500;
|
|
117
|
+
}
|
|
118
|
+
.total {
|
|
119
|
+
color: var(--kern-ink-500);
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
font-variant-numeric: tabular-nums;
|
|
122
|
+
}
|
|
123
|
+
.actions {
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: 8px;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { coreApi, keys, SidebarGroup, SidebarItem, session } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import type { CoreApi } from '../core-api.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { HR_CAPABILITIES } from '../permissions.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HR's own column.
|
|
10
|
+
*
|
|
11
|
+
* The rail switches modules and the sidebar holds the one you are in, so the module fills the whole
|
|
12
|
+
* column rather than reaching into a shell that happens to leave a gap.
|
|
13
|
+
*
|
|
14
|
+
* Rows are filtered on the workspace's capabilities — a company that never switched attendance on
|
|
15
|
+
* has no attendance row, the same way it has no attendance route and no attendance API. Read
|
|
16
|
+
* through the same `capabilitiesOf` the shell uses, on the same query key, so this shares the
|
|
17
|
+
* layout's cached result rather than fetching it again.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
workspaceId: string
|
|
21
|
+
workspaceSlug: string
|
|
22
|
+
pathname: string
|
|
23
|
+
}
|
|
24
|
+
const { workspaceId, workspaceSlug, pathname }: Props = $props()
|
|
25
|
+
|
|
26
|
+
const api = coreApi<CoreApi>()
|
|
27
|
+
|
|
28
|
+
const modulesQuery = createQuery(() => ({
|
|
29
|
+
queryKey: keys.modules(workspaceId),
|
|
30
|
+
enabled: Boolean(workspaceId),
|
|
31
|
+
queryFn: () => api.workspaces.modules.list({ workspaceId }),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
const capabilities = $derived(session.capabilities)
|
|
35
|
+
const has = (id: string) => capabilities.has(`hr.${id}`)
|
|
36
|
+
|
|
37
|
+
const href = (path: string) => `/${workspaceSlug}${path}`
|
|
38
|
+
const active = (path: string) => pathname === `/${workspaceSlug}${path}`
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<SidebarGroup>
|
|
42
|
+
<SidebarItem href={href('/hr')} icon="users" active={active('/hr')} label={t('title')} />
|
|
43
|
+
{#if has(HR_CAPABILITIES.leave)}
|
|
44
|
+
<SidebarItem
|
|
45
|
+
href={href('/hr/leave')}
|
|
46
|
+
icon="tree-palm"
|
|
47
|
+
active={active('/hr/leave')}
|
|
48
|
+
label={t('leave_title')}
|
|
49
|
+
/>
|
|
50
|
+
{/if}
|
|
51
|
+
{#if has(HR_CAPABILITIES.attendance)}
|
|
52
|
+
<SidebarItem
|
|
53
|
+
href={href('/hr/attendance')}
|
|
54
|
+
icon="timer"
|
|
55
|
+
active={active('/hr/attendance')}
|
|
56
|
+
label={t('attendance_title')}
|
|
57
|
+
/>
|
|
58
|
+
{/if}
|
|
59
|
+
<SidebarItem
|
|
60
|
+
href={href('/hr/approvals')}
|
|
61
|
+
icon="check-check"
|
|
62
|
+
active={active('/hr/approvals')}
|
|
63
|
+
label={t('approvals_title')}
|
|
64
|
+
/>
|
|
65
|
+
{#if has(HR_CAPABILITIES.offices)}
|
|
66
|
+
<SidebarItem
|
|
67
|
+
href={href('/hr/offices')}
|
|
68
|
+
icon="building"
|
|
69
|
+
active={active('/hr/offices')}
|
|
70
|
+
label={t('offices_title')}
|
|
71
|
+
/>
|
|
72
|
+
{/if}
|
|
73
|
+
</SidebarGroup>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Field, Input, navigation, Select, Textarea, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { canHr } from '../permissions.js'
|
|
7
|
+
import { formatDays, hrKeys, isoDate } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Book time off.
|
|
11
|
+
*
|
|
12
|
+
* The Request time off button already went to `?new=1`. This is the form that URL was promising:
|
|
13
|
+
* pick a type and a range, see what it would cost, then submit. Simulation runs before create so a
|
|
14
|
+
* blocked request is refused here rather than after the click.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
open: boolean
|
|
18
|
+
workspaceId: string
|
|
19
|
+
workspaceSlug: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let { open, workspaceId, workspaceSlug }: Props = $props()
|
|
23
|
+
|
|
24
|
+
const api = getHrApi()
|
|
25
|
+
const queryClient = useQueryClient()
|
|
26
|
+
|
|
27
|
+
let shown = $state(false)
|
|
28
|
+
$effect(() => {
|
|
29
|
+
if (open) shown = true
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
let leaveTypeId = $state('')
|
|
33
|
+
let startsOn = $state(isoDate())
|
|
34
|
+
let endsOn = $state(isoDate())
|
|
35
|
+
let reason = $state('')
|
|
36
|
+
|
|
37
|
+
const typesQuery = createQuery(() => ({
|
|
38
|
+
queryKey: hrKeys.leaveTypes(workspaceId),
|
|
39
|
+
enabled: Boolean(workspaceId) && open,
|
|
40
|
+
queryFn: () => api.leave.types.list({ workspaceId, includeArchived: false }),
|
|
41
|
+
}))
|
|
42
|
+
const types = $derived(typesQuery.data ?? [])
|
|
43
|
+
const typeOptions = $derived(types.map((type) => ({ value: type.id, label: type.name })))
|
|
44
|
+
|
|
45
|
+
$effect(() => {
|
|
46
|
+
if (open && !leaveTypeId && types[0]) leaveTypeId = types[0].id
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const simQuery = createQuery(() => ({
|
|
50
|
+
queryKey: ['hr', 'leave-sim', workspaceId, leaveTypeId, startsOn, endsOn] as const,
|
|
51
|
+
enabled: Boolean(workspaceId && leaveTypeId && startsOn && endsOn && open),
|
|
52
|
+
queryFn: () =>
|
|
53
|
+
api.leave.requests.simulate({
|
|
54
|
+
workspaceId,
|
|
55
|
+
leaveTypeId,
|
|
56
|
+
startsOn,
|
|
57
|
+
endsOn,
|
|
58
|
+
}),
|
|
59
|
+
}))
|
|
60
|
+
const sim = $derived(simQuery.data)
|
|
61
|
+
const blocked = $derived((sim?.blockers.length ?? 0) > 0)
|
|
62
|
+
|
|
63
|
+
const close = () => {
|
|
64
|
+
shown = false
|
|
65
|
+
void navigation.go(`/${workspaceSlug}/hr/leave`, { replaceState: true, keepFocus: true, noScroll: true })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const create = createMutation(() => ({
|
|
69
|
+
mutationFn: () =>
|
|
70
|
+
api.leave.requests.create({
|
|
71
|
+
workspaceId,
|
|
72
|
+
leaveTypeId,
|
|
73
|
+
startsOn,
|
|
74
|
+
endsOn,
|
|
75
|
+
reason: reason.trim() || null,
|
|
76
|
+
idempotencyKey: crypto.randomUUID(),
|
|
77
|
+
}),
|
|
78
|
+
onSuccess: () => {
|
|
79
|
+
toast.success(t('leave_submitted'))
|
|
80
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-balance'] })
|
|
81
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-requests'] })
|
|
82
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'leave-calendar'] })
|
|
83
|
+
reason = ''
|
|
84
|
+
close()
|
|
85
|
+
},
|
|
86
|
+
onError: (error: Error) => toast.error(error.message),
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
const canSubmit = $derived(
|
|
90
|
+
Boolean(leaveTypeId && startsOn && endsOn) && !blocked && canHr('leaveRequest') && !simQuery.isFetching,
|
|
91
|
+
)
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<Dialog
|
|
95
|
+
bind:open={shown}
|
|
96
|
+
title={t('request_leave')}
|
|
97
|
+
onOpenChange={(next) => {
|
|
98
|
+
if (!next) close()
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div class="form">
|
|
102
|
+
<Field label={t('leave_type')} id="hr-leave-type" required>
|
|
103
|
+
{#snippet children(id)}
|
|
104
|
+
<Select {id} bind:value={leaveTypeId} options={typeOptions} />
|
|
105
|
+
{/snippet}
|
|
106
|
+
</Field>
|
|
107
|
+
<div class="dates">
|
|
108
|
+
<Field label={t('leave_from')} id="hr-leave-from" required>
|
|
109
|
+
{#snippet children(id)}
|
|
110
|
+
<Input {id} type="date" bind:value={startsOn} />
|
|
111
|
+
{/snippet}
|
|
112
|
+
</Field>
|
|
113
|
+
<Field label={t('leave_to')} id="hr-leave-to" required>
|
|
114
|
+
{#snippet children(id)}
|
|
115
|
+
<Input {id} type="date" bind:value={endsOn} />
|
|
116
|
+
{/snippet}
|
|
117
|
+
</Field>
|
|
118
|
+
</div>
|
|
119
|
+
<Field label={t('leave_reason')} id="hr-leave-reason" hint={t('common.optional')}>
|
|
120
|
+
{#snippet children(id)}
|
|
121
|
+
<Textarea {id} bind:value={reason} rows={3} />
|
|
122
|
+
{/snippet}
|
|
123
|
+
</Field>
|
|
124
|
+
|
|
125
|
+
{#if sim}
|
|
126
|
+
{#if blocked}
|
|
127
|
+
<p class="block" role="alert">
|
|
128
|
+
{t('leave_blocked')}
|
|
129
|
+
{sim.blockers[0]?.message ?? ''}
|
|
130
|
+
</p>
|
|
131
|
+
{:else}
|
|
132
|
+
<p class="cost">
|
|
133
|
+
{t('leave_would_cost', { days: formatDays(sim.workingDays) })}
|
|
134
|
+
·
|
|
135
|
+
{t('leave_after', { days: formatDays(sim.balanceAfterMinutes / 480) })}
|
|
136
|
+
</p>
|
|
137
|
+
{/if}
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{#snippet footer()}
|
|
142
|
+
<Button variant="ghost" onclick={close}>{t('common.cancel')}</Button>
|
|
143
|
+
<Button onclick={() => create.mutate()} disabled={!canSubmit} loading={create.isPending}>
|
|
144
|
+
{t('request_leave')}
|
|
145
|
+
</Button>
|
|
146
|
+
{/snippet}
|
|
147
|
+
</Dialog>
|
|
148
|
+
|
|
149
|
+
<style>
|
|
150
|
+
.form {
|
|
151
|
+
display: grid;
|
|
152
|
+
gap: 14px;
|
|
153
|
+
}
|
|
154
|
+
.dates {
|
|
155
|
+
display: grid;
|
|
156
|
+
grid-template-columns: 1fr 1fr;
|
|
157
|
+
gap: 12px;
|
|
158
|
+
}
|
|
159
|
+
.cost {
|
|
160
|
+
margin: 0;
|
|
161
|
+
font-size: 13px;
|
|
162
|
+
color: var(--kern-ink-500);
|
|
163
|
+
}
|
|
164
|
+
.block {
|
|
165
|
+
margin: 0;
|
|
166
|
+
font-size: 13px;
|
|
167
|
+
color: var(--kern-danger);
|
|
168
|
+
}
|
|
169
|
+
</style>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Field, Input, navigation, Select, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { canHr } from '../permissions.js'
|
|
7
|
+
import { isoDate } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hire someone into the directory.
|
|
11
|
+
*
|
|
12
|
+
* The Add person button already went to `?new=1`; this is the screen that URL was promising.
|
|
13
|
+
* Office is optional: a workspace that never switched offices on still has a default one, and the
|
|
14
|
+
* server assigns it. Asking for an office on that workspace would offer a feature it does not have.
|
|
15
|
+
*/
|
|
16
|
+
interface OfficeOpt {
|
|
17
|
+
id: string
|
|
18
|
+
name: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
open: boolean
|
|
23
|
+
workspaceId: string
|
|
24
|
+
workspaceSlug: string
|
|
25
|
+
offices: OfficeOpt[]
|
|
26
|
+
showOffice: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let { open, workspaceId, workspaceSlug, offices, showOffice }: Props = $props()
|
|
30
|
+
|
|
31
|
+
const api = getHrApi()
|
|
32
|
+
const queryClient = useQueryClient()
|
|
33
|
+
|
|
34
|
+
/** Local so the dialog can close before the URL has dropped `?new=1`. */
|
|
35
|
+
let shown = $state(false)
|
|
36
|
+
/** Set when create succeeded, so closing the dialog does not wipe the person we just opened. */
|
|
37
|
+
let createdId = $state<string | null>(null)
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (open) {
|
|
40
|
+
shown = true
|
|
41
|
+
createdId = null
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
let displayName = $state('')
|
|
46
|
+
let workEmail = $state('')
|
|
47
|
+
let employeeNo = $state('')
|
|
48
|
+
let hiredOn = $state(isoDate())
|
|
49
|
+
let officeId = $state('')
|
|
50
|
+
let employmentType = $state('full_time')
|
|
51
|
+
|
|
52
|
+
const typeOptions = [
|
|
53
|
+
{ value: 'full_time', label: t('employment_full_time') },
|
|
54
|
+
{ value: 'part_time', label: t('employment_part_time') },
|
|
55
|
+
{ value: 'contract', label: t('employment_contract') },
|
|
56
|
+
{ value: 'intern', label: t('employment_intern') },
|
|
57
|
+
{ value: 'temporary', label: t('employment_temporary') },
|
|
58
|
+
{ value: 'freelance', label: t('employment_freelance') },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const officeOptions = $derived(offices.map((o) => ({ value: o.id, label: o.name })))
|
|
62
|
+
|
|
63
|
+
$effect(() => {
|
|
64
|
+
if (open && !officeId && offices[0]) officeId = offices[0].id
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const dismiss = () => {
|
|
68
|
+
if (createdId) {
|
|
69
|
+
void navigation.go(`/${workspaceSlug}/hr?person=${createdId}`, {
|
|
70
|
+
replaceState: true,
|
|
71
|
+
keepFocus: true,
|
|
72
|
+
noScroll: true,
|
|
73
|
+
})
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
void navigation.go(`/${workspaceSlug}/hr`, { replaceState: true, keepFocus: true, noScroll: true })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const reset = () => {
|
|
80
|
+
displayName = ''
|
|
81
|
+
workEmail = ''
|
|
82
|
+
employeeNo = ''
|
|
83
|
+
hiredOn = isoDate()
|
|
84
|
+
officeId = offices[0]?.id ?? ''
|
|
85
|
+
employmentType = 'full_time'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const create = createMutation(() => ({
|
|
89
|
+
mutationFn: () =>
|
|
90
|
+
api.people.create({
|
|
91
|
+
workspaceId,
|
|
92
|
+
displayName: displayName.trim(),
|
|
93
|
+
workEmail: workEmail.trim() || null,
|
|
94
|
+
employeeNo: employeeNo.trim() || null,
|
|
95
|
+
hiredOn: hiredOn || null,
|
|
96
|
+
officeId: showOffice && officeId ? officeId : null,
|
|
97
|
+
employmentType: employmentType as
|
|
98
|
+
| 'full_time'
|
|
99
|
+
| 'part_time'
|
|
100
|
+
| 'contract'
|
|
101
|
+
| 'intern'
|
|
102
|
+
| 'temporary'
|
|
103
|
+
| 'freelance',
|
|
104
|
+
}),
|
|
105
|
+
onSuccess: (person) => {
|
|
106
|
+
createdId = person.id
|
|
107
|
+
toast.success(t('person_created', { name: person.displayName }))
|
|
108
|
+
void queryClient.invalidateQueries({ queryKey: ['hr', 'people'] })
|
|
109
|
+
reset()
|
|
110
|
+
shown = false
|
|
111
|
+
dismiss()
|
|
112
|
+
},
|
|
113
|
+
onError: (error: Error) => toast.error(error.message),
|
|
114
|
+
}))
|
|
115
|
+
|
|
116
|
+
const canSubmit = $derived(displayName.trim().length > 0 && canHr('personManage'))
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<Dialog
|
|
120
|
+
bind:open={shown}
|
|
121
|
+
title={t('add_person')}
|
|
122
|
+
description={t('add_person_desc')}
|
|
123
|
+
onOpenChange={(next) => {
|
|
124
|
+
if (!next) {
|
|
125
|
+
shown = false
|
|
126
|
+
dismiss()
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<div class="form">
|
|
131
|
+
<Field label={t('display_name')} id="hr-person-name" required>
|
|
132
|
+
{#snippet children(id)}
|
|
133
|
+
<Input {id} bind:value={displayName} autocomplete="name" />
|
|
134
|
+
{/snippet}
|
|
135
|
+
</Field>
|
|
136
|
+
<Field label={t('work_email')} id="hr-person-email" hint={t('common.optional')}>
|
|
137
|
+
{#snippet children(id)}
|
|
138
|
+
<Input {id} type="email" bind:value={workEmail} autocomplete="email" />
|
|
139
|
+
{/snippet}
|
|
140
|
+
</Field>
|
|
141
|
+
<Field label={t('employee_no')} id="hr-person-no" hint={t('common.optional')}>
|
|
142
|
+
{#snippet children(id)}
|
|
143
|
+
<Input {id} bind:value={employeeNo} />
|
|
144
|
+
{/snippet}
|
|
145
|
+
</Field>
|
|
146
|
+
<Field label={t('hired_on')} id="hr-person-hired">
|
|
147
|
+
{#snippet children(id)}
|
|
148
|
+
<Input {id} type="date" bind:value={hiredOn} />
|
|
149
|
+
{/snippet}
|
|
150
|
+
</Field>
|
|
151
|
+
{#if showOffice && officeOptions.length}
|
|
152
|
+
<Field label={t('office')} id="hr-person-office">
|
|
153
|
+
{#snippet children(id)}
|
|
154
|
+
<Select {id} bind:value={officeId} options={officeOptions} />
|
|
155
|
+
{/snippet}
|
|
156
|
+
</Field>
|
|
157
|
+
{/if}
|
|
158
|
+
<Field label={t('employment')} id="hr-person-type">
|
|
159
|
+
{#snippet children(id)}
|
|
160
|
+
<Select {id} bind:value={employmentType} options={typeOptions} />
|
|
161
|
+
{/snippet}
|
|
162
|
+
</Field>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{#snippet footer()}
|
|
166
|
+
<Button
|
|
167
|
+
variant="ghost"
|
|
168
|
+
onclick={() => {
|
|
169
|
+
shown = false
|
|
170
|
+
dismiss()
|
|
171
|
+
}}>{t('common.cancel')}</Button
|
|
172
|
+
>
|
|
173
|
+
<Button onclick={() => create.mutate()} disabled={!canSubmit} loading={create.isPending}>
|
|
174
|
+
{t('add_person')}
|
|
175
|
+
</Button>
|
|
176
|
+
{/snippet}
|
|
177
|
+
</Dialog>
|
|
178
|
+
|
|
179
|
+
<style>
|
|
180
|
+
.form {
|
|
181
|
+
display: grid;
|
|
182
|
+
gap: 14px;
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Avatar } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getHrApi } from '../api-instance.js'
|
|
5
|
+
import { hrKeys } from '../query.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A person, rendered wherever another module mentions one — a chat message, an issue's assignee.
|
|
9
|
+
*
|
|
10
|
+
* This is what `objectTypes: [{ type: 'person' }]` on the server buys: HR owns how a person looks,
|
|
11
|
+
* so every module shows the same name and avatar without importing anything of HR's.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
id: string
|
|
15
|
+
workspaceId: string
|
|
16
|
+
}
|
|
17
|
+
const { id, workspaceId }: Props = $props()
|
|
18
|
+
|
|
19
|
+
const api = getHrApi()
|
|
20
|
+
|
|
21
|
+
const personQuery = createQuery(() => ({
|
|
22
|
+
queryKey: hrKeys.person(workspaceId, id),
|
|
23
|
+
enabled: Boolean(workspaceId && id),
|
|
24
|
+
queryFn: () => api.people.get({ workspaceId, personId: id }),
|
|
25
|
+
}))
|
|
26
|
+
const person = $derived(personQuery.data)
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<span class="inline">
|
|
30
|
+
{#if person}
|
|
31
|
+
<Avatar name={person.displayName} id={person.id} size={18} />
|
|
32
|
+
<span>{person.displayName}</span>
|
|
33
|
+
{:else}
|
|
34
|
+
<!-- No skeleton: an inline mention that pulses inside a sentence is worse than a plain name. -->
|
|
35
|
+
<span class="pending">…</span>
|
|
36
|
+
{/if}
|
|
37
|
+
</span>
|
|
38
|
+
|
|
39
|
+
<style>
|
|
40
|
+
.inline {
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 4px;
|
|
44
|
+
vertical-align: baseline;
|
|
45
|
+
}
|
|
46
|
+
.pending {
|
|
47
|
+
color: var(--kern-ink-500);
|
|
48
|
+
}
|
|
49
|
+
</style>
|