263 lines
8.4 KiB
React
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 />
|
|
}
|