jeremy 4 bulan lalu
induk
melakukan
c37cab8963
3 mengubah file dengan 74 tambahan dan 44 penghapusan
  1. 3 0
      backend/src/db.js
  2. 31 20
      backend/src/routes/charges.js
  3. 40 24
      frontend/src/pages/Charges.jsx

+ 3 - 0
backend/src/db.js

@@ -146,6 +146,9 @@ async function initDb() {
   await pool.query(`ALTER TABLE users ALTER COLUMN password DROP NOT NULL`);
   await pool.query(`ALTER TABLE leases ADD COLUMN IF NOT EXISTS owner_id INTEGER REFERENCES owners(id)`);
   await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable INTEGER NOT NULL DEFAULT 1`);
+  await pool.query(`ALTER TABLE charges ADD COLUMN IF NOT EXISTS recoverable_type TEXT DEFAULT 'recoverable'`);
+  // Backfill recoverable_type from legacy integer column
+  await pool.query(`UPDATE charges SET recoverable_type = 'none' WHERE recoverable = 0 AND (recoverable_type IS NULL OR recoverable_type = 'recoverable')`);
   await pool.query(`ALTER TABLE payments ADD COLUMN IF NOT EXISTS charge_regularization REAL DEFAULT 0`);
   await pool.query(`ALTER TABLE payments ADD COLUMN IF NOT EXISTS is_prorata INTEGER DEFAULT 0`);
   await pool.query(`ALTER TABLE payments ADD COLUMN IF NOT EXISTS prorata_days INTEGER`);

+ 31 - 20
backend/src/routes/charges.js

@@ -49,7 +49,7 @@ router.get('/', async (req, res) => {
 
 // Create charge (with optional invoice file)
 router.post('/', upload.single('invoice'), async (req, res) => {
-  const { property_id, category, label, amount, date, year, notes, recoverable } = req.body;
+  const { property_id, category, label, amount, date, year, notes, recoverable_type } = req.body;
   if (!property_id || !category || !label || !amount || !date || !year)
     return res.status(400).json({ error: 'Champs requis manquants' });
 
@@ -58,11 +58,12 @@ router.post('/', upload.single('invoice'), async (req, res) => {
 
   const invoice_path = req.file ? req.file.filename : null;
   const invoice_name = req.file ? req.file.originalname : null;
+  const rtype = ['recoverable', 'deductible', 'none'].includes(recoverable_type) ? recoverable_type : 'recoverable';
 
   const result = await pool.query(`
-    INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable)
-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id
-  `, [req.userId, property_id, category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, recoverable === 'false' || recoverable === '0' ? 0 : 1]);
+    INSERT INTO charges (user_id, property_id, category, label, amount, date, year, invoice_path, invoice_name, notes, recoverable, recoverable_type)
+    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id
+  `, [req.userId, property_id, category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, rtype === 'recoverable' ? 1 : 0, rtype]);
 
   res.status(201).json({ id: result.rows[0].id });
 });
