MVP frontend+backend

This commit is contained in:
2026-06-14 09:58:30 +02:00
parent d9539f23cc
commit 772d853e37
56 changed files with 5099 additions and 207 deletions
+14 -2
View File
@@ -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')
+6 -5
View File
@@ -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">
+265
View File
@@ -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()
})
})
+136 -17
View File
@@ -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>
)
+219
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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>