Set up initial frontend with Vite and integrated Docker for full-stack build

This commit is contained in:
alexandre grondin
2026-04-19 22:24:59 +02:00
commit ac63c4be99
37 changed files with 9796 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Budget" />
<meta name="theme-color" content="#0f0e0c" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=DM+Mono:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<title>Budget Commun</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1677
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "budget-commun-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.2.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#1a1814"/>
<text x="16" y="22" text-anchor="middle" font-family="serif" font-size="18" font-weight="700" fill="#e8b86d">B</text>
</svg>

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+32
View File
@@ -0,0 +1,32 @@
const CACHE = 'budget-commun-v3'
const ASSETS = ['/', '/index.html', '/manifest.json']
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)))
self.skipWaiting()
})
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
)
self.clients.claim()
})
self.addEventListener('fetch', e => {
// API : réseau uniquement, jamais de cache
if (e.request.url.includes('/api/')) return
// Assets statiques : network first → cache en fallback offline
e.respondWith(
fetch(e.request)
.then(response => {
const clone = response.clone()
caches.open(CACHE).then(c => c.put(e.request, clone))
return response
})
.catch(() => caches.match(e.request))
)
})
+262
View File
@@ -0,0 +1,262 @@
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 />
}
+50
View File
@@ -0,0 +1,50 @@
// Centralized API client
// Injects Authorization + X-Budget-Id headers on every request
const BASE = '/api'
function getToken() {
return localStorage.getItem('budget-token')
}
function getActiveBudgetId() {
return localStorage.getItem('budget-active-id') || '1'
}
export async function apiFetch(method, path, body) {
const token = getToken()
const headers = { 'Content-Type': 'application/json' }
if (token) {
headers['Authorization'] = 'Bearer ' + token
}
// X-Budget-Id sera utilisé à l'étape 2 (middleware backend)
headers['X-Budget-Id'] = getActiveBudgetId()
const opts = { method, headers }
if (body !== undefined) opts.body = JSON.stringify(body)
const res = await fetch(BASE + path, opts)
if (res.status === 401) {
// Token expiré : vider la session
localStorage.removeItem('budget-token')
window.dispatchEvent(new Event('auth:expired'))
throw new Error('Session expirée')
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Erreur serveur')
}
return res.json()
}
export const api = {
get: (path) => apiFetch('GET', path),
post: (path, body) => apiFetch('POST', path, body),
put: (path, body) => apiFetch('PUT', path, body),
delete: (path) => apiFetch('DELETE', path),
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { api } from '../api/client'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null) // { username, isAdmin }
const [loading, setLoading] = useState(true)
const logout = useCallback(() => {
localStorage.removeItem('budget-token')
setUser(null)
}, [])
// Écoute les expirations de token détectées par le client API
useEffect(() => {
const handler = () => logout()
window.addEventListener('auth:expired', handler)
return () => window.removeEventListener('auth:expired', handler)
}, [logout])
// Vérification du token au démarrage
useEffect(() => {
const token = localStorage.getItem('budget-token')
if (!token) { setLoading(false); return }
api.get('/auth/me')
.then(me => setUser(me))
.catch(() => {
localStorage.removeItem('budget-token')
})
.finally(() => setLoading(false))
}, [])
const login = useCallback(async (username, password) => {
const data = await api.post('/auth/login', { username, password })
localStorage.setItem('budget-token', data.token)
setUser({ username: data.username, isAdmin: data.isAdmin })
return data
}, [])
const changePassword = useCallback((currentPassword, newPassword) =>
api.post('/auth/change-password', { currentPassword, newPassword })
, [])
return (
<AuthContext.Provider value={{ user, loading, login, logout, changePassword }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext)
}
+61
View File
@@ -0,0 +1,61 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { useAuth } from './AuthContext'
import { api } from '../api/client'
const BudgetContext = createContext(null)
export function BudgetProvider({ children }) {
const { user } = useAuth()
const [activeBudgetId, setActiveBudgetId] = useState(() => {
return Number.parseInt(localStorage.getItem('budget-active-id') || '1', 10)
})
const [names, setNames] = useState({ nameA: '', nameB: '' })
useEffect(() => {
if (!user) return
api.get('/settings').then(s => {
if (s.nameA || s.nameB) setNames({ nameA: s.nameA || '', nameB: s.nameB || '' })
}).catch(() => {})
}, [user?.username])
const BUDGETS = [
{ id: 1, nom: 'Commun', type: 'commun', emoji: '👥' },
{ id: 2, nom: names.nameA || 'user1 (perso)', type: 'perso', emoji: '👤', owner: 'user1' },
{ id: 3, nom: names.nameB || 'user2 (perso)', type: 'perso', emoji: '👤', owner: 'user2' },
]
// Recalcule les budgets accessibles selon l'utilisateur connecté
const accessibleBudgets = user
? BUDGETS.filter(b => b.type === 'commun' || b.owner === user.username)
: [BUDGETS[0]]
const activeBudget = BUDGETS.find(b => b.id === activeBudgetId) || BUDGETS[0]
// Si le budget actif n'est plus accessible (changement d'user), reset vers Commun
useEffect(() => {
if (user && !accessibleBudgets.find(b => b.id === activeBudgetId)) {
switchBudget(1)
}
}, [user?.username])
function switchBudget(id) {
localStorage.setItem('budget-active-id', String(id))
setActiveBudgetId(id)
}
return (
<BudgetContext.Provider value={{
activeBudget,
activeBudgetId,
accessibleBudgets,
switchBudget,
}}>
{children}
</BudgetContext.Provider>
)
}
export function useBudget() {
return useContext(BudgetContext)
}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, useState, useEffect } from 'react'
const ThemeContext = createContext(null)
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function applyTheme(pref) {
const resolved = pref === 'system' ? getSystemTheme() : pref
document.documentElement.setAttribute('data-theme', resolved)
}
export function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(
() => localStorage.getItem('theme') || 'system'
)
useEffect(() => {
applyTheme(theme)
if (theme === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => applyTheme('system')
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
}, [theme])
function setTheme(t) {
localStorage.setItem('theme', t)
setThemeState(t)
}
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
+57
View File
@@ -0,0 +1,57 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const THRESHOLD = 72
export function usePullToRefresh(onRefresh) {
const [pullY, setPullY] = useState(0)
const [refreshing, setRefreshing] = useState(false)
const startY = useRef(0)
const active = useRef(false)
const onRefreshRef = useRef(onRefresh)
useEffect(() => { onRefreshRef.current = onRefresh }, [onRefresh])
useEffect(() => {
function onTouchStart(e) {
if (window.scrollY === 0) {
startY.current = e.touches[0].clientY
active.current = true
}
}
function onTouchMove(e) {
if (!active.current) return
const dy = e.touches[0].clientY - startY.current
if (dy > 0) setPullY(Math.min(dy * 0.5, THRESHOLD + 16))
else { active.current = false; setPullY(0) }
}
async function onTouchEnd() {
if (!active.current) return
active.current = false
if (pullY >= THRESHOLD) {
setRefreshing(true)
setPullY(0)
// Invalider le cache SW avant de recharger les données
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.getRegistration()
if (reg) await reg.update()
}
await onRefreshRef.current()
setRefreshing(false)
} else {
setPullY(0)
}
}
window.addEventListener('touchstart', onTouchStart, { passive: true })
window.addEventListener('touchmove', onTouchMove, { passive: true })
window.addEventListener('touchend', onTouchEnd)
return () => {
window.removeEventListener('touchstart', onTouchStart)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
}, [pullY])
return { pullY, refreshing, triggered: pullY >= THRESHOLD }
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
+174
View File
@@ -0,0 +1,174 @@
import { useState } from 'react'
import { api } from '../api/client'
const EMOJI_LIST = [
'🏠','🛒','🚗','📱','💊','🎉','👶','📦','✈️','🍕','🎬','📚',
'🏋️','🐶','🌿','⚡','🚿','💡','🎮','🛡️','🎵','🍺','🏖','🏔',
'💼','🎓','🐱','🌊','🍜','⛽','🔧','🏥',
]
export default function AdminPage({ categories, onCategoriesChange, onToast }) {
const [newEmoji, setNewEmoji] = useState('📦')
const [newLabel, setNewLabel] = useState('')
const [saving, setSaving] = useState(false)
const [editEmojis, setEditEmojis] = useState({}) // { catId: showPicker }
const [pending, setPending] = useState({}) // { catId: { label, emoji } }
async function addCategory() {
if (!newLabel.trim()) { onToast('Nom requis', 'err'); return }
setSaving(true)
try {
const created = await api.post('/categories', { label: newLabel.trim(), emoji: newEmoji })
onCategoriesChange([...categories, created])
setNewLabel('')
setNewEmoji('📦')
onToast('Catégorie ajoutée ✓')
} catch (err) {
onToast(err.message, 'err')
} finally {
setSaving(false)
}
}
async function toggleActive(cat) {
try {
const updated = await api.put('/categories/' + cat.id, { active: cat.active ? 0 : 1 })
onCategoriesChange(categories.map(c => c.id === cat.id ? updated : c))
} catch (err) {
onToast(err.message, 'err')
}
}
async function saveLabel(cat) {
const p = pending[cat.id]
if (!p) return
try {
const updated = await api.put('/categories/' + cat.id, {
label: p.label ?? cat.label,
emoji: p.emoji ?? cat.emoji,
})
onCategoriesChange(categories.map(c => c.id === cat.id ? updated : c))
setPending(prev => { const n = { ...prev }; delete n[cat.id]; return n })
onToast('Catégorie mise à jour ✓')
} catch (err) {
onToast(err.message, 'err')
}
}
async function deleteCategory(cat) {
try {
await api.delete('/categories/' + cat.id)
onCategoriesChange(categories.filter(c => c.id !== cat.id))
onToast('Catégorie supprimée')
} catch (err) {
onToast(err.message, 'err')
}
}
function setPendingField(catId, field, value) {
setPending(prev => ({
...prev,
[catId]: { ...prev[catId], [field]: value },
}))
}
return (
<div className="wrap">
<div className="card admin-card full">
<div className="card-lbl">Administration</div>
<div className="card-name" style={{ color: '#a78bfa' }}> Gestion des catégories</div>
{/* Existing categories */}
<div className="admin-section">
<div className="admin-section-title">Catégories existantes</div>
<div className="cat-admin-list">
{categories.map(cat => {
const p = pending[cat.id] || {}
const showPicker = editEmojis[cat.id]
return (
<div key={cat.id}>
<div className={`cat-admin-item${cat.active ? '' : ' inactive'}`}>
<button
className="cat-emoji-btn"
onClick={() => setEditEmojis(prev => ({ ...prev, [cat.id]: !prev[cat.id] }))}
>
{p.emoji ?? cat.emoji}
</button>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>#{cat.sort}</span>
<input
className="cat-label-inp"
value={p.label ?? cat.label}
onChange={e => setPendingField(cat.id, 'label', e.target.value)}
onBlur={() => pending[cat.id] && saveLabel(cat)}
onKeyDown={e => e.key === 'Enter' && saveLabel(cat)}
/>
<button
className={`toggle-btn${cat.active ? ' active' : ''}`}
onClick={() => toggleActive(cat)}
>
{cat.active ? 'Actif' : 'Inactif'}
</button>
<button className="btn-icon" onClick={() => deleteCategory(cat)} title="Supprimer"></button>
</div>
{showPicker && (
<div className="emoji-picker">
{EMOJI_LIST.map(em => (
<span
key={em}
className="emoji-opt"
onClick={() => {
setPendingField(cat.id, 'emoji', em)
setEditEmojis(prev => ({ ...prev, [cat.id]: false }))
// Auto-save emoji
setTimeout(() => saveLabel({ ...cat, ...pending[cat.id], emoji: em }), 0)
}}
>
{em}
</span>
))}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Add category */}
<div className="admin-section" style={{ marginBottom: 0 }}>
<div className="admin-section-title">Ajouter une catégorie</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 8, alignItems: 'end' }}>
<div>
<label className="inp-lbl">Emoji</label>
<input
type="text"
value={newEmoji}
onChange={e => setNewEmoji(e.target.value)}
style={{
background: 'var(--surface2)', border: '1px solid var(--border)',
borderRadius: 8, color: 'var(--text)', fontSize: 20,
padding: 9, width: 48, textAlign: 'center', outline: 'none',
}}
/>
</div>
<div>
<label className="inp-lbl">Nom de la catégorie</label>
<div className="inp-row">
<input
type="text"
placeholder="ex: Vacances"
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addCategory()}
/>
</div>
</div>
<button className="btn-add" onClick={addCategory} disabled={saving}>
{saving ? '…' : '+ Ajouter'}
</button>
</div>
</div>
</div>
</div>
)
}
+423
View File
@@ -0,0 +1,423 @@
import { useState } from 'react'
import { useBudget } from '../contexts/BudgetContext'
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
const fmtPct = n => n.toFixed(1) + '%'
const FREQS = {
mensuel: { label: 'mensuel', mult: 1 },
hebdo: { label: 'hebdo ×4', mult: 4.33 },
annuel: { label: 'annuel ÷12', mult: 1 / 12 },
ponctuel: { label: 'ponctuel', mult: 0 },
}
function mly(expense) {
return expense.freq === 'ponctuel' ? 0 : expense.amount * (FREQS[expense.freq]?.mult ?? 1)
}
// ── Couleurs par catégorie ────────────────────────────────────
const CAT_COLORS = {
logement: '#e8b86d',
alimentation: '#7eb8a4',
transport: '#f9a875',
abonnements: '#a78bfa',
sante: '#60a5fa',
loisirs: '#f472b6',
enfants: '#34d399',
autre: '#6b7280',
}
function catColor(slug) { return CAT_COLORS[slug] || '#9ca3af' }
const PERIODS = [
{ id: 'mensuel', label: 'Mensuel', mult: 1 },
{ id: 'hebdo', label: 'Hebdo', mult: 1 / 4.33 },
{ id: 'annuel', label: 'Annuel', mult: 12 },
]
// ── Donut SVG ─────────────────────────────────────────────────
function DonutChart({ slices, size = 150 }) {
const total = slices.reduce((s, d) => s + d.value, 0)
const cx = size / 2, cy = size / 2
const outerR = size / 2 - 4
const innerR = outerR * 0.58
if (total === 0) {
return (
<svg width={size} height={size} style={{ flexShrink: 0 }}>
<circle cx={cx} cy={cy} r={(outerR + innerR) / 2} fill="none"
stroke="var(--border)" strokeWidth={outerR - innerR} />
<text x={cx} y={cy} textAnchor="middle" dy=".35em"
fill="var(--muted)" fontSize="10" fontFamily="DM Mono,monospace">vide</text>
</svg>
)
}
let angle = -Math.PI / 2
const paths = slices.map(d => {
const sweep = (d.value / total) * 2 * Math.PI
if (sweep < 0.005) return null
const start = angle, end = angle + sweep
angle = end
const large = sweep > Math.PI ? 1 : 0
const x1o = cx + outerR * Math.cos(start), y1o = cy + outerR * Math.sin(start)
const x2o = cx + outerR * Math.cos(end), y2o = cy + outerR * Math.sin(end)
const x1i = cx + innerR * Math.cos(end), y1i = cy + innerR * Math.sin(end)
const x2i = cx + innerR * Math.cos(start), y2i = cy + innerR * Math.sin(start)
return (
<path key={d.slug}
d={`M${x1o} ${y1o} A${outerR} ${outerR} 0 ${large} 1 ${x2o} ${y2o} L${x1i} ${y1i} A${innerR} ${innerR} 0 ${large} 0 ${x2i} ${y2i}Z`}
fill={d.color} />
)
}).filter(Boolean)
return (
<svg width={size} height={size} style={{ flexShrink: 0 }}>
{paths}
<text x={cx} y={cy - 8} textAnchor="middle"
fill="var(--muted)" fontSize="9" fontFamily="DM Mono,monospace">TOTAL</text>
<text x={cx} y={cy + 8} textAnchor="middle"
fill="var(--text)" fontSize="13" fontFamily="DM Mono,monospace" fontWeight="600">
{fmt(total)}
</text>
</svg>
)
}
// ── Graphique synthèse ────────────────────────────────────────
function BudgetCharts({ recurExpenses, categories, color }) {
const [period, setPeriod] = useState('mensuel')
const catMap = {}
categories.forEach(c => { catMap[c.slug] = c })
// Regrouper par catégorie
const bycat = {}
recurExpenses.forEach(e => {
const m = mly(e)
if (m > 0) bycat[e.cat] = (bycat[e.cat] || 0) + m
})
const slices = Object.entries(bycat)
.map(([slug, value]) => ({
slug, value,
color: catColor(slug),
label: catMap[slug]?.label || slug,
emoji: catMap[slug]?.emoji || '📦',
}))
.sort((a, b) => b.value - a.value)
const total = slices.reduce((s, d) => s + d.value, 0)
const mult = PERIODS.find(p => p.id === period)?.mult || 1
const max = slices[0]?.value || 1
if (total === 0) return (
<div className="card full" style={{ borderTop: `3px solid ${color || 'var(--border)'}` }}>
<div className="card-lbl">Synthèse par catégorie</div>
<div style={{ color: 'var(--muted)', fontSize: 12, padding: '12px 0' }}>
Ajoutez des dépenses récurrentes pour voir la synthèse.
</div>
</div>
)
return (
<div className="card full" style={{ borderTop: `3px solid ${color || 'var(--aa)'}` }}>
<div className="card-lbl">Synthèse par catégorie</div>
{/* Sélecteur période */}
<div className="period-tabs">
{PERIODS.map(p => (
<button key={p.id}
className={`period-tab${period === p.id ? ' active' : ''}`}
onClick={() => setPeriod(p.id)}>
{p.label}
</button>
))}
</div>
<div className="chart-layout">
{/* Donut */}
<div className="donut-wrap">
<DonutChart slices={slices} size={150} />
</div>
{/* Légende + barres */}
<div className="chart-legend">
{slices.map(s => (
<div key={s.slug} className="legend-row">
<span className="legend-emoji">{s.emoji}</span>
<span className="legend-label">{s.label}</span>
<span className="legend-val">{fmt(s.value * mult)}</span>
<div className="legend-bar-track">
<div className="legend-bar-fill"
style={{ width: `${s.value / max * 100}%`, background: s.color }} />
</div>
<span className="legend-pct">{fmtPct(s.value / total * 100)}</span>
</div>
))}
{/* Totaux période */}
<div className="chart-totals">
{PERIODS.map(p => (
<div key={p.id} className={`chart-total-item${period === p.id ? ' active' : ''}`}>
<span className="ct-label">{p.label}</span>
<span className="ct-val">{fmt(total * p.mult)}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ── Dashboard principal ───────────────────────────────────────
export default function DashboardPage({ expenses, commonExpenses = [], categories = [], settings }) {
const { activeBudget } = useBudget()
const ia = parseFloat(settings.incomeA) || 0
const ib = parseFloat(settings.incomeB) || 0
const pct = ia + ib > 0 ? Math.round(ia / (ia + ib) * 100) : 63
// Calculs basés sur le budget commun (contributions et restes)
const commonRecur = commonExpenses.filter(e => e.freq !== 'ponctuel')
const recur = expenses.filter(e => e.freq !== 'ponctuel')
const total = commonRecur.reduce((s, e) => s + mly(e), 0)
const cA = total * pct / 100
const cB = total - cA
const rA = ia - cA
const rB = ib - cB
const alert = rA < 0 || rB < 0
? { type: 'warn', msg: `⚠ Budget dépasse le revenu de : ${[rA < 0 && settings.nameA, rB < 0 && settings.nameB].filter(Boolean).join(', ')}.` }
: total > 0 && Math.abs(rA / (ia || 1) * 100 - rB / (ib || 1) * 100) < 5
? { type: 'ok-al', msg: `✓ Répartition équitable (${fmtPct(rA / (ia || 1) * 100)} vs ${fmtPct(rB / (ib || 1) * 100)} restant).` }
: null
// ── Vue personnelle ──────────────────────────────────────────
if (activeBudget.type === 'perso') {
const isA = activeBudget.owner === 'user1'
const name = isA ? settings.nameA : settings.nameB
const color = isA ? 'var(--aa)' : 'var(--ab)'
const income = isA ? ia : ib
const contrib = isA ? cA : cB
const montant = isA ? rA : rB
const persoRecur = expenses.filter(e => e.freq !== 'ponctuel')
const persoTotal = persoRecur.reduce((s, e) => s + mly(e), 0)
const solde = montant - persoTotal
return (
<div className="wrap">
<div className="grid">
{/* Budget perso */}
<div className="card full">
<div className="card-lbl">Budget perso {name}</div>
<div className="stat-row">
<span className="stat-lbl">Dépenses récurrentes / mois</span>
<span className="stat-val">{fmt(persoTotal)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Par an</span>
<span className="stat-val">{fmt(persoTotal * 12)}</span>
</div>
{persoRecur.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 8px' }}>
Ajoutez des dépenses dans l'onglet Dépenses (budget perso actif).
</div>
)}
<div className="rep-grid" style={{ marginTop: 16 }}>
<div className="rep-card">
<div className="rep-head">Montant défini</div>
<div className="rep-amt" style={{ color }}>{fmt(montant)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
</div>
<div className="rep-card">
<div className="rep-head">Dépenses perso</div>
<div className="rep-amt">{fmt(persoTotal)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
</div>
</div>
<div style={{ marginTop: 12, padding: '10px 14px', borderRadius: 8, background: 'var(--surface2)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Reste disponible</span>
<span style={{ fontWeight: 700, fontSize: 18, color: solde < 0 ? 'var(--danger)' : color }}>
{fmt(solde)} / mois
</span>
</div>
{solde < 0 && (
<div className="alert warn" style={{ marginTop: 10 }}>
⚠ Les dépenses perso dépassent le montant défini de {fmt(Math.abs(solde))}.
</div>
)}
</div>
{/* Contribution budget commun */}
<div className="card cc">
<div className="card-lbl">Contribution budget commun</div>
<div className="stat-row">
<span className="stat-lbl">Par mois</span>
<span className="stat-val">{fmt(contrib)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Par an</span>
<span className="stat-val">{fmt(contrib * 12)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Répartition</span>
<span className="stat-val">{isA ? pct : 100 - pct}%</span>
</div>
</div>
{/* Montant défini */}
<div className="card" style={{ borderTop: `3px solid ${color}` }}>
<div className="card-lbl">Montant défini</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8 }}>
Reste du budget commun disponible pour le perso
</div>
<div className="budget-big" style={{ color: montant < 0 ? 'var(--danger)' : color }}>
{fmt(montant)}
</div>
<div className="budget-sub">Par mois · {fmt(montant * 12)} / an</div>
</div>
{/* Graphiques perso */}
<BudgetCharts recurExpenses={persoRecur} categories={categories} color={color} />
</div>
</div>
)
}
// ── Vue commune ──────────────────────────────────────────────
return (
<div className="wrap">
<div className="grid">
{/* Revenus A */}
<div className="card ca">
<div className="card-lbl">Revenus</div>
<div className="card-name">
<div className="dot" style={{ background: 'var(--aa)' }} />
{settings.nameA}
</div>
<div className="stat-row">
<span className="stat-lbl">Revenu mensuel net</span>
<span className="stat-val">{fmt(ia)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Annuel</span>
<span className="stat-val">{fmt(ia * 12)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Part des revenus</span>
<span className="stat-val">{fmtPct(ia + ib > 0 ? ia / (ia + ib) * 100 : 50)}</span>
</div>
</div>
{/* Revenus B */}
<div className="card cb">
<div className="card-lbl">Revenus</div>
<div className="card-name">
<div className="dot" style={{ background: 'var(--ab)' }} />
{settings.nameB}
</div>
<div className="stat-row">
<span className="stat-lbl">Revenu mensuel net</span>
<span className="stat-val">{fmt(ib)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Annuel</span>
<span className="stat-val">{fmt(ib * 12)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Part des revenus</span>
<span className="stat-val">{fmtPct(ia + ib > 0 ? ib / (ia + ib) * 100 : 50)}</span>
</div>
</div>
{/* Budget commun */}
<div className="card cc full">
<div className="card-lbl">Budget commun</div>
<div className="stat-row">
<span className="stat-lbl">Dépenses récurrentes / mois</span>
<span className="stat-val">{fmt(total)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">Par an</span>
<span className="stat-val">{fmt(total * 12)}</span>
</div>
{recur.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 8px' }}>
Ajoutez des dépenses récurrentes dans l'onglet Dépenses.
</div>
)}
<div className="rep-grid">
<div className="rep-card">
<div className="rep-head">
<div className="dot" style={{ background: 'var(--aa)' }} /> {settings.nameA}
</div>
<div className="rep-amt">{fmt(cA)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
<div className="rep-sub">{fmt(cA * 12)} / an</div>
</div>
<div className="rep-card">
<div className="rep-head">
<div className="dot" style={{ background: 'var(--ab)' }} /> {settings.nameB}
</div>
<div className="rep-amt">{fmt(cB)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
<div className="rep-sub">{fmt(cB * 12)} / an</div>
</div>
</div>
<div className="bar-wrap">
<div className="bar-lbl">
<span>{settings.nameA} : {pct}%</span>
<span>{settings.nameB} : {100 - pct}%</span>
</div>
<div className="bar-track">
<div className="bar-seg" style={{ width: pct + '%', background: 'var(--aa)' }} />
<div className="bar-seg" style={{ width: (100 - pct) + '%', background: 'var(--ab)' }} />
</div>
</div>
{alert && <div className={`alert ${alert.type}`}>{alert.msg}</div>}
</div>
{/* Reste à vivre */}
<div className="card ca">
<div className="card-lbl">Reste à vivre</div>
<div className="card-name">
<div className="dot" style={{ background: 'var(--aa)' }} />
{settings.nameA}
</div>
<div className="stat-row">
<span className="stat-lbl">Par mois</span>
<span className="stat-val" style={{ color: rA < 0 ? 'var(--danger)' : 'inherit' }}>{fmt(rA)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">% du revenu</span>
<span className="stat-val">{ia > 0 ? fmtPct(rA / ia * 100) : '—'}</span>
</div>
</div>
<div className="card cb">
<div className="card-lbl">Reste à vivre</div>
<div className="card-name">
<div className="dot" style={{ background: 'var(--ab)' }} />
{settings.nameB}
</div>
<div className="stat-row">
<span className="stat-lbl">Par mois</span>
<span className="stat-val" style={{ color: rB < 0 ? 'var(--danger)' : 'inherit' }}>{fmt(rB)}</span>
</div>
<div className="stat-row">
<span className="stat-lbl">% du revenu</span>
<span className="stat-val">{ib > 0 ? fmtPct(rB / ib * 100) : '—'}</span>
</div>
</div>
{/* Graphiques commun */}
<BudgetCharts recurExpenses={commonRecur} categories={categories} color="var(--aa)" />
</div>
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../api/client'
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
export default function EpargnesPage({ onToast }) {
const [savings, setSavings] = useState([])
const [loading, setLoading] = useState(true)
const [showClosed, setShowClosed] = useState(false)
// form state for new saving
const [emoji, setEmoji] = useState('💰')
const [name, setName] = useState('')
const [monthly, setMonthly] = useState('')
const [target, setTarget] = useState('')
// transaction state: { savingId, type: 'credit'|'debit' }
const [txForm, setTxForm] = useState(null)
const [txAmount, setTxAmount] = useState('')
const [txNote, setTxNote] = useState('')
const [closeConfirm, setCloseConfirm] = useState(null)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await api.get('/savings')
setSavings(data)
} catch { onToast('Erreur chargement épargnes', 'err') }
finally { setLoading(false) }
}, [onToast])
useEffect(() => { load() }, [load])
async function handleCreate(e) {
e.preventDefault()
if (!name.trim()) { onToast('Nom requis', 'err'); return }
try {
const created = await api.post('/savings', {
name: name.trim(), emoji,
monthly_amount: monthly || 0,
target: target || null,
})
setSavings(prev => [created, ...prev])
setName(''); setEmoji('💰'); setMonthly(''); setTarget('')
onToast('Épargne créée ✓')
} catch { onToast('Erreur création', 'err') }
}
async function handleTransaction(e) {
e.preventDefault()
if (!txAmount || isNaN(parseFloat(txAmount))) { onToast('Montant invalide', 'err'); return }
const amount = txForm.type === 'debit' ? -Math.abs(parseFloat(txAmount)) : Math.abs(parseFloat(txAmount))
try {
const updated = await api.post(`/savings/${txForm.savingId}/transaction`, { amount, note: txNote })
setSavings(prev => prev.map(s => s.id === updated.id ? updated : s))
setTxForm(null); setTxAmount(''); setTxNote('')
onToast(txForm.type === 'credit' ? 'Montant ajouté ✓' : 'Retrait effectué ✓')
} catch { onToast('Erreur transaction', 'err') }
}
async function handleClose(id) {
try {
const updated = await api.post(`/savings/${id}/close`, {})
setSavings(prev => prev.map(s => s.id === updated.id ? updated : s))
setCloseConfirm(null)
onToast('Épargne clôturée')
} catch { onToast('Erreur clôture', 'err') }
}
async function handleDelete(id) {
try {
await api.delete(`/savings/${id}`)
setSavings(prev => prev.filter(s => s.id !== id))
onToast('Épargne supprimée')
} catch { onToast('Erreur suppression', 'err') }
}
const active = savings.filter(s => !s.closed)
const closed = savings.filter(s => s.closed)
if (loading) return null
return (
<div className="wrap">
{/* Create form */}
<div className="card full" style={{ borderTop: '3px solid var(--ab)' }}>
<div className="card-lbl">Nouvelle épargne</div>
<form onSubmit={handleCreate}>
<div className="simu-form-grid">
<div>
<label className="inp-lbl">Emoji</label>
<input type="text" value={emoji} onChange={e => setEmoji(e.target.value)} maxLength={2}
style={{ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:8,
color:'var(--text)', fontSize:20, padding:9, width:48, textAlign:'center', outline:'none' }} />
</div>
<div>
<label className="inp-lbl">Nom</label>
<div className="inp-row">
<input type="text" placeholder="ex: Vacances" value={name} onChange={e => setName(e.target.value)} />
</div>
</div>
<div>
<label className="inp-lbl">Versement mensuel</label>
<div className="inp-row">
<input type="number" placeholder="0" min="0" value={monthly} onChange={e => setMonthly(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
<div>
<label className="inp-lbl">Objectif (optionnel)</label>
<div className="inp-row">
<input type="number" placeholder="—" min="0" value={target} onChange={e => setTarget(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
</div>
<button className="btn-add" style={{ marginTop:14, width:'100%' }} type="submit">+ Créer</button>
</form>
</div>
{/* Active savings */}
{active.length === 0 && (
<div style={{ textAlign:'center', color:'var(--muted)', fontSize:13, padding:'24px 0' }}>
Aucune épargne active. Créez votre première ci-dessus.
</div>
)}
{active.map(s => {
const progress = s.target ? Math.min(100, s.balance / s.target * 100) : null
const isTx = txForm?.savingId === s.id
return (
<div key={s.id} className="card full">
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:8 }}>
<div>
<div style={{ fontSize:22, lineHeight:1 }}>{s.emoji}</div>
<div style={{ fontWeight:600, fontSize:15, marginTop:4 }}>{s.name}</div>
{s.monthly_amount > 0 && (
<div style={{ fontSize:11, color:'var(--muted)' }}>{fmt(s.monthly_amount)}/mois versé</div>
)}
</div>
<div style={{ textAlign:'right' }}>
<div style={{ fontSize:22, fontWeight:700, color:'var(--ab)' }}>{fmt(s.balance)}</div>
{s.target && <div style={{ fontSize:11, color:'var(--muted)' }}>sur {fmt(s.target)}</div>}
</div>
</div>
{progress !== null && (
<div className="simu-progress-track" style={{ marginBottom:10 }}>
<div className="simu-progress-fill" style={{ width: progress + '%', background:'var(--ab)' }} />
</div>
)}
{/* Transaction inline form */}
{isTx && (
<form onSubmit={handleTransaction} style={{ background:'var(--surface2)', borderRadius:8, padding:12, marginBottom:10 }}>
<div style={{ fontSize:11, color:'var(--muted)', marginBottom:8 }}>
{txForm.type === 'credit' ? '+ Ajouter des fonds' : ' Retirer des fonds'}
</div>
<div style={{ display:'flex', gap:8, marginBottom:8 }}>
<div className="inp-row" style={{ flex:1 }}>
<input type="number" placeholder="Montant" min="0" step="0.01"
value={txAmount} onChange={e => setTxAmount(e.target.value)} autoFocus />
<span className="inp-sfx"></span>
</div>
<div className="inp-row" style={{ flex:2 }}>
<input type="text" placeholder="Note (optionnel)"
value={txNote} onChange={e => setTxNote(e.target.value)} />
</div>
</div>
<div style={{ display:'flex', gap:8 }}>
<button className="btn-add" type="submit" style={{ flex:1 }}>Confirmer</button>
<button className="btn-cancel" type="button" onClick={() => setTxForm(null)}>Annuler</button>
</div>
</form>
)}
{closeConfirm === s.id && (
<div style={{ background:'var(--surface2)', borderRadius:8, padding:12, marginBottom:10, fontSize:12, color:'var(--muted)' }}>
Clôturer supprimera le versement mensuel lié. Confirmer ?
<div style={{ display:'flex', gap:8, marginTop:8 }}>
<button className="btn-confirm" onClick={() => handleClose(s.id)}>Oui, clôturer</button>
<button className="btn-cancel" onClick={() => setCloseConfirm(null)}>Annuler</button>
</div>
</div>
)}
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
<button className="btn-sm" style={{ color:'var(--ab)', borderColor:'var(--ab)' }}
onClick={() => { setTxForm({ savingId: s.id, type:'credit' }); setTxAmount(''); setTxNote('') }}>
+ Ajouter
</button>
<button className="btn-sm"
onClick={() => { setTxForm({ savingId: s.id, type:'debit' }); setTxAmount(''); setTxNote('') }}>
Retirer
</button>
<button className="btn-sm" style={{ marginLeft:'auto', color:'var(--muted)' }}
onClick={() => setCloseConfirm(s.id)}>
Clôturer
</button>
</div>
</div>
)
})}
{/* Closed savings */}
{closed.length > 0 && (
<div className="card full">
<button className="card-lbl" style={{ background:'none', border:'none', cursor:'pointer', color:'var(--muted)', textAlign:'left', width:'100%', padding:0 }}
onClick={() => setShowClosed(v => !v)}>
Épargnes clôturées ({closed.length}) {showClosed ? '▲' : '▼'}
</button>
{showClosed && closed.map(s => (
<div key={s.id} style={{ display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0', borderTop:'1px solid var(--border)', opacity:0.6 }}>
<div style={{ display:'flex', gap:10, alignItems:'center' }}>
<span style={{ fontSize:18 }}>{s.emoji}</span>
<div>
<div style={{ fontSize:13, fontWeight:600 }}>{s.name}</div>
<div style={{ fontSize:11, color:'var(--muted)' }}>Solde final : {fmt(s.balance)}</div>
</div>
</div>
<button className="btn-icon" onClick={() => handleDelete(s.id)} title="Supprimer"></button>
</div>
))}
</div>
)}
</div>
)
}
+513
View File
@@ -0,0 +1,513 @@
import { useState } from 'react'
import { api } from '../api/client'
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
const FREQS = {
mensuel: { label: 'mensuel', mult: 1 },
hebdo: { label: 'hebdo ×4', mult: 4.33 },
annuel: { label: 'annuel ÷12', mult: 1 / 12 },
ponctuel: { label: 'ponctuel', mult: 0 },
echelonne: { label: 'en x fois', mult: 1 },
}
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
function mly(e) {
if (e.freq === 'ponctuel') return 0
if (e.freq === 'echelonne') return (e.installments_paid >= e.installments) ? 0 : e.amount
return e.amount * (FREQS[e.freq]?.mult ?? 1)
}
function fmtMonth(m) {
if (!m) return ''
const [y, mo] = m.split('-')
const labels = { '01':'Jan','02':'Fév','03':'Mar','04':'Avr','05':'Mai','06':'Jun','07':'Jul','08':'Aoû','09':'Sep','10':'Oct','11':'Nov','12':'Déc' }
return (labels[mo] || mo) + ' ' + y
}
function monthOptions() {
const now = new Date()
const opts = []
for (let i = -3; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + i, 1)
const val = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
opts.push({ value: val, label: MONTHS_FR[d.getMonth()] + ' ' + d.getFullYear() })
}
return opts
}
// ── Edit Modal ────────────────────────────────────────────────
function EditModal({ expense, categories, onSave, onClose }) {
const isEch = expense.freq === 'echelonne'
const [name, setName] = useState(expense.name)
const [total, setTotal] = useState(isEch ? Math.round(expense.amount * expense.installments) : expense.amount)
const [amount, setAmount] = useState(expense.amount)
const [installments, setInstallments] = useState(expense.installments || 1)
const [installments_paid, setInstallmentsPaid] = useState(expense.installments_paid || 0)
const [cat, setCat] = useState(expense.cat)
const [freq, setFreq] = useState(expense.freq)
const [month, setMonth] = useState(expense.month || monthOptions()[3]?.value)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const monthlyPreview = freq === 'echelonne' && installments > 0
? parseFloat(total) / parseInt(installments)
: null
async function handleSave() {
setError('')
if (!name.trim()) { setError('Libellé requis.'); return }
const finalAmount = freq === 'echelonne'
? (parseFloat(total) / parseInt(installments || 1))
: parseFloat(amount)
if (!finalAmount || finalAmount <= 0) { setError('Montant invalide.'); return }
setSaving(true)
try {
const payload = {
name: name.trim(), amount: finalAmount, cat, freq,
month: freq === 'ponctuel' ? month : null,
}
if (freq === 'echelonne') {
payload.installments = parseInt(installments)
payload.installments_paid = parseInt(installments_paid)
}
const updated = await api.put('/expenses/' + expense.id, payload)
onSave(updated)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-card">
<div className="modal-title">Modifier la dépense</div>
{error && <div className="modal-err">{error}</div>}
<div className="inp-grp">
<label className="inp-lbl">Libellé</label>
<div className="inp-row">
<input type="text" value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSave()} />
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Catégorie</label>
<div className="inp-row">
<select value={cat} onChange={e => setCat(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{categories.filter(c => c.active).map(c => (
<option key={c.slug} value={c.slug}>{c.emoji} {c.label}</option>
))}
</select>
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Fréquence</label>
<div className="inp-row">
<select value={freq} onChange={e => setFreq(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{Object.entries(FREQS).map(([k, v]) => (
<option key={k} value={k}>{v.label.charAt(0).toUpperCase() + v.label.slice(1)}</option>
))}
</select>
</div>
</div>
{freq === 'echelonne' ? (
<>
<div className="inp-grp">
<label className="inp-lbl">Montant total</label>
<div className="inp-row">
<input type="number" min="1" value={total} onChange={e => setTotal(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Nombre de mensualités</label>
<div className="inp-row">
<input type="number" min="2" max="120" value={installments}
onChange={e => setInstallments(e.target.value)} />
<span className="inp-sfx">fois</span>
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Mensualités déjà payées</label>
<div className="inp-row">
<input type="number" min="0" max={installments} value={installments_paid}
onChange={e => setInstallmentsPaid(e.target.value)} />
<span className="inp-sfx">/ {installments}</span>
</div>
</div>
{monthlyPreview > 0 && (
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 10 }}>
= {fmt(monthlyPreview)} / mois
</div>
)}
</>
) : (
<div className="inp-grp">
<label className="inp-lbl">Montant</label>
<div className="inp-row">
<input type="number" min="0" value={amount} onChange={e => setAmount(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSave()} />
<span className="inp-sfx"></span>
</div>
</div>
)}
{freq === 'ponctuel' && (
<div className="inp-grp">
<label className="inp-lbl">Mois</label>
<div className="inp-row">
<select value={month} onChange={e => setMonth(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{monthOptions().map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
)}
<div className="modal-btns">
<button className="btn-cancel" onClick={onClose}>Annuler</button>
<button className="btn-confirm" onClick={handleSave} disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</div>
</div>
</div>
)
}
// ── Expense item ──────────────────────────────────────────────
function ExpItem({ expense, categories, onEdit, onDelete, onPayInstallment }) {
const cat = categories.find(c => c.slug === expense.cat) || { label: 'Autre', emoji: '📦' }
const isPonctuel = expense.freq === 'ponctuel'
const isEchelonne = expense.freq === 'echelonne'
const isDone = isEchelonne && expense.installments_paid >= expense.installments
const progress = isEchelonne ? Math.min(100, expense.installments_paid / expense.installments * 100) : null
return (
<div className={`exp-item${isDone ? ' exp-done' : ''}`}>
<div className="exp-icon" style={{ background: isDone ? 'rgba(130,130,130,.1)' : 'rgba(232,184,109,.12)' }}>{cat.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="exp-name">{expense.name}</div>
<div className="exp-meta">{cat.label}</div>
{isEchelonne && (
<div className="ech-progress-track">
<div className="ech-progress-fill" style={{ width: progress + '%' }} />
</div>
)}
</div>
<div className="exp-amt">{fmt(expense.amount)}{isEchelonne ? '/mois' : ''}</div>
{isEchelonne ? (
<span className={`exp-badge${isDone ? ' done' : ''}`}>
{expense.installments_paid}/{expense.installments}
</span>
) : (
<span className={`exp-badge${isPonctuel ? ' ponctuel' : ''}`}>
{isPonctuel ? `🗓 ${fmtMonth(expense.month)}` : FREQS[expense.freq]?.label || expense.freq}
</span>
)}
<div style={{ display: 'flex', gap: 4 }}>
{isEchelonne && !isDone && (
<button className="btn-del" onClick={() => onPayInstallment(expense.id)} title="Mensualité payée" style={{ color: 'var(--aa)' }}></button>
)}
<button className="btn-del" onClick={() => onEdit(expense)} title="Modifier"></button>
<button className="btn-del" onClick={() => onDelete(expense.id)} title="Supprimer"></button>
</div>
</div>
)
}
// ── Main component ────────────────────────────────────────────
export default function ExpensesPage({ expenses, categories, onExpensesChange, onToast }) {
const [name, setName] = useState('')
const [amount, setAmount] = useState('')
const [totalAmount, setTotalAmount] = useState('')
const [installments,setInstallments]= useState('3')
const [cat, setCat] = useState('')
const [freq, setFreq] = useState('mensuel')
const [month, setMonth] = useState(() => {
const now = new Date()
return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0')
})
const [filter, setFilter] = useState('all')
const [editExp, setEditExp] = useState(null)
const [saving, setSaving] = useState(false)
const activeCat = cat || (categories.find(c => c.active)?.slug ?? 'autre')
const monthlyPreview = freq === 'echelonne' && totalAmount > 0 && installments > 0
? parseFloat(totalAmount) / parseInt(installments)
: null
const isDoneEch = e => e.freq === 'echelonne' && e.installments_paid >= e.installments
const recur = expenses.filter(e => e.freq !== 'ponctuel' && !isDoneEch(e))
const ponctuel = expenses.filter(e => e.freq === 'ponctuel')
const termines = expenses.filter(isDoneEch)
const total = recur.reduce((s, e) => s + mly(e), 0)
async function handleAdd() {
const n = name.trim()
const a = freq === 'echelonne'
? parseFloat(totalAmount) / parseInt(installments || 1)
: parseFloat(amount)
if (!n || !a || a <= 0) { onToast('Libellé et montant requis', 'err'); return }
if (freq === 'echelonne' && parseInt(installments) < 2) { onToast('Minimum 2 mensualités', 'err'); return }
setSaving(true)
try {
const payload = {
name: n, amount: a, cat: activeCat, freq,
month: freq === 'ponctuel' ? month : null,
}
if (freq === 'echelonne') payload.installments = parseInt(installments)
const created = await api.post('/expenses', payload)
onExpensesChange([...expenses, created])
setName(''); setAmount(''); setTotalAmount(''); setInstallments('3')
onToast('Dépense ajoutée ✓')
} catch (err) {
onToast(err.message, 'err')
} finally {
setSaving(false)
}
}
async function handleDelete(id) {
try {
await api.delete('/expenses/' + id)
onExpensesChange(expenses.filter(e => e.id !== id))
} catch (err) {
onToast(err.message, 'err')
}
}
async function handlePayInstallment(id) {
try {
const updated = await api.post('/expenses/' + id + '/pay-installment', {})
onExpensesChange(expenses.map(e => e.id === updated.id ? updated : e))
const e = expenses.find(e => e.id === id)
if (e && e.installments_paid + 1 >= e.installments) {
onToast('Remboursement terminé ! 🎉')
} else {
onToast('Mensualité enregistrée ✓')
}
} catch (err) {
onToast(err.message, 'err')
}
}
function handleSaved(updated) {
onExpensesChange(expenses.map(e => e.id === updated.id ? updated : e))
setEditExp(null)
onToast('Dépense modifiée ✓')
}
const showPonctuel = filter === '__ponctuel__'
const showTermines = filter === '__termines__'
const filtered = showPonctuel ? ponctuel
: showTermines ? termines
: filter === 'all' ? recur
: recur.filter(e => e.cat === filter)
const catTotals = Object.entries(
recur.reduce((acc, e) => { acc[e.cat] = (acc[e.cat] || 0) + mly(e); return acc }, {})
).sort((a, b) => b[1] - a[1])
return (
<div className="wrap">
<div className="card cc full">
{/* Summary */}
<div className="dep-sum">
<div className="sum-box">
<div className="sum-val">{fmt(total)}</div>
<div className="sum-lbl">/ mois</div>
</div>
<div className="sum-box">
<div className="sum-val">{fmt(total * 12)}</div>
<div className="sum-lbl">/ an</div>
</div>
<div className="sum-box">
<div className="sum-val">{expenses.length}</div>
<div className="sum-lbl">dépenses</div>
</div>
</div>
{/* Add form */}
<div className="add-form">
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Libellé</label>
<div className="inp-row">
<input type="text" placeholder="Loyer, EDF…" value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()} />
</div>
</div>
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Catégorie</label>
<div className="inp-row">
<select value={activeCat} onChange={e => setCat(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{categories.filter(c => c.active).map(c => (
<option key={c.slug} value={c.slug}>{c.emoji} {c.label}</option>
))}
</select>
</div>
</div>
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Fréquence</label>
<div className="inp-row">
<select value={freq} onChange={e => setFreq(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{Object.entries(FREQS).map(([k, v]) => (
<option key={k} value={k}>{v.label.charAt(0).toUpperCase() + v.label.slice(1)}</option>
))}
</select>
</div>
</div>
{freq === 'echelonne' ? (
<>
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Montant total</label>
<div className="inp-row">
<input type="number" placeholder="1200" min="1" value={totalAmount}
onChange={e => setTotalAmount(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Nombre de mensualités</label>
<div className="inp-row">
<input type="number" placeholder="12" min="2" max="120" value={installments}
onChange={e => setInstallments(e.target.value)} />
<span className="inp-sfx">fois</span>
</div>
</div>
{monthlyPreview > 0 && (
<div style={{ fontSize: 11, color: '#f9a875', gridColumn: '1 / -1' }}>
= {fmt(monthlyPreview)} / mois
</div>
)}
</>
) : (
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Montant</label>
<div className="inp-row">
<input type="number" placeholder="0" min="0" value={amount}
onChange={e => setAmount(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()} />
<span className="inp-sfx"></span>
</div>
</div>
)}
{freq === 'ponctuel' && (
<div className="inp-grp" style={{ margin: 0 }}>
<label className="inp-lbl">Mois</label>
<div className="inp-row">
<select value={month} onChange={e => setMonth(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{monthOptions().map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
)}
<button className="btn-add" onClick={handleAdd} disabled={saving} style={{ alignSelf: 'flex-end' }}>
{saving ? '…' : '+ Ajouter'}
</button>
</div>
{/* Category filters */}
<div className="cat-filters">
<button className={`cat-pill${filter === 'all' ? ' active' : ''}`} onClick={() => setFilter('all')}>Toutes</button>
<button className={`cat-pill${filter === '__ponctuel__' ? ' active' : ''}`} onClick={() => setFilter('__ponctuel__')}>🗓 Ponctuelles</button>
{termines.length > 0 && (
<button className={`cat-pill${filter === '__termines__' ? ' active' : ''}`} onClick={() => setFilter('__termines__')}> Soldés</button>
)}
{categories.filter(c => c.active).map(c => (
<button key={c.slug} className={`cat-pill${filter === c.slug ? ' active' : ''}`} onClick={() => setFilter(c.slug)}>
{c.emoji}
</button>
))}
</div>
{/* List */}
{showPonctuel ? (
<>
<div className="section-title">
Dépenses ponctuelles <span className="section-badge">{ponctuel.length}</span>
</div>
<div className="exp-list">
{ponctuel.length === 0
? <div className="exp-empty">Aucune dépense ponctuelle.</div>
: ponctuel.map(e => (
<ExpItem key={e.id} expense={e} categories={categories}
onEdit={setEditExp} onDelete={handleDelete} onPayInstallment={handlePayInstallment} />
))}
</div>
</>
) : showTermines ? (
<>
<div className="section-title">
Remboursements soldés <span className="section-badge">{termines.length}</span>
</div>
<div className="exp-list">
{termines.map(e => (
<ExpItem key={e.id} expense={e} categories={categories}
onEdit={setEditExp} onDelete={handleDelete} onPayInstallment={handlePayInstallment} />
))}
</div>
</>
) : (
<>
<div className="exp-list">
{filtered.length === 0
? <div className="exp-empty">
{recur.length === 0
? 'Aucune dépense pour l\'instant.\nAjoutez votre première dépense ci-dessus.'
: 'Aucune dépense dans cette catégorie.'}
</div>
: filtered.map(e => (
<ExpItem key={e.id} expense={e} categories={categories}
onEdit={setEditExp} onDelete={handleDelete} onPayInstallment={handlePayInstallment} />
))}
</div>
{catTotals.length > 0 && (
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 10, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--muted)', marginBottom: 12 }}>
Répartition par catégorie (récurrentes)
</div>
{catTotals.map(([slug, amount]) => {
const c = categories.find(c => c.slug === slug) || { label: 'Autre', emoji: '📦' }
const pct = total > 0 ? amount / total * 100 : 0
return (
<div className="cat-tot-row" key={slug}>
<span style={{ fontSize: 12 }}>{c.emoji} {c.label}</span>
<div className="cat-bar-wrap">
<div className="cat-bar-fill" style={{ width: pct + '%', background: 'var(--aa)' }} />
</div>
<span style={{ fontSize: 12, color: 'var(--muted)' }}>{fmt(amount)}</span>
</div>
)
})}
</div>
)}
</>
)}
</div>
{editExp && (
<EditModal
expense={editExp}
categories={categories}
onSave={handleSaved}
onClose={() => setEditExp(null)}
/>
)}
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
export default function LoginPage() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setError('')
if (!username || !password) { setError('Remplissez tous les champs.'); return }
setLoading(true)
try {
await login(username, password)
} catch (err) {
setError(err.message || 'Identifiants incorrects')
} finally {
setLoading(false)
}
}
return (
<div className="login-screen">
<div className="login-card">
<div style={{ textAlign: 'center', marginBottom: 28 }}>
<div className="eyebrow">Espace privé</div>
<h1>Budget <span>Commun</span></h1>
</div>
{error && <div className="login-err">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="inp-grp">
<label className="inp-lbl">Identifiant</label>
<div className="inp-row">
<input
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="user1"
/>
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Mot de passe</label>
<div className="inp-row">
<input
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
</div>
<button className="btn-login" type="submit" disabled={loading}>
{loading ? 'Connexion…' : 'Se connecter'}
</button>
</form>
</div>
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../api/client'
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
const MONTH_LABELS = {
'01':'Janvier','02':'Février','03':'Mars','04':'Avril','05':'Mai','06':'Juin',
'07':'Juillet','08':'Août','09':'Septembre','10':'Octobre','11':'Novembre','12':'Décembre',
}
function fmtMonth(m) {
if (!m) return ''
const [y, mo] = m.split('-')
return (MONTH_LABELS[mo] || mo) + ' ' + y
}
function addMonths(m, delta) {
const [y, mo] = m.split('-').map(Number)
const d = new Date(y, mo - 1 + delta, 1)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
}
function currentBillingMonth() {
const d = new Date()
if (d.getDate() < 26) {
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
return prev.getFullYear() + '-' + String(prev.getMonth() + 1).padStart(2, '0')
}
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
}
export default function PointagePage({ categories }) {
const [month, setMonth] = useState(currentBillingMonth)
const [rows, setRows] = useState([])
const [unpointedPrev,setUnpointedPrev]= useState([])
const [prevMonth, setPrevMonth] = useState('')
const [loading, setLoading] = useState(false)
const currentBilling = currentBillingMonth()
const load = useCallback(async (m) => {
setLoading(true)
try {
const data = await api.get('/pointage?month=' + m)
setRows(data.rows)
setUnpointedPrev(data.unpointedPrev)
setPrevMonth(data.prevMonth)
} catch {
// silently ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(month) }, [month, load])
async function togglePointed(row) {
const newVal = !row.pointed
try {
const updated = await api.put('/pointage/' + row.id, { pointed: newVal })
setRows(rs => rs.map(r => r.id === row.id ? { ...r, ...updated, cat: r.cat, freq: r.freq } : r))
} catch { /* ignore */ }
}
async function pointAll() {
const unpointed = rows.filter(r => !r.pointed)
await Promise.all(unpointed.map(r => togglePointed(r)))
}
const pointed = rows.filter(r => r.pointed)
const unpointed = rows.filter(r => !r.pointed)
const totalPointed = pointed.reduce((s, r) => s + r.amount, 0)
const progress = rows.length > 0 ? (pointed.length / rows.length) * 100 : 0
function getCat(row) {
return categories.find(c => c.slug === row.cat) || { emoji: '📦', label: 'Autre' }
}
const PointageItem = ({ row, fromPrev }) => {
const cat = getCat(row)
return (
<div
className={`ptg-item${row.pointed ? ' pointed' : ''}`}
onClick={() => togglePointed(row)}
>
<div className="ptg-check">{row.pointed ? '✓' : ''}</div>
<div>
<div className="ptg-name">{row.name}</div>
<div className="ptg-meta">{cat.label}{fromPrev ? ` · ${fmtMonth(row.month)}` : ''}</div>
</div>
<div className="ptg-amt">{fmt(row.amount)}</div>
{row.freq && (
<span className="ptg-badge">{row.freq}</span>
)}
</div>
)
}
return (
<div className="wrap">
<div className="card full">
<div className="card-lbl">Pointage mensuel</div>
{/* Navigation mois */}
<div className="ptg-month-nav">
<button className="ptg-nav-btn" onClick={() => setMonth(m => addMonths(m, -1))}></button>
<div style={{ textAlign: 'center' }}>
<div className="ptg-month-label">{fmtMonth(month)}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
{month === currentBilling ? 'Mois en cours' : 'Mois passé'}
</div>
</div>
<button
className="ptg-nav-btn"
onClick={() => setMonth(m => addMonths(m, 1))}
disabled={month >= currentBilling}
></button>
</div>
{/* Barre de progression */}
<div className="ptg-progress">
<div className="ptg-progress-fill" style={{ width: progress + '%' }} />
</div>
{/* Résumé */}
<div className="ptg-summary">
<div className="sum-box">
<div className="sum-val" style={{ color: 'var(--ab)' }}>{pointed.length}</div>
<div className="sum-lbl">Pointées</div>
</div>
<div className="sum-box">
<div className="sum-val" style={{ color: 'var(--danger)' }}>{unpointed.length}</div>
<div className="sum-lbl">Restantes</div>
</div>
<div className="sum-box">
<div className="sum-val">{fmt(totalPointed)}</div>
<div className="sum-lbl">Total pointé</div>
</div>
</div>
{/* Alertes mois précédent */}
{unpointedPrev.length > 0 && (
<div className="alert warn">
{unpointedPrev.length} dépense{unpointedPrev.length > 1 ? 's' : ''} non pointée{unpointedPrev.length > 1 ? 's' : ''} en {fmtMonth(prevMonth)}
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>
<div className="spinner" style={{ margin: '0 auto' }} />
</div>
) : (
<>
{/* À pointer */}
{unpointed.length > 0 && (
<>
<div className="ptg-section-title">
<span>À pointer ({unpointed.length})</span>
<button className="btn-sm" onClick={pointAll}> Tout pointer</button>
</div>
{unpointed.map(r => <PointageItem key={r.id} row={r} />)}
</>
)}
{/* Déjà pointées */}
{pointed.length > 0 && (
<>
<div className="ptg-section-title">
<span>Pointées ({pointed.length})</span>
</div>
{pointed.map(r => <PointageItem key={r.id} row={r} />)}
</>
)}
{rows.length === 0 && (
<div className="exp-empty">
Aucune dépense récurrente pour ce mois.<br />
Ajoutez des dépenses dans l'onglet Dépenses.
</div>
)}
{/* Mois précédent non pointé */}
{unpointedPrev.length > 0 && (
<>
<div className="ptg-section-title" style={{ color: 'var(--danger)' }}>
Non pointées {fmtMonth(prevMonth)}
</div>
{unpointedPrev.map(r => <PointageItem key={r.id} row={r} fromPrev />)}
</>
)}
</>
)}
</div>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { api } from '../api/client'
export default function SettingsPage({ onToast }) {
const { user, changePassword } = useAuth()
const { theme, setTheme } = useTheme()
const [current, setCurrent] = useState('')
const [newPwd, setNewPwd] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [saving, setSaving] = useState(false)
const isUserA = user?.username === 'user1'
const [prénom, setPrénom] = useState('')
const [revenu, setRevenu] = useState('')
const [profilSaved, setProfilSaved] = useState(false)
const [profilError, setProfilError] = useState('')
useEffect(() => {
api.get('/settings').then(s => {
setPrénom((isUserA ? s.nameA : s.nameB) || '')
setRevenu((isUserA ? s.incomeA : s.incomeB) || '')
}).catch(() => {})
}, [user?.username])
async function handleSaveProfil(e) {
e.preventDefault()
setProfilError('')
if (!prénom.trim()) { setProfilError('Le prénom ne peut pas être vide.'); return }
try {
const nameField = isUserA ? 'nameA' : 'nameB'
const incomeField = isUserA ? 'incomeA' : 'incomeB'
await api.post('/settings', { [nameField]: prénom.trim(), [incomeField]: revenu || '0' })
setProfilSaved(true)
setTimeout(() => setProfilSaved(false), 3000)
onToast('Profil mis à jour', 'ok')
} catch {
setProfilError('Erreur lors de la sauvegarde.')
}
}
async function handleChangePassword(e) {
e.preventDefault()
setError('')
setSuccess(false)
if (!current || !newPwd || !confirm) { setError('Remplissez tous les champs.'); return }
if (newPwd !== confirm) { setError('Les mots de passe ne correspondent pas.'); return }
if (newPwd.length < 6) { setError('6 caractères minimum.'); return }
setSaving(true)
try {
await changePassword(current, newPwd)
setSuccess(true)
setCurrent(''); setNewPwd(''); setConfirm('')
setTimeout(() => setSuccess(false), 3000)
} catch (err) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<div className="wrap">
<div className="card cc full">
<div className="card-lbl">Compte</div>
<div className="card-name">{user?.username}</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, marginBottom: 24 }}>
<div className="admin-section-title">Apparence</div>
<div className="theme-selector">
{[
{ id: 'dark', label: '🌙 Sombre' },
{ id: 'light', label: '☀️ Clair' },
{ id: 'system', label: '⚙️ Système' },
].map(opt => (
<button
key={opt.id}
className={`theme-btn${theme === opt.id ? ' active' : ''}`}
onClick={() => setTheme(opt.id)}
>
{opt.label}
</button>
))}
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, marginBottom: 24 }}>
<div className="admin-section-title">Profil</div>
{profilError && <div className="modal-err">{profilError}</div>}
{profilSaved && <div className="modal-ok">Profil mis à jour </div>}
<form onSubmit={handleSaveProfil}>
<div className="inp-grp">
<label className="inp-lbl">Prénom</label>
<div className="inp-row">
<input type="text" value={prénom} onChange={e => setPrénom(e.target.value)} />
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Revenu mensuel net</label>
<div className="inp-row">
<input type="number" min="0" value={revenu} onChange={e => setRevenu(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
<button className="btn-add" type="submit">Enregistrer</button>
</form>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<div className="admin-section-title">Changer le mot de passe</div>
{error && <div className="modal-err">{error}</div>}
{success && <div className="modal-ok">Mot de passe modifié </div>}
<form onSubmit={handleChangePassword}>
<div className="inp-grp">
<label className="inp-lbl">Mot de passe actuel</label>
<div className="inp-row">
<input type="password" value={current} onChange={e => setCurrent(e.target.value)} />
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Nouveau mot de passe</label>
<div className="inp-row">
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} />
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Confirmer</label>
<div className="inp-row">
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
</div>
</div>
<button className="btn-add" type="submit" disabled={saving}>
{saving ? 'Enregistrement…' : 'Changer le mot de passe'}
</button>
</form>
</div>
</div>
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react'
import { api } from '../api/client'
export default function SetupPage({ onComplete }) {
const [nameA, setNameA] = useState('')
const [incomeA, setIncomeA] = useState('')
const [nameB, setNameB] = useState('')
const [incomeB, setIncomeB] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
setError('')
if (!nameA.trim() || !nameB.trim()) {
setError('Les deux prénoms sont requis.')
return
}
setSaving(true)
try {
await api.post('/settings', {
nameA: nameA.trim(),
nameB: nameB.trim(),
incomeA: incomeA || '0',
incomeB: incomeB || '0',
setupDone: 'true',
})
onComplete()
} catch {
setError('Erreur lors de la sauvegarde.')
setSaving(false)
}
}
return (
<div className="setup-shell">
<div className="setup-card">
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div className="eyebrow">Première connexion</div>
<h1>Budget <span>Commun</span></h1>
<p className="setup-desc">Configurez votre espace en quelques secondes.</p>
</div>
<form onSubmit={handleSubmit}>
<div className="setup-section">
<div className="setup-section-lbl">
<div className="dot" style={{ background: 'var(--aa)' }} />
user1 première personne
</div>
<div className="inp-grp">
<label className="inp-lbl">Prénom</label>
<div className="inp-row">
<input
type="text"
placeholder="Prénom"
value={nameA}
onChange={e => setNameA(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Revenu mensuel net</label>
<div className="inp-row">
<input
type="number"
placeholder="0"
min="0"
value={incomeA}
onChange={e => setIncomeA(e.target.value)}
/>
<span className="inp-sfx"></span>
</div>
</div>
</div>
<div className="setup-section">
<div className="setup-section-lbl">
<div className="dot" style={{ background: 'var(--ab)' }} />
user2 deuxième personne
</div>
<div className="inp-grp">
<label className="inp-lbl">Prénom</label>
<div className="inp-row">
<input
type="text"
placeholder="Prénom"
value={nameB}
onChange={e => setNameB(e.target.value)}
/>
</div>
</div>
<div className="inp-grp">
<label className="inp-lbl">Revenu mensuel net</label>
<div className="inp-row">
<input
type="number"
placeholder="0"
min="0"
value={incomeB}
onChange={e => setIncomeB(e.target.value)}
/>
<span className="inp-sfx"></span>
</div>
</div>
</div>
{error && <div className="login-err" style={{ marginBottom: 16 }}>{error}</div>}
<button className="btn-login" type="submit" disabled={saving}>
{saving ? 'Sauvegarde…' : 'Commencer →'}
</button>
</form>
</div>
</div>
)
}
+351
View File
@@ -0,0 +1,351 @@
import { useState, useEffect } from 'react'
import { api } from '../api/client'
import { useBudget } from '../contexts/BudgetContext'
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
const fmtPct = n => n.toFixed(1) + '%'
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
function monthOptions(count = 24) {
const now = new Date()
const opts = []
for (let i = 0; i < count; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + i, 1)
const val = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
opts.push({ value: val, label: MONTHS_FR[d.getMonth()] + ' ' + d.getFullYear() })
}
return opts
}
function computeMonthsLeft(deadline) {
if (!deadline) return null
const [y, m] = deadline.split('-').map(Number)
const now = new Date()
const diff = (y - now.getFullYear()) * 12 + (m - now.getMonth() - 1)
return Math.max(0, diff)
}
// ── Validate Modal ────────────────────────────────────────────
function ValidateModal({ project, settings, isPerso, onConfirm, onClose }) {
const [month, setMonth] = useState(() => {
const now = new Date()
return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0')
})
const ia = parseFloat(settings.incomeA) || 0
const ib = parseFloat(settings.incomeB) || 0
const pct = ia + ib > 0 ? Math.round(ia / (ia + ib) * 100) : 50
const remaining = project.target - project.saved
const monthsLeft = computeMonthsLeft(project.deadline)
const monthlyTotal = monthsLeft > 0 ? remaining / monthsLeft : remaining
const shareA = isPerso ? monthlyTotal
: project.split === '50' ? monthlyTotal / 2
: project.split === 'auto' ? monthlyTotal * pct / 100
: monthlyTotal * (project.customPct || 50) / 100
const shareB = monthlyTotal - shareA
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal-card">
<div className="modal-title"> Valider le projet</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 18 }}>
{project.emoji} {project.name} {fmt(project.target)}
</div>
<div className="inp-grp">
<label className="inp-lbl">À partir de quel mois ?</label>
<div className="inp-row">
<select value={month} onChange={e => setMonth(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
{monthOptions().map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div style={{
background: 'var(--surface2)', border: '1px solid var(--border)',
borderRadius: 8, padding: 12, fontSize: 11, color: 'var(--muted)',
lineHeight: 1.7, marginBottom: 4,
}}>
{monthsLeft != null
? `Sur ${monthsLeft} mois restants → ${fmt(monthlyTotal)}/mois`
: `Montant total : ${fmt(remaining)}`}
{!isPerso && <><br />{settings.nameA} : {fmt(shareA)}/mois · {settings.nameB} : {fmt(shareB)}/mois</>}
</div>
<div className="modal-btns">
<button className="btn-cancel" onClick={onClose}>Annuler</button>
<button
className="btn-confirm"
style={{ background: '#f9a875' }}
onClick={() => onConfirm(month, monthlyTotal, shareA, shareB)}
>
Ajouter aux dépenses
</button>
</div>
</div>
</div>
)
}
// ── Project card ──────────────────────────────────────────────
function ProjectCard({ project, settings, isPerso, onDelete, onValidate }) {
const remaining = project.target - project.saved
const progress = Math.min(100, project.saved / project.target * 100)
const monthsLeft = computeMonthsLeft(project.deadline)
const monthly = monthsLeft > 0 ? remaining / monthsLeft : null
const ia = parseFloat(settings.incomeA) || 0
const ib = parseFloat(settings.incomeB) || 0
const pct = ia + ib > 0 ? Math.round(ia / (ia + ib) * 100) : 50
const shareA = (!isPerso && monthly)
? (project.split === '50' ? monthly / 2
: project.split === 'auto' ? monthly * pct / 100
: monthly * (project.customPct || 50) / 100)
: null
return (
<div className="simu-project">
<div className="simu-proj-header">
<div className="simu-proj-icon">{project.emoji}</div>
<div>
<div className="simu-proj-name">{project.name}</div>
<div className="simu-proj-sub">
{fmt(project.saved)} économisé sur {fmt(project.target)}
{project.deadline ? ` · échéance ${project.deadline}` : ''}
</div>
</div>
<div className="simu-proj-actions">
<button className="btn-sm" onClick={() => onValidate(project)}> Valider</button>
<button className="btn-icon" onClick={() => onDelete(project.id)}></button>
</div>
</div>
<div className="simu-progress-track">
<div className="simu-progress-fill" style={{ width: progress + '%' }} />
</div>
<div className="simu-stats">
<div>
<div className="simu-stat-lbl">Restant</div>
<div className="simu-stat-val">{fmt(remaining)}</div>
</div>
{monthsLeft != null && (
<div>
<div className="simu-stat-lbl">Mois restants</div>
<div className="simu-stat-val">{monthsLeft}</div>
</div>
)}
{monthly && (
<div>
<div className="simu-stat-lbl">/ mois</div>
<div className="simu-stat-val">{fmt(monthly)}</div>
</div>
)}
{shareA && (
<div>
<div className="simu-stat-lbl">{settings.nameA}</div>
<div className="simu-stat-val">{fmt(shareA)}</div>
</div>
)}
{shareA && (
<div>
<div className="simu-stat-lbl">{settings.nameB}</div>
<div className="simu-stat-val">{fmt(monthly - shareA)}</div>
</div>
)}
</div>
</div>
)
}
// ── Main component ────────────────────────────────────────────
const STORAGE_KEY = 'budget-simulations'
export default function SimulateurPage({ settings, onExpensesChange, expenses, onToast }) {
const { activeBudget } = useBudget()
const isPerso = activeBudget.type === 'perso'
const [projects, setProjects] = useState(() => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') }
catch { return [] }
})
const [emoji, setEmoji] = useState('🏖')
const [name, setName] = useState('')
const [target, setTarget] = useState('')
const [saved, setSaved] = useState('0')
const [deadline, setDeadline] = useState('')
const [split, setSplit] = useState('auto')
const [customPct, setCustomPct] = useState(50)
const [validate, setValidate] = useState(null)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
}, [projects])
function addProject() {
if (!name.trim() || !target || parseFloat(target) <= 0) {
onToast('Nom et montant cible requis', 'err'); return
}
const project = {
id: Date.now(),
emoji, name: name.trim(),
target: parseFloat(target),
saved: parseFloat(saved) || 0,
deadline, split,
customPct: parseInt(customPct),
}
setProjects(prev => [...prev, project])
setName(''); setTarget(''); setSaved('0'); setDeadline('')
onToast('Projet créé ✓')
}
function deleteProject(id) {
setProjects(prev => prev.filter(p => p.id !== id))
}
async function handleValidate(month, monthlyTotal, shareA, shareB) {
try {
const created = await api.post('/expenses', {
name: validate.emoji + ' ' + validate.name,
amount: monthlyTotal,
cat: 'autre',
freq: 'mensuel',
month: null,
})
onExpensesChange([...expenses, created])
deleteProject(validate.id)
setValidate(null)
onToast('Projet ajouté aux dépenses ✓')
} catch (err) {
onToast(err.message, 'err')
}
}
const monthOpts = monthOptions()
return (
<div className="wrap">
<div className="card simu-card full">
<div className="card-lbl">Simulateur de budget</div>
<div className="card-name" style={{ color: '#f9a875' }}>🎯 Projets & Objectifs</div>
{/* Form */}
<div className="add-simu-form">
<div className="add-simu-title">Nouveau projet</div>
<div className="simu-form-grid">
<div>
<label className="inp-lbl">Emoji</label>
<input
type="text" value={emoji} onChange={e => setEmoji(e.target.value)} maxLength={2}
style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, color: 'var(--text)', fontSize: 20,
padding: 9, width: 48, textAlign: 'center', outline: 'none',
}}
/>
</div>
<div>
<label className="inp-lbl">Nom du projet</label>
<div className="inp-row">
<input type="text" placeholder="ex: Vacances Espagne" value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addProject()} />
</div>
</div>
<div>
<label className="inp-lbl">Montant cible</label>
<div className="inp-row">
<input type="number" placeholder="3000" min="1" value={target}
onChange={e => setTarget(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
<div>
<label className="inp-lbl">Déjà économisé</label>
<div className="inp-row">
<input type="number" placeholder="0" min="0" value={saved}
onChange={e => setSaved(e.target.value)} />
<span className="inp-sfx"></span>
</div>
</div>
<div>
<label className="inp-lbl">Échéance</label>
<div className="inp-row">
<input type="month" value={deadline} onChange={e => setDeadline(e.target.value)}
style={{ padding: '11px 8px', fontSize: 13 }} />
</div>
</div>
{!isPerso && (
<div>
<label className="inp-lbl">Répartition</label>
<div className="inp-row">
<select value={split} onChange={e => setSplit(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
<option value="auto">Proportionnelle aux revenus</option>
<option value="50">50% / 50%</option>
<option value="custom">Personnalisée</option>
</select>
</div>
</div>
)}
</div>
{!isPerso && split === 'custom' && (
<div style={{ marginTop: 10 }}>
<label className="inp-lbl">Part de {settings.nameA} (%)</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="range" min="0" max="100" step="1" value={customPct}
onChange={e => setCustomPct(e.target.value)}
style={{ flex: 1, accentColor: '#f9a875' }}
/>
<span style={{ fontSize: 12, color: '#f9a875', whiteSpace: 'nowrap', minWidth: 90 }}>
{customPct}% / {100 - customPct}%
</span>
</div>
</div>
)}
<button
className="btn-add"
style={{ background: '#f9a875', marginTop: 14, width: '100%' }}
onClick={addProject}
>
+ Créer le projet
</button>
</div>
{/* Projects list */}
{projects.length === 0 ? (
<div className="simu-empty">
Aucun projet pour l'instant.<br />
Créez votre premier objectif d'épargne ci-dessus.
</div>
) : (
projects.map(p => (
<ProjectCard
key={p.id}
project={p}
settings={settings}
isPerso={isPerso}
onDelete={deleteProject}
onValidate={setValidate}
/>
))
)}
</div>
{validate && (
<ValidateModal
project={validate}
settings={settings}
isPerso={isPerso}
onConfirm={handleValidate}
onClose={() => setValidate(null)}
/>
)}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000'
}
},
build: {
outDir: '../backend/public',
emptyOutDir: true
}
})