diff --git a/frontend/src/components/planning/PlanResults.tsx b/frontend/src/components/planning/PlanResults.tsx index 4c3c46e..4a870e7 100644 --- a/frontend/src/components/planning/PlanResults.tsx +++ b/frontend/src/components/planning/PlanResults.tsx @@ -18,6 +18,7 @@ import type { PlanApplyResponse, } from '@/types' import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n' +import { nameResolver } from '@/utils/parentNames' export interface PlanResultsProps { proposal: PlanProposal @@ -102,10 +103,6 @@ const REJECTION_KEYS: Record = { 'planApply.rejection.insufficientBalance': 'planResults.rejectionInsufficientBalance', } -function nameResolver(members: PersonalData[]): (uuid: string) => string { - return (uuid: string) => members.find((m) => m.uuid === uuid)?.name ?? uuid -} - /** `data` plus the resolved parent name (when the surface carries a * `parent_personal_data_uuid`) — every warning/advice body template below * references `{parent_name}`, and the backend only ever sends the uuid. diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 07b2e82..050cff6 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -116,6 +116,15 @@ describe('Dashboard', () => { isRunning: false, isTerminal: false, } as any) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ + data: [ + { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + ], + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) }) it('renders loading spinner initially', () => { @@ -465,4 +474,95 @@ describe('Dashboard', () => { renderWithRouter() expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() }) + + it('shows only the combined expiring figure for a single-parent workspace', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument() + expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument() + }) + + it('breaks down expiring days per parent alongside the combined total', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1') + const samMetric = screen.getByTestId('metric-expiring-parent-pd-2') + const combinedMetric = screen.getByTestId('metric-expiring-combined') + expect(alexMetric).toHaveTextContent('Alex') + expect(alexMetric).toHaveTextContent('60') + expect(samMetric).toHaveTextContent('Sam') + expect(samMetric).toHaveTextContent('44') + expect(combinedMetric).toHaveTextContent('104') + }) + + it('falls back to a numbered parent label while members are still loading', () => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ + data: [], + isLoading: true, + error: null, + refresh: vi.fn(), + } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1') + expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2') + }) + + it('falls back to a numbered parent label for a projection with no matching member', () => { + vi.mocked(hooks.useAnalytics).mockReturnValue({ + data: { + ...mockAnalyticsData, + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedExpiringAtB4: 104, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + } as any) + + renderWithRouter() + expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex') + expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2') + }) }) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7d33012..3f5e342 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,9 +1,10 @@ import { ReactNode, useState } from 'react' import { useWorkspace } from '@/context' -import { useAnalytics, usePlannedDays, useScraperFetch, useParentalLeave } from '@/hooks' +import { useAnalytics, usePlannedDays, useScraperFetch, useParentalLeave, useWorkspaceMembers } from '@/hooks' import type { PlannedLeaveDay } from '@/types' import * as api from '@/services/api' import { getLabelForType } from '@/utils/leaveTypes' +import { nameResolver } from '@/utils/parentNames' import { useTranslation } from '@/i18n' import { Layout } from '@/components/layout/Layout' import { LoadingSpinner } from '@/components/common/LoadingSpinner' @@ -24,6 +25,7 @@ export function Dashboard(): ReactNode { ) const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined) const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null) + const { data: members, isLoading: membersLoading } = useWorkspaceMembers(workspace?.id ?? null) const scraper = useScraperFetch() const [childName, setChildName] = useState('') @@ -127,6 +129,41 @@ export function Dashboard(): ReactNode { const currentWeeks = Math.floor((analytics?.combinedDaysUsed ?? 0) / 5) const targetWeeks = Math.floor(((analytics?.combinedDaysUsed ?? 0) + (analytics?.combinedDaysLeft ?? 0)) / 5) + // Per-parent expiring-day figures are only informative once there's more + // than one parent to compare — with a single parent they'd just repeat + // the combined total below, so skip the breakdown in that case. + const nameFor = nameResolver(members) + const parentProjections = analytics?.parentProjections ?? [] + const showPerParentExpiring = parentProjections.length > 1 + + const parentDisplayName = (uuid: string, index: number): string => { + const resolved = membersLoading ? uuid : nameFor(uuid) + return resolved === uuid + ? t('dashboard.expiringAtB4ParentFallback', { number: index + 1 }) + : resolved + } + + const overviewMetrics = analytics + ? [ + { label: t('dashboard.usedDays'), value: analytics.combinedDaysUsed }, + { label: t('dashboard.daysLeft'), value: analytics.combinedDaysLeft }, + ...(showPerParentExpiring + ? parentProjections.map((projection, index) => ({ + label: t('dashboard.expiringAtB4ForParent', { + name: parentDisplayName(projection.personalDataUuid, index), + }), + value: projection.expiringAtB4, + testId: `metric-expiring-parent-${projection.personalDataUuid}`, + })) + : []), + { + label: t('dashboard.expiringAtB4'), + value: analytics.combinedExpiringAtB4, + testId: 'metric-expiring-combined', + }, + ] + : [] + if (children.length === 0) { return ( @@ -201,11 +238,7 @@ export function Dashboard(): ReactNode { {analytics && ( )} diff --git a/frontend/src/utils/parentNames.test.ts b/frontend/src/utils/parentNames.test.ts new file mode 100644 index 0000000..4f49d50 --- /dev/null +++ b/frontend/src/utils/parentNames.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { nameResolver } from './parentNames' +import type { PersonalData } from '@/types' + +const member = (overrides: Partial = {}): PersonalData => ({ + uuid: 'pd-1', + workspaceId: 'ws-1', + name: 'Alex', + salaryType: 'monthly', + employmentStatus: 'employed', + createdAt: '2026-01-01', + ...overrides, +}) + +describe('nameResolver', () => { + it('resolves a uuid to the matching member name', () => { + const nameFor = nameResolver([member(), member({ uuid: 'pd-2', name: 'Sam' })]) + expect(nameFor('pd-1')).toBe('Alex') + expect(nameFor('pd-2')).toBe('Sam') + }) + + it('falls back to the uuid itself when no member matches', () => { + const nameFor = nameResolver([member()]) + expect(nameFor('unknown-uuid')).toBe('unknown-uuid') + }) + + it('falls back to the uuid for an empty members list', () => { + const nameFor = nameResolver([]) + expect(nameFor('pd-1')).toBe('pd-1') + }) +}) diff --git a/frontend/src/utils/parentNames.ts b/frontend/src/utils/parentNames.ts new file mode 100644 index 0000000..77fd6a3 --- /dev/null +++ b/frontend/src/utils/parentNames.ts @@ -0,0 +1,15 @@ +import type { PersonalData } from '@/types' + +/** + * Builds a uuid → display-name lookup from a workspace's members. + * + * Falls back to returning the uuid itself when no member matches — callers + * that need a friendlier fallback (e.g. while members are still loading) + * should detect that case themselves rather than this helper guessing. + * + * Shared by `PlanResults.tsx` and `Dashboard.tsx` — keep behaviour identical + * for both call sites. + */ +export function nameResolver(members: PersonalData[]): (uuid: string) => string { + return (uuid: string) => members.find((m) => m.uuid === uuid)?.name ?? uuid +}