@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.
Files changed (31) hide show
  1. package/package.json +22 -2
  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
@@ -0,0 +1,119 @@
1
+ <script lang="ts">
2
+ import { Badge, Card, EmptyState, navigation, Page, PageHeader, Skeleton, session } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getHrApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { hrKeys } from '../query.js'
7
+
8
+ /**
9
+ * Where the company works.
10
+ *
11
+ * Each card shows the office's **current local time** alongside its country, because that is what an
12
+ * office list is for once there is more than one — knowing whether Amsterdam is awake. The default
13
+ * office is marked, since it is where somebody with no assignment lands and where the resolution
14
+ * ladder bottoms out.
15
+ */
16
+ const api = getHrApi()
17
+
18
+ const workspaceSlug = $derived(navigation.workspaceSlug)
19
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
20
+ const workspaceId = $derived(workspace?.id ?? '')
21
+
22
+ const officesQuery = createQuery(() => ({
23
+ queryKey: hrKeys.offices(workspaceId),
24
+ enabled: Boolean(workspaceId),
25
+ queryFn: () => api.offices.list({ workspaceId, includeArchived: false }),
26
+ }))
27
+ const offices = $derived(officesQuery.data ?? [])
28
+
29
+ let tick = $state(0)
30
+ $effect(() => {
31
+ const handle = setInterval(() => {
32
+ tick++
33
+ }, 60_000)
34
+ return () => clearInterval(handle)
35
+ })
36
+
37
+ function localTime(timezone: string, _tick: number): string {
38
+ void _tick
39
+ try {
40
+ return new Intl.DateTimeFormat(undefined, {
41
+ timeZone: timezone,
42
+ hour: 'numeric',
43
+ minute: '2-digit',
44
+ }).format(new Date())
45
+ } catch {
46
+ return ''
47
+ }
48
+ }
49
+
50
+ /** The country's own name for itself, rather than a two-letter code nobody reads. */
51
+ function countryName(code: string): string {
52
+ try {
53
+ return new Intl.DisplayNames(undefined, { type: 'region' }).of(code) ?? code
54
+ } catch {
55
+ return code
56
+ }
57
+ }
58
+ </script>
59
+
60
+ <PageHeader
61
+ crumbs={[{ label: workspace?.name ?? '' }, { label: t('offices_title') }]}
62
+ title={t('offices_title')}
63
+ />
64
+
65
+ <Page>
66
+ {#if officesQuery.isLoading}
67
+ <div class="grid">{#each [1, 2, 3] as n (n)}<Skeleton height="96px" />{/each}</div>
68
+ {:else if offices.length === 0}
69
+ <EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')} />
70
+ {:else}
71
+ <div class="grid">
72
+ {#each offices as office (office.id)}
73
+ <Card>
74
+ <div class="head">
75
+ <span class="name">{office.name}</span>
76
+ {#if office.isDefault}<Badge tone="accent">{t('office_default')}</Badge>{/if}
77
+ </div>
78
+ <p class="meta">{countryName(office.country)}</p>
79
+ <div class="foot">
80
+ <span class="time">{localTime(office.timezone, tick)}</span>
81
+ <span class="meta">{t('headcount', { count: String(office.headcount) })}</span>
82
+ </div>
83
+ </Card>
84
+ {/each}
85
+ </div>
86
+ {/if}
87
+ </Page>
88
+
89
+ <style>
90
+ .grid {
91
+ display: grid;
92
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
93
+ gap: 12px;
94
+ }
95
+ .head {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 8px;
99
+ justify-content: space-between;
100
+ }
101
+ .name {
102
+ font-weight: 500;
103
+ }
104
+ .meta {
105
+ color: var(--kern-ink-500);
106
+ font-size: 12px;
107
+ margin: 4px 0 0;
108
+ }
109
+ .foot {
110
+ display: flex;
111
+ align-items: baseline;
112
+ justify-content: space-between;
113
+ margin-block-start: 12px;
114
+ }
115
+ .time {
116
+ font-size: 15px;
117
+ font-variant-numeric: tabular-nums;
118
+ }
119
+ </style>
@@ -0,0 +1,31 @@
1
+ import { session } from '@kernhq/ui'
2
+ import { HR_CAPABILITIES, HR_PERMISSIONS } from './index.js'
3
+
4
+ /**
5
+ * What this module lets somebody do, and what this workspace has switched on.
6
+ *
7
+ * Two different questions, and the screens have to keep them apart:
8
+ *
9
+ * - a **permission** is about the person. Hide what they may never do; disable — with a reason —
10
+ * what they cannot do right now. Somebody else in the same workspace may well see it.
11
+ * - a **capability** is about the workspace. When it is off the feature is not there for anyone,
12
+ * the shell never renders the contribution, and the API answers 404 rather than 403.
13
+ *
14
+ * The server checks both again regardless. This is about not offering a door that will not open.
15
+ */
16
+ export { HR_CAPABILITIES, HR_PERMISSIONS }
17
+
18
+ export type HrPermission = keyof typeof HR_PERMISSIONS
19
+
20
+ export function canHr(permission: HrPermission): boolean {
21
+ return session.can(HR_PERMISSIONS[permission])
22
+ }
23
+
24
+ /**
25
+ * Whether the viewer can see anybody's record but their own.
26
+ *
27
+ * Three widths that do not imply one another — a country HR manager must not silently become a
28
+ * global one — so "can this person open somebody else's page" is asked once, here.
29
+ */
30
+ export const canSeeOthers = (): boolean =>
31
+ canHr('personViewTeam') || canHr('personViewOffice') || canHr('personViewAll')
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatDays, formatDuration, hrKeys, monthRange } from './query.js'
3
+
4
+ const words = { hours: (n: string) => `${n}h`, minutes: (n: string) => `${n}m` }
5
+
6
+ describe('hrKeys', () => {
7
+ it('scopes a balance by person, so two people do not share a cache entry', () => {
8
+ expect(hrKeys.leaveBalance('ws', 'alice')).not.toEqual(hrKeys.leaveBalance('ws', 'bob'))
9
+ // No person means "me", and that must be its own entry rather than colliding with a named one.
10
+ expect(hrKeys.leaveBalance('ws', undefined)).toEqual(['hr', 'leave-balance', 'ws', 'me'])
11
+ })
12
+
13
+ it('scopes attendance by range, so changing the month refetches', () => {
14
+ expect(hrKeys.attendanceDays('ws', 'a', '2026-01-01', '2026-01-31')).not.toEqual(
15
+ hrKeys.attendanceDays('ws', 'a', '2026-02-01', '2026-02-28'),
16
+ )
17
+ })
18
+
19
+ it('starts every key with the module, so one invalidation can clear all of HR', () => {
20
+ for (const key of [
21
+ hrKeys.people('ws'),
22
+ hrKeys.offices('ws'),
23
+ hrKeys.clockState('ws'),
24
+ hrKeys.approvalInbox('ws'),
25
+ ])
26
+ expect(key[0]).toBe('hr')
27
+ })
28
+ })
29
+
30
+ describe('monthRange', () => {
31
+ it('covers a whole 31-day month', () => {
32
+ expect(monthRange(new Date(2026, 0, 15))).toEqual({ from: '2026-01-01', to: '2026-01-31' })
33
+ })
34
+ it('covers February in a leap year and a common one', () => {
35
+ expect(monthRange(new Date(2024, 1, 10)).to).toBe('2024-02-29')
36
+ expect(monthRange(new Date(2026, 1, 10)).to).toBe('2026-02-28')
37
+ })
38
+ it('covers a 30-day month', () => {
39
+ expect(monthRange(new Date(2026, 3, 5))).toEqual({ from: '2026-04-01', to: '2026-04-30' })
40
+ })
41
+ })
42
+
43
+ describe('formatDuration', () => {
44
+ it('shows hours and minutes together', () => {
45
+ expect(formatDuration(495, words, 'en')).toBe('8h 15m')
46
+ })
47
+ it('drops an empty part', () => {
48
+ expect(formatDuration(480, words, 'en')).toBe('8h')
49
+ expect(formatDuration(45, words, 'en')).toBe('45m')
50
+ expect(formatDuration(0, words, 'en')).toBe('0m')
51
+ })
52
+ it('keeps a negative readable', () => {
53
+ expect(formatDuration(-90, words, 'en')).toBe('-1h 30m')
54
+ })
55
+ it('uses the locale’s digits', () => {
56
+ // A Persian screen with Latin numerals in the one place a number appears looks broken.
57
+ expect(formatDuration(480, words, 'fa')).toBe('۸h')
58
+ })
59
+ })
60
+
61
+ describe('formatDays', () => {
62
+ it('keeps halves and drops trailing zeros', () => {
63
+ expect(formatDays(20, 'en')).toBe('20')
64
+ expect(formatDays(19.5, 'en')).toBe('19.5')
65
+ expect(formatDays(19.25, 'en')).toBe('19.25')
66
+ })
67
+ it('uses the locale’s digits', () => {
68
+ expect(formatDays(20, 'fa')).toBe('۲۰')
69
+ })
70
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Query keys for HR.
3
+ *
4
+ * `[module, entity, …scope]`, so a realtime `change` event invalidates precisely what it touched.
5
+ * The scope is part of the key wherever a screen can ask the same question about different subjects
6
+ * — a balance for me and a balance for somebody I manage are different answers, and sharing a key
7
+ * would serve one person the other's numbers from cache.
8
+ */
9
+ export const hrKeys = {
10
+ people: (ws: string, filters?: Record<string, unknown>) =>
11
+ filters ? (['hr', 'people', ws, filters] as const) : (['hr', 'people', ws] as const),
12
+ person: (ws: string, id: string) => ['hr', 'person', ws, id] as const,
13
+ me: (ws: string) => ['hr', 'me', ws] as const,
14
+ resolution: (ws: string, personId: string) => ['hr', 'resolution', ws, personId] as const,
15
+ employment: (ws: string, personId: string) => ['hr', 'employment', ws, personId] as const,
16
+ orgUnits: (ws: string) => ['hr', 'org-units', ws] as const,
17
+ offices: (ws: string) => ['hr', 'offices', ws] as const,
18
+ calendars: (ws: string) => ['hr', 'calendars', ws] as const,
19
+ calendarDays: (ws: string, calendarId: string, from: string, to: string) =>
20
+ ['hr', 'calendar-days', ws, calendarId, from, to] as const,
21
+ leaveTypes: (ws: string) => ['hr', 'leave-types', ws] as const,
22
+ leaveBalance: (ws: string, personId: string | undefined) =>
23
+ ['hr', 'leave-balance', ws, personId ?? 'me'] as const,
24
+ leaveRequests: (ws: string, personId: string | undefined) =>
25
+ ['hr', 'leave-requests', ws, personId ?? 'me'] as const,
26
+ leaveCalendar: (ws: string, from: string, to: string) => ['hr', 'leave-calendar', ws, from, to] as const,
27
+ clockState: (ws: string) => ['hr', 'clock-state', ws] as const,
28
+ attendanceDays: (ws: string, personId: string | undefined, from: string, to: string) =>
29
+ ['hr', 'attendance-days', ws, personId ?? 'me', from, to] as const,
30
+ schedules: (ws: string) => ['hr', 'schedules', ws] as const,
31
+ approvalInbox: (ws: string) => ['hr', 'approvals', ws] as const,
32
+ } as const
33
+
34
+ /** `YYYY-MM-DD` for a date, in the viewer's own zone rather than UTC. */
35
+ export const isoDate = (d: Date = new Date()): string => new Intl.DateTimeFormat('en-CA').format(d)
36
+
37
+ /** The first and last day of the month containing `d`, as ISO dates. */
38
+ export function monthRange(d: Date = new Date()): { from: string; to: string } {
39
+ const y = d.getFullYear()
40
+ const mo = d.getMonth()
41
+ const last = new Date(y, mo + 1, 0).getDate()
42
+ const p = (n: number) => String(n).padStart(2, '0')
43
+ return { from: `${y}-${p(mo + 1)}-01`, to: `${y}-${p(mo + 1)}-${p(last)}` }
44
+ }
45
+
46
+ /**
47
+ * Minutes as a duration somebody reads.
48
+ *
49
+ * Takes the wording as parameters rather than importing `$msg`, because a `.ts` module that imports
50
+ * `$msg` cannot be unit-tested — SvelteKit's aliases come from a plugin vitest does not run.
51
+ */
52
+ export function formatDuration(
53
+ minutes: number,
54
+ words: { hours: (n: string) => string; minutes: (n: string) => string },
55
+ locale?: string,
56
+ ): string {
57
+ const sign = minutes < 0 ? '-' : ''
58
+ const abs = Math.abs(Math.round(minutes))
59
+ const h = Math.floor(abs / 60)
60
+ const mi = abs % 60
61
+ const n = (v: number) => new Intl.NumberFormat(locale).format(v)
62
+ if (h && mi) return `${sign}${words.hours(n(h))} ${words.minutes(n(mi))}`
63
+ if (h) return `${sign}${words.hours(n(h))}`
64
+ return `${sign}${words.minutes(n(mi))}`
65
+ }
66
+
67
+ /** Days, in the viewer's digits, with halves kept and trailing zeros dropped. */
68
+ export const formatDays = (days: number, locale?: string): string =>
69
+ new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(days)
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import { EmptyState, SettingsPage } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+
5
+ /**
6
+ * calendars settings.
7
+ *
8
+ * The API behind this is complete — the module's server implements the full surface — but the
9
+ * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
+ * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
+ * because the capability is on.
12
+ */
13
+ </script>
14
+
15
+ <SettingsPage title={t('settings_calendars')}>
16
+ <EmptyState
17
+ icon="calendar"
18
+ title={t('settings_calendars')}
19
+ description={t('settings_not_built')}
20
+ />
21
+ </SettingsPage>
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import { Card, coreApi, keys, navigation, SettingsPage, Skeleton, Switch, session } from '@kernhq/ui'
3
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import type { CoreApi } from '../core-api.js'
5
+ import { t } from '../i18n.js'
6
+
7
+ /**
8
+ * Which parts of HR this workspace has.
9
+ *
10
+ * The switchboard the whole module is built around: turning one off removes its navigation, its
11
+ * widgets, its commands, its settings pages and its API — and destroys nothing, so turning it back
12
+ * on restores exactly what was there.
13
+ *
14
+ * Reads the definitions from the module's own manifest rather than a list kept here. A capability
15
+ * added on the server appears in this screen without anybody editing it, which is the point of
16
+ * declaring them as data.
17
+ */
18
+ const workspaceSlug = $derived(navigation.workspaceSlug)
19
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
20
+ const workspaceId = $derived(workspace?.id ?? '')
21
+
22
+ const api = coreApi<CoreApi>()
23
+ const queryClient = useQueryClient()
24
+
25
+ const modulesQuery = createQuery(() => ({
26
+ queryKey: keys.modules(workspaceId),
27
+ enabled: Boolean(workspaceId),
28
+ queryFn: () => api.workspaces.modules.list({ workspaceId }),
29
+ }))
30
+
31
+ const hr = $derived(modulesQuery.data?.find((entry) => entry.manifest.id === 'hr'))
32
+ const definitions = $derived(hr?.manifest.capabilities ?? [])
33
+ const enabled = $derived(new Set(hr?.state.capabilities ?? []))
34
+
35
+ const setCapability = createMutation(() => ({
36
+ mutationFn: async (vars: { id: string; on: boolean }) => {
37
+ const stored = ((hr?.state.settings as Record<string, unknown>)?.$capabilities ?? {}) as Record<
38
+ string,
39
+ boolean
40
+ >
41
+ // The reserved key is sent whole. Core lifts it out before the module's own settings schema
42
+ // sees it, which is what stops a zod object stripping every switch on the way past.
43
+ return api.workspaces.modules.updateSettings({
44
+ workspaceId,
45
+ moduleId: 'hr',
46
+ settings: { $capabilities: { ...stored, [vars.id]: vars.on } },
47
+ })
48
+ },
49
+ onSuccess: () => {
50
+ // Navigation, widgets and routes are all derived from this, so the whole shell needs to re-read.
51
+ void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
52
+ void queryClient.invalidateQueries({ queryKey: ['hr'] })
53
+ },
54
+ }))
55
+
56
+ const nameOf = (id: string): string =>
57
+ definitions.find((d: { id: string; label: string }) => d.id === id)?.label ?? id
58
+
59
+ /** A capability whose dependency is off cannot be switched on — the server would prune it anyway. */
60
+ const blockedBy = (deps: string[]) => deps.filter((d) => !enabled.has(d))
61
+ </script>
62
+
63
+ <SettingsPage title={t('settings_capabilities')} description={t('capabilities_desc')}>
64
+
65
+ {#if modulesQuery.isLoading}
66
+ <Skeleton height="220px" />
67
+ {:else}
68
+ <div class="list">
69
+ {#each definitions as capability (capability.id)}
70
+ {@const missing = blockedBy(capability.dependsOn)}
71
+ <Card>
72
+ <div class="row">
73
+ <div class="what">
74
+ <span class="name">{capability.label}</span>
75
+ {#if capability.description}
76
+ <span class="meta">{capability.description}</span>
77
+ {/if}
78
+ {#if missing.length}
79
+ <span class="meta">
80
+ {t('capability_requires', { name: missing.map(nameOf).join(', ') })}
81
+ </span>
82
+ {/if}
83
+ </div>
84
+ <!-- The module's own foundation is always on, and a capability whose dependency is off
85
+ cannot be switched on: the server prunes it anyway, so the control says so. -->
86
+ <Switch
87
+ checked={enabled.has(capability.id)}
88
+ disabled={capability.required || missing.length > 0 || setCapability.isPending}
89
+ onCheckedChange={(on) => setCapability.mutate({ id: capability.id, on })}
90
+ label={capability.label}
91
+ />
92
+ </div>
93
+ </Card>
94
+ {/each}
95
+ </div>
96
+ {/if}
97
+ </SettingsPage>
98
+
99
+ <style>
100
+ .list {
101
+ display: grid;
102
+ gap: 8px;
103
+ }
104
+ .row {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: space-between;
108
+ gap: 16px;
109
+ }
110
+ .what {
111
+ display: flex;
112
+ flex-direction: column;
113
+ min-width: 0;
114
+ }
115
+ .name {
116
+ font-weight: 500;
117
+ }
118
+ .meta {
119
+ color: var(--kern-ink-500);
120
+ font-size: 12px;
121
+ }
122
+ </style>
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import { EmptyState, SettingsPage } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+
5
+ /**
6
+ * leave settings.
7
+ *
8
+ * The API behind this is complete — the module's server implements the full surface — but the
9
+ * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
+ * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
+ * because the capability is on.
12
+ */
13
+ </script>
14
+
15
+ <SettingsPage title={t('settings_leave')}>
16
+ <EmptyState
17
+ icon="tree-palm"
18
+ title={t('settings_leave')}
19
+ description={t('settings_not_built')}
20
+ />
21
+ </SettingsPage>
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import { EmptyState, SettingsPage } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+
5
+ /**
6
+ * offices settings.
7
+ *
8
+ * The API behind this is complete — the module's server implements the full surface — but the
9
+ * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
+ * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
+ * because the capability is on.
12
+ */
13
+ </script>
14
+
15
+ <SettingsPage title={t('settings_offices')}>
16
+ <EmptyState
17
+ icon="building"
18
+ title={t('settings_offices')}
19
+ description={t('settings_not_built')}
20
+ />
21
+ </SettingsPage>
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import { EmptyState, SettingsPage } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+
5
+ /**
6
+ * schedules settings.
7
+ *
8
+ * The API behind this is complete — the module's server implements the full surface — but the
9
+ * editing screen is not built yet. Saying so is deliberate: a settings page that renders controls
10
+ * doing nothing is worse than one that admits what it is, and the shell only reaches this route
11
+ * because the capability is on.
12
+ */
13
+ </script>
14
+
15
+ <SettingsPage title={t('settings_schedules')}>
16
+ <EmptyState
17
+ icon="clock"
18
+ title={t('settings_schedules')}
19
+ description={t('settings_not_built')}
20
+ />
21
+ </SettingsPage>
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import { Badge, Button, EmptyState, Skeleton, type WidgetProps } 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 { hrKeys } from '../query.js'
7
+
8
+ /**
9
+ * Requests waiting on me, decidable from the card.
10
+ *
11
+ * Acting on a row rather than linking away from it: the whole value of this card is approving three
12
+ * leave requests without leaving the dashboard, and a card that only counts them is a link with
13
+ * extra steps.
14
+ */
15
+ const { workspaceId, editing }: WidgetProps = $props()
16
+ const api = getHrApi()
17
+ const queryClient = useQueryClient()
18
+
19
+ const inboxQuery = createQuery(() => ({
20
+ queryKey: hrKeys.approvalInbox(workspaceId),
21
+ enabled: Boolean(workspaceId),
22
+ queryFn: () => api.approvals.inbox({ workspaceId, limit: 5, includeDecided: false }),
23
+ }))
24
+ const items = $derived(inboxQuery.data?.items ?? [])
25
+
26
+ const decide = createMutation(() => ({
27
+ mutationFn: (vars: { requestId: string; decision: 'approve' | 'reject' }) =>
28
+ api.approvals.decide({ workspaceId, ...vars }),
29
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['hr'] }),
30
+ }))
31
+ </script>
32
+
33
+ {#if inboxQuery.isLoading}
34
+ <Skeleton height="96px" />
35
+ {:else if items.length === 0}
36
+ <EmptyState bare compact icon="check-check" title={t('approvals_none')} />
37
+ {:else}
38
+ <ul>
39
+ {#each items as item (item.id)}
40
+ <li>
41
+ <span class="summary">{item.summary}</span>
42
+ <!-- Row actions go while the grid is being rearranged: the data stays, the buttons do not. -->
43
+ {#if editing}
44
+ <Badge tone="upcoming">{t('leave_pending')}</Badge>
45
+ {:else}
46
+ <Button
47
+ size="sm"
48
+ variant="ghost"
49
+ disabled={decide.isPending}
50
+ onclick={() => decide.mutate({ requestId: item.id, decision: 'reject' })}
51
+ >{t('reject')}</Button
52
+ >
53
+ <Button
54
+ size="sm"
55
+ disabled={decide.isPending}
56
+ onclick={() => decide.mutate({ requestId: item.id, decision: 'approve' })}
57
+ >{t('approve')}</Button
58
+ >
59
+ {/if}
60
+ </li>
61
+ {/each}
62
+ </ul>
63
+ {/if}
64
+
65
+ <style>
66
+ ul {
67
+ display: grid;
68
+ gap: 8px;
69
+ list-style: none;
70
+ margin: 0;
71
+ padding: 0;
72
+ }
73
+ li {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 8px;
77
+ }
78
+ .summary {
79
+ flex: 1;
80
+ min-width: 0;
81
+ overflow: hidden;
82
+ text-overflow: ellipsis;
83
+ white-space: nowrap;
84
+ font-size: 12px;
85
+ }
86
+ </style>
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ import type { WidgetProps } from '@kernhq/ui'
3
+ import ClockControls from '../components/ClockControls.svelte'
4
+
5
+ /** The clock, on the dashboard. The frame belongs to the shell, so this is only the control. */
6
+ const { workspaceId }: WidgetProps = $props()
7
+ </script>
8
+
9
+ <ClockControls {workspaceId} />
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { Skeleton, StatTile, type WidgetProps } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getHrApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { hrKeys } from '../query.js'
7
+
8
+ /** How many people work here. One number, so the frame's header is dropped (`compact`). */
9
+ const { workspaceId }: WidgetProps = $props()
10
+ const api = getHrApi()
11
+
12
+ const peopleQuery = createQuery(() => ({
13
+ queryKey: hrKeys.people(workspaceId, { status: 'active' }),
14
+ enabled: Boolean(workspaceId),
15
+ queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['active'] }),
16
+ }))
17
+ // `total` rather than `items.length`: the request asks for one row, because drawing a number does
18
+ // not need the list behind it.
19
+ const count = $derived(peopleQuery.data?.total ?? 0)
20
+ </script>
21
+
22
+ {#if peopleQuery.isLoading}
23
+ <Skeleton height="72px" />
24
+ {:else}
25
+ <StatTile
26
+ label={t('widget_headcount_title')}
27
+ value={new Intl.NumberFormat().format(count)}
28
+ />
29
+ {/if}