瀏覽代碼

modif scanner

jeremy 4 月之前
父節點
當前提交
47c8809bab

+ 13 - 0
backend/src/db.js

@@ -137,6 +137,19 @@ async function initDb() {
     );
   `);
 
+  // Charge categories table
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS charge_categories (
+      id SERIAL PRIMARY KEY,
+      user_id INTEGER NOT NULL REFERENCES users(id),
+      value TEXT NOT NULL,
+      label TEXT NOT NULL,
+      icon TEXT DEFAULT '📦',
+      sort_order INTEGER DEFAULT 0,
+      created_at TIMESTAMP DEFAULT NOW()
+    );
+  `);
+
   // Invoice scanning tables
   await pool.query(`
     CREATE TABLE IF NOT EXISTS scanned_invoices (

+ 118 - 0
backend/src/routes/categories.js

@@ -0,0 +1,118 @@
+const express = require('express');
+const pool = require('../db');
+const { authMiddleware } = require('../auth');
+
+const router = express.Router();
+router.use(authMiddleware);
+
+const DEFAULT_CATEGORIES = [
+  { value: 'eau', label: 'Eau', icon: '💧' },
+  { value: 'gaz', label: 'Gaz', icon: '🔥' },
+  { value: 'electricite', label: 'Électricité', icon: '⚡' },
+  { value: 'entretien', label: 'Entretien / Réparations', icon: '🔧' },
+  { value: 'ascenseur', label: 'Ascenseur', icon: '🛗' },
+  { value: 'ordures', label: 'Ordures ménagères', icon: '🗑️' },
+  { value: 'assurance', label: 'Assurance', icon: '🛡️' },
+  { value: 'autre', label: 'Autre', icon: '📦' },
+];
+
+// Seed default categories for a user if none exist
+async function ensureDefaults(userId) {
+  const count = (await pool.query('SELECT COUNT(*) FROM charge_categories WHERE user_id = $1', [userId])).rows[0].count;
+  if (parseInt(count) === 0) {
+    for (let i = 0; i < DEFAULT_CATEGORIES.length; i++) {
+      const c = DEFAULT_CATEGORIES[i];
+      await pool.query(
+        'INSERT INTO charge_categories (user_id, value, label, icon, sort_order) VALUES ($1, $2, $3, $4, $5)',
+        [userId, c.value, c.label, c.icon, i]
+      );
+    }
+  }
+}
+
+// GET /api/categories - List all categories for user
+router.get('/', async (req, res) => {
+  await ensureDefaults(req.userId);
+  const rows = (await pool.query(
+    'SELECT * FROM charge_categories WHERE user_id = $1 ORDER BY sort_order, id',
+    [req.userId]
+  )).rows;
+  res.json(rows);
+});
+
+// POST /api/categories - Create a new category
+router.post('/', async (req, res) => {
+  const { value, label, icon } = req.body;
+  if (!value || !label) return res.status(400).json({ error: 'Valeur et libellé requis' });
+
+  // Check uniqueness
+  const existing = (await pool.query(
+    'SELECT id FROM charge_categories WHERE user_id = $1 AND value = $2',
+    [req.userId, value]
+  )).rows[0];
+  if (existing) return res.status(400).json({ error: 'Cette valeur existe déjà' });
+
+  const maxOrder = (await pool.query(
+    'SELECT COALESCE(MAX(sort_order), 0) + 1 as next FROM charge_categories WHERE user_id = $1',
+    [req.userId]
+  )).rows[0].next;
+
+  const result = await pool.query(
+    'INSERT INTO charge_categories (user_id, value, label, icon, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING *',
+    [req.userId, value, label, icon || '📦', maxOrder]
+  );
+  res.status(201).json(result.rows[0]);
+});
+
+// PUT /api/categories/reorder - Reorder categories (must be before /:id)
+router.put('/reorder', async (req, res) => {
+  const { order } = req.body;
+  if (!Array.isArray(order)) return res.status(400).json({ error: 'Tableau d\'ordre requis' });
+
+  for (let i = 0; i < order.length; i++) {
+    await pool.query(
+      'UPDATE charge_categories SET sort_order = $1 WHERE id = $2 AND user_id = $3',
+      [i, order[i], req.userId]
+    );
+  }
+  res.json({ success: true });
+});
+
+// PUT /api/categories/:id - Update a category
+router.put('/:id', async (req, res) => {
+  const { label, icon } = req.body;
+  const cat = (await pool.query(
+    'SELECT id FROM charge_categories WHERE id = $1 AND user_id = $2',
+    [req.params.id, req.userId]
+  )).rows[0];
+  if (!cat) return res.status(404).json({ error: 'Catégorie non trouvée' });
+
+  await pool.query(
+    'UPDATE charge_categories SET label = $1, icon = $2 WHERE id = $3',
+    [label, icon || '📦', req.params.id]
+  );
+  res.json({ success: true });
+});
+
+// DELETE /api/categories/:id - Delete a category
+router.delete('/:id', async (req, res) => {
+  const cat = (await pool.query(
+    'SELECT * FROM charge_categories WHERE id = $1 AND user_id = $2',
+    [req.params.id, req.userId]
+  )).rows[0];
+  if (!cat) return res.status(404).json({ error: 'Catégorie non trouvée' });
+
+  // Check if category is in use
+  const inUse = (await pool.query(
+    'SELECT COUNT(*) FROM charges WHERE user_id = $1 AND category = $2',
+    [req.userId, cat.value]
+  )).rows[0].count;
+  if (parseInt(inUse) > 0) {
+    return res.status(400).json({ error: `Impossible de supprimer : ${inUse} charge(s) utilisent cette catégorie` });
+  }
+
+  await pool.query('DELETE FROM charge_categories WHERE id = $1', [req.params.id]);
+  res.json({ success: true });
+});
+
+module.exports = router;

+ 4 - 1
backend/src/routes/charges.js

@@ -394,7 +394,10 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
     trop_percu,
   };
 
-  const catLabels = { eau: 'Eau', gaz: 'Gaz', electricite: 'Électricité', entretien: 'Entretien / Réparations', ascenseur: 'Ascenseur', ordures: 'Ordures ménagères', assurance: 'Assurance', autre: 'Autre' };
+  // Load dynamic category labels from DB
+  const catRows = (await pool.query('SELECT value, label FROM charge_categories WHERE user_id = $1', [req.userId])).rows;
+  const catLabels = {};
+  catRows.forEach(r => { catLabels[r.value] = r.label; });
   const fmtEur = v => `${Number(v).toFixed(2)} EUR`;
   const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('fr-FR') : '—';
   const isPos = trop_percu >= 0;

+ 1 - 0
backend/src/server.js

@@ -24,6 +24,7 @@ app.use('/api/receipts', require('./routes/receipts'));
 app.use('/api/charges', require('./routes/charges'));
 app.use('/api/irl', require('./routes/irl'));
 app.use('/api/scan', require('./routes/scan'));
+app.use('/api/categories', require('./routes/categories'));
 
 app.get('/api/health', (_, res) => res.json({ ok: true }));
 

+ 1 - 1
frontend/src/components/Layout.jsx

@@ -11,7 +11,7 @@ const navItems = [
   { to: '/payments', label: '💰 Paiements' },
   { to: '/charges', label: '🧾 Charges' },
   { to: '/scanner', label: '📄 Scanner' },
-  { to: '/profile', label: '⚙️ Mon profil' },
+  { to: '/profile', label: '⚙️ Paramètres' },
 ]
 
 export default function Layout() {

+ 11 - 21
frontend/src/pages/Charges.jsx

@@ -3,17 +3,6 @@ import api from '../api'
 import toast from 'react-hot-toast'
 import { useAuth } from '../context/AuthContext'
 
-const CATEGORIES = [
-  { value: 'eau', label: '💧 Eau' },
-  { value: 'gaz', label: '🔥 Gaz' },
-  { value: 'electricite', label: '⚡ Électricité' },
-  { value: 'entretien', label: '🔧 Entretien / Réparations' },
-  { value: 'ascenseur', label: '🛗 Ascenseur' },
-  { value: 'ordures', label: '🗑️ Ordures ménagères' },
-  { value: 'assurance', label: '🛡️ Assurance' },
-  { value: 'autre', label: '📦 Autre' },
-]
-
 const CURRENT_YEAR = new Date().getFullYear()
 const YEARS = Array.from({ length: 6 }, (_, i) => CURRENT_YEAR - i)
 
@@ -31,9 +20,9 @@ function Modal({ title, onClose, children }) {
   )
 }
 
-function CategoryBadge({ value }) {
-  const cat = CATEGORIES.find(c => c.value === value)
-  return <span className="inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">{cat?.label || value}</span>
+function CategoryBadge({ value, categories }) {
+  const cat = categories.find(c => c.value === value)
+  return <span className="inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">{cat ? `${cat.icon} ${cat.label}` : value}</span>
 }
 
 export default function Charges() {
@@ -53,6 +42,7 @@ export default function Charges() {
   const [editCharge, setEditCharge] = useState(null)
   const [deductibleSummary, setDeductibleSummary] = useState(null)
   const [showDeductible, setShowDeductible] = useState(false)
+  const [categories, setCategories] = useState([])
 
   const emptyForm = {
     property_id: '',
@@ -89,7 +79,7 @@ export default function Charges() {
     } catch { setDeductibleSummary(null) }
   }
 
-  useEffect(() => { loadProperties() }, [])
+  useEffect(() => { loadProperties(); api.get('/categories').then(r => setCategories(r.data)) }, [])
   useEffect(() => { if (filterProperty) { loadCharges(); loadDeductibleSummary() } }, [filterProperty, filterYear])
 
   const openAdd = () => {
@@ -169,7 +159,7 @@ export default function Charges() {
 
   const printBilan = () => {
     if (!bilan) return
-    const catLabel = v => CATEGORIES.find(c => c.value === v)?.label || v
+    const catLabel = v => { const c = categories.find(c => c.value === v); return c ? `${c.icon} ${c.label}` : v }
     const fmtEur = v => `${Number(v).toFixed(2)} €`
     const solde = bilan.total_provisions_percues - bilan.total_charges_reelles
     const isPos = solde >= 0
@@ -509,7 +499,7 @@ export default function Charges() {
                 {charges.map(c => (
                   <tr key={c.id} className="hover:bg-gray-50">
                     <td className="px-6 py-4 text-sm text-gray-600">{fmtDate(c.date)}</td>
-                    <td className="px-6 py-4"><CategoryBadge value={c.category} /></td>
+                    <td className="px-6 py-4"><CategoryBadge value={c.category} categories={categories} /></td>
                     <td className="px-6 py-4 text-gray-900 font-medium">{c.label}</td>
                     <td className="px-6 py-4 text-sm text-gray-500">{c.property_name}</td>
                     <td className="px-6 py-4 text-right font-semibold text-gray-900">{fmt(c.amount)}</td>
@@ -552,7 +542,7 @@ export default function Charges() {
                   <div className="flex-1 min-w-0">
                     <p className="font-semibold text-gray-900 truncate">{c.label}</p>
                     <div className="flex items-center gap-2 mt-1 flex-wrap">
-                      <CategoryBadge value={c.category} />
+                      <CategoryBadge value={c.category} categories={categories} />
                       {c.recoverable_type === 'recoverable'
                         ? <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">Locataire</span>
                         : c.recoverable_type === 'deductible'
@@ -599,7 +589,7 @@ export default function Charges() {
                 <label className="label">Catégorie *</label>
                 <select className="input" value={form.category}
                   onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
-                  {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
+                  {categories.map(c => <option key={c.value} value={c.value}>{c.icon} {c.label}</option>)}
                 </select>
               </div>
               <div>
@@ -735,11 +725,11 @@ export default function Charges() {
                 <h3 className="text-sm font-semibold text-gray-700 mb-2">Répartition par catégorie</h3>
                 <div className="space-y-1">
                   {bilan.charges_by_category.map(c => {
-                    const cat = CATEGORIES.find(x => x.value === c.category)
+                    const cat = categories.find(x => x.value === c.category)
                     const pct = bilan.total_charges_reelles > 0 ? (c.total / bilan.total_charges_reelles * 100).toFixed(1) : 0
                     return (
                       <div key={c.category} className="flex items-center justify-between text-sm py-1">
-                        <span className="text-gray-600">{cat?.label || c.category}</span>
+                        <span className="text-gray-600">{cat ? `${cat.icon} ${cat.label}` : c.category}</span>
                         <div className="flex items-center gap-3">
                           <div className="w-24 bg-gray-100 rounded-full h-1.5">
                             <div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${pct}%` }}></div>

