MVP frontend+backend
This commit is contained in:
@@ -1,22 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useWorkspace } from '@/context/WorkspaceContext'
|
||||
|
||||
export function Auth() {
|
||||
const { login, register } = useAuth()
|
||||
const { login, register, isAuthenticated } = useAuth()
|
||||
const { isLoading: wsLoading } = useWorkspace()
|
||||
const navigate = useNavigate()
|
||||
const [isRegister, setIsRegister] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !wsLoading) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [isAuthenticated, wsLoading, navigate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
if (isRegister) {
|
||||
await register({ email, password, displayName })
|
||||
navigate('/', { replace: true })
|
||||
} else {
|
||||
await login({ email, password })
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Något gick fel')
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { usePlannedDays } from '@/hooks'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { CalendarDay } from '@/components/common/CalendarDay'
|
||||
import { CalendarEvent } from '@/components/common/CalendarEvent'
|
||||
|
||||
export function Calendar(): ReactNode {
|
||||
const { workspace, isLoading: wsLoading } = useWorkspace()
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(workspace?.id ?? null)
|
||||
const { isLoading: wsLoading, selectedChildId } = useWorkspace()
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date(2026, 5))
|
||||
|
||||
const isLoading = wsLoading || pdLoading
|
||||
@@ -49,8 +50,8 @@ export function Calendar(): ReactNode {
|
||||
|
||||
const getEventsForDate = (date: string) => {
|
||||
return (plannedDays ?? [])
|
||||
.filter((pd: any) => pd.date === date)
|
||||
.map((pd: any) => ({
|
||||
.filter((pd: PlannedLeaveDay) => pd.date === date)
|
||||
.map((pd: PlannedLeaveDay) => ({
|
||||
type: pd.dayType,
|
||||
color: getColorForType(pd.dayType),
|
||||
title: pd.dayType,
|
||||
@@ -66,7 +67,7 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={{ title: 'Kalender' }}>
|
||||
<Layout>
|
||||
<div data-testid="calendar" className="calendar">
|
||||
<div className="calendar__header">
|
||||
<button onClick={handlePrevMonth} aria-label="Previous month">
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Dashboard } from './Dashboard'
|
||||
import * as hooks from '@/hooks'
|
||||
import { useWorkspace, useAuth } from '@/context'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
vi.mock('@/context')
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||||
}
|
||||
|
||||
describe('Dashboard', () => {
|
||||
const mockWorkspaceContext = {
|
||||
workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced' as const, createdAt: '2026-01-01' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
workspaceId: 'ws-1',
|
||||
setWorkspaceId: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
|
||||
selectedChildId: 'c-1',
|
||||
selectChild: vi.fn(),
|
||||
personalDataUuid: 'pd-1',
|
||||
parentName: 'Test Parent',
|
||||
refreshContext: vi.fn(),
|
||||
}
|
||||
|
||||
const mockAnalyticsData = {
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
parentProjections: [],
|
||||
combinedDaysUsed: 30,
|
||||
combinedDaysLeft: 270,
|
||||
combinedExpiringAtB4: 100,
|
||||
}
|
||||
|
||||
const mockParentalLeaveData = {
|
||||
id: 'pf-1',
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
source: 'scraped' as const,
|
||||
fetchedAt: '2026-06-13T10:00:00Z',
|
||||
totalDays: 300,
|
||||
daysUsed: 30,
|
||||
daysLeft: 270,
|
||||
reservedDaysTotal: 90,
|
||||
reservedDaysLeft: 80,
|
||||
transferableDaysLeft: 50,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: {
|
||||
sjukpenningTotal: 100,
|
||||
sjukpenningUsed: 10,
|
||||
sjukpenningLeft: 90,
|
||||
lagstaNivaTotal: 200,
|
||||
lagstaNivaUsed: 20,
|
||||
lagstaNivaLeft: 180,
|
||||
},
|
||||
createdAt: '2026-06-13T10:00:00Z',
|
||||
updatedAt: '2026-06-13T10:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
accessToken: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any)
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue({
|
||||
data: mockAnalyticsData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(hooks.useParentalLeave).mockReturnValue({
|
||||
data: mockParentalLeaveData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(hooks.usePlannedDays).mockReturnValue({
|
||||
data: [
|
||||
{ id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' },
|
||||
{ id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(hooks.useBreakPeriods).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue({
|
||||
sessionId: null,
|
||||
pollData: null,
|
||||
resolvedIds: null,
|
||||
error: null,
|
||||
isUnauthorized: false,
|
||||
start: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isRunning: false,
|
||||
isTerminal: false,
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('renders loading spinner initially', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
isLoading: true,
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Loading dashboard/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows add child form when no children', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
children: [],
|
||||
selectedChildId: null,
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Du har inga barn registrerade ännu/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows overview metrics when children exist', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Days Used/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows parental leave breakdown when data exists', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reserverade dagar/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders FK fetch button', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables FK fetch button when no child selected', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({
|
||||
...mockWorkspaceContext,
|
||||
selectedChildId: null,
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables FK fetch button when child selected', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows planned days breakdown', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows progress chart with real data', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('progress-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Swedish section headings', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
|
||||
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
|
||||
expect(screen.getByText(/Återstående till 4 års ålder/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows break periods section when periods exist', () => {
|
||||
vi.mocked(hooks.useBreakPeriods).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'bp-1',
|
||||
workspaceId: 'ws-1',
|
||||
name: 'Summer Break',
|
||||
startDate: '2026-06-20',
|
||||
endDate: '2026-08-31',
|
||||
targetWeeks: 8,
|
||||
createdAt: '2026-06-13T10:00:00Z',
|
||||
updatedAt: '2026-06-13T10:00:00Z',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Break Periods/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Summer Break/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays error message if analytics fails', () => {
|
||||
vi.mocked(hooks.useAnalytics).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: 'Failed to load analytics',
|
||||
refresh: vi.fn(),
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Failed to load analytics/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state for parental leave when data is null', () => {
|
||||
vi.mocked(hooks.useParentalLeave).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
} as any)
|
||||
renderWithRouter(<Dashboard />)
|
||||
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens QR code modal on FK fetch button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockStart = vi.fn()
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue({
|
||||
sessionId: null,
|
||||
pollData: null,
|
||||
resolvedIds: null,
|
||||
error: null,
|
||||
isUnauthorized: false,
|
||||
start: mockStart,
|
||||
cancel: vi.fn(),
|
||||
isRunning: false,
|
||||
isTerminal: false,
|
||||
} as any)
|
||||
|
||||
renderWithRouter(<Dashboard />)
|
||||
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
|
||||
await user.click(button)
|
||||
|
||||
expect(mockStart).toHaveBeenCalledWith('c-1')
|
||||
})
|
||||
|
||||
it('shows FK button is not disabled by default with child selected', () => {
|
||||
renderWithRouter(<Dashboard />)
|
||||
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,33 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { useAnalytics, usePlannedDays, useBreakPeriods } from '@/hooks'
|
||||
import { useAnalytics, usePlannedDays, useBreakPeriods, useScraperFetch, useParentalLeave } from '@/hooks'
|
||||
import type { PlannedLeaveDay, BreakPeriod } from '@/types'
|
||||
import * as api from '@/services/api'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
|
||||
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
|
||||
import { ProgressChart } from '@/components/dashboard/ProgressChart'
|
||||
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
|
||||
export function Dashboard(): ReactNode {
|
||||
const { workspace, isLoading: wsLoading } = useWorkspace()
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useAnalytics(
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics(
|
||||
workspace?.id ?? null,
|
||||
'' // placeholder child ID; in real app would come from workspace context
|
||||
selectedChildId ?? ''
|
||||
)
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(workspace?.id ?? null)
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const { data: breakPeriods, isLoading: bpLoading } = useBreakPeriods(workspace?.id ?? null)
|
||||
const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null)
|
||||
const scraper = useScraperFetch()
|
||||
|
||||
const [childName, setChildName] = useState('')
|
||||
const [childBirthDate, setChildBirthDate] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const isLoading = wsLoading || analyticsLoading || pdLoading || bpLoading
|
||||
|
||||
@@ -22,16 +35,98 @@ export function Dashboard(): ReactNode {
|
||||
return <LoadingSpinner message="Loading dashboard..." />
|
||||
}
|
||||
|
||||
const handleAddChild = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
if (!workspace || !childName || !childBirthDate) {
|
||||
setSubmitError('Please fill in all fields')
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await api.createChild({
|
||||
workspace_id: workspace.id,
|
||||
name: childName,
|
||||
birth_date: childBirthDate,
|
||||
})
|
||||
setChildName('')
|
||||
setChildBirthDate('')
|
||||
await refreshContext()
|
||||
} catch (err: unknown) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Failed to create child')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchFK = async () => {
|
||||
setShowModal(true)
|
||||
await scraper.start(selectedChildId ?? undefined)
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
if (scraper.isRunning) return
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const handleFetchDone = async () => {
|
||||
setShowModal(false)
|
||||
await Promise.all([refreshAnalytics(), refreshParentalLeave()])
|
||||
}
|
||||
|
||||
const breakdown = [
|
||||
{ type: 'Vacation', count: plannedDays?.filter((d: any) => d.dayType === 'vacation').length ?? 0 },
|
||||
{ type: 'Sick Leave', count: plannedDays?.filter((d: any) => d.dayType === 'sjukpenning').length ?? 0 },
|
||||
{ type: 'Parental', count: plannedDays?.filter((d: any) => d.dayType === 'lagsta').length ?? 0 },
|
||||
{ type: 'Vacation', count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'vacation').length ?? 0 },
|
||||
{ type: 'Sick Leave', count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'sjukpenning').length ?? 0 },
|
||||
{ type: 'Parental', count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'lagsta').length ?? 0 },
|
||||
]
|
||||
|
||||
const totalPlanned = breakdown.reduce((sum, item) => sum + item.count, 0)
|
||||
const currentWeeks = Math.floor((analytics?.combinedDaysUsed ?? 0) / 5)
|
||||
const targetWeeks = Math.floor(((analytics?.combinedDaysUsed ?? 0) + (analytics?.combinedDaysLeft ?? 0)) / 5)
|
||||
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="dashboard" className="dashboard">
|
||||
<section className="dashboard__section">
|
||||
<h2>Lägg till barn</h2>
|
||||
<p>Du har inga barn registrerade ännu. Lägg till ditt första barn för att börja.</p>
|
||||
<form onSubmit={(e) => void handleAddChild(e)}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="child-name">Barnets namn</label>
|
||||
<input
|
||||
id="child-name"
|
||||
type="text"
|
||||
placeholder="Namn"
|
||||
value={childName}
|
||||
onChange={(e) => setChildName(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<ChildBirthdayInput
|
||||
birthDate={childBirthDate}
|
||||
onBirthDateChange={setChildBirthDate}
|
||||
label="Födelsedag"
|
||||
/>
|
||||
</div>
|
||||
{submitError && (
|
||||
<div role="alert" className="error-message">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Lägger till...' : 'Lägg till barn'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={{ title: 'Översikt' }}>
|
||||
<Layout>
|
||||
<div data-testid="dashboard" className="dashboard">
|
||||
{analyticsError && (
|
||||
<div className="dashboard__error" data-testid="error-banner" role="alert">
|
||||
@@ -40,9 +135,19 @@ export function Dashboard(): ReactNode {
|
||||
)}
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>Overview</h2>
|
||||
<button
|
||||
onClick={handleFetchFK}
|
||||
disabled={!selectedChildId}
|
||||
style={{ padding: '0.5rem 1rem', cursor: selectedChildId ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
Hämta från Försäkringskassan
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
{analytics && (
|
||||
<OverviewMetrics
|
||||
title="Översikt"
|
||||
metrics={[
|
||||
{ label: 'Days Used', value: analytics.combinedDaysUsed },
|
||||
{ label: 'Days Left', value: analytics.combinedDaysLeft },
|
||||
@@ -50,18 +155,19 @@ export function Dashboard(): ReactNode {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<ParentalLeaveBreakdown data={parentalLeave} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>Planned Days</h2>
|
||||
<h2>Planerade dagar</h2>
|
||||
<DaysBreakdown breakdown={breakdown} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>Progress Toward Age 4</h2>
|
||||
<h2>Återstående till 4 års ålder</h2>
|
||||
<ProgressChart
|
||||
currentWeeks={Math.floor(totalPlanned / 5)}
|
||||
targetWeeks={50}
|
||||
currentWeeks={currentWeeks}
|
||||
targetWeeks={targetWeeks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -69,7 +175,7 @@ export function Dashboard(): ReactNode {
|
||||
<section className="dashboard__section">
|
||||
<h2>Break Periods ({breakPeriods.length})</h2>
|
||||
<ul className="dashboard__break-list">
|
||||
{breakPeriods.map((period: any) => (
|
||||
{breakPeriods.map((period: BreakPeriod) => (
|
||||
<li key={period.id} className="dashboard__break-item">
|
||||
<strong>{period.name}</strong>
|
||||
<small>
|
||||
@@ -80,6 +186,19 @@ export function Dashboard(): ReactNode {
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<QRCodeModal
|
||||
isOpen={showModal}
|
||||
onClose={handleModalClose}
|
||||
onDone={handleFetchDone}
|
||||
status={scraper.pollData?.status ?? null}
|
||||
qrData={scraper.pollData?.qr_data}
|
||||
result={scraper.pollData?.result}
|
||||
message={scraper.pollData?.message}
|
||||
error={scraper.error}
|
||||
isRunning={scraper.isRunning}
|
||||
onCancel={scraper.cancel}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Dev-only scraper test harness — /dev/fetch
|
||||
*
|
||||
* ⚠️ This page is mounted ONLY in DEV mode (import.meta.env.DEV).
|
||||
* It is never included in production builds.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Run `npm run login` in the frontend directory.
|
||||
* 2. Log in through the normal app flow first (the scraper endpoints require auth).
|
||||
* 3. Click "Refresh" — the backend resolves your IDs automatically.
|
||||
* 4. Scan the QR with BankID and watch status transitions until data appears.
|
||||
*
|
||||
* This runs the REAL FK scraper against the REAL Försäkringskassan site with YOUR BankID.
|
||||
* Only run it intentionally. SCRAPER_ENABLED=true is set by npm run login:backend.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useScraperFetch } from '@/hooks'
|
||||
import { isUnauthorized } from '@/services/errors'
|
||||
|
||||
export function DevFetch() {
|
||||
const [context, setContext] = useState<api.UserContext | null>(null)
|
||||
const [contextError, setContextError] = useState<string | null>(null)
|
||||
const [childName, setChildName] = useState('')
|
||||
const [childBirth, setChildBirth] = useState('')
|
||||
const [creatingChild, setCreatingChild] = useState(false)
|
||||
|
||||
const scraper = useScraperFetch()
|
||||
|
||||
const loadContext = async () => {
|
||||
try {
|
||||
const ctx = await api.getUserContext()
|
||||
setContext(ctx)
|
||||
setContextError(null)
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) {
|
||||
setContextError('Not authenticated')
|
||||
} else {
|
||||
setContextError(err instanceof Error ? err.message : 'Failed to load context')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadContext()
|
||||
}, [])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await scraper.start()
|
||||
}
|
||||
|
||||
const handleCreateChild = async () => {
|
||||
if (!context || !childName || !childBirth) return
|
||||
setCreatingChild(true)
|
||||
try {
|
||||
await api.createChild({
|
||||
workspace_id: context.workspace_id,
|
||||
name: childName,
|
||||
birth_date: childBirth,
|
||||
})
|
||||
await loadContext()
|
||||
setChildName('')
|
||||
setChildBirth('')
|
||||
} catch (err) {
|
||||
setContextError(err instanceof Error ? err.message : 'Failed to create child')
|
||||
} finally {
|
||||
setCreatingChild(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasChildren = context && context.children.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'monospace', maxWidth: 700, margin: '2rem auto', padding: '1rem' }}>
|
||||
<h1 style={{ fontSize: '1.2rem', marginBottom: 4 }}>🔧 FK Scraper Test Harness</h1>
|
||||
<p style={{ color: '#888', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
|
||||
Dev-only. Runs the real FK scraper against Försäkringskassan with your BankID.
|
||||
<br />
|
||||
Log in through the normal app first — these endpoints require an app session.
|
||||
</p>
|
||||
|
||||
{contextError && (
|
||||
<div style={{ background: '#fee', border: '1px solid #f88', padding: '0.75rem', marginBottom: '1rem' }}>
|
||||
<strong>Setup-fel:</strong> {contextError}
|
||||
{scraper.isUnauthorized && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<a href="/auth" style={{ color: '#0066cc', textDecoration: 'underline' }}>
|
||||
Logga in först
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{context && (
|
||||
<div style={{ background: '#f6f8fa', border: '1px solid #ddd', padding: '0.75rem', marginBottom: '1rem', fontSize: '0.8rem' }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>Workspace:</strong> {context.workspace_id}
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>PersonalData:</strong> {context.personal_data_uuid}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Barn:</strong>{' '}
|
||||
{context.children.length === 0
|
||||
? <span style={{ color: '#c00' }}>Inga barn registrerade</span>
|
||||
: context.children.map(c => `${c.name} (${c.birth_date})`).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{context && !hasChildren && (
|
||||
<div style={{ background: '#fffbe6', border: '1px solid #ffe58f', padding: '0.75rem', marginBottom: '1rem' }}>
|
||||
<div style={{ marginBottom: '0.5rem', fontWeight: 'bold', fontSize: '0.9rem' }}>Registrera ett barn för att kunna köra scrapern</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
placeholder="Barnets namn"
|
||||
value={childName}
|
||||
onChange={e => setChildName(e.target.value)}
|
||||
style={{ padding: '0.4rem', fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="Födelsedatum"
|
||||
value={childBirth}
|
||||
onChange={e => setChildBirth(e.target.value)}
|
||||
style={{ padding: '0.4rem', fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateChild}
|
||||
disabled={creatingChild || !childName || !childBirth}
|
||||
style={{ padding: '0.4rem 1rem', cursor: 'pointer' }}
|
||||
>
|
||||
{creatingChild ? 'Skapar…' : 'Skapa barn'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={!hasChildren}
|
||||
style={{ padding: '0.5rem 1.5rem', cursor: hasChildren ? 'pointer' : 'not-allowed', fontWeight: 'bold', opacity: hasChildren ? 1 : 0.5 }}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scraper.resolvedIds && (
|
||||
<div style={{ color: '#aaa', fontSize: '0.75rem', marginBottom: '1rem', lineHeight: 1.6 }}>
|
||||
<div>workspace_id: {scraper.resolvedIds.workspace_id}</div>
|
||||
<div>personal_data_uuid: {scraper.resolvedIds.personal_data_uuid}</div>
|
||||
<div>child_id: {scraper.resolvedIds.child_id}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scraper.error && (
|
||||
<div style={{ background: '#fee', border: '1px solid #f88', padding: '0.75rem', marginBottom: '1rem' }}>
|
||||
<strong>Fel:</strong> {scraper.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scraper.pollData && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>Status:</strong>{' '}
|
||||
<span style={{ color: scraper.pollData.status === 'done' ? 'green' : scraper.pollData.status === 'error' ? 'red' : 'inherit' }}>
|
||||
{scraper.pollData.status}
|
||||
</span>
|
||||
{scraper.pollData.message && (
|
||||
<span style={{ color: '#888', marginLeft: '0.5rem' }}>— {scraper.pollData.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scraper.pollData.status === 'awaiting_scan' && scraper.pollData.qr_data && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '0.9rem' }}>
|
||||
Öppna BankID-appen och skanna QR-koden (uppdateras automatiskt):
|
||||
</p>
|
||||
<img
|
||||
src={`data:image/png;base64,${scraper.pollData.qr_data}`}
|
||||
alt="BankID QR-kod"
|
||||
style={{ width: 220, height: 220, imageRendering: 'pixelated', border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(scraper.pollData.status === 'authenticating' || scraper.pollData.status === 'fetching') && (
|
||||
<p style={{ color: '#555' }}>⏳ {scraper.pollData.status === 'authenticating' ? 'Autentiserar…' : 'Hämtar data från FK…'}</p>
|
||||
)}
|
||||
|
||||
{scraper.pollData.status === 'done' && scraper.pollData.result && (
|
||||
<div>
|
||||
<p style={{ color: 'green', marginBottom: '0.5rem' }}>✅ Klar! Hämtad data:</p>
|
||||
<pre style={{ background: '#f6f8fa', padding: '1rem', overflow: 'auto', fontSize: '0.8rem', border: '1px solid #ddd' }}>
|
||||
{JSON.stringify(scraper.pollData.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scraper.pollData.status === 'cancelled' && (
|
||||
<p style={{ color: '#888' }}>Sessionen avbröts.</p>
|
||||
)}
|
||||
|
||||
{scraper.isRunning && (
|
||||
<button
|
||||
onClick={scraper.cancel}
|
||||
style={{ marginTop: '0.5rem', padding: '0.4rem 0.8rem', cursor: 'pointer', background: '#fee', border: '1px solid #faa' }}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+155
-24
@@ -1,6 +1,7 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { useVacationDays, useBreakPeriods } from '@/hooks'
|
||||
import * as api from '@/services/api'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
@@ -10,7 +11,7 @@ import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
|
||||
import { BreakPeriodsInput } from '@/components/inputs/BreakPeriodsInput'
|
||||
|
||||
export function Settings(): ReactNode {
|
||||
const { workspace, isLoading: wsLoading } = useWorkspace()
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
|
||||
const { data: vacDays, isLoading: vacLoading, upsert } = useVacationDays(workspace?.id ?? '')
|
||||
const { data: breakPeriods, isLoading: bpLoading, add: addBreakPeriod, remove: removeBreakPeriod } = useBreakPeriods(
|
||||
workspace?.id ?? null
|
||||
@@ -19,20 +20,38 @@ export function Settings(): ReactNode {
|
||||
const [childBirthDate, setChildBirthDate] = useState('')
|
||||
const [parentData, setParentData] = useState({ salaryType: '', employmentStatus: '' })
|
||||
const [vacationDaysValue, setVacationDaysValue] = useState(vacDays?.[0]?.totalDays ?? 25)
|
||||
const [planningGoal, setPlanningGoal] = useState(workspace?.planningGoal ?? 'balance')
|
||||
const [planningGoal, setPlanningGoal] = useState<'max_time' | 'max_money' | 'balanced'>(
|
||||
(workspace?.planningGoal as 'max_time' | 'max_money' | 'balanced') ?? 'balanced',
|
||||
)
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isLoading = wsLoading || vacLoading || bpLoading
|
||||
|
||||
useEffect(() => {
|
||||
if (vacDays?.[0]?.totalDays) {
|
||||
setVacationDaysValue(vacDays[0].totalDays)
|
||||
}
|
||||
}, [vacDays])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace?.planningGoal) {
|
||||
setPlanningGoal(workspace.planningGoal)
|
||||
}
|
||||
}, [workspace?.planningGoal])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Loading settings..." />
|
||||
}
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
|
||||
const handleSaveVacationDays = async () => {
|
||||
try {
|
||||
if (workspace?.id) {
|
||||
if (workspace?.id && personalDataUuid) {
|
||||
setIsSaving(true)
|
||||
await upsert({
|
||||
personalDataUuid: 'current-user',
|
||||
personalDataUuid,
|
||||
workspaceId: workspace.id,
|
||||
year: new Date().getFullYear(),
|
||||
totalDays: vacationDaysValue,
|
||||
@@ -40,30 +59,112 @@ export function Settings(): ReactNode {
|
||||
setSaveMessage({ type: 'success', text: 'Vacation days saved!' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
}
|
||||
} catch {
|
||||
setSaveMessage({ type: 'error', text: 'Failed to save vacation days' })
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to save vacation days',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddBreakPeriod = async (period: any) => {
|
||||
const handleSaveParentProfile = async () => {
|
||||
try {
|
||||
if (personalDataUuid) {
|
||||
setIsSaving(true)
|
||||
await api.updateParentSettings(personalDataUuid, {
|
||||
salary_type: parentData.salaryType || undefined,
|
||||
employment_status: parentData.employmentStatus || undefined,
|
||||
})
|
||||
setSaveMessage({ type: 'success', text: 'Parent profile saved!' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to save parent profile',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePlanningGoal = async () => {
|
||||
try {
|
||||
if (workspace?.id) {
|
||||
await addBreakPeriod(
|
||||
period.startDate,
|
||||
period.endDate,
|
||||
period.name,
|
||||
period.targetWeeks
|
||||
)
|
||||
setIsSaving(true)
|
||||
await api.updateWorkspace(workspace.id, {
|
||||
planningGoal: planningGoal as 'max_time' | 'max_money' | 'balanced',
|
||||
})
|
||||
await refreshContext()
|
||||
setSaveMessage({ type: 'success', text: 'Planning goal saved!' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to save planning goal',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveChildBirthDate = async () => {
|
||||
try {
|
||||
if (selectedChildId && childBirthDate) {
|
||||
setIsSaving(true)
|
||||
await api.updateChildSettings(selectedChildId, { birth_date: childBirthDate })
|
||||
setSaveMessage({ type: 'success', text: 'Child birth date saved!' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to save child birth date',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddBreakPeriod = async (period: { name: string; startDate: string; endDate: string; targetWeeks: number }) => {
|
||||
try {
|
||||
if (workspace?.id) {
|
||||
setIsSaving(true)
|
||||
await addBreakPeriod(period.startDate, period.endDate, period.name, period.targetWeeks)
|
||||
setSaveMessage({ type: 'success', text: 'Break period added!' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
}
|
||||
} catch {
|
||||
setSaveMessage({ type: 'error', text: 'Failed to add break period' })
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to add break period',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveBreakPeriod = async (id: string) => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await removeBreakPeriod(id)
|
||||
setSaveMessage({ type: 'success', text: 'Break period removed!' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to remove break period',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={{ title: 'Inställningar' }}>
|
||||
<Layout>
|
||||
<div data-testid="settings-page" className="settings-page">
|
||||
<h1>Settings</h1>
|
||||
|
||||
@@ -79,10 +180,23 @@ export function Settings(): ReactNode {
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>Child Information</h2>
|
||||
<ChildBirthdayInput
|
||||
birthDate={childBirthDate}
|
||||
onBirthDateChange={setChildBirthDate}
|
||||
/>
|
||||
{selectedChild && (
|
||||
<>
|
||||
<p>Selected child: <strong>{selectedChild.name}</strong></p>
|
||||
<ChildBirthdayInput
|
||||
birthDate={childBirthDate}
|
||||
onBirthDateChange={setChildBirthDate}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveChildBirthDate}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="save-child-button"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Child Birth Date'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
@@ -91,14 +205,30 @@ export function Settings(): ReactNode {
|
||||
parent={parentData}
|
||||
onChange={(field, val) => setParentData({ ...parentData, [field]: val })}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveParentProfile}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="save-parent-button"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Parent Profile'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>Workspace Settings</h2>
|
||||
<PlanningGoalSelector
|
||||
value={planningGoal}
|
||||
onChange={(goal) => setPlanningGoal(goal)}
|
||||
onChange={(goal) => setPlanningGoal(goal as 'max_time' | 'max_money' | 'balanced')}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSavePlanningGoal}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="save-planning-goal-button"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Planning Goal'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
@@ -113,9 +243,10 @@ export function Settings(): ReactNode {
|
||||
<button
|
||||
onClick={handleSaveVacationDays}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="save-vacation-button"
|
||||
>
|
||||
Save Vacation Days
|
||||
{isSaving ? 'Saving...' : 'Save Vacation Days'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -124,7 +255,7 @@ export function Settings(): ReactNode {
|
||||
<BreakPeriodsInput
|
||||
periods={breakPeriods ?? []}
|
||||
onAdd={handleAddBreakPeriod}
|
||||
onRemove={removeBreakPeriod}
|
||||
onRemove={handleRemoveBreakPeriod}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Tips(): ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={{ title: 'Tips' }}>
|
||||
<Layout>
|
||||
<div data-testid="tips-page" className="tips-page">
|
||||
<h1>Optimization Tips</h1>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user