feat: améliorations du système de facturation (multi-lignes, comptes bancaires EUR/CHF, interface comptable)

This commit is contained in:
robin 2025-05-23 15:42:32 +02:00
parent 29887300b3
commit ee48d0dbe6
9 changed files with 1221 additions and 55 deletions

View 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")

View File

@ -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

View File

@ -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
View 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
View 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);
}
});

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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")

View File

@ -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