feat: gestion des factures, génération PDF, corrections DB, .gitignore
This commit is contained in:
parent
1ada11b765
commit
29887300b3
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Base de données
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Fichiers générés
|
||||||
|
generated/
|
||||||
|
*.pdf
|
||||||
|
*.typ
|
||||||
|
|
||||||
|
# Fichiers Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Environnement virtuel
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Système
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
206
database.py
Normal file
206
database.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import time
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_file="invoices.db"):
|
||||||
|
self.db_file = db_file
|
||||||
|
self.version = 1
|
||||||
|
self.init_db()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection(self, timeout=30, max_retries=3):
|
||||||
|
retries = 0
|
||||||
|
while retries < max_retries:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(self.db_file, timeout=timeout)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL") # Utiliser le mode WAL pour de meilleures performances
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000") # Timeout de 5 secondes
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
break
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "database is locked" in str(e) and retries < max_retries - 1:
|
||||||
|
retries += 1
|
||||||
|
time.sleep(0.1 * (2 ** retries)) # Backoff exponentiel
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
def init_db(self):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Table des versions
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS db_version (
|
||||||
|
version INTEGER PRIMARY KEY
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Vérifier la version actuelle
|
||||||
|
c.execute('SELECT version FROM db_version')
|
||||||
|
result = c.fetchone()
|
||||||
|
current_version = result[0] if result else 0
|
||||||
|
|
||||||
|
# Appliquer les migrations si nécessaire
|
||||||
|
if current_version < self.version:
|
||||||
|
self._apply_migrations(conn, current_version)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _apply_migrations(self, conn, current_version):
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Migration 1: Structure initiale
|
||||||
|
if current_version < 1:
|
||||||
|
# Table des clients
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
postal_code TEXT NOT NULL,
|
||||||
|
town TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
vat_number TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Table des factures
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
invoice_number TEXT UNIQUE NOT NULL,
|
||||||
|
client_id INTEGER NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'issued',
|
||||||
|
issue_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
due_date TIMESTAMP,
|
||||||
|
typst_content TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (client_id) REFERENCES clients (id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Mettre à jour la version
|
||||||
|
c.execute('INSERT INTO db_version (version) VALUES (1)')
|
||||||
|
current_version = 1
|
||||||
|
|
||||||
|
def get_next_invoice_number(self):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT MAX(CAST(SUBSTR(invoice_number, 1) AS INTEGER)) FROM invoices')
|
||||||
|
result = c.fetchone()
|
||||||
|
last_number = result[0] if result[0] is not None else 0
|
||||||
|
return str(last_number + 1).zfill(3)
|
||||||
|
|
||||||
|
def add_client(self, name, address, postal_code, town, country, vat_number=None):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO clients (name, address, postal_code, town, country, vat_number)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (name, address, postal_code, town, country, vat_number))
|
||||||
|
client_id = c.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
return client_id
|
||||||
|
|
||||||
|
def add_invoice(self, invoice_number, client_id, amount, currency, language, typst_content, due_date=None):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO invoices (invoice_number, client_id, amount, currency, language, typst_content, due_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (invoice_number, client_id, amount, currency, language, typst_content, due_date))
|
||||||
|
invoice_id = c.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
return invoice_id
|
||||||
|
|
||||||
|
def get_invoices(self, filters=None):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
query = '''
|
||||||
|
SELECT i.*, c.name as client_name, c.address, c.postal_code, c.town, c.country, c.vat_number
|
||||||
|
FROM invoices i
|
||||||
|
JOIN clients c ON i.client_id = c.id
|
||||||
|
'''
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if filters:
|
||||||
|
conditions = []
|
||||||
|
if 'status' in filters:
|
||||||
|
conditions.append('i.status = ?')
|
||||||
|
params.append(filters['status'])
|
||||||
|
if 'client_id' in filters:
|
||||||
|
conditions.append('i.client_id = ?')
|
||||||
|
params.append(filters['client_id'])
|
||||||
|
if 'date_from' in filters:
|
||||||
|
conditions.append('i.issue_date >= ?')
|
||||||
|
params.append(filters['date_from'])
|
||||||
|
if 'date_to' in filters:
|
||||||
|
conditions.append('i.issue_date <= ?')
|
||||||
|
params.append(filters['date_to'])
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query += ' WHERE ' + ' AND '.join(conditions)
|
||||||
|
|
||||||
|
query += ' ORDER BY i.issue_date DESC'
|
||||||
|
|
||||||
|
c.execute(query, params)
|
||||||
|
columns = [description[0] for description in c.description]
|
||||||
|
invoices = [dict(zip(columns, row)) for row in c.fetchall()]
|
||||||
|
|
||||||
|
return invoices
|
||||||
|
|
||||||
|
def get_invoice(self, invoice_id):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
SELECT i.*, c.name as client_name, c.address, c.postal_code, c.town, c.country, c.vat_number
|
||||||
|
FROM invoices i
|
||||||
|
JOIN clients c ON i.client_id = c.id
|
||||||
|
WHERE i.id = ?
|
||||||
|
''', (invoice_id,))
|
||||||
|
columns = [description[0] for description in c.description]
|
||||||
|
invoice = dict(zip(columns, c.fetchone()))
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
def update_invoice_status(self, invoice_id, status):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('UPDATE invoices SET status = ? WHERE id = ?', (status, invoice_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_statistics(self):
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# Total des factures
|
||||||
|
c.execute('SELECT COUNT(*) FROM invoices')
|
||||||
|
stats['total_invoices'] = c.fetchone()[0]
|
||||||
|
|
||||||
|
# Montant total des factures
|
||||||
|
c.execute('SELECT SUM(amount) FROM invoices')
|
||||||
|
stats['total_amount'] = c.fetchone()[0] or 0
|
||||||
|
|
||||||
|
# Montant total des factures payées
|
||||||
|
c.execute('SELECT SUM(amount) FROM invoices WHERE status = "paid"')
|
||||||
|
stats['total_paid'] = c.fetchone()[0] or 0
|
||||||
|
|
||||||
|
# Montant total des factures en retard
|
||||||
|
c.execute('SELECT SUM(amount) FROM invoices WHERE status = "overdue"')
|
||||||
|
stats['total_overdue'] = c.fetchone()[0] or 0
|
||||||
|
|
||||||
|
return stats
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask==3.0.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
typst==0.1.0
|
||||||
319
server.py
Normal file
319
server.py
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
from flask import Flask, request, jsonify, send_file
|
||||||
|
from database import Database
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
db = Database()
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return app.send_static_file('generator.html')
|
||||||
|
|
||||||
|
@app.route('/generator')
|
||||||
|
def generator():
|
||||||
|
return app.send_static_file('generator.html')
|
||||||
|
|
||||||
|
@app.route('/dashboard')
|
||||||
|
def dashboard():
|
||||||
|
return app.send_static_file('dashboard.html')
|
||||||
|
|
||||||
|
@app.route('/preview')
|
||||||
|
def preview():
|
||||||
|
return app.send_static_file('preview.html')
|
||||||
|
|
||||||
|
@app.route('/api/invoices', methods=['GET'])
|
||||||
|
def get_invoices():
|
||||||
|
filters = {}
|
||||||
|
if request.args.get('status'):
|
||||||
|
filters['status'] = request.args.get('status')
|
||||||
|
if request.args.get('date_from'):
|
||||||
|
filters['date_from'] = request.args.get('date_from')
|
||||||
|
if request.args.get('date_to'):
|
||||||
|
filters['date_to'] = request.args.get('date_to')
|
||||||
|
|
||||||
|
invoices = db.get_invoices(filters)
|
||||||
|
statistics = db.get_statistics()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'invoices': invoices,
|
||||||
|
'statistics': statistics
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/invoices/<int:invoice_id>', methods=['GET'])
|
||||||
|
def get_invoice(invoice_id):
|
||||||
|
invoice = db.get_invoice(invoice_id)
|
||||||
|
if invoice:
|
||||||
|
return jsonify(invoice)
|
||||||
|
return jsonify({'error': 'Facture non trouvée'}), 404
|
||||||
|
|
||||||
|
@app.route('/api/invoices/<int:invoice_id>/status', methods=['PUT'])
|
||||||
|
def update_invoice_status(invoice_id):
|
||||||
|
data = request.get_json()
|
||||||
|
if 'status' not in data:
|
||||||
|
return jsonify({'error': 'Statut manquant'}), 400
|
||||||
|
|
||||||
|
db.update_invoice_status(invoice_id, data['status'])
|
||||||
|
return jsonify({'message': 'Statut mis à jour avec succès'})
|
||||||
|
|
||||||
|
@app.route('/api/invoices/<int:invoice_id>/download', methods=['GET'])
|
||||||
|
def download_invoice(invoice_id):
|
||||||
|
invoice = db.get_invoice(invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
return jsonify({'error': 'Facture non trouvée'}), 404
|
||||||
|
|
||||||
|
# Créer le dossier pour les fichiers générés s'il n'existe pas
|
||||||
|
os.makedirs('generated', exist_ok=True)
|
||||||
|
|
||||||
|
# Créer un fichier temporaire pour la facture
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
temp_file = os.path.join('generated', f'invoice_{invoice_id}_{timestamp}.typ')
|
||||||
|
output_pdf = temp_file.replace('.typ', '.pdf')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Écrire le contenu Typst
|
||||||
|
with open(temp_file, 'w') as f:
|
||||||
|
f.write(invoice['typst_content'])
|
||||||
|
|
||||||
|
# Générer le PDF avec Typst
|
||||||
|
result = subprocess.run(['typst', 'compile', temp_file, output_pdf],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return jsonify({'error': f'Erreur lors de la génération du PDF: {result.stderr}'}), 500
|
||||||
|
|
||||||
|
# Vérifier que le PDF a été créé
|
||||||
|
if not os.path.exists(output_pdf):
|
||||||
|
return jsonify({'error': 'Le PDF n\'a pas été généré'}), 500
|
||||||
|
|
||||||
|
# Envoyer le PDF
|
||||||
|
return send_file(
|
||||||
|
output_pdf,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f'invoice_{invoice["invoice_number"]}.pdf'
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Nettoyer les fichiers temporaires
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
os.remove(temp_file)
|
||||||
|
if os.path.exists(output_pdf):
|
||||||
|
os.remove(output_pdf)
|
||||||
|
|
||||||
|
@app.route('/api/invoices', methods=['POST'])
|
||||||
|
def create_invoice():
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Générer automatiquement le numéro de facture
|
||||||
|
invoice_number = db.get_next_invoice_number()
|
||||||
|
|
||||||
|
# Ajouter le client
|
||||||
|
client_id = db.add_client(
|
||||||
|
name=data['recipient_name'],
|
||||||
|
address=data['recipient_address'],
|
||||||
|
postal_code=data['recipient_postal_code'],
|
||||||
|
town=data['recipient_town'],
|
||||||
|
country=data['recipient_country'],
|
||||||
|
vat_number=data.get('recipient_vat_number')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créer le contenu du template Typst
|
||||||
|
typst_content = f'''#let language = "{data['language']}"
|
||||||
|
#let invoice_number = "{invoice_number}"
|
||||||
|
#let amount = "{data['amount']}"
|
||||||
|
#let currency = "{data['currency']}"
|
||||||
|
#let current_date = datetime.today()
|
||||||
|
#let recipient_name = [{data['recipient_name']}]
|
||||||
|
#let recipient_adress = [{data['recipient_address']}]
|
||||||
|
#let recipient_postal_code = [{data['recipient_postal_code']}]
|
||||||
|
#let recipient_town = [{data['recipient_town']}]
|
||||||
|
#let recipient_country = [{data['recipient_country']}]
|
||||||
|
#let recipient_vat_number = {"none" if data.get('recipient_vat_number') is None else f'"{data["recipient_vat_number"]}"'}
|
||||||
|
|
||||||
|
// Dictionnaire des traductions
|
||||||
|
#let translations = (
|
||||||
|
français: (
|
||||||
|
sender: "ÉMETTEUR",
|
||||||
|
recipient: "DESTINATAIRE",
|
||||||
|
invoice: "FACTURE",
|
||||||
|
invoice_number: "Facture N° :",
|
||||||
|
date: "Date :",
|
||||||
|
due_date: "Échéance :",
|
||||||
|
description: "Description",
|
||||||
|
amount: "Montant",
|
||||||
|
total: "Total :",
|
||||||
|
vat_notice: "Association non soumise à la TVA selon l'Art. 10 LTVA",
|
||||||
|
payment_terms: "Cette facture est payable dans les 30 jours suivant sa réception.",
|
||||||
|
banking_info: "COORDONNÉES BANCAIRES",
|
||||||
|
bank: "Banque :",
|
||||||
|
account_holder: "Titulaire :",
|
||||||
|
account_notice: "Compte au nom d'un membre de l'association",
|
||||||
|
association_notice: "Association à but non lucratif non inscrite au registre du commerce",
|
||||||
|
),
|
||||||
|
deutsch: (
|
||||||
|
sender: "ABSENDER",
|
||||||
|
recipient: "EMPFÄNGER",
|
||||||
|
invoice: "RECHNUNG",
|
||||||
|
invoice_number: "Rechnungs-Nr.:",
|
||||||
|
date: "Datum:",
|
||||||
|
due_date: "Fälligkeitsdatum:",
|
||||||
|
description: "Beschreibung",
|
||||||
|
amount: "Betrag",
|
||||||
|
total: "Gesamtbetrag:",
|
||||||
|
vat_notice: "Verein nicht mehrwertsteuerpflichtig gemäß Art. 10 MWSTG",
|
||||||
|
payment_terms: "Diese Rechnung ist innerhalb von 30 Tagen nach Erhalt zu bezahlen.",
|
||||||
|
banking_info: "BANKVERBINDUNG",
|
||||||
|
bank: "Bank:",
|
||||||
|
account_holder: "Kontoinhaber:",
|
||||||
|
account_notice: "Konto auf den Namen eines Vereinsmitglieds",
|
||||||
|
association_notice: "Gemeinnütziger Verein, nicht im Handelsregister eingetragen",
|
||||||
|
),
|
||||||
|
english: (
|
||||||
|
sender: "FROM",
|
||||||
|
recipient: "TO",
|
||||||
|
invoice: "INVOICE",
|
||||||
|
invoice_number: "Invoice No.:",
|
||||||
|
date: "Date:",
|
||||||
|
due_date: "Due date:",
|
||||||
|
description: "Description",
|
||||||
|
amount: "Amount",
|
||||||
|
total: "Total:",
|
||||||
|
vat_notice: "Association not subject to VAT according to Art. 10 VAT Act",
|
||||||
|
payment_terms: "This invoice is payable within 30 days of receipt.",
|
||||||
|
banking_info: "BANKING DETAILS",
|
||||||
|
bank: "Bank:",
|
||||||
|
account_holder: "Account holder:",
|
||||||
|
account_notice: "Account in the name of an association member",
|
||||||
|
association_notice: "Non-profit association not registered in the commercial register",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sélection du dictionnaire en fonction de la langue
|
||||||
|
#let t = translations.at(language, default: translations.français)
|
||||||
|
|
||||||
|
#set page(
|
||||||
|
margin: 2cm,
|
||||||
|
numbering: none,
|
||||||
|
)
|
||||||
|
|
||||||
|
#set text(font: "Arial", size: 11pt)
|
||||||
|
|
||||||
|
// En-tête de l'association
|
||||||
|
#align(center)[
|
||||||
|
#text(weight: "bold", size: 14pt)[Cheap Motion Pictures ]
|
||||||
|
|
||||||
|
#text(style: "italic")[#t.association_notice]
|
||||||
|
]
|
||||||
|
|
||||||
|
#v(0.5cm)
|
||||||
|
|
||||||
|
#grid(
|
||||||
|
columns: (1fr, 1fr),
|
||||||
|
row-gutter: 0.5cm,
|
||||||
|
|
||||||
|
// Informations de l'émetteur
|
||||||
|
align(left)[
|
||||||
|
#text(weight: "bold", size: 14pt)[#t.sender]
|
||||||
|
#v(0.2cm)
|
||||||
|
Cheap Motion Pictures
|
||||||
|
#v(0.1cm)
|
||||||
|
rue de l'ale 1
|
||||||
|
#v(0.1cm)
|
||||||
|
1003 Lausanne, Suisse
|
||||||
|
#v(0.1cm)
|
||||||
|
Tél: +41 77 471 11 34
|
||||||
|
#v(0.1cm)
|
||||||
|
Email: contact\@cheapmo.ch
|
||||||
|
],
|
||||||
|
|
||||||
|
// Informations du destinataire
|
||||||
|
align(right)[
|
||||||
|
#text(weight: "bold", size: 14pt)[#t.recipient]
|
||||||
|
#v(0.2cm)
|
||||||
|
#recipient_name
|
||||||
|
#v(0.1cm)
|
||||||
|
#recipient_adress
|
||||||
|
#v(0.1cm)
|
||||||
|
#recipient_postal_code #recipient_town, #recipient_country
|
||||||
|
|
||||||
|
#if recipient_vat_number != none [VAT: #recipient_vat_number] else [ ];
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
#v(0.5cm)
|
||||||
|
|
||||||
|
// Titre et informations de la facture
|
||||||
|
#align(center)[
|
||||||
|
#text(weight: "bold", size: 18pt)[#t.invoice]
|
||||||
|
#v(0.3cm)
|
||||||
|
#t.invoice_number 2025-#invoice_number - #t.date #current_date.display("[day]/[month]/[year]")
|
||||||
|
#v(0.2cm)
|
||||||
|
#t.due_date 30/03/2025
|
||||||
|
]
|
||||||
|
|
||||||
|
#v(0.5cm)
|
||||||
|
|
||||||
|
// Tableau des prestations
|
||||||
|
#table(
|
||||||
|
columns: (auto, 1fr),
|
||||||
|
inset: 10pt,
|
||||||
|
align: (left, right),
|
||||||
|
table.header(
|
||||||
|
[*#t.description*],
|
||||||
|
[*#t.amount (#currency)* ],
|
||||||
|
),
|
||||||
|
[Poong rental], [#amount],
|
||||||
|
[*#t.total*], [*#amount #currency*],
|
||||||
|
)
|
||||||
|
|
||||||
|
#v(.5cm)
|
||||||
|
|
||||||
|
// Informations complémentaires
|
||||||
|
#align(left)[
|
||||||
|
#t.vat_notice
|
||||||
|
#v(0.3cm)
|
||||||
|
#t.payment_terms
|
||||||
|
]
|
||||||
|
|
||||||
|
#v(0.5cm)
|
||||||
|
|
||||||
|
// Coordonnées bancaires avec notification spéciale
|
||||||
|
#text(weight: "bold", size: 13pt)[#t.banking_info]
|
||||||
|
#v(0.2cm)
|
||||||
|
#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)[
|
||||||
|
#v(1cm)
|
||||||
|
#line(length: 100%, stroke: 0.5pt + gray)
|
||||||
|
#v(0.3cm)
|
||||||
|
Cheap Motion Pictures - 1 rue de l'ale - 1003 Lausanne - contact\@cheapmo.ch
|
||||||
|
]
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Ajouter la facture
|
||||||
|
invoice_id = db.add_invoice(
|
||||||
|
invoice_number=invoice_number,
|
||||||
|
client_id=client_id,
|
||||||
|
amount=data['amount'],
|
||||||
|
currency=data['currency'],
|
||||||
|
language=data['language'],
|
||||||
|
typst_content=typst_content
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Facture créée avec succès',
|
||||||
|
'invoice_id': invoice_id
|
||||||
|
})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
||||||
106
static/dashboard.html
Normal file
106
static/dashboard.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gestion des Factures</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 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>
|
||||||
|
|
||||||
|
<!-- Statistiques -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-gray-500 text-sm">Total Factures</h3>
|
||||||
|
<p class="text-2xl font-bold" id="totalInvoices">0</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-gray-500 text-sm">Montant Total</h3>
|
||||||
|
<p class="text-2xl font-bold" id="totalAmount">0 €</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-gray-500 text-sm">Factures Payées</h3>
|
||||||
|
<p class="text-2xl font-bold text-green-500" id="totalPaid">0 €</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-gray-500 text-sm">Factures en Retard</h3>
|
||||||
|
<p class="text-2xl font-bold text-red-500" id="totalOverdue">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">Statut</label>
|
||||||
|
<select id="statusFilter" 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="issued">Émise</option>
|
||||||
|
<option value="paid">Payée</option>
|
||||||
|
<option value="overdue">En retard</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Date de début</label>
|
||||||
|
<input type="date" id="dateFrom" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Date de fin</label>
|
||||||
|
<input type="date" id="dateTo" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Liste des factures -->
|
||||||
|
<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">N° Facture</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Client</th>
|
||||||
|
<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">Montant</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</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="invoicesList">
|
||||||
|
<!-- Les factures seront ajoutées ici dynamiquement -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal pour les détails de la facture -->
|
||||||
|
<div id="invoiceModal" 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" id="modalTitle"></h3>
|
||||||
|
<div class="mt-2 px-7 py-3">
|
||||||
|
<p class="text-sm text-gray-500" id="modalContent"></p>
|
||||||
|
</div>
|
||||||
|
<div class="items-center px-4 py-3">
|
||||||
|
<button id="closeModal" 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 src="/static/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
204
static/dashboard.js
Normal file
204
static/dashboard.js
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// Éléments du DOM
|
||||||
|
const invoicesList = document.getElementById("invoicesList");
|
||||||
|
const statusFilter = document.getElementById("statusFilter");
|
||||||
|
const dateFrom = document.getElementById("dateFrom");
|
||||||
|
const dateTo = document.getElementById("dateTo");
|
||||||
|
const applyFilters = document.getElementById("applyFilters");
|
||||||
|
const modal = document.getElementById("invoiceModal");
|
||||||
|
const modalTitle = document.getElementById("modalTitle");
|
||||||
|
const modalContent = document.getElementById("modalContent");
|
||||||
|
const closeModal = document.getElementById("closeModal");
|
||||||
|
|
||||||
|
// Charger les factures au démarrage
|
||||||
|
loadInvoices();
|
||||||
|
|
||||||
|
// Gestionnaire d'événements pour les filtres
|
||||||
|
applyFilters.addEventListener("click", () => {
|
||||||
|
loadInvoices({
|
||||||
|
status: statusFilter.value,
|
||||||
|
date_from: dateFrom.value,
|
||||||
|
date_to: dateTo.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestionnaire d'événements pour fermer le modal
|
||||||
|
closeModal.addEventListener("click", () => {
|
||||||
|
modal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fonction pour charger les factures
|
||||||
|
async function loadInvoices(filters = {}) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"/api/invoices?" + new URLSearchParams(filters)
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Mettre à jour les statistiques
|
||||||
|
updateStatistics(data.statistics);
|
||||||
|
|
||||||
|
// Mettre à jour la liste des factures
|
||||||
|
displayInvoices(data.invoices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement des factures:", error);
|
||||||
|
showMessage("Erreur lors du chargement des factures", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour mettre à jour les statistiques
|
||||||
|
function updateStatistics(stats) {
|
||||||
|
document.getElementById("totalInvoices").textContent = stats.total_invoices;
|
||||||
|
document.getElementById(
|
||||||
|
"totalAmount"
|
||||||
|
).textContent = `${stats.total_amount.toFixed(2)} €`;
|
||||||
|
document.getElementById(
|
||||||
|
"totalPaid"
|
||||||
|
).textContent = `${stats.total_paid.toFixed(2)} €`;
|
||||||
|
document.getElementById(
|
||||||
|
"totalOverdue"
|
||||||
|
).textContent = `${stats.total_overdue.toFixed(2)} €`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour afficher les factures
|
||||||
|
function displayInvoices(invoices) {
|
||||||
|
invoicesList.innerHTML = "";
|
||||||
|
|
||||||
|
invoices.forEach((invoice) => {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
${invoice.invoice_number}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
${invoice.client_name}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
${new Date(invoice.issue_date).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
${invoice.amount} ${invoice.currency}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||||
|
${getStatusClass(invoice.status)}">
|
||||||
|
${getStatusText(invoice.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button onclick="viewInvoice(${
|
||||||
|
invoice.id
|
||||||
|
})" class="text-blue-600 hover:text-blue-900 mr-3">
|
||||||
|
Voir
|
||||||
|
</button>
|
||||||
|
<button onclick="updateStatus(${
|
||||||
|
invoice.id
|
||||||
|
})" class="text-green-600 hover:text-green-900 mr-3">
|
||||||
|
Marquer comme payée
|
||||||
|
</button>
|
||||||
|
<button onclick="downloadInvoice(${
|
||||||
|
invoice.id
|
||||||
|
})" class="text-gray-600 hover:text-gray-900">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
invoicesList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour obtenir la classe CSS du statut
|
||||||
|
function getStatusClass(status) {
|
||||||
|
switch (status) {
|
||||||
|
case "paid":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case "overdue":
|
||||||
|
return "bg-red-100 text-red-800";
|
||||||
|
default:
|
||||||
|
return "bg-yellow-100 text-yellow-800";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour obtenir le texte du statut
|
||||||
|
function getStatusText(status) {
|
||||||
|
switch (status) {
|
||||||
|
case "paid":
|
||||||
|
return "Payée";
|
||||||
|
case "overdue":
|
||||||
|
return "En retard";
|
||||||
|
default:
|
||||||
|
return "Émise";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour afficher une facture
|
||||||
|
window.viewInvoice = async (invoiceId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||||
|
const invoice = await response.json();
|
||||||
|
|
||||||
|
modalTitle.textContent = `Facture ${invoice.invoice_number}`;
|
||||||
|
modalContent.innerHTML = `
|
||||||
|
<div class="text-left">
|
||||||
|
<p><strong>Client:</strong> ${invoice.client_name}</p>
|
||||||
|
<p><strong>Date:</strong> ${new Date(
|
||||||
|
invoice.issue_date
|
||||||
|
).toLocaleDateString()}</p>
|
||||||
|
<p><strong>Montant:</strong> ${invoice.amount} ${
|
||||||
|
invoice.currency
|
||||||
|
}</p>
|
||||||
|
<p><strong>Statut:</strong> ${getStatusText(
|
||||||
|
invoice.status
|
||||||
|
)}</p>
|
||||||
|
<p><strong>Adresse:</strong> ${invoice.address}</p>
|
||||||
|
<p>${invoice.postal_code} ${invoice.town}</p>
|
||||||
|
<p>${invoice.country}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement de la facture:", error);
|
||||||
|
showMessage("Erreur lors du chargement de la facture", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour mettre à jour le statut d'une facture
|
||||||
|
window.updateStatus = async (invoiceId) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/invoices/${invoiceId}/status`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: "paid" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
loadInvoices();
|
||||||
|
showMessage("Statut de la facture mis à jour");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la mise à jour du statut:", error);
|
||||||
|
showMessage("Erreur lors de la mise à jour du statut", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour télécharger une facture
|
||||||
|
window.downloadInvoice = async (invoiceId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${invoiceId}/download`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `invoice_${invoiceId}.pdf`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du téléchargement de la facture:", error);
|
||||||
|
showMessage("Erreur lors du téléchargement de la facture", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
99
static/generator.html
Normal file
99
static/generator.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Générateur de Factures</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">Générateur de Factures</h1>
|
||||||
|
<a href="/dashboard" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600">
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="invoiceForm" class="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Informations générales -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Informations générales</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Langue</label>
|
||||||
|
<select name="language" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<option value="français">Français</option>
|
||||||
|
<option value="deutsch">Deutsch</option>
|
||||||
|
<option value="english">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Numéro de facture</label>
|
||||||
|
<input type="text" name="invoice_number" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="001">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Montant</label>
|
||||||
|
<input type="number" step="0.01" name="amount" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Devise</label>
|
||||||
|
<select name="currency" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informations du destinataire -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Destinataire</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Nom</label>
|
||||||
|
<input type="text" name="recipient_name" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Adresse</label>
|
||||||
|
<input type="text" name="recipient_address" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Code postal</label>
|
||||||
|
<input type="text" name="recipient_postal_code" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Ville</label>
|
||||||
|
<input type="text" name="recipient_town" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Pays</label>
|
||||||
|
<input type="text" name="recipient_country" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Numéro de TVA (optionnel)</label>
|
||||||
|
<input type="text" name="recipient_vat_number" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<button type="submit" class="bg-blue-500 text-white px-6 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
|
Générer la facture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="/static/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
static/preview.html
Normal file
68
static/preview.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Prévisualisation de la Facture</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">Prévisualisation de la Facture</h1>
|
||||||
|
<div class="space-x-4">
|
||||||
|
<a href="/generator" class="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600">
|
||||||
|
Retour
|
||||||
|
</a>
|
||||||
|
<button id="validateInvoice" class="bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600">
|
||||||
|
Valider la Facture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-8 max-w-4xl mx-auto">
|
||||||
|
<div class="grid grid-cols-2 gap-8">
|
||||||
|
<!-- Informations de l'émetteur -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Émetteur</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p>Cheap Motion Pictures</p>
|
||||||
|
<p>rue de l'ale 1</p>
|
||||||
|
<p>1003 Lausanne, Suisse</p>
|
||||||
|
<p>Tél: +41 77 471 11 34</p>
|
||||||
|
<p>Email: contact@cheapmo.ch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informations du destinataire -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Destinataire</h2>
|
||||||
|
<div class="space-y-2" id="recipientInfo">
|
||||||
|
<!-- Rempli dynamiquement -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Détails de la Facture</h2>
|
||||||
|
<div class="space-y-4" id="invoiceDetails">
|
||||||
|
<!-- Rempli dynamiquement -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Informations de Paiement</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p>Banque: Wise</p>
|
||||||
|
<p>IBAN: BE22905094540247</p>
|
||||||
|
<p>BIC/SWIFT: TRWIBEB1XXX</p>
|
||||||
|
<p>Titulaire: Robin Szymczak</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/preview.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
static/preview.js
Normal file
80
static/preview.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Récupérer les données du formulaire depuis l'URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const formData = JSON.parse(decodeURIComponent(urlParams.get("data")));
|
||||||
|
|
||||||
|
// Afficher les informations du destinataire
|
||||||
|
const recipientInfo = document.getElementById("recipientInfo");
|
||||||
|
recipientInfo.innerHTML = `
|
||||||
|
<p>${formData.recipient_name}</p>
|
||||||
|
<p>${formData.recipient_address}</p>
|
||||||
|
<p>${formData.recipient_postal_code} ${formData.recipient_town}</p>
|
||||||
|
<p>${formData.recipient_country}</p>
|
||||||
|
${
|
||||||
|
formData.recipient_vat_number
|
||||||
|
? `<p>TVA: ${formData.recipient_vat_number}</p>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Gérer la validation de la facture
|
||||||
|
document
|
||||||
|
.getElementById("validateInvoice")
|
||||||
|
.addEventListener("click", async function () {
|
||||||
|
try {
|
||||||
|
console.log("Envoi des données à l'API:", formData);
|
||||||
|
|
||||||
|
const response = await fetch("/api/invoices", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Réponse reçue:", response.status);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Facture créée avec succès:", result);
|
||||||
|
alert("Facture créée avec succès !");
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error("Erreur API:", error);
|
||||||
|
alert(
|
||||||
|
"Erreur lors de la création de la facture : " +
|
||||||
|
(error.error || "Erreur inconnue")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la requête:", error);
|
||||||
|
alert("Erreur lors de la création de la facture : " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
65
static/script.js
Normal file
65
static/script.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const form = document.getElementById("invoiceForm");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Afficher le message
|
||||||
|
setTimeout(() => messageDiv.classList.add("show"), 100);
|
||||||
|
|
||||||
|
// Supprimer le message après 3 secondes
|
||||||
|
setTimeout(() => {
|
||||||
|
messageDiv.classList.remove("show");
|
||||||
|
setTimeout(() => messageDiv.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour valider le formulaire
|
||||||
|
function validateForm(formData) {
|
||||||
|
const requiredFields = [
|
||||||
|
"invoice_number",
|
||||||
|
"amount",
|
||||||
|
"recipient_name",
|
||||||
|
"recipient_address",
|
||||||
|
"recipient_postal_code",
|
||||||
|
"recipient_town",
|
||||||
|
"recipient_country",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!formData.get(field)) {
|
||||||
|
showMessage(`Le champ ${field} est requis`, "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestionnaire de soumission du formulaire
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Récupérer les données du formulaire
|
||||||
|
const formData = {
|
||||||
|
language: form.language.value,
|
||||||
|
invoice_number: form.invoice_number.value,
|
||||||
|
amount: form.amount.value,
|
||||||
|
currency: form.currency.value,
|
||||||
|
recipient_name: form.recipient_name.value,
|
||||||
|
recipient_address: form.recipient_address.value,
|
||||||
|
recipient_postal_code: form.recipient_postal_code.value,
|
||||||
|
recipient_town: form.recipient_town.value,
|
||||||
|
recipient_country: form.recipient_country.value,
|
||||||
|
recipient_vat_number: form.recipient_vat_number.value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rediriger vers la page de prévisualisation avec les données
|
||||||
|
const queryString = encodeURIComponent(JSON.stringify(formData));
|
||||||
|
window.location.href = `/preview?data=${queryString}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
52
static/styles.css
Normal file
52
static/styles.css
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/* Styles personnalisés */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
@apply border border-gray-300 rounded-md px-3 py-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation de chargement */
|
||||||
|
.loading {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: -10px 0 0 -10px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: loading 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message de succès */
|
||||||
|
.success-message {
|
||||||
|
@apply fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-md shadow-lg transform transition-transform duration-300;
|
||||||
|
transform: translateX(120%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message d'erreur */
|
||||||
|
.error-message {
|
||||||
|
@apply fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-md shadow-lg transform transition-transform duration-300;
|
||||||
|
transform: translateX(120%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user