@@ -72,7 +73,7 @@ router.put('/:id', upload.single('invoice'), async (req, res) => {
   const charge = (await pool.query('SELECT * FROM charges WHERE id = $1 AND user_id = $2', [req.params.id, req.userId])).rows[0];
   if (!charge) return res.status(404).json({ error: 'Charge non trouvée' });
 
-  const { category, label, amount, date, year, notes, recoverable } = req.body;
+  const { category, label, amount, date, year, notes, recoverable_type } = req.body;
 
   let invoice_path = charge.invoice_path;
   let invoice_name = charge.invoice_name;
@@ -87,10 +88,12 @@ router.put('/:id', upload.single('invoice'), async (req, res) => {
     invoice_name = req.file.originalname;
   }
 
+  const rtype = ['recoverable', 'deductible', 'none'].includes(recoverable_type) ? recoverable_type : 'recoverable';
+
   await pool.query(`
-    UPDATE charges SET category=$1, label=$2, amount=$3, date=$4, year=$5, invoice_path=$6, invoice_name=$7, notes=$8, recoverable=$9
-    WHERE id=$10
-  `, [category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, recoverable === 'false' || recoverable === '0' ? 0 : 1, req.params.id]);
+    UPDATE charges SET category=$1, label=$2, amount=$3, date=$4, year=$5, invoice_path=$6, invoice_name=$7, notes=$8, recoverable=$9, recoverable_type=$10
+    WHERE id=$11
+  `, [category, label, parseFloat(amount), date, parseInt(year), invoice_path, invoice_name, notes || null, rtype === 'recoverable' ? 1 : 0, rtype, req.params.id]);
 
   res.json({ success: true });
 });
@@ -132,29 +135,36 @@ router.get('/bilan/:property_id/:year', async (req, res) => {
   // Total recoverable charges for this property/year (for tenant billing)
   const totalChargesRow = (await pool.query(`
     SELECT SUM(amount) as total, COUNT(*) as count
-    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 1
+    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
+  `, [property_id, year, req.userId])).rows[0];
+
+  // Total deductible (landlord fiscal charges, for info)
+  const totalDeductibleRow = (await pool.query(`
+    SELECT SUM(amount) as total
+    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'deductible'
   `, [property_id, year, req.userId])).rows[0];
 
-  // Total non-recoverable (landlord's own charges, for info)
+  // Total non-recoverable / non-deductible (landlord's own charges, for info)
   const totalNonRecovRow = (await pool.query(`
-    SELECT SUM(amount) as total, COUNT(*) as count
-    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 0
+    SELECT SUM(amount) as total
+    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'none'
   `, [property_id, year, req.userId])).rows[0];
 
-  const totalChargesReelles = totalChargesRow?.total || 0;
-  const totalNonRecoverable = totalNonRecovRow?.total || 0;
+  const totalChargesReelles = parseFloat(totalChargesRow?.total || 0);
+  const totalDeductible = parseFloat(totalDeductibleRow?.total || 0);
+  const totalNonRecoverable = parseFloat(totalNonRecovRow?.total || 0);
 
   // Charges by category (recoverable only)
   const chargesByCategory = (await pool.query(`
     SELECT category, SUM(amount) as total
-    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 1
+    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
     GROUP BY category ORDER BY total DESC
   `, [property_id, year, req.userId])).rows;
 
   // Non-recoverable by category (for info)
   const chargesNonRecovByCategory = (await pool.query(`
     SELECT category, SUM(amount) as total
-    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 0
+    FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type != 'recoverable'
     GROUP BY category ORDER BY total DESC
   `, [property_id, year, req.userId])).rows;
 
@@ -204,6 +214,7 @@ router.get('/bilan/:property_id/:year', async (req, res) => {
     property: prop,
     year: parseInt(year),
     total_charges_reelles: Math.round(totalChargesReelles * 100) / 100,
+    total_deductible: Math.round(totalDeductible * 100) / 100,
     total_non_recoverable: Math.round(totalNonRecoverable * 100) / 100,
     charges_count: totalChargesRow?.count || 0,
     charges_by_category: chargesByCategory,
@@ -251,9 +262,9 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
 
   const totalChargesRow = (await pool.query(`
     SELECT SUM(amount) as total, COUNT(*) as count FROM charges
-    WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 1
+    WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
   `, [property_id, year, req.userId])).rows[0];
-  const totalChargesReelles = totalChargesRow?.total || 0;
+  const totalChargesReelles = parseFloat(totalChargesRow?.total || 0);
 
   const chargesList = (await pool.query(`
     SELECT * FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3
@@ -262,7 +273,7 @@ router.get('/bilan/:property_id/:year/pdf', async (req, res) => {
 
   const chargesByCategory = (await pool.query(`
     SELECT category, SUM(amount) as total FROM charges
-    WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 1
+    WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'
     GROUP BY category ORDER BY total DESC
   `, [property_id, year, req.userId])).rows;
 
@@ -489,7 +500,7 @@ router.post('/bilan/:property_id/:year/close', async (req, res) => {
 
   // Recompute bilan (recoverable only)
   const totalRow = (await pool.query(
-    'SELECT SUM(amount) as total FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable = 1',
+    `SELECT SUM(amount) as total FROM charges WHERE property_id = $1 AND year = $2 AND user_id = $3 AND recoverable_type = 'recoverable'`,
     [property_id, year, req.userId]
   )).rows[0];
   const totalCharges = totalRow?.total || 0;

+ 40 - 24
frontend/src/pages/Charges.jsx

@@ -60,7 +60,7 @@ export default function Charges() {
     year: String(CURRENT_YEAR),
     notes: '',
     invoice: null,
-    recoverable: true,
+    recoverable_type: 'recoverable',
   }
   const [form, setForm] = useState(emptyForm)
 
@@ -98,7 +98,7 @@ export default function Charges() {
       year: String(c.year),
       notes: c.notes || '',
       invoice: null,
-      recoverable: c.recoverable !== 0,
+      recoverable_type: c.recoverable_type || (c.recoverable !== 0 ? 'recoverable' : 'none'),
     })
     setShowModal(true)
   }
@@ -109,7 +109,6 @@ export default function Charges() {
       const fd = new FormData()
       Object.entries(form).forEach(([k, v]) => {
         if (k === 'invoice') { if (v) fd.append('invoice', v) }
-        else if (k === 'recoverable') fd.append(k, v ? '1' : '0')
         else fd.append(k, v)
       })
       if (editCharge) {
@@ -414,9 +413,11 @@ export default function Charges() {
                     <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>
                     <td className="px-6 py-4 text-center">
-                      {c.recoverable !== 0
-                        ? <span className="text-xs bg-green-100 text-green-700 font-semibold px-2 py-0.5 rounded-full">✓ Oui</span>
-                        : <span className="text-xs bg-gray-100 text-gray-500 font-semibold px-2 py-0.5 rounded-full">✗ Non</span>}
+                      {c.recoverable_type === 'recoverable'
+                        ? <span className="text-xs bg-green-100 text-green-700 font-semibold px-2 py-0.5 rounded-full">✓ Locataire</span>
+                        : c.recoverable_type === 'deductible'
+                          ? <span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">💼 Déductible</span>
+                          : <span className="text-xs bg-gray-100 text-gray-500 font-semibold px-2 py-0.5 rounded-full">✗ Aucun</span>}
                     </td>
                     <td className="px-6 py-4 text-center">
                       {c.invoice_path ? (
@@ -451,9 +452,11 @@ export default function Charges() {
                     <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} />
-                      {c.recoverable !== 0
-                        ? <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">Récup.</span>
-                        : <span className="text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded-full font-medium">Non récup.</span>}
+                      {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'
+                          ? <span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full font-medium">Déductible</span>
+                          : <span className="text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded-full font-medium">Aucun</span>}
                     </div>
                   </div>
                   <p className="font-bold text-gray-900 text-lg ml-3 shrink-0">{fmt(c.amount)}</p>
@@ -531,20 +534,25 @@ export default function Charges() {
                 placeholder="Numéro de facture, prestataire…" />
             </div>
             <div>
-              <label className="flex items-center gap-3 cursor-pointer select-none">
-                <input
-                  type="checkbox"
-                  className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
-                  checked={form.recoverable}
-                  onChange={e => setForm(f => ({ ...f, recoverable: e.target.checked }))}
-                />
-                <span className="text-sm font-medium text-gray-700">
-                  Charge récupérable auprès du locataire
-                </span>
-              </label>
-              <p className="text-xs text-gray-400 mt-1 ml-7">
-                Si cochée, cette charge sera incluse dans le bilan de régularisation annuel.
-              </p>
+              <label className="label">Affectation de la charge</label>
+              <div className="space-y-2 mt-1">
+                {[
+                  { value: 'recoverable', label: '✓ Récupérable auprès du locataire', sub: 'Incluse dans le bilan de régularisation annuel', color: 'green' },
+                  { value: 'deductible', label: '💼 Déductible des impôts', sub: 'Charge du propriétaire déductible fiscalement', color: 'purple' },
+                  { value: 'none', label: '✗ Aucun des deux', sub: 'Charge non récupérable et non déductible', color: 'gray' },
+                ].map(opt => (
+                  <label key={opt.value} className={`flex items-start gap-3 cursor-pointer rounded-xl border-2 p-3 transition-colors ${form.recoverable_type === opt.value ? `border-${opt.color}-400 bg-${opt.color}-50` : 'border-gray-100 hover:border-gray-200'}`}>
+                    <input type="radio" name="recoverable_type" value={opt.value}
+                      checked={form.recoverable_type === opt.value}
+                      onChange={e => setForm(f => ({ ...f, recoverable_type: e.target.value }))}
+                      className="mt-0.5 accent-blue-600" />
+                    <div>
+                      <p className="text-sm font-medium text-gray-800">{opt.label}</p>
+                      <p className="text-xs text-gray-400">{opt.sub}</p>
+                    </div>
+                  </label>
+                ))}
+              </div>
             </div>
             <div>
               <label className="label">Facture (PDF, JPG, PNG — max 10 Mo)</label>
@@ -596,10 +604,18 @@ export default function Charges() {
               </div>
             </div>
 
+            {/* Charges déductibles (info) */}
+            {bilan.total_deductible > 0 && (
+              <div className="bg-purple-50 border border-purple-200 rounded-xl p-3 flex items-center justify-between text-sm">
+                <span className="text-purple-700">💼 Charges déductibles des impôts (à votre charge)</span>
+                <span className="font-semibold text-purple-800">{fmt(bilan.total_deductible)}</span>
+              </div>
+            )}
+
             {/* Charges non récupérables (info) */}
             {bilan.total_non_recoverable > 0 && (
               <div className="bg-gray-50 border border-gray-200 rounded-xl p-3 flex items-center justify-between text-sm">
-                <span className="text-gray-500">🔒 Charges non récupérables (à votre charge)</span>
+                <span className="text-gray-500">✗ Sans affectation (à votre charge, non déductible)</span>
                 <span className="font-semibold text-gray-700">{fmt(bilan.total_non_recoverable)}</span>
               </div>
             )}