Files
budget-commun/frontend/src/App.jsx
T

263 lines
8.4 KiB
React

import { useState, useEffect, useCallback } from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { BudgetProvider, useBudget } from './contexts/BudgetContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { usePullToRefresh } from './hooks/usePullToRefresh'
import { api } from './api/client'
import LoginPage from './pages/LoginPage'
import SetupPage from './pages/SetupPage'
import DashboardPage from './pages/DashboardPage'
import ExpensesPage from './pages/ExpensesPage'
import PointagePage from './pages/PointagePage'
import SimulateurPage from './pages/SimulateurPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import EpargnesPage from './pages/EpargnesPage'
// ── Toast ─────────────────────────────────────────────────────
function Toast({ msg, type }) {
return (
<div className={`toast show t-${type}`}>{msg}</div>
)
}
// ── Budget switcher ───────────────────────────────────────────
function BudgetSwitcher() {
const { activeBudgetId, accessibleBudgets, switchBudget } = useBudget()
if (accessibleBudgets.length <= 1) return null
return (
<div className="budget-switcher">
{accessibleBudgets.map(b => (
<button
key={b.id}
className={`budget-switch-btn${b.id === activeBudgetId ? ' active' : ''}`}
onClick={() => switchBudget(b.id)}
>
{b.emoji} {b.nom}
</button>
))}
</div>
)
}
// ── Main shell (requires auth) ────────────────────────────────
const DEFAULT_SETTINGS = {
nameA: '', nameB: '',
incomeA: '', incomeB: '',
sliderVal: 63, sliderLocked: false,
setupDone: 'false',
}
const TABS = [
{ id: 'budget', label: 'Budget', dot: 'var(--aa)' },
{ id: 'depenses', label: 'Dépenses', dot: 'var(--ab)' },
{ id: 'pointage', label: 'Pointage', dot: 'var(--aa)' },
{ id: 'simu', label: 'Simulateur', dot: '#f9a875' },
{ id: 'epargnes', label: 'Épargnes', dot: 'var(--ab)' },
]
function AppShell() {
const { user, logout } = useAuth()
const { activeBudget, activeBudgetId } = useBudget()
const [tab, setTab] = useState('budget')
const [accountOpen, setAccountOpen] = useState(false)
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [expenses, setExpenses] = useState([])
const [commonExpenses, setCommonExpenses] = useState([])
const [categories, setCategories] = useState([])
const [loading, setLoading] = useState(true)
const [toast, setToast] = useState(null)
const [toastTimer, setToastTimer] = useState(null)
function showToast(msg, type = 'ok') {
setToast({ msg, type })
clearTimeout(toastTimer)
const t = setTimeout(() => setToast(null), 2600)
setToastTimer(t)
}
const loadAll = useCallback(async () => {
setLoading(true)
try {
const [s, e, c, ce] = await Promise.all([
api.get('/settings'),
api.get('/expenses'),
api.get('/categories'),
api.get('/expenses?budget_id=1'),
])
setSettings({
...DEFAULT_SETTINGS, ...s,
sliderVal: parseInt(s.sliderVal) || 63,
sliderLocked: s.sliderLocked === 'true',
})
setExpenses(e)
setCategories(c)
setCommonExpenses(ce)
} catch {
showToast('Impossible de joindre le serveur', 'err')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { if (user) loadAll() }, [user, activeBudgetId, loadAll])
const { pullY, refreshing, triggered } = usePullToRefresh(loadAll)
const visibleTabs = TABS
if (loading) {
return (
<div className="loading-overlay">
<div className="spinner" />
</div>
)
}
if (settings.setupDone !== 'true') {
return <SetupPage onComplete={loadAll} />
}
return (
<div className="app-shell">
{/* Pull-to-refresh indicator */}
{(pullY > 0 || refreshing) && (
<div className="ptr-indicator" style={{ '--ptr-y': `${pullY}px` }}>
<div className={`ptr-spinner${triggered || refreshing ? ' spin' : ''}`}></div>
</div>
)}
<div className="wrap">
<header>
<div className="eyebrow">Espace familial</div>
<h1>Budget <span>{activeBudget.nom === 'Commun' ? 'Commun' : activeBudget.nom}</span></h1>
<div className="account-wrap">
<button
className={`account-btn${(tab === 'compte' || tab === 'admin') ? ' active' : ''}`}
onClick={() => setAccountOpen(o => !o)}
title="Compte"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
</button>
{accountOpen && (
<>
<div className="account-backdrop" onClick={() => setAccountOpen(false)} />
<div className="account-dropdown">
<button onClick={() => { setTab('compte'); setAccountOpen(false) }}>
Compte
</button>
{user?.isAdmin && (
<button onClick={() => { setTab('admin'); setAccountOpen(false) }}>
Admin
</button>
)}
<button className="dropdown-logout" onClick={() => { setAccountOpen(false); logout() }}>
Déconnexion
</button>
</div>
</>
)}
</div>
</header>
{/* Budget switcher (multi-profil — étape 4) */}
<div style={{ marginBottom: 14 }}>
<BudgetSwitcher />
</div>
{/* Tab nav */}
<nav className="tabs-nav">
{visibleTabs.map(t => (
<button
key={t.id}
className={`tab-btn${tab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}
>
<span className="tdot" style={{ background: t.dot }} />
{t.label}
</button>
))}
</nav>
</div>
{/* Page content */}
{tab === 'budget' && (
<DashboardPage
expenses={expenses}
commonExpenses={commonExpenses}
categories={categories}
settings={settings}
onSettingsChange={setSettings}
/>
)}
{tab === 'depenses' && (
<ExpensesPage
expenses={expenses}
categories={categories}
onExpensesChange={setExpenses}
onToast={showToast}
/>
)}
{tab === 'pointage' && (
<PointagePage categories={categories} />
)}
{tab === 'simu' && (
<SimulateurPage
settings={settings}
expenses={expenses}
onExpensesChange={setExpenses}
onToast={showToast}
/>
)}
{tab === 'epargnes' && <EpargnesPage onToast={showToast} />}
{tab === 'admin' && user?.isAdmin && (
<AdminPage
categories={categories}
onCategoriesChange={setCategories}
onToast={showToast}
/>
)}
{tab === 'compte' && (
<SettingsPage onToast={showToast} />
)}
<footer className="wrap">Données hébergées sur votre réseau · Budget Commun</footer>
{toast && <Toast msg={toast.msg} type={toast.type} />}
</div>
)
}
// ── Root ──────────────────────────────────────────────────────
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<BudgetProvider>
<AppInner />
</BudgetProvider>
</AuthProvider>
</ThemeProvider>
)
}
function AppInner() {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="loading-overlay">
<div className="spinner" />
</div>
)
}
if (!user) return <LoginPage />
return <AppShell />
}