feat: améliorations du système de facturation (multi-lignes, comptes bancaires EUR/CHF, interface comptable)
This commit is contained in:
parent
29887300b3
commit
ee48d0dbe6
312
accounting_data/accounting_manager.py
Normal file
312
accounting_data/accounting_manager.py
Normal file
@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import sqlite3
|
||||
import pyexcel_ods
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
class AccountingManager:
|
||||
"""Gestionnaire des fichiers de comptabilité"""
|
||||
|
||||
def __init__(self, db_path="invoices.db"):
|
||||
"""Initialise le gestionnaire de comptabilité
|
||||
|
||||
Args:
|
||||
db_path: Chemin vers la base de données SQLite
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.accounting_files = {
|
||||
"revenue": "compta.ods",
|
||||
"expenses": "spending.ods"
|
||||
}
|
||||
self.ensure_tables()
|
||||
|
||||
def ensure_tables(self):
|
||||
"""Assure que les tables nécessaires existent dans la base de données"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Table pour les revenus
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS accounting_revenue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT,
|
||||
invoice_id INTEGER,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Table pour les dépenses
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS accounting_expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT,
|
||||
payment_method TEXT,
|
||||
receipt_path TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def import_revenue_data(self, file_path=None):
|
||||
"""Importe les données de revenus depuis le fichier compta.ods
|
||||
|
||||
Args:
|
||||
file_path: Chemin vers le fichier ODS (optionnel)
|
||||
|
||||
Returns:
|
||||
int: Nombre d'entrées importées
|
||||
"""
|
||||
if file_path is None:
|
||||
file_path = self.accounting_files["revenue"]
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"Erreur: Fichier {file_path} introuvable")
|
||||
return 0
|
||||
|
||||
try:
|
||||
data = pyexcel_ods.get_data(file_path)
|
||||
# Supposons que la première feuille contient les données
|
||||
sheet_name = list(data.keys())[0]
|
||||
sheet_data = data[sheet_name]
|
||||
|
||||
# Supposons que la première ligne contient les en-têtes
|
||||
headers = sheet_data[0]
|
||||
rows = sheet_data[1:]
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
# Validation de base
|
||||
if len(row) < 3: # Au moins date, description, montant
|
||||
continue
|
||||
|
||||
# Mappage des colonnes selon l'en-tête
|
||||
row_data = dict(zip(headers, row))
|
||||
|
||||
# Conversion des formats de date si nécessaire
|
||||
date_str = row_data.get('Date', '')
|
||||
if isinstance(date_str, str) and date_str:
|
||||
try:
|
||||
# Supposons le format JJ/MM/AAAA
|
||||
date_obj = datetime.strptime(date_str, '%d/%m/%Y')
|
||||
date_str = date_obj.strftime('%Y-%m-%d')
|
||||
except ValueError:
|
||||
# Garder la chaîne d'origine si la conversion échoue
|
||||
pass
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO accounting_revenue
|
||||
(date, description, amount, category, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
date_str,
|
||||
row_data.get('Description', ''),
|
||||
float(row_data.get('Montant', 0)),
|
||||
row_data.get('Catégorie', ''),
|
||||
row_data.get('Notes', '')
|
||||
))
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de l'importation des données: {e}")
|
||||
return 0
|
||||
|
||||
def import_expenses_data(self, file_path=None):
|
||||
"""Importe les données de dépenses depuis le fichier spending.ods
|
||||
|
||||
Args:
|
||||
file_path: Chemin vers le fichier ODS (optionnel)
|
||||
|
||||
Returns:
|
||||
int: Nombre d'entrées importées
|
||||
"""
|
||||
if file_path is None:
|
||||
file_path = self.accounting_files["expenses"]
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"Erreur: Fichier {file_path} introuvable")
|
||||
return 0
|
||||
|
||||
try:
|
||||
data = pyexcel_ods.get_data(file_path)
|
||||
# Supposons que la première feuille contient les données
|
||||
sheet_name = list(data.keys())[0]
|
||||
sheet_data = data[sheet_name]
|
||||
|
||||
# Supposons que la première ligne contient les en-têtes
|
||||
headers = sheet_data[0]
|
||||
rows = sheet_data[1:]
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
# Validation de base
|
||||
if len(row) < 3: # Au moins date, description, montant
|
||||
continue
|
||||
|
||||
# Mappage des colonnes selon l'en-tête
|
||||
row_data = dict(zip(headers, row))
|
||||
|
||||
# Conversion des formats de date si nécessaire
|
||||
date_str = row_data.get('Date', '')
|
||||
if isinstance(date_str, str) and date_str:
|
||||
try:
|
||||
# Supposons le format JJ/MM/AAAA
|
||||
date_obj = datetime.strptime(date_str, '%d/%m/%Y')
|
||||
date_str = date_obj.strftime('%Y-%m-%d')
|
||||
except ValueError:
|
||||
# Garder la chaîne d'origine si la conversion échoue
|
||||
pass
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO accounting_expenses
|
||||
(date, description, amount, category, payment_method, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
date_str,
|
||||
row_data.get('Description', ''),
|
||||
float(row_data.get('Montant', 0)),
|
||||
row_data.get('Catégorie', ''),
|
||||
row_data.get('Méthode de Paiement', ''),
|
||||
row_data.get('Notes', '')
|
||||
))
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de l'importation des données: {e}")
|
||||
return 0
|
||||
|
||||
def get_balance_sheet(self, year=None):
|
||||
"""Génère un bilan comptable
|
||||
|
||||
Args:
|
||||
year: Année pour filtrer les résultats (optionnel)
|
||||
|
||||
Returns:
|
||||
dict: Bilan avec revenus, dépenses et solde
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
date_filter = ""
|
||||
params = []
|
||||
|
||||
if year:
|
||||
date_filter = "WHERE date LIKE ?"
|
||||
params = [f"{year}%"]
|
||||
|
||||
# Calculer le total des revenus
|
||||
cursor.execute(f'''
|
||||
SELECT SUM(amount) FROM accounting_revenue
|
||||
{date_filter}
|
||||
''', params)
|
||||
total_revenue = cursor.fetchone()[0] or 0
|
||||
|
||||
# Calculer le total des dépenses
|
||||
cursor.execute(f'''
|
||||
SELECT SUM(amount) FROM accounting_expenses
|
||||
{date_filter}
|
||||
''', params)
|
||||
total_expenses = cursor.fetchone()[0] or 0
|
||||
|
||||
# Obtenir la répartition par catégorie pour les revenus
|
||||
cursor.execute(f'''
|
||||
SELECT category, SUM(amount) as total
|
||||
FROM accounting_revenue
|
||||
{date_filter}
|
||||
GROUP BY category
|
||||
ORDER BY total DESC
|
||||
''', params)
|
||||
revenue_by_category = {row[0] or 'Non catégorisé': row[1] for row in cursor.fetchall()}
|
||||
|
||||
# Obtenir la répartition par catégorie pour les dépenses
|
||||
cursor.execute(f'''
|
||||
SELECT category, SUM(amount) as total
|
||||
FROM accounting_expenses
|
||||
{date_filter}
|
||||
GROUP BY category
|
||||
ORDER BY total DESC
|
||||
''', params)
|
||||
expenses_by_category = {row[0] or 'Non catégorisé': row[1] for row in cursor.fetchall()}
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total_revenue': total_revenue,
|
||||
'total_expenses': total_expenses,
|
||||
'balance': total_revenue - total_expenses,
|
||||
'revenue_by_category': revenue_by_category,
|
||||
'expenses_by_category': expenses_by_category
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Usage comme script indépendant
|
||||
manager = AccountingManager()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "import":
|
||||
print("Importation des données comptables...")
|
||||
|
||||
rev_count = manager.import_revenue_data()
|
||||
exp_count = manager.import_expenses_data()
|
||||
|
||||
print(f"Importation terminée: {rev_count} revenus et {exp_count} dépenses importés.")
|
||||
|
||||
elif command == "balance":
|
||||
year = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
balance = manager.get_balance_sheet(year)
|
||||
|
||||
print("\n=== BILAN COMPTABLE ===")
|
||||
if year:
|
||||
print(f"Année: {year}")
|
||||
print(f"Total des revenus: {balance['total_revenue']:.2f} €")
|
||||
print(f"Total des dépenses: {balance['total_expenses']:.2f} €")
|
||||
print(f"Solde: {balance['balance']:.2f} €")
|
||||
|
||||
print("\nRépartition des revenus par catégorie:")
|
||||
for cat, amount in balance['revenue_by_category'].items():
|
||||
print(f" - {cat}: {amount:.2f} €")
|
||||
|
||||
print("\nRépartition des dépenses par catégorie:")
|
||||
for cat, amount in balance['expenses_by_category'].items():
|
||||
print(f" - {cat}: {amount:.2f} €")
|
||||
|
||||
else:
|
||||
print(f"Commande inconnue: {command}")
|
||||
print("Utilisations possibles:")
|
||||
print(" - import: Importer les données des fichiers ODS")
|
||||
print(" - balance [année]: Afficher le bilan comptable")
|
||||
else:
|
||||
print("Veuillez spécifier une commande:")
|
||||
print(" - import: Importer les données des fichiers ODS")
|
||||
print(" - balance [année]: Afficher le bilan comptable")
|
||||
@ -1,3 +1,4 @@
|
||||
flask==3.0.2
|
||||
python-dotenv==1.0.1
|
||||
typst==0.1.0
|
||||
typst==0.1.0
|
||||
pyexcel-ods==0.6.0
|
||||
51
server.py
51
server.py
@ -23,6 +23,10 @@ def dashboard():
|
||||
def preview():
|
||||
return app.send_static_file('preview.html')
|
||||
|
||||
@app.route('/accounting')
|
||||
def accounting():
|
||||
return app.send_static_file('accounting.html')
|
||||
|
||||
@app.route('/api/invoices', methods=['GET'])
|
||||
def get_invoices():
|
||||
filters = {}
|
||||
@ -117,6 +121,17 @@ def create_invoice():
|
||||
vat_number=data.get('recipient_vat_number')
|
||||
)
|
||||
|
||||
# Traiter les lignes de facturation
|
||||
invoice_items = data.get('items', [])
|
||||
if not invoice_items:
|
||||
# Utiliser une ligne par défaut si aucun élément n'est fourni
|
||||
invoice_items = [{"description": "Poong rental", "amount": data['amount']}]
|
||||
|
||||
# Générer les lignes pour le tableau Typst
|
||||
invoice_rows = ""
|
||||
for item in invoice_items:
|
||||
invoice_rows += f" [{item['description']}], [{item['amount']}],\n"
|
||||
|
||||
# Créer le contenu du template Typst
|
||||
typst_content = f'''#let language = "{data['language']}"
|
||||
#let invoice_number = "{invoice_number}"
|
||||
@ -262,8 +277,7 @@ def create_invoice():
|
||||
[*#t.description*],
|
||||
[*#t.amount (#currency)* ],
|
||||
),
|
||||
[Poong rental], [#amount],
|
||||
[*#t.total*], [*#amount #currency*],
|
||||
{invoice_rows} [*#t.total*], [*#amount #currency*],
|
||||
)
|
||||
|
||||
#v(.5cm)
|
||||
@ -278,8 +292,12 @@ def create_invoice():
|
||||
#v(0.5cm)
|
||||
|
||||
// Coordonnées bancaires avec notification spéciale
|
||||
#text(weight: "bold", size: 13pt)[#t.banking_info]
|
||||
#v(0.2cm)
|
||||
#text(weight: "bold", size: 13pt)[#t.banking_info]
|
||||
#v(0.2cm)
|
||||
|
||||
// Afficher le compte bancaire en fonction de la devise
|
||||
#if currency == "EUR" [
|
||||
// Compte pour les transactions en euros
|
||||
#t.bank Wise, Rue du Trône 100, 3rd floor, Brussels, 1050, Belgium
|
||||
#v(0.1cm)
|
||||
IBAN: BE22905094540247
|
||||
@ -287,9 +305,28 @@ def create_invoice():
|
||||
BIC/SWIFT: TRWIBEB1XXX
|
||||
#v(0.1cm)
|
||||
#t.account_holder Robin Szymczak
|
||||
#v(0.2cm)
|
||||
#t.account_notice
|
||||
] else if currency == "CHF" [
|
||||
// Compte pour les transactions en francs suisses
|
||||
#t.bank PostFinance SA, Mingerstrasse 20, 3030 Bern, Switzerland
|
||||
#v(0.1cm)
|
||||
IBAN: CH56 0900 0000 1527 2120 9
|
||||
#v(0.1cm)
|
||||
BIC/SWIFT: POFICHBEXXX
|
||||
#v(0.1cm)
|
||||
#t.account_holder Robin Szymczak
|
||||
] else [
|
||||
// Compte par défaut (identique à EUR pour la compatibilité)
|
||||
#t.bank Wise, Rue du Trône 100, 3rd floor, Brussels, 1050, Belgium
|
||||
#v(0.1cm)
|
||||
IBAN: BE22905094540247
|
||||
#v(0.1cm)
|
||||
BIC/SWIFT: TRWIBEB1XXX
|
||||
#v(0.1cm)
|
||||
#t.account_holder Robin Szymczak
|
||||
]
|
||||
|
||||
#v(0.2cm)
|
||||
#t.account_notice
|
||||
|
||||
// Pied de page avec contact
|
||||
#align(center)[
|
||||
@ -316,4 +353,4 @@ def create_invoice():
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
app.run(debug=True, port=5001)
|
||||
199
static/accounting.html
Normal file
199
static/accounting.html
Normal file
@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gestion Comptable</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Gestion Comptable</h1>
|
||||
<div class="flex space-x-4">
|
||||
<a href="/dashboard" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/generator" class="bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600">
|
||||
Nouvelle Facture
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiques -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="text-gray-500 text-sm">Revenus</h3>
|
||||
<p class="text-2xl font-bold text-green-500" id="totalRevenue">0 €</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="text-gray-500 text-sm">Dépenses</h3>
|
||||
<p class="text-2xl font-bold text-red-500" id="totalExpenses">0 €</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="text-gray-500 text-sm">Solde</h3>
|
||||
<p class="text-2xl font-bold" id="balanceAmount">0 €</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Année</label>
|
||||
<select id="yearFilter" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Toutes</option>
|
||||
<option value="2023">2023</option>
|
||||
<option value="2024">2024</option>
|
||||
<option value="2025">2025</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Catégorie</label>
|
||||
<select id="categoryFilter" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Toutes</option>
|
||||
<!-- Les catégories seront ajoutées dynamiquement -->
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Type</label>
|
||||
<select id="typeFilter" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Tous</option>
|
||||
<option value="revenue">Revenus</option>
|
||||
<option value="expense">Dépenses</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button id="applyFilters" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 w-full">
|
||||
Appliquer les filtres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets -->
|
||||
<div class="mb-4">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex">
|
||||
<button id="tabTransactions" class="tab-button active w-1/3 py-4 px-1 text-center border-b-2 border-blue-500 font-medium text-sm text-blue-600">
|
||||
Transactions
|
||||
</button>
|
||||
<button id="tabRevenues" class="tab-button w-1/3 py-4 px-1 text-center border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
Revenus par catégorie
|
||||
</button>
|
||||
<button id="tabExpenses" class="tab-button w-1/3 py-4 px-1 text-center border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
Dépenses par catégorie
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu des onglets -->
|
||||
<div id="tabContent">
|
||||
<!-- Transactions -->
|
||||
<div id="transactionsContent" class="tab-content">
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Catégorie</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200" id="transactionsList">
|
||||
<!-- Les transactions seront ajoutées ici dynamiquement -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenus par catégorie -->
|
||||
<div id="revenuesContent" class="tab-content hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div id="revenuesByCategoryChart" class="h-64">
|
||||
<!-- Le graphique sera ajouté ici dynamiquement -->
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Catégorie</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Pourcentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200" id="revenuesByCategoryList">
|
||||
<!-- Les données seront ajoutées ici dynamiquement -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dépenses par catégorie -->
|
||||
<div id="expensesContent" class="tab-content hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div id="expensesByCategoryChart" class="h-64">
|
||||
<!-- Le graphique sera ajouté ici dynamiquement -->
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Catégorie</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Pourcentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200" id="expensesByCategoryList">
|
||||
<!-- Les données seront ajoutées ici dynamiquement -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton d'importation -->
|
||||
<div class="mt-8 flex justify-center">
|
||||
<button id="importButton" class="bg-purple-500 text-white px-6 py-2 rounded-md hover:bg-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
|
||||
Importer des données
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal pour l'importation -->
|
||||
<div id="importModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3 text-center">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Importer des données</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500 mb-4">Sélectionnez le type de données à importer.</p>
|
||||
<div class="space-y-4">
|
||||
<button id="importRevenues" class="w-full bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600">
|
||||
Importer les revenus
|
||||
</button>
|
||||
<button id="importExpenses" class="w-full bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600">
|
||||
Importer les dépenses
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-center px-4 py-3">
|
||||
<button id="closeImportModal" class="px-4 py-2 bg-gray-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script pour les graphiques -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="/static/accounting.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
452
static/accounting.js
Normal file
452
static/accounting.js
Normal file
@ -0,0 +1,452 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Éléments du DOM
|
||||
const yearFilter = document.getElementById("yearFilter");
|
||||
const categoryFilter = document.getElementById("categoryFilter");
|
||||
const typeFilter = document.getElementById("typeFilter");
|
||||
const applyFilters = document.getElementById("applyFilters");
|
||||
const transactionsList = document.getElementById("transactionsList");
|
||||
const totalRevenue = document.getElementById("totalRevenue");
|
||||
const totalExpenses = document.getElementById("totalExpenses");
|
||||
const balanceAmount = document.getElementById("balanceAmount");
|
||||
const revenuesByCategoryList = document.getElementById(
|
||||
"revenuesByCategoryList"
|
||||
);
|
||||
const expensesByCategoryList = document.getElementById(
|
||||
"expensesByCategoryList"
|
||||
);
|
||||
|
||||
// Boutons d'onglets
|
||||
const tabButtons = document.querySelectorAll(".tab-button");
|
||||
const tabContents = document.querySelectorAll(".tab-content");
|
||||
|
||||
// Modal d'importation
|
||||
const importButton = document.getElementById("importButton");
|
||||
const importModal = document.getElementById("importModal");
|
||||
const closeImportModal = document.getElementById("closeImportModal");
|
||||
const importRevenues = document.getElementById("importRevenues");
|
||||
const importExpenses = document.getElementById("importExpenses");
|
||||
|
||||
// Variables pour les graphiques
|
||||
let revenuesChart = null;
|
||||
let expensesChart = null;
|
||||
|
||||
// Initialiser la page
|
||||
initPage();
|
||||
|
||||
// Gestionnaire d'événements pour les filtres
|
||||
applyFilters.addEventListener("click", () => {
|
||||
loadData({
|
||||
year: yearFilter.value,
|
||||
category: categoryFilter.value,
|
||||
type: typeFilter.value,
|
||||
});
|
||||
});
|
||||
|
||||
// Gestionnaires d'événements pour les onglets
|
||||
tabButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const tabId = button.id.replace("tab", "").toLowerCase();
|
||||
switchTab(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
// Gestionnaires d'événements pour le modal d'importation
|
||||
importButton.addEventListener("click", () => {
|
||||
importModal.classList.remove("hidden");
|
||||
});
|
||||
|
||||
closeImportModal.addEventListener("click", () => {
|
||||
importModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
importRevenues.addEventListener("click", () => {
|
||||
importData("revenue");
|
||||
});
|
||||
|
||||
importExpenses.addEventListener("click", () => {
|
||||
importData("expense");
|
||||
});
|
||||
|
||||
// Fonction d'initialisation
|
||||
function initPage() {
|
||||
// Années pour le filtre (année courante et les deux précédentes)
|
||||
const currentYear = new Date().getFullYear();
|
||||
yearFilter.innerHTML = `<option value="">Toutes</option>`;
|
||||
for (let year = currentYear; year >= currentYear - 2; year--) {
|
||||
yearFilter.innerHTML += `<option value="${year}">${year}</option>`;
|
||||
}
|
||||
|
||||
// Charger les données initiales
|
||||
loadData();
|
||||
|
||||
// Générer les couleurs pour les graphiques
|
||||
generateChartColors();
|
||||
}
|
||||
|
||||
// Fonction pour charger les données
|
||||
async function loadData(filters = {}) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/accounting?" + new URLSearchParams(filters)
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Mettre à jour les statistiques
|
||||
updateStatistics(data.statistics);
|
||||
|
||||
// Mettre à jour les transactions
|
||||
displayTransactions(data.transactions);
|
||||
|
||||
// Mettre à jour les catégories de filtres
|
||||
updateCategoryFilters(data.categories);
|
||||
|
||||
// Mettre à jour les graphiques
|
||||
updateCharts(data.statistics);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des données:", error);
|
||||
showMessage("Erreur lors du chargement des données", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour importer des données
|
||||
async function importData(type) {
|
||||
try {
|
||||
const response = await fetch(`/api/accounting/import/${type}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
showMessage(result.message || "Importation réussie");
|
||||
importModal.classList.add("hidden");
|
||||
|
||||
// Recharger les données
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'importation:", error);
|
||||
showMessage("Erreur lors de l'importation", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour mettre à jour les statistiques
|
||||
function updateStatistics(stats) {
|
||||
totalRevenue.textContent = `${stats.total_revenue.toFixed(2)} €`;
|
||||
totalExpenses.textContent = `${stats.total_expenses.toFixed(2)} €`;
|
||||
|
||||
const balance = stats.balance;
|
||||
balanceAmount.textContent = `${Math.abs(balance).toFixed(2)} €`;
|
||||
|
||||
if (balance >= 0) {
|
||||
balanceAmount.classList.remove("text-red-500");
|
||||
balanceAmount.classList.add("text-green-500");
|
||||
} else {
|
||||
balanceAmount.classList.remove("text-green-500");
|
||||
balanceAmount.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour mettre à jour les catégories du filtre
|
||||
function updateCategoryFilters(categories) {
|
||||
categoryFilter.innerHTML = '<option value="">Toutes</option>';
|
||||
|
||||
categories.forEach((category) => {
|
||||
categoryFilter.innerHTML += `<option value="${category}">${category}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Fonction pour afficher les transactions
|
||||
function displayTransactions(transactions) {
|
||||
transactionsList.innerHTML = "";
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const date = new Date(transaction.date);
|
||||
const formattedDate = date.toLocaleDateString();
|
||||
|
||||
const isRevenue = transaction.type === "revenue";
|
||||
const amount = `${isRevenue ? "+" : "-"} ${Math.abs(
|
||||
transaction.amount
|
||||
).toFixed(2)} €`;
|
||||
const amountClass = isRevenue ? "text-green-500" : "text-red-500";
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${formattedDate}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${transaction.description}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium ${amountClass}">
|
||||
${amount}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${transaction.category || "Non catégorisé"}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${
|
||||
isRevenue
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}">
|
||||
${isRevenue ? "Revenu" : "Dépense"}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button onclick="editTransaction(${
|
||||
transaction.id
|
||||
})" class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
Éditer
|
||||
</button>
|
||||
<button onclick="deleteTransaction(${
|
||||
transaction.id
|
||||
})" class="text-red-600 hover:text-red-900">
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
transactionsList.appendChild(row);
|
||||
});
|
||||
|
||||
if (transactions.length === 0) {
|
||||
transactionsList.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Aucune transaction trouvée
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour changer d'onglet
|
||||
function switchTab(tabId) {
|
||||
// Mettre à jour les classes des boutons
|
||||
tabButtons.forEach((button) => {
|
||||
const buttonTabId = button.id.replace("tab", "").toLowerCase();
|
||||
|
||||
if (buttonTabId === tabId) {
|
||||
button.classList.add("active", "border-blue-500", "text-blue-600");
|
||||
button.classList.remove("border-transparent", "text-gray-500");
|
||||
} else {
|
||||
button.classList.remove("active", "border-blue-500", "text-blue-600");
|
||||
button.classList.add("border-transparent", "text-gray-500");
|
||||
}
|
||||
});
|
||||
|
||||
// Afficher le contenu de l'onglet sélectionné
|
||||
tabContents.forEach((content) => {
|
||||
const contentId = content.id;
|
||||
|
||||
if (contentId === `${tabId}Content`) {
|
||||
content.classList.remove("hidden");
|
||||
} else {
|
||||
content.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Génération de couleurs pour les graphiques
|
||||
function generateChartColors(count = 10) {
|
||||
const colors = [
|
||||
"#4F46E5",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#EC4899",
|
||||
"#8B5CF6",
|
||||
"#06B6D4",
|
||||
"#84CC16",
|
||||
"#F97316",
|
||||
"#6366F1",
|
||||
];
|
||||
|
||||
// Si on a besoin de plus de couleurs, on les génère aléatoirement
|
||||
if (count > colors.length) {
|
||||
for (let i = colors.length; i < count; i++) {
|
||||
const r = Math.floor(Math.random() * 200);
|
||||
const g = Math.floor(Math.random() * 200);
|
||||
const b = Math.floor(Math.random() * 200);
|
||||
colors.push(`rgb(${r}, ${g}, ${b})`);
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Mise à jour des graphiques
|
||||
function updateCharts(statistics) {
|
||||
updateRevenuesChart(statistics.revenue_by_category);
|
||||
updateExpensesChart(statistics.expenses_by_category);
|
||||
}
|
||||
|
||||
// Mise à jour du graphique des revenus
|
||||
function updateRevenuesChart(revenuesByCategory) {
|
||||
const ctx = document.getElementById("revenuesByCategoryChart");
|
||||
const colors = generateChartColors(Object.keys(revenuesByCategory).length);
|
||||
|
||||
// Détruire le graphique précédent s'il existe
|
||||
if (revenuesChart) {
|
||||
revenuesChart.destroy();
|
||||
}
|
||||
|
||||
// Créer le nouveau graphique
|
||||
revenuesChart = new Chart(ctx, {
|
||||
type: "pie",
|
||||
data: {
|
||||
labels: Object.keys(revenuesByCategory),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(revenuesByCategory),
|
||||
backgroundColor: colors,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const value = context.raw;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${value.toFixed(2)} € (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mettre à jour la liste des revenus par catégorie
|
||||
updateCategoryList(revenuesByCategoryList, revenuesByCategory);
|
||||
}
|
||||
|
||||
// Mise à jour du graphique des dépenses
|
||||
function updateExpensesChart(expensesByCategory) {
|
||||
const ctx = document.getElementById("expensesByCategoryChart");
|
||||
const colors = generateChartColors(Object.keys(expensesByCategory).length);
|
||||
|
||||
// Détruire le graphique précédent s'il existe
|
||||
if (expensesChart) {
|
||||
expensesChart.destroy();
|
||||
}
|
||||
|
||||
// Créer le nouveau graphique
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: "pie",
|
||||
data: {
|
||||
labels: Object.keys(expensesByCategory),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(expensesByCategory),
|
||||
backgroundColor: colors,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const value = context.raw;
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${value.toFixed(2)} € (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mettre à jour la liste des dépenses par catégorie
|
||||
updateCategoryList(expensesByCategoryList, expensesByCategory);
|
||||
}
|
||||
|
||||
// Mise à jour des listes de catégories
|
||||
function updateCategoryList(listElement, categoryData) {
|
||||
listElement.innerHTML = "";
|
||||
|
||||
const total = Object.values(categoryData).reduce((a, b) => a + b, 0);
|
||||
|
||||
Object.entries(categoryData)
|
||||
.sort((a, b) => b[1] - a[1]) // Trier par montant décroissant
|
||||
.forEach(([category, amount]) => {
|
||||
const percentage = ((amount / total) * 100).toFixed(1);
|
||||
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${category}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${amount.toFixed(2)} €
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
${percentage}%
|
||||
</td>
|
||||
`;
|
||||
|
||||
listElement.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Fonctions globales pour l'édition et la suppression
|
||||
window.editTransaction = async (transactionId) => {
|
||||
alert("Fonctionnalité d'édition à implémenter");
|
||||
// TODO: Implémenter l'édition des transactions
|
||||
};
|
||||
|
||||
window.deleteTransaction = async (transactionId) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette transaction ?")) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/accounting/transaction/${transactionId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
showMessage("Transaction supprimée avec succès");
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la suppression:", error);
|
||||
showMessage("Erreur lors de la suppression", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour afficher des messages à l'utilisateur
|
||||
function showMessage(message, type = "success") {
|
||||
// On pourrait implémenter un système de notification plus élaboré ici
|
||||
alert(message);
|
||||
}
|
||||
});
|
||||
@ -11,9 +11,14 @@
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Gestion des Factures</h1>
|
||||
<a href="generator" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600">
|
||||
Nouvelle Facture
|
||||
</a>
|
||||
<div class="flex space-x-4">
|
||||
<a href="/accounting" class="bg-purple-500 text-white px-4 py-2 rounded-md hover:bg-purple-600">
|
||||
Comptabilité
|
||||
</a>
|
||||
<a href="generator" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600">
|
||||
Nouvelle Facture
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiques -->
|
||||
|
||||
@ -48,6 +48,17 @@
|
||||
<option value="CHF">CHF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Éléments de facture -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-2">Lignes de facturation</h3>
|
||||
<div id="invoiceItems" class="space-y-3">
|
||||
<!-- Les lignes de facturation seront ajoutées ici -->
|
||||
</div>
|
||||
<button type="button" id="addItemButton" class="mt-3 inline-flex items-center px-3 py-1 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
+ Ajouter une ligne
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informations du destinataire -->
|
||||
|
||||
@ -19,29 +19,91 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
// Afficher les détails de la facture
|
||||
const invoiceDetails = document.getElementById("invoiceDetails");
|
||||
invoiceDetails.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-gray-600">Numéro de facture</p>
|
||||
<p class="font-semibold">${formData.invoice_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600">Date</p>
|
||||
<p class="font-semibold">${new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600">Langue</p>
|
||||
<p class="font-semibold">${formData.language}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600">Montant</p>
|
||||
<p class="font-semibold">${formData.amount} ${
|
||||
formData.currency
|
||||
}</p>
|
||||
</div>
|
||||
|
||||
// Créer le contenu de base des détails
|
||||
let detailsHTML = `
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<p class="text-gray-600">Numéro de facture</p>
|
||||
<p class="font-semibold">${formData.invoice_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600">Date</p>
|
||||
<p class="font-semibold">${new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600">Langue</p>
|
||||
<p class="font-semibold">${formData.language}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-600">Montant Total</p>
|
||||
<p class="font-semibold">${formData.amount} ${formData.currency}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Ajouter le tableau des lignes de facturation si présentes
|
||||
if (formData.items && formData.items.length > 0) {
|
||||
detailsHTML += `
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold text-lg mb-3">Lignes de facturation</h3>
|
||||
<table class="min-w-full bg-white border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2 px-4 border-b text-left">Description</th>
|
||||
<th class="py-2 px-4 border-b text-right">Montant (${formData.currency})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
// Ajouter chaque ligne
|
||||
formData.items.forEach((item) => {
|
||||
detailsHTML += `
|
||||
<tr>
|
||||
<td class="py-2 px-4 border-b">${item.description}</td>
|
||||
<td class="py-2 px-4 border-b text-right">${item.amount}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
detailsHTML += `
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="py-2 px-4 border-b font-semibold">Total</td>
|
||||
<td class="py-2 px-4 border-b text-right font-semibold">${formData.amount}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
invoiceDetails.innerHTML = detailsHTML;
|
||||
|
||||
// Mettre à jour les informations de paiement en fonction de la devise
|
||||
const paymentInfoDiv = document.querySelector(
|
||||
".mt-8.border-t.pt-8 .space-y-2"
|
||||
);
|
||||
if (paymentInfoDiv) {
|
||||
if (formData.currency === "CHF") {
|
||||
paymentInfoDiv.innerHTML = `
|
||||
<p>Banque: PostFinance SA, Berne, Suisse</p>
|
||||
<p>IBAN: CH56 0900 0000 1527 2120 9</p>
|
||||
<p>BIC/SWIFT: POFICHBEXXX</p>
|
||||
<p>Titulaire: Robin Szymczak</p>
|
||||
`;
|
||||
} else {
|
||||
paymentInfoDiv.innerHTML = `
|
||||
<p>Banque: Wise, Bruxelles, Belgique</p>
|
||||
<p>IBAN: BE22905094540247</p>
|
||||
<p>BIC/SWIFT: TRWIBEB1XXX</p>
|
||||
<p>Titulaire: Robin Szymczak</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer la validation de la facture
|
||||
document
|
||||
.getElementById("validateInvoice")
|
||||
|
||||
135
static/script.js
135
static/script.js
@ -1,54 +1,140 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById("invoiceForm");
|
||||
const invoiceItems = document.getElementById("invoiceItems");
|
||||
const addItemButton = document.getElementById("addItemButton");
|
||||
|
||||
// Fonction pour afficher les messages
|
||||
function showMessage(message, type = "success") {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.className = `${type}-message`;
|
||||
messageDiv.textContent = message;
|
||||
document.body.appendChild(messageDiv);
|
||||
// Initialiser avec une ligne de facturation par défaut
|
||||
addInvoiceItem();
|
||||
|
||||
// Afficher le message
|
||||
setTimeout(() => messageDiv.classList.add("show"), 100);
|
||||
// Ajouter une ligne de facturation lorsqu'on clique sur le bouton
|
||||
addItemButton.addEventListener("click", addInvoiceItem);
|
||||
|
||||
// Supprimer le message après 3 secondes
|
||||
setTimeout(() => {
|
||||
messageDiv.classList.remove("show");
|
||||
setTimeout(() => messageDiv.remove(), 300);
|
||||
}, 3000);
|
||||
// Fonction pour créer une nouvelle ligne de facturation
|
||||
function addInvoiceItem(description = "", amount = "") {
|
||||
const itemId = Date.now(); // ID unique pour l'élément
|
||||
const itemDiv = document.createElement("div");
|
||||
itemDiv.className = "invoice-item grid grid-cols-5 gap-2";
|
||||
itemDiv.dataset.id = itemId;
|
||||
|
||||
itemDiv.innerHTML = `
|
||||
<div class="col-span-3">
|
||||
<input type="text" name="item_description_${itemId}" placeholder="Description" value="${description}"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<input type="number" step="0.01" name="item_amount_${itemId}" placeholder="Montant" value="${amount}"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<button type="button" class="delete-item text-red-500 hover:text-red-700" data-id="${itemId}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Ajouter un gestionnaire d'événements pour supprimer la ligne
|
||||
const deleteButton = itemDiv.querySelector(".delete-item");
|
||||
deleteButton.addEventListener("click", function () {
|
||||
const id = this.getAttribute("data-id");
|
||||
deleteInvoiceItem(id);
|
||||
});
|
||||
|
||||
invoiceItems.appendChild(itemDiv);
|
||||
}
|
||||
|
||||
// Fonction pour supprimer une ligne de facturation
|
||||
function deleteInvoiceItem(id) {
|
||||
const item = document.querySelector(`.invoice-item[data-id="${id}"]`);
|
||||
if (item) {
|
||||
item.remove();
|
||||
}
|
||||
|
||||
// S'assurer qu'il reste au moins une ligne
|
||||
if (invoiceItems.children.length === 0) {
|
||||
addInvoiceItem();
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour collecter toutes les lignes de facturation
|
||||
function collectInvoiceItems() {
|
||||
const items = [];
|
||||
document.querySelectorAll(".invoice-item").forEach((item) => {
|
||||
const id = item.dataset.id;
|
||||
const description = item.querySelector(
|
||||
`[name="item_description_${id}"]`
|
||||
).value;
|
||||
const amount = item.querySelector(`[name="item_amount_${id}"]`).value;
|
||||
|
||||
if (description && amount) {
|
||||
items.push({ description, amount });
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// Fonction pour valider le formulaire
|
||||
function validateForm(formData) {
|
||||
function validateForm() {
|
||||
const requiredFields = [
|
||||
"invoice_number",
|
||||
"amount",
|
||||
"recipient_name",
|
||||
"recipient_address",
|
||||
"recipient_postal_code",
|
||||
"recipient_town",
|
||||
"recipient_country",
|
||||
form.invoice_number,
|
||||
form.amount,
|
||||
form.recipient_name,
|
||||
form.recipient_address,
|
||||
form.recipient_postal_code,
|
||||
form.recipient_town,
|
||||
form.recipient_country,
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!formData.get(field)) {
|
||||
showMessage(`Le champ ${field} est requis`, "error");
|
||||
if (!field.value) {
|
||||
showMessage(`Le champ ${field.name} est requis`, "error");
|
||||
field.focus();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const items = collectInvoiceItems();
|
||||
if (items.length === 0) {
|
||||
showMessage("Ajoutez au moins une ligne de facturation", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fonction pour afficher les messages
|
||||
function showMessage(message, type = "success") {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.className = `${type}-message fixed top-4 right-4 p-4 rounded-md shadow-lg ${
|
||||
type === "success" ? "bg-green-500" : "bg-red-500"
|
||||
} text-white`;
|
||||
messageDiv.textContent = message;
|
||||
document.body.appendChild(messageDiv);
|
||||
|
||||
// Afficher le message
|
||||
setTimeout(() => messageDiv.classList.add("opacity-100"), 100);
|
||||
|
||||
// Supprimer le message après 3 secondes
|
||||
setTimeout(() => {
|
||||
messageDiv.classList.remove("opacity-100");
|
||||
setTimeout(() => messageDiv.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Gestionnaire de soumission du formulaire
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer les données du formulaire
|
||||
const formData = {
|
||||
language: form.language.value,
|
||||
invoice_number: form.invoice_number.value,
|
||||
amount: form.amount.value,
|
||||
amount: form.amount.value, // Montant total
|
||||
currency: form.currency.value,
|
||||
recipient_name: form.recipient_name.value,
|
||||
recipient_address: form.recipient_address.value,
|
||||
@ -56,6 +142,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
recipient_town: form.recipient_town.value,
|
||||
recipient_country: form.recipient_country.value,
|
||||
recipient_vat_number: form.recipient_vat_number.value || null,
|
||||
items: collectInvoiceItems(), // Ajouter les lignes de facturation
|
||||
};
|
||||
|
||||
// Rediriger vers la page de prévisualisation avec les données
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user