+ 80 - 18
frontend/src/pages/InvoiceScanner.jsx

@@ -2,17 +2,6 @@ import { useEffect, useState } from 'react'
 import api from '../api'
 import toast from 'react-hot-toast'
 
-const CATEGORIES = [
-  { value: 'eau', label: 'Eau' },
-  { value: 'gaz', label: 'Gaz' },
-  { value: 'electricite', label: 'Électricité' },
-  { value: 'entretien', label: 'Entretien' },
-  { value: 'ascenseur', label: 'Ascenseur' },
-  { value: 'ordures', label: 'Ordures ménagères' },
-  { value: 'assurance', label: 'Assurance' },
-  { value: 'autre', label: 'Autre' },
-]
-
 const STATUS_LABELS = {
   pending: { label: 'En attente', class: 'bg-yellow-100 text-yellow-800' },
   approved: { label: 'Approuvée', class: 'bg-green-100 text-green-800' },
@@ -60,11 +49,15 @@ export default function InvoiceScanner() {
   const [editForm, setEditForm] = useState({})
   const [previewId, setPreviewId] = useState(null)
   const [previewUrl, setPreviewUrl] = useState(null)
+  const [categories, setCategories] = useState([])
+  const [selected, setSelected] = useState(new Set())
+  const [filterText, setFilterText] = useState('')
 
   const [editPreviewUrl, setEditPreviewUrl] = useState(null)
 
   const loadProperties = () => api.get('/properties').then(r => setProperties(r.data))
   const loadStats = () => api.get('/scan/stats').then(r => setStats(r.data))
+  const loadCategories = () => api.get('/categories').then(r => setCategories(r.data))
 
   const loadInvoices = () => {
     const params = new URLSearchParams()
@@ -74,8 +67,8 @@ export default function InvoiceScanner() {
     api.get(`/scan/invoices?${params}`).then(r => setInvoices(r.data))
   }
 
-  useEffect(() => { loadProperties(); loadStats() }, [])
-  useEffect(() => { loadInvoices() }, [selectedProperty, filterStatus, filterYear])
+  useEffect(() => { loadProperties(); loadStats(); loadCategories() }, [])
+  useEffect(() => { loadInvoices(); setSelected(new Set()) }, [selectedProperty, filterStatus, filterYear])
 
   const propertiesWithScan = properties.filter(p => p.scan_directory)
 
@@ -229,6 +222,56 @@ export default function InvoiceScanner() {
   const years = [...new Set(invoices.map(i => i.year).filter(Boolean))].sort((a, b) => b - a)
   const allYears = years.length > 0 ? years : [new Date().getFullYear()]
 
+  // Client-side text filter on filename
+  const filteredInvoices = filterText
+    ? invoices.filter(inv => inv.file_path.toLowerCase().includes(filterText.toLowerCase()))
+    : invoices
+
+  // Selection helpers
+  const toggleSelect = (id) => {
+    setSelected(prev => {
+      const next = new Set(prev)
+      next.has(id) ? next.delete(id) : next.add(id)
+      return next
+    })
+  }
+  const toggleSelectAll = () => {
+    if (selected.size === filteredInvoices.length) {
+      setSelected(new Set())
+    } else {
+      setSelected(new Set(filteredInvoices.map(i => i.id)))
+    }
+  }
+  const selectedInvoices = filteredInvoices.filter(i => selected.has(i.id))
+
+  const handleBulkReject = async () => {
+    const pending = selectedInvoices.filter(i => i.status === 'pending')
+    if (pending.length === 0) return toast.error('Aucune facture en attente sélectionnée')
+    if (!confirm(`Rejeter ${pending.length} facture(s) ?`)) return
+    let ok = 0
+    for (const inv of pending) {
+      try { await api.post(`/scan/invoices/${inv.id}/reject`); ok++ } catch {}
+    }
+    toast.success(`${ok} facture(s) rejetée(s)`)
+    setSelected(new Set())
+    loadInvoices()
+    loadStats()
+  }
+
+  const handleBulkDelete = async () => {
+    const deletable = selectedInvoices.filter(i => i.status !== 'approved')
+    if (deletable.length === 0) return toast.error('Aucune facture supprimable sélectionnée')
+    if (!confirm(`Supprimer ${deletable.length} facture(s) ?`)) return
+    let ok = 0
+    for (const inv of deletable) {
+      try { await api.delete(`/scan/invoices/${inv.id}`); ok++ } catch {}
+    }
+    toast.success(`${ok} facture(s) supprimée(s)`)
+    setSelected(new Set())
+    loadInvoices()
+    loadStats()
+  }
+
   return (
     <div>
       <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-8 gap-4">
@@ -315,18 +358,37 @@ export default function InvoiceScanner() {
           <option value="">Toutes les années</option>
           {allYears.map(y => <option key={y} value={y}>{y}</option>)}
         </select>
+        <input className="input w-auto" type="text" value={filterText} onChange={e => setFilterText(e.target.value)} placeholder="🔍 Filtrer par nom de fichier..." />
       </div>
 
+      {/* Selection toolbar */}
+      {selected.size > 0 && (
+        <div className="flex items-center gap-3 mb-4 p-3 bg-blue-50 rounded-lg">
+          <span className="text-sm font-medium text-blue-800">{selected.size} sélectionnée(s)</span>
+          <button onClick={handleBulkReject} className="text-sm text-red-600 hover:text-red-800 font-medium">❌ Rejeter</button>
+          <button onClick={handleBulkDelete} className="text-sm text-red-600 hover:text-red-800 font-medium">🗑️ Supprimer</button>
+          <button onClick={() => setSelected(new Set())} className="text-sm text-gray-500 hover:text-gray-700 ml-auto">Désélectionner</button>
+        </div>
+      )}
+
       {/* Invoice list */}
-      {invoices.length === 0 ? (
+      {filteredInvoices.length === 0 ? (
         <div className="card text-center py-12">
           <p className="text-gray-400">Aucune facture scannée pour les filtres sélectionnés</p>
         </div>
       ) : (
         <div className="space-y-3">
-          {invoices.map(inv => (
-            <div key={inv.id} className="card hover:shadow-md transition-shadow">
+          {/* Select all header */}
+          <div className="flex items-center gap-3 px-1">
+            <input type="checkbox" checked={selected.size === filteredInvoices.length && filteredInvoices.length > 0}
+              onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600" />
+            <span className="text-xs text-gray-500">Tout sélectionner ({filteredInvoices.length})</span>
+          </div>
+          {filteredInvoices.map(inv => (
+            <div key={inv.id} className={`card hover:shadow-md transition-shadow ${selected.has(inv.id) ? 'ring-2 ring-blue-300' : ''}`}>
               <div className="flex flex-col sm:flex-row sm:items-center gap-3">
+                <input type="checkbox" checked={selected.has(inv.id)} onChange={() => toggleSelect(inv.id)}
+                  className="rounded border-gray-300 text-blue-600 shrink-0 mt-1 sm:mt-0" />
                 <div className="flex-1 min-w-0">
                   <div className="flex items-center gap-2 mb-1">
                     <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[inv.status]?.class}`}>
@@ -338,7 +400,7 @@ export default function InvoiceScanner() {
                   <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
                     <span>🏠 {inv.property_name}</span>
                     {inv.detected_supplier && <span>🏢 {inv.detected_supplier}</span>}
-                    {inv.detected_category && <span>📁 {CATEGORIES.find(c => c.value === inv.detected_category)?.label || inv.detected_category}</span>}
+                    {inv.detected_category && <span>📁 {categories.find(c => c.value === inv.detected_category)?.label || inv.detected_category}</span>}
                     {inv.detected_amount != null && <span className="font-medium text-gray-700">💰 {inv.detected_amount.toFixed(2)} €</span>}
                   </div>
                   <p className="text-xs text-gray-400 mt-1 truncate" title={inv.file_path}>{inv.file_path.split('/').slice(-2).join('/')}</p>
@@ -410,7 +472,7 @@ export default function InvoiceScanner() {
                 <div>
                   <label className="label">Catégorie</label>
                   <select className="input" value={editForm.detected_category} onChange={e => setEditForm(f => ({ ...f, detected_category: e.target.value }))}>
-                    {CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
+                    {categories.map(c => <option key={c.value} value={c.value}>{c.icon} {c.label}</option>)}
                   </select>
                 </div>
                 <div>

+ 170 - 23
frontend/src/pages/Profile.jsx

@@ -3,7 +3,21 @@ import api from '../api'
 import toast from 'react-hot-toast'
 import { useAuth } from '../context/AuthContext'
 
-export default function Profile() {
+function Modal({ title, onClose, children }) {
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
+      <div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-lg max-h-[92vh] flex flex-col">
+        <div className="flex items-center justify-between p-4 sm:p-6 border-b shrink-0">
+          <h2 className="text-lg font-semibold">{title}</h2>
+          <button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
+        </div>
+        <div className="p-4 sm:p-6 overflow-y-auto">{children}</div>
+      </div>
+    </div>
+  )
+}
+
+function ProfileTab() {
   const { user, setUser } = useAuth()
   const [form, setForm] = useState({ first_name: '', last_name: '', email: '', address: '', phone: '', name: '' })
   const [loading, setLoading] = useState(false)
@@ -49,34 +63,167 @@ export default function Profile() {
   )
 
   return (
-    <div className="max-w-xl">
-      <div className="mb-6">
-        <h1 className="text-2xl font-bold text-gray-900">👤 Mon profil bailleur</h1>
-        <p className="text-gray-500 text-sm mt-1">Ces informations apparaîtront dans vos quittances et bilans de charges</p>
+    <div className="card">
+      <form onSubmit={handleSubmit} className="space-y-4">
+        <div className="grid grid-cols-2 gap-4">
+          {field('Prénom', 'first_name', 'text', 'Jean')}
+          {field('Nom', 'last_name', 'text', 'Dupont')}
+        </div>
+        {field('Adresse postale', 'address', 'text', '12 rue de la Paix, 75001 Paris')}
+        {field('Adresse e-mail *', 'email', 'email', 'vous@exemple.fr')}
+        {field('Numéro de téléphone', 'phone', 'tel', '06 00 00 00 00')}
+
+        <div className="pt-2 border-t border-gray-100">
+          <p className="text-xs text-gray-400 mb-3">
+            Le champ "Nom d'affichage" est utilisé si prénom/nom ne sont pas renseignés.
+          </p>
+          {field("Nom d'affichage *", 'name', 'text', 'Jean Dupont')}
+        </div>
+
+        <button type="submit" disabled={loading} className="btn-primary w-full">
+          {loading ? '...' : '💾 Enregistrer le profil'}
+        </button>
+      </form>
+    </div>
+  )
+}
+
+function CategoriesTab() {
+  const [categories, setCategories] = useState([])
+  const [showModal, setShowModal] = useState(false)
+  const [editCat, setEditCat] = useState(null)
+  const [form, setForm] = useState({ value: '', label: '', icon: '📦' })
+
+  const load = () => api.get('/categories').then(r => setCategories(r.data))
+  useEffect(() => { load() }, [])
+
+  const openAdd = () => { setForm({ value: '', label: '', icon: '📦' }); setEditCat(null); setShowModal(true) }
+  const openEdit = (cat) => { setForm({ value: cat.value, label: cat.label, icon: cat.icon }); setEditCat(cat); setShowModal(true) }
+
+  const handleSubmit = async (e) => {
+    e.preventDefault()
+    try {
+      if (editCat) {
+        await api.put(`/categories/${editCat.id}`, { label: form.label, icon: form.icon })
+        toast.success('Catégorie modifiée')
+      } else {
+        await api.post('/categories', form)
+        toast.success('Catégorie ajoutée')
+      }
+      setShowModal(false)
+      load()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    }
+  }
+
+  const handleDelete = async (cat) => {
+    if (!confirm(`Supprimer la catégorie "${cat.label}" ?`)) return
+    try {
+      await api.delete(`/categories/${cat.id}`)
+      toast.success('Catégorie supprimée')
+      load()
+    } catch (err) {
+      toast.error(err.response?.data?.error || 'Erreur')
+    }
+  }
+
+  const move = async (index, direction) => {
+    const newCats = [...categories]
+    const swapIdx = index + direction
+    if (swapIdx < 0 || swapIdx >= newCats.length) return
+    ;[newCats[index], newCats[swapIdx]] = [newCats[swapIdx], newCats[index]]
+    setCategories(newCats)
+    try {
+      await api.put('/categories/reorder', { order: newCats.map(c => c.id) })
+    } catch {
+      load()
+    }
+  }
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-4">
+        <p className="text-sm text-gray-500">Gérez les catégories utilisées pour classer les charges</p>
+        <button onClick={openAdd} className="btn-primary text-sm">+ Ajouter</button>
       </div>
 
-      <div className="card">
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="grid grid-cols-2 gap-4">
-            {field('Prénom', 'first_name', 'text', 'Jean')}
-            {field('Nom', 'last_name', 'text', 'Dupont')}
-          </div>
-          {field('Adresse postale', 'address', 'text', '12 rue de la Paix, 75001 Paris')}
-          {field('Adresse e-mail *', 'email', 'email', 'vous@exemple.fr')}
-          {field('Numéro de téléphone', 'phone', 'tel', '06 00 00 00 00')}
-
-          <div className="pt-2 border-t border-gray-100">
-            <p className="text-xs text-gray-400 mb-3">
-              Le champ "Nom d'affichage" est utilisé si prénom/nom ne sont pas renseignés.
-            </p>
-            {field("Nom d'affichage *", 'name', 'text', 'Jean Dupont')}
+      <div className="card divide-y">
+        {categories.length === 0 ? (
+          <p className="text-gray-400 text-center py-8">Aucune catégorie</p>
+        ) : categories.map((cat, i) => (
+          <div key={cat.id} className="flex items-center gap-3 py-3 px-1">
+            <div className="flex flex-col gap-0.5">
+              <button onClick={() => move(i, -1)} disabled={i === 0} className="text-gray-300 hover:text-gray-600 text-xs disabled:opacity-30">▲</button>
+              <button onClick={() => move(i, 1)} disabled={i === categories.length - 1} className="text-gray-300 hover:text-gray-600 text-xs disabled:opacity-30">▼</button>
+            </div>
+            <span className="text-lg">{cat.icon}</span>
+            <div className="flex-1 min-w-0">
+              <p className="font-medium text-gray-800 text-sm">{cat.label}</p>
+              <p className="text-xs text-gray-400">{cat.value}</p>
+            </div>
+            <button onClick={() => openEdit(cat)} className="text-gray-400 hover:text-blue-600 text-sm">✏️</button>
+            <button onClick={() => handleDelete(cat)} className="text-gray-400 hover:text-red-600 text-sm">🗑️</button>
           </div>
+        ))}
+      </div>
 
-          <button type="submit" disabled={loading} className="btn-primary w-full">
-            {loading ? '...' : '💾 Enregistrer le profil'}
+      {showModal && (
+        <Modal title={editCat ? 'Modifier la catégorie' : 'Ajouter une catégorie'} onClose={() => setShowModal(false)}>
+          <form onSubmit={handleSubmit} className="space-y-4">
+            <div>
+              <label className="label">Identifiant technique *</label>
+              <input className="input" required value={form.value} disabled={!!editCat}
+                onChange={e => setForm(f => ({ ...f, value: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_') }))}
+                placeholder="ex: taxe_fonciere" />
+              {editCat && <p className="text-xs text-gray-400 mt-1">L'identifiant ne peut pas être modifié</p>}
+            </div>
+            <div>
+              <label className="label">Libellé *</label>
+              <input className="input" required value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} placeholder="ex: Taxe foncière" />
+            </div>
+            <div>
+              <label className="label">Icône (emoji)</label>
+              <input className="input" value={form.icon} onChange={e => setForm(f => ({ ...f, icon: e.target.value }))} placeholder="📦" />
+            </div>
+            <div className="flex gap-3 pt-2">
+              <button type="button" onClick={() => setShowModal(false)} className="btn-secondary flex-1">Annuler</button>
+              <button type="submit" className="btn-primary flex-1">Enregistrer</button>
+            </div>
+          </form>
+        </Modal>
+      )}
+    </div>
+  )
+}
+
+const TABS = [
+  { id: 'profile', label: '👤 Profil' },
+  { id: 'categories', label: '🏷️ Catégories de charges' },
+]
+
+export default function Profile() {
+  const [tab, setTab] = useState('profile')
+
+  return (
+    <div className="max-w-2xl">
+      <div className="mb-6">
+        <h1 className="text-2xl font-bold text-gray-900">⚙️ Paramètres</h1>
+      </div>
+
+      <div className="flex gap-1 mb-6 border-b">
+        {TABS.map(t => (
+          <button key={t.id} onClick={() => setTab(t.id)}
+            className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
+              tab === t.id ? 'border-blue-600 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700'
+            }`}>
+            {t.label}
           </button>
-        </form>
+        ))}
       </div>
+
+      {tab === 'profile' && <ProfileTab />}
+      {tab === 'categories' && <CategoriesTab />}
     </div>
   )
 }