MVP frontend+backend
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ParentalLeaveBreakdown } from './ParentalLeaveBreakdown'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
describe('ParentalLeaveBreakdown', () => {
|
||||
const mockData: ParentalLeaveFetch = {
|
||||
id: 'pf1',
|
||||
personalDataUuid: 'pd1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'c1',
|
||||
source: 'scraped',
|
||||
fetchedAt: '2026-06-13T10:00:00Z',
|
||||
totalDays: 300,
|
||||
daysUsed: 50,
|
||||
daysLeft: 250,
|
||||
reservedDaysTotal: 90,
|
||||
reservedDaysLeft: 80,
|
||||
transferableDaysLeft: 50,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: {
|
||||
sjukpenningTotal: 100,
|
||||
sjukpenningUsed: 20,
|
||||
sjukpenningLeft: 80,
|
||||
lagstaNivaTotal: 200,
|
||||
lagstaNivaUsed: 30,
|
||||
lagstaNivaLeft: 170,
|
||||
},
|
||||
createdAt: '2026-06-13T10:00:00Z',
|
||||
updatedAt: '2026-06-13T10:00:00Z',
|
||||
}
|
||||
|
||||
it('renders empty state when data is null', () => {
|
||||
render(<ParentalLeaveBreakdown data={null} />)
|
||||
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders breakdown table with data', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reserverade dagar/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays reserved days', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const row = screen.getByTestId('breakdown-reserved')
|
||||
expect(row).toHaveTextContent('80')
|
||||
expect(row).toHaveTextContent('90')
|
||||
})
|
||||
|
||||
it('displays transferable days', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const row = screen.getByTestId('breakdown-transferable')
|
||||
expect(row).toHaveTextContent('50')
|
||||
})
|
||||
|
||||
it('displays dubbeldagar left', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const row = screen.getByTestId('breakdown-dubbeldagar')
|
||||
expect(row).toHaveTextContent('60')
|
||||
})
|
||||
|
||||
it('displays tier data', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const sjukRow = screen.getByTestId('breakdown-sjukpenning')
|
||||
expect(sjukRow).toHaveTextContent('80')
|
||||
expect(sjukRow).toHaveTextContent('100')
|
||||
|
||||
const lagstaRow = screen.getByTestId('breakdown-lagsta-niva')
|
||||
expect(lagstaRow).toHaveTextContent('170')
|
||||
expect(lagstaRow).toHaveTextContent('200')
|
||||
})
|
||||
|
||||
it('displays scraped source label', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByText(/Hämtad från Försäkringskassan/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays manual source label', () => {
|
||||
const manualData = { ...mockData, source: 'manual' as const }
|
||||
render(<ParentalLeaveBreakdown data={manualData} />)
|
||||
expect(screen.getByText(/Manuellt angiven/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays formatted date', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByText(/2026-06-13/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
export interface ParentalLeaveBreakdownProps {
|
||||
data: ParentalLeaveFetch | null
|
||||
}
|
||||
|
||||
export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): ReactNode {
|
||||
if (!data) {
|
||||
return (
|
||||
<div data-testid="parental-leave-breakdown" className="parental-leave-breakdown">
|
||||
<p className="parental-leave-breakdown__empty">
|
||||
Ingen data hämtad ännu — tryck på Hämta från Försäkringskassan.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('sv-SE')
|
||||
}
|
||||
|
||||
const sourceLabel = data.source === 'scraped' ? 'Hämtad från Försäkringskassan' : 'Manuellt angiven'
|
||||
|
||||
return (
|
||||
<div data-testid="parental-leave-breakdown" className="parental-leave-breakdown">
|
||||
<div className="parental-leave-breakdown__section">
|
||||
<table className="parental-leave-breakdown__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Typ</th>
|
||||
<th>Återstående</th>
|
||||
<th>Totalt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr data-testid="breakdown-reserved">
|
||||
<td>Reserverade dagar</td>
|
||||
<td>{data.reservedDaysLeft}</td>
|
||||
<td>{data.reservedDaysTotal}</td>
|
||||
</tr>
|
||||
<tr data-testid="breakdown-transferable">
|
||||
<td>Övergripbara dagar</td>
|
||||
<td>{data.transferableDaysLeft}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr data-testid="breakdown-dubbeldagar">
|
||||
<td>Dubbeldagar kvar</td>
|
||||
<td>{data.dubbeldagarLeft}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
{data.tierData && (
|
||||
<>
|
||||
<tr data-testid="breakdown-sjukpenning">
|
||||
<td>Sjukpenning</td>
|
||||
<td>{data.tierData.sjukpenningLeft}</td>
|
||||
<td>{data.tierData.sjukpenningTotal}</td>
|
||||
</tr>
|
||||
<tr data-testid="breakdown-lagsta-niva">
|
||||
<td>Lägsta nivå</td>
|
||||
<td>{data.tierData.lagstaNivaLeft}</td>
|
||||
<td>{data.tierData.lagstaNivaTotal}</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="parental-leave-breakdown__provenance">
|
||||
<small>
|
||||
{sourceLabel} {formatDate(data.fetchedAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
|
||||
const renderWithRouter = (element: React.ReactElement) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{element}
|
||||
</BrowserRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders header with default title', () => {
|
||||
render(<Header />)
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.getByTestId('header-title')).toHaveTextContent('Föräldraledigplannerare')
|
||||
})
|
||||
|
||||
it('renders header with custom title', () => {
|
||||
render(<Header title="Custom Title" />)
|
||||
expect(screen.getByTestId('header-title')).toHaveTextContent('Custom Title')
|
||||
it('renders parent name when provided', () => {
|
||||
renderWithRouter(<Header parentName="Elin" />)
|
||||
expect(screen.getByTestId('parent-name')).toHaveTextContent('Elin')
|
||||
})
|
||||
|
||||
it('renders child select when children provided', () => {
|
||||
const childrenList = [
|
||||
{ id: '1', name: 'Emma' },
|
||||
{ id: '2', name: 'Oscar' },
|
||||
]
|
||||
renderWithRouter(<Header childrenList={childrenList} selectedChildId="1" />)
|
||||
expect(screen.getByTestId('child-select')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Emma')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChildSelect when child selection changes', async () => {
|
||||
const onChildSelect = vi.fn()
|
||||
const childrenList = [
|
||||
{ id: '1', name: 'Emma' },
|
||||
{ id: '2', name: 'Oscar' },
|
||||
]
|
||||
renderWithRouter(
|
||||
<Header childrenList={childrenList} selectedChildId="1" onChildSelect={onChildSelect} />,
|
||||
)
|
||||
const select = screen.getByTestId('child-select') as HTMLSelectElement
|
||||
await userEvent.selectOptions(select, '2')
|
||||
expect(onChildSelect).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('renders navigation links', () => {
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.getByText('Översikt')).toBeInTheDocument()
|
||||
expect(screen.getByText('Kalender')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tips')).toBeInTheDocument()
|
||||
expect(screen.getByText('Inställningar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders logout button when onLogout provided', () => {
|
||||
const onLogout = vi.fn()
|
||||
render(<Header onLogout={onLogout} />)
|
||||
renderWithRouter(<Header onLogout={onLogout} />)
|
||||
const button = screen.getByTestId('logout-button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onLogout when logout button clicked', async () => {
|
||||
const onLogout = vi.fn()
|
||||
render(<Header onLogout={onLogout} />)
|
||||
renderWithRouter(<Header onLogout={onLogout} />)
|
||||
const button = screen.getByTestId('logout-button')
|
||||
await userEvent.click(button)
|
||||
expect(onLogout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders workspace selector button when onWorkspaceSelect provided', () => {
|
||||
const onWorkspaceSelect = vi.fn()
|
||||
render(<Header onWorkspaceSelect={onWorkspaceSelect} />)
|
||||
expect(screen.getByTestId('workspace-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onWorkspaceSelect when workspace button clicked', async () => {
|
||||
const onWorkspaceSelect = vi.fn()
|
||||
render(<Header onWorkspaceSelect={onWorkspaceSelect} />)
|
||||
const button = screen.getByTestId('workspace-selector')
|
||||
await userEvent.click(button)
|
||||
expect(onWorkspaceSelect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not render buttons when no handlers provided', () => {
|
||||
render(<Header />)
|
||||
it('does not render logout button when not provided', () => {
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.queryByTestId('logout-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workspace-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,91 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import './header.css'
|
||||
|
||||
export interface HeaderProps {
|
||||
title?: string
|
||||
onLogout?: () => void
|
||||
onWorkspaceSelect?: () => void
|
||||
parentName?: string | undefined
|
||||
selectedChildName?: string | undefined
|
||||
childrenList?: Array<{ id: string; name: string }> | undefined
|
||||
selectedChildId?: string | null | undefined
|
||||
onChildSelect?: ((childId: string) => void) | undefined
|
||||
onLogout?: (() => void) | undefined
|
||||
}
|
||||
|
||||
export function Header({ title = 'Föräldraledigplannerare', onLogout, onWorkspaceSelect }: HeaderProps): ReactNode {
|
||||
export function Header({
|
||||
parentName,
|
||||
selectedChildName,
|
||||
childrenList,
|
||||
selectedChildId,
|
||||
onChildSelect,
|
||||
onLogout,
|
||||
}: HeaderProps): ReactNode {
|
||||
return (
|
||||
<header role="banner" data-testid="header">
|
||||
<div data-testid="header-title">{title}</div>
|
||||
<nav role="navigation" data-testid="header-nav">
|
||||
{onWorkspaceSelect && (
|
||||
<button
|
||||
data-testid="workspace-selector"
|
||||
onClick={onWorkspaceSelect}
|
||||
aria-label="Select workspace"
|
||||
>
|
||||
Select Workspace
|
||||
</button>
|
||||
)}
|
||||
<header className="header" role="banner" data-testid="header">
|
||||
<div className="header__top">
|
||||
<div className="header__title" data-testid="header-title">
|
||||
Föräldraledigplannerare
|
||||
</div>
|
||||
<div className="header__identity">
|
||||
{parentName && (
|
||||
<span className="header__parent-name" data-testid="parent-name">
|
||||
{parentName}
|
||||
</span>
|
||||
)}
|
||||
{childrenList && childrenList.length > 0 && (
|
||||
<select
|
||||
className="header__child-select"
|
||||
value={selectedChildId ?? ''}
|
||||
onChange={(e) => onChildSelect?.(e.target.value)}
|
||||
data-testid="child-select"
|
||||
aria-label="Select child"
|
||||
>
|
||||
{childrenList.map((child) => (
|
||||
<option key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedChildName && childrenList && childrenList.length === 1 && (
|
||||
<span className="header__child-name" data-testid="child-name">
|
||||
{selectedChildName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="header__nav" role="navigation" data-testid="header-nav">
|
||||
<NavLink
|
||||
to="/"
|
||||
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
|
||||
>
|
||||
Översikt
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/calendar"
|
||||
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
|
||||
>
|
||||
Kalender
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/tips"
|
||||
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
|
||||
>
|
||||
Tips
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
|
||||
>
|
||||
Inställningar
|
||||
</NavLink>
|
||||
{onLogout && (
|
||||
<button
|
||||
data-testid="logout-button"
|
||||
className="header__logout-button"
|
||||
onClick={onLogout}
|
||||
data-testid="logout-button"
|
||||
aria-label="Log out"
|
||||
>
|
||||
Logout
|
||||
Logga ut
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useWorkspace } from '@/context/WorkspaceContext'
|
||||
import { Header, HeaderProps } from './Header'
|
||||
import { Footer } from './Footer'
|
||||
|
||||
@@ -8,9 +11,30 @@ interface LayoutProps {
|
||||
}
|
||||
|
||||
export function Layout({ children, header }: LayoutProps): ReactNode {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
const { parentName, children: childrenList, selectedChildId, selectChild } = useWorkspace()
|
||||
|
||||
const selectedChild = childrenList.find((c) => c.id === selectedChildId)
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout()
|
||||
navigate('/auth', { replace: true })
|
||||
}, [logout, navigate])
|
||||
|
||||
const headerProps: HeaderProps = {
|
||||
...header,
|
||||
parentName: parentName ?? undefined,
|
||||
selectedChildName: selectedChild?.name,
|
||||
childrenList,
|
||||
selectedChildId,
|
||||
onChildSelect: selectChild,
|
||||
onLogout: handleLogout,
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="main" data-testid="layout">
|
||||
<Header {...(header || {})} />
|
||||
<Header {...headerProps} />
|
||||
<main data-testid="layout-main">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.header {
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header__parent-name {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header__child-select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header__child-name {
|
||||
font-size: 14px;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header__nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header__nav-link {
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.header__nav-link:hover {
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
.header__nav-link--active {
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header__logout-button {
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.header__logout-button:hover {
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QRCodeModal } from './QRCodeModal'
|
||||
|
||||
describe('QRCodeModal', () => {
|
||||
const mockHandlers = {
|
||||
onClose: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={false}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status={null}
|
||||
isRunning={false}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText(/Hämta från Försäkringskassan/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders when isOpen is true', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="launching"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Hämta från Försäkringskassan/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays QR code when status is awaiting_scan', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="awaiting_scan"
|
||||
qrData="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Öppna BankID-appen/)).toBeInTheDocument()
|
||||
expect(screen.getByAltText(/BankID QR-kod/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays loading text during authenticating', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="authenticating"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Autentiserar/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays loading text during fetching', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="fetching"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Hämtar data från FK/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays done message and close button on success', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="done"
|
||||
result={{ data: 'fetched' }}
|
||||
isRunning={false}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Klar/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Stäng/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays cancel button when running', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="awaiting_scan"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /Avbryt/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays error message when error prop is set', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="error"
|
||||
error="Authentication failed"
|
||||
isRunning={false}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Authentication failed/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays cancelled message on cancel', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="cancelled"
|
||||
isRunning={false}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Sessionen avbröts/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables close button when running', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="fetching"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
const closeButton = screen.getByRole('button', { name: '×' })
|
||||
expect(closeButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onClose when close button clicked and not running', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="launching"
|
||||
isRunning={false}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '×' }))
|
||||
expect(mockHandlers.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onDone when done button clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="done"
|
||||
result={{ data: 'fetched' }}
|
||||
isRunning={false}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /Stäng/ }))
|
||||
expect(mockHandlers.onDone).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="awaiting_scan"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /Avbryt/ }))
|
||||
expect(mockHandlers.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays status text with message', () => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status="fetching"
|
||||
message="In progress"
|
||||
isRunning={true}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/fetching/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/In progress/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { ScraperStatus } from '@/hooks/useScraperFetch'
|
||||
|
||||
interface QRCodeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onDone: () => void
|
||||
status: ScraperStatus | null
|
||||
qrData?: string | undefined
|
||||
result?: Record<string, unknown> | undefined
|
||||
message?: string | undefined
|
||||
error?: string | null
|
||||
isRunning: boolean
|
||||
onCancel: () => Promise<void>
|
||||
}
|
||||
|
||||
export function QRCodeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDone,
|
||||
status,
|
||||
qrData,
|
||||
result,
|
||||
message,
|
||||
error,
|
||||
isRunning,
|
||||
onCancel,
|
||||
}: QRCodeModalProps): ReactNode {
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleClose = () => {
|
||||
if (isRunning) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: 'white', borderRadius: 8, padding: '2rem', maxWidth: 500, width: '90%', boxShadow: '0 2px 8px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Hämta från Försäkringskassan</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '1.5rem',
|
||||
cursor: isRunning ? 'not-allowed' : 'pointer',
|
||||
opacity: isRunning ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong>Status:</strong>{' '}
|
||||
<span style={{ color: status === 'done' ? 'green' : status === 'error' ? 'red' : 'inherit' }}>
|
||||
{status}
|
||||
</span>
|
||||
{message && <span style={{ color: '#888', marginLeft: '0.5rem' }}>— {message}</span>}
|
||||
</div>
|
||||
|
||||
{status === 'awaiting_scan' && qrData && (
|
||||
<div style={{ marginBottom: '1rem', textAlign: 'center' }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '0.9rem' }}>Öppna BankID-appen och skanna QR-koden:</p>
|
||||
<img
|
||||
src={`data:image/png;base64,${qrData}`}
|
||||
alt="BankID QR-kod"
|
||||
style={{ width: 220, height: 220, imageRendering: 'pixelated', border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'authenticating' || status === 'fetching') && (
|
||||
<p style={{ color: '#555', textAlign: 'center' }}>
|
||||
⏳ {status === 'authenticating' ? 'Autentiserar…' : 'Hämtar data från FK…'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'done' && result && (
|
||||
<div>
|
||||
<p style={{ color: 'green', marginBottom: '0.5rem' }}>✅ Klar! Data hämtad från Försäkringskassan.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && (
|
||||
<p style={{ color: '#888' }}>Sessionen avbröts.</p>
|
||||
)}
|
||||
|
||||
{status === 'done' && (
|
||||
<button
|
||||
onClick={onDone}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
>
|
||||
Stäng
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#f44336',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ background: '#fee', border: '1px solid #f88', padding: '0.75rem', borderRadius: 4, color: '#c00' }}>
|
||||
<strong>Fel:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user