@kernhq/module-hr 0.8.0 → 0.9.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.
Files changed (31) hide show
  1. package/package.json +25 -5
  2. package/src/client/api-instance.ts +29 -0
  3. package/src/client/components/ClockControls.svelte +127 -0
  4. package/src/client/components/HrSidebar.svelte +73 -0
  5. package/src/client/components/LeaveRequestDialog.svelte +169 -0
  6. package/src/client/components/PersonFormDialog.svelte +184 -0
  7. package/src/client/components/PersonInline.svelte +49 -0
  8. package/src/client/components/PersonPanel.svelte +328 -0
  9. package/src/client/core-api.ts +34 -0
  10. package/src/client/i18n.ts +640 -0
  11. package/src/client/index.ts +3 -0
  12. package/src/client/mock.ts +447 -0
  13. package/src/client/module.ts +295 -0
  14. package/src/client/pages/ApprovalsPage.svelte +129 -0
  15. package/src/client/pages/AttendancePage.svelte +147 -0
  16. package/src/client/pages/DirectoryPage.svelte +428 -0
  17. package/src/client/pages/LeavePage.svelte +180 -0
  18. package/src/client/pages/OfficesPage.svelte +119 -0
  19. package/src/client/permissions.ts +31 -0
  20. package/src/client/query.test.ts +70 -0
  21. package/src/client/query.ts +69 -0
  22. package/src/client/settings/CalendarsSettings.svelte +21 -0
  23. package/src/client/settings/CapabilitiesSettings.svelte +122 -0
  24. package/src/client/settings/LeaveSettings.svelte +21 -0
  25. package/src/client/settings/OfficesSettings.svelte +21 -0
  26. package/src/client/settings/SchedulesSettings.svelte +21 -0
  27. package/src/client/widgets/ApprovalsWidget.svelte +86 -0
  28. package/src/client/widgets/ClockWidget.svelte +9 -0
  29. package/src/client/widgets/HeadcountWidget.svelte +29 -0
  30. package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
  31. 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.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -34,9 +34,9 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@kernhq/contracts": "^0.5.0",
38
- "@kernhq/kernel": "^0.6.0",
39
- "@kernhq/sdk": "^0.1.0",
37
+ "@kernhq/contracts": "^0.5.1",
38
+ "@kernhq/kernel": "^0.7.0",
39
+ "@kernhq/sdk": "^0.1.5",
40
40
  "@orpc/contract": "^1.15.0",
41
41
  "@orpc/server": "^1.15.0",
42
42
  "drizzle-orm": "^0.45.0",
@@ -44,17 +44,37 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@kernhq/tsconfig": "^0.1.0",
47
+ "@kernhq/ui": "^0.8.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.8.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 && tsc -p tsconfig.client.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>