Set up initial frontend with Vite and integrated Docker for full-stack build
This commit is contained in:
@@ -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>
|
||||
Generated
+1677
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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))
|
||||
)
|
||||
})
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user