Init project

This commit is contained in:
seb
2026-02-21 23:26:50 +01:00
parent df61e93871
commit b7046b125c
29 changed files with 2553 additions and 0 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Informations de l'association
ASSO_NOM=HAUM - Hackerspace Au Mans
ASSO_ADRESSE=1 rue de l'Innovation
ASSO_CODE_POSTAL=72000
ASSO_VILLE=Le Mans
ASSO_EMAIL=contact@haum.org
ASSO_TELEPHONE=
ASSO_RNA=W722012345
ASSO_SIRET=
ASSO_OBJET=Hackerspace et promotion de la culture libre
# Coordonnées bancaires (pour le PDF)
ASSO_IBAN=FR76 0000 0000 0000 0000 0000 000
ASSO_BIC=XXXXXXXX
# Base de données
DATABASE_URL=sqlite:///./factures.db
# Préfixes numérotation
DEVIS_PREFIX=DEV
# Sécurité (générer avec: python3 -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=changez-moi-en-production
# SESSION_MAX_AGE=604800 # 7 jours en secondes

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# Facturation Association
Application de facturation légale française pour associations (loi 1901),
non assujetties à la TVA (art. 293B du CGI).
## Fonctionnalités
- Gestion des clients
- Devis avec numérotation automatique (DEV-AAAA-XXXX)
- Factures avec numérotation chronologique (AAAA-XXXX)
- Conversion devis → facture
- Génération PDF avec toutes les mentions légales françaises
- Suivi des statuts (émise / payée / annulée)
## Stack
- **FastAPI** + Jinja2 (interface web)
- **SQLite** + SQLAlchemy (stockage)
- **WeasyPrint** (génération PDF)
## Installation
```bash
# Cloner / copier le projet
cd factures/
# Créer un environnement virtuel
python3 -m venv venv
source venv/bin/activate
# Installer les dépendances
pip install -r requirements.txt
# Configurer l'association
cp .env.example .env
nano .env # remplir les informations de l'association
# Lancer en développement
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Déploiement VPS (production)
### Avec systemd
Créer `/etc/systemd/system/facturation.service` :
```ini
[Unit]
Description=Facturation Association
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/facturation
Environment="PATH=/opt/facturation/venv/bin"
ExecStart=/opt/facturation/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable facturation
sudo systemctl start facturation
```
### Reverse proxy nginx
```nginx
server {
listen 80;
server_name factures.monasso.fr;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### HTTPS avec Certbot
```bash
sudo certbot --nginx -d factures.monasso.fr
```
### Dépendances système pour WeasyPrint (Debian/Ubuntu)
```bash
sudo apt install python3-dev python3-pip libpango-1.0-0 libpangoft2-1.0-0 \
libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info
```
## Configuration (.env)
| Variable | Description |
|---|---|
| `ASSO_NOM` | Nom de l'association |
| `ASSO_ADRESSE` | Adresse |
| `ASSO_CODE_POSTAL` | Code postal |
| `ASSO_VILLE` | Ville |
| `ASSO_EMAIL` | Email de contact |
| `ASSO_RNA` | Numéro RNA (W...) |
| `ASSO_SIRET` | SIRET si disponible |
| `ASSO_IBAN` | IBAN pour le pied de page PDF |
| `ASSO_BIC` | BIC |
| `DEVIS_PREFIX` | Préfixe devis (défaut: DEV) |
## Mentions légales incluses dans les PDF
- Numéro de facture chronologique unique
- Date d'émission et d'échéance
- Identification complète émetteur et destinataire
- Détail des lignes HT
- Mention TVA non applicable art. 293B CGI
- Conditions de règlement
- Pénalités de retard légales (art. L.441-10 C. com.)
- Indemnité forfaitaire recouvrement 40€
- Coordonnées bancaires
## Sauvegarde
La base de données est le fichier `factures.db`. Il suffit de le copier régulièrement.
```bash
# Exemple cron quotidien
0 2 * * * cp /opt/facturation/factures.db /backup/factures-$(date +\%Y\%m\%d).db
```

73
auth.py Normal file
View File

@@ -0,0 +1,73 @@
import bcrypt
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from fastapi import Request, HTTPException, Depends
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from config import settings
from database import get_db
# ── Serializer de session ──────────────────────────────────────────────────────
_serializer = URLSafeTimedSerializer(settings.secret_key)
COOKIE_NAME = "session"
def creer_session(user_id: int) -> str:
return _serializer.dumps({"user_id": user_id})
def lire_session(token: str) -> int | None:
try:
data = _serializer.loads(token, max_age=settings.session_max_age)
return data["user_id"]
except (BadSignature, SignatureExpired, KeyError):
return None
# ── Mots de passe ──────────────────────────────────────────────────────────────
def hasher_mot_de_passe(mdp: str) -> str:
return bcrypt.hashpw(mdp.encode(), bcrypt.gensalt()).decode()
def verifier_mot_de_passe(mdp: str, hash_: str) -> bool:
return bcrypt.checkpw(mdp.encode(), hash_.encode())
# ── Dépendances FastAPI ────────────────────────────────────────────────────────
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Retourne l'utilisateur connecté ou lève une redirection vers /login."""
from models import User
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=302, headers={"Location": "/login"})
user_id = lire_session(token)
if not user_id:
raise HTTPException(status_code=302, headers={"Location": "/login"})
user = db.query(User).filter(User.id == user_id, User.actif == True).first()
if not user:
raise HTTPException(status_code=302, headers={"Location": "/login"})
return user
def get_current_admin(user=Depends(get_current_user)):
"""Retourne l'utilisateur uniquement s'il est admin."""
if not user.is_admin:
raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs.")
return user
def redirect_if_not_logged(request: Request, db: Session):
"""Variante utilisable hors Depends pour les routes avec gestion manuelle."""
from models import User
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
user_id = lire_session(token)
if not user_id:
return None
return db.query(User).filter(User.id == user_id, User.actif == True).first()

33
config.py Normal file
View File

@@ -0,0 +1,33 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Facturation Association"
database_url: str = "sqlite:///./factures.db"
# Informations de l'association (modifiables via .env)
asso_nom: str = "Mon Association"
asso_adresse: str = "1 rue de la Paix"
asso_code_postal: str = "75001"
asso_ville: str = "Paris"
asso_email: str = "contact@association.fr"
asso_telephone: str = ""
asso_siret: str = ""
asso_rna: str = "" # Numéro RNA (W...)
asso_objet: str = ""
asso_iban: str = ""
asso_bic: str = ""
# Numérotation
facture_prefix: str = "" # vide = format AAAA-XXXX
devis_prefix: str = "DEV"
# Sécurité sessions (générer avec: python3 -c "import secrets; print(secrets.token_hex(32))")
secret_key: str = "changez-moi-en-production"
session_max_age: int = 86400 * 7 # 7 jours
class Config:
env_file = ".env"
settings = Settings()

26
database.py Normal file
View File

@@ -0,0 +1,26 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} # nécessaire pour SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
from models import Client, Devis, Facture, LigneDevis, LigneFacture # noqa
Base.metadata.create_all(bind=engine)

111
main.py Normal file
View File

@@ -0,0 +1,111 @@
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.exceptions import HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from database import init_db
from routers import clients, devis, factures
from routers import auth as auth_router
from config import settings
app = FastAPI(title=settings.app_name)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
app.include_router(auth_router.router)
app.include_router(clients.router)
app.include_router(devis.router)
app.include_router(factures.router)
# ── Middleware : current_user dans request.state ───────────────────────────────
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
from database import SessionLocal
from auth import redirect_if_not_logged
db = SessionLocal()
try:
request.state.current_user = redirect_if_not_logged(request, db)
except Exception:
request.state.current_user = None
finally:
db.close()
try:
return await call_next(request)
except Exception:
request.state.current_user = None
raise
app.add_middleware(AuthMiddleware)
# ── Startup ────────────────────────────────────────────────────────────────────
@app.on_event("startup")
def startup():
init_db()
_creer_admin_si_absent()
def _creer_admin_si_absent():
from database import SessionLocal
from models import User
from auth import hasher_mot_de_passe
db = SessionLocal()
try:
if db.query(User).count() == 0:
admin = User(
username="admin",
hashed_password=hasher_mot_de_passe("admin"),
is_admin=True,
)
db.add(admin)
db.commit()
print("⚠️ Compte admin créé : admin / admin — À changer immédiatement !")
finally:
db.close()
# ── Gestion erreurs ────────────────────────────────────────────────────────────
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if exc.status_code == 302:
return RedirectResponse(url=exc.headers["Location"])
return templates.TemplateResponse("erreur.html", {
"request": request,
"status_code": exc.status_code,
"detail": exc.detail,
"current_user": getattr(request.state, "current_user", None),
}, status_code=exc.status_code)
@app.get("/", response_class=HTMLResponse)
def accueil():
return RedirectResponse("/factures/")
# ── Filtres Jinja2 ─────────────────────────────────────────────────────────────
def format_montant(valeur):
if valeur is None:
return "0,00 €"
return f"{valeur:,.2f}".replace(",", " ").replace(".", ",")
def format_date_fr(d):
if d is None:
return ""
mois = ["janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
return f"{d.day} {mois[d.month - 1]} {d.year}"
for tpl in [templates, clients.templates, devis.templates,
factures.templates, auth_router.templates]:
tpl.env.filters["montant"] = format_montant
tpl.env.filters["date_fr"] = format_date_fr

131
models.py Normal file
View File

@@ -0,0 +1,131 @@
from datetime import date
from sqlalchemy import (
Column, Integer, String, Float, Date, Boolean,
ForeignKey, Enum, Text
)
from sqlalchemy.orm import relationship
import enum
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(200))
hashed_password = Column(String(200), nullable=False)
is_admin = Column(Boolean, default=False)
actif = Column(Boolean, default=True)
created_at = Column(Date, default=date.today)
class StatutDevis(str, enum.Enum):
brouillon = "brouillon"
envoye = "envoye"
accepte = "accepte"
refuse = "refuse"
class StatutFacture(str, enum.Enum):
emise = "emise"
payee = "payee"
annulee = "annulee"
class Client(Base):
__tablename__ = "clients"
id = Column(Integer, primary_key=True, index=True)
nom = Column(String(200), nullable=False)
adresse = Column(String(300), nullable=False)
code_postal = Column(String(10), nullable=False)
ville = Column(String(100), nullable=False)
email = Column(String(200))
telephone = Column(String(20))
siret = Column(String(14))
actif = Column(Boolean, default=True)
devis = relationship("Devis", back_populates="client")
factures = relationship("Facture", back_populates="client")
class LigneDevis(Base):
__tablename__ = "lignes_devis"
id = Column(Integer, primary_key=True, index=True)
devis_id = Column(Integer, ForeignKey("devis.id"), nullable=False)
description = Column(Text, nullable=False)
quantite = Column(Float, nullable=False, default=1.0)
prix_unitaire_ht = Column(Float, nullable=False)
ordre = Column(Integer, default=0)
devis = relationship("Devis", back_populates="lignes")
@property
def total_ht(self):
return self.quantite * self.prix_unitaire_ht
class Devis(Base):
__tablename__ = "devis"
id = Column(Integer, primary_key=True, index=True)
numero = Column(String(20), unique=True, nullable=False)
date_emission = Column(Date, nullable=False, default=date.today)
date_validite = Column(Date, nullable=False)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
statut = Column(Enum(StatutDevis), default=StatutDevis.brouillon)
notes = Column(Text)
conditions = Column(Text)
client = relationship("Client", back_populates="devis")
lignes = relationship("LigneDevis", back_populates="devis",
order_by="LigneDevis.ordre", cascade="all, delete-orphan")
factures = relationship("Facture", back_populates="devis_origine")
@property
def total_ht(self):
return sum(l.total_ht for l in self.lignes)
class LigneFacture(Base):
__tablename__ = "lignes_factures"
id = Column(Integer, primary_key=True, index=True)
facture_id = Column(Integer, ForeignKey("factures.id"), nullable=False)
description = Column(Text, nullable=False)
quantite = Column(Float, nullable=False, default=1.0)
prix_unitaire_ht = Column(Float, nullable=False)
ordre = Column(Integer, default=0)
facture = relationship("Facture", back_populates="lignes")
@property
def total_ht(self):
return self.quantite * self.prix_unitaire_ht
class Facture(Base):
__tablename__ = "factures"
id = Column(Integer, primary_key=True, index=True)
numero = Column(String(20), unique=True, nullable=False)
date_emission = Column(Date, nullable=False, default=date.today)
date_echeance = Column(Date, nullable=False)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
devis_id = Column(Integer, ForeignKey("devis.id"), nullable=True)
statut = Column(Enum(StatutFacture), default=StatutFacture.emise)
notes = Column(Text)
conditions_reglement = Column(Text, default="Paiement à réception de facture.")
date_paiement = Column(Date, nullable=True)
client = relationship("Client", back_populates="factures")
devis_origine = relationship("Devis", back_populates="factures")
lignes = relationship("LigneFacture", back_populates="facture",
order_by="LigneFacture.ordre", cascade="all, delete-orphan")
@property
def total_ht(self):
return sum(l.total_ht for l in self.lignes)

44
numerotation.py Normal file
View File

@@ -0,0 +1,44 @@
from datetime import date
from sqlalchemy.orm import Session
def generer_numero_facture(db: Session) -> str:
from models import Facture
annee = date.today().year
# Cherche le dernier numéro de l'année en cours
factures = db.query(Facture).filter(
Facture.numero.like(f"{annee}-%")
).all()
if not factures:
seq = 1
else:
sequences = []
for f in factures:
try:
sequences.append(int(f.numero.split("-")[-1]))
except (ValueError, IndexError):
pass
seq = max(sequences) + 1 if sequences else 1
return f"{annee}-{seq:04d}"
def generer_numero_devis(db: Session) -> str:
from models import Devis
from config import settings
annee = date.today().year
prefix = settings.devis_prefix
pattern = f"{prefix}-{annee}-%"
devis = db.query(Devis).filter(
Devis.numero.like(pattern)
).all()
if not devis:
seq = 1
else:
sequences = []
for d in devis:
try:
sequences.append(int(d.numero.split("-")[-1]))
except (ValueError, IndexError):
pass
seq = max(sequences) + 1 if sequences else 1
return f"{prefix}-{annee}-{seq:04d}"

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
jinja2==3.1.4
python-multipart==0.0.12
weasyprint>=63.0
pydantic==2.9.2
pydantic-settings==2.5.2
bcrypt==4.2.0
itsdangerous==2.2.0

162
routers/auth.py Normal file
View File

@@ -0,0 +1,162 @@
from template_helper import render
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from database import get_db
from models import User
from auth import (
hasher_mot_de_passe, verifier_mot_de_passe,
creer_session, get_current_admin, COOKIE_NAME
)
from config import settings
router = APIRouter(tags=["auth"])
templates = Jinja2Templates(directory="templates")
# ── Login / Logout ─────────────────────────────────────────────────────────────
@router.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
return render(templates, "auth/login.html", request, {
"erreur": None
})
@router.post("/login")
def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db)
):
user = db.query(User).filter(
User.username == username, User.actif == True
).first()
if not user or not verifier_mot_de_passe(password, user.hashed_password):
return render(templates, "auth/login.html", request, {
"erreur": "Identifiants incorrects."
}, status_code=401)
token = creer_session(user.id)
response = RedirectResponse("/factures/", status_code=303)
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=settings.session_max_age,
httponly=True,
samesite="lax",
secure=False # Passer à True si HTTPS (recommandé en prod)
)
return response
@router.post("/logout")
def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(COOKIE_NAME)
return response
# ── Gestion utilisateurs (admin) ───────────────────────────────────────────────
@router.get("/admin/utilisateurs", response_class=HTMLResponse)
def liste_utilisateurs(
request: Request,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin)
):
users = db.query(User).order_by(User.username).all()
return render(templates, "auth/utilisateurs.html", request, {
"users": users, "current_user": current_user
})
@router.get("/admin/utilisateurs/nouveau", response_class=HTMLResponse)
def nouveau_user_form(request: Request, current_user=Depends(get_current_admin)):
return render(templates, "auth/user_form.html", request, {
"user": None,
"titre": "Nouvel utilisateur", "current_user": current_user
})
@router.post("/admin/utilisateurs/nouveau")
def creer_user(
username: str = Form(...),
email: str = Form(""),
password: str = Form(...),
is_admin: bool = Form(False),
db: Session = Depends(get_db),
current_user=Depends(get_current_admin)
):
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="Ce nom d'utilisateur existe déjà.")
user = User(
username=username,
email=email,
hashed_password=hasher_mot_de_passe(password),
is_admin=is_admin
)
db.add(user)
db.commit()
return RedirectResponse("/admin/utilisateurs", status_code=303)
@router.get("/admin/utilisateurs/{user_id}/modifier", response_class=HTMLResponse)
def modifier_user_form(
request: Request,
user_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin)
):
user = db.query(User).get(user_id)
if not user:
raise HTTPException(status_code=404)
return render(templates, "auth/user_form.html", request, {
"user": user,
"titre": "Modifier l'utilisateur", "current_user": current_user
})
@router.post("/admin/utilisateurs/{user_id}/modifier")
def modifier_user(
user_id: int,
username: str = Form(...),
email: str = Form(""),
password: str = Form(""),
is_admin: bool = Form(False),
db: Session = Depends(get_db),
current_user=Depends(get_current_admin)
):
user = db.query(User).get(user_id)
if not user:
raise HTTPException(status_code=404)
# Empêcher de se retirer les droits admin à soi-même
if user.id == current_user.id and not is_admin:
raise HTTPException(status_code=400, detail="Vous ne pouvez pas retirer vos propres droits admin.")
user.username = username
user.email = email
user.is_admin = is_admin
if password:
user.hashed_password = hasher_mot_de_passe(password)
db.commit()
return RedirectResponse("/admin/utilisateurs", status_code=303)
@router.post("/admin/utilisateurs/{user_id}/desactiver")
def desactiver_user(
user_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin)
):
user = db.query(User).get(user_id)
if not user:
raise HTTPException(status_code=404)
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Impossible de se désactiver soi-même.")
user.actif = not user.actif
db.commit()
return RedirectResponse("/admin/utilisateurs", status_code=303)

95
routers/clients.py Normal file
View File

@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from database import get_db
from models import Client
from auth import get_current_user
from template_helper import render
router = APIRouter(prefix="/clients", tags=["clients"],
dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates")
@router.get("/", response_class=HTMLResponse)
def liste_clients(request: Request, db: Session = Depends(get_db)):
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
return render(templates, "clients/liste.html", request, {
"clients": clients
})
@router.get("/nouveau", response_class=HTMLResponse)
def nouveau_client_form(request: Request):
return render(templates, "clients/form.html", request, {
"client": None, "titre": "Nouveau client"
})
@router.post("/nouveau")
def creer_client(
request: Request,
nom: str = Form(...),
adresse: str = Form(...),
code_postal: str = Form(...),
ville: str = Form(...),
email: str = Form(""),
telephone: str = Form(""),
siret: str = Form(""),
db: Session = Depends(get_db)
):
client = Client(
nom=nom, adresse=adresse, code_postal=code_postal,
ville=ville, email=email, telephone=telephone, siret=siret
)
db.add(client)
db.commit()
return RedirectResponse("/clients/", status_code=303)
@router.get("/{client_id}/modifier", response_class=HTMLResponse)
def modifier_client_form(request: Request, client_id: int, db: Session = Depends(get_db)):
client = db.query(Client).get(client_id)
if not client:
raise HTTPException(status_code=404)
return render(templates, "clients/form.html", request, {
"client": client, "titre": "Modifier le client"
})
@router.post("/{client_id}/modifier")
def modifier_client(
client_id: int,
nom: str = Form(...),
adresse: str = Form(...),
code_postal: str = Form(...),
ville: str = Form(...),
email: str = Form(""),
telephone: str = Form(""),
siret: str = Form(""),
db: Session = Depends(get_db)
):
client = db.query(Client).get(client_id)
if not client:
raise HTTPException(status_code=404)
client.nom = nom
client.adresse = adresse
client.code_postal = code_postal
client.ville = ville
client.email = email
client.telephone = telephone
client.siret = siret
db.commit()
return RedirectResponse("/clients/", status_code=303)
@router.post("/{client_id}/supprimer")
def supprimer_client(client_id: int, db: Session = Depends(get_db)):
client = db.query(Client).get(client_id)
if not client:
raise HTTPException(status_code=404)
client.actif = False
db.commit()
return RedirectResponse("/clients/", status_code=303)

186
routers/devis.py Normal file
View File

@@ -0,0 +1,186 @@
import json
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from database import get_db
from models import Devis, LigneDevis, Client, StatutDevis
from numerotation import generer_numero_devis
from auth import get_current_user
from template_helper import render
router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates")
@router.get("/", response_class=HTMLResponse)
def liste_devis(request: Request, db: Session = Depends(get_db)):
devis = db.query(Devis).order_by(Devis.date_emission.desc()).all()
return render(templates, "devis/liste.html", request, {
"devis": devis
})
@router.get("/nouveau", response_class=HTMLResponse)
def nouveau_devis_form(request: Request, db: Session = Depends(get_db)):
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
return render(templates, "devis/form.html", request, {
"devis": None,
"clients": clients,
"titre": "Nouveau devis",
"date_aujourd_hui": date.today().isoformat(),
"date_validite_defaut": (date.today() + timedelta(days=30)).isoformat(),
})
@router.post("/nouveau")
def creer_devis(
request: Request,
client_id: int = Form(...),
date_emission: str = Form(...),
date_validite: str = Form(...),
notes: str = Form(""),
conditions: str = Form(""),
lignes_json: str = Form(...),
db: Session = Depends(get_db)
):
numero = generer_numero_devis(db)
devis = Devis(
numero=numero,
client_id=client_id,
date_emission=date.fromisoformat(date_emission),
date_validite=date.fromisoformat(date_validite),
notes=notes,
conditions=conditions,
)
db.add(devis)
db.flush()
lignes = json.loads(lignes_json)
for i, l in enumerate(lignes):
ligne = LigneDevis(
devis_id=devis.id,
description=l["description"],
quantite=float(l["quantite"]),
prix_unitaire_ht=float(l["prix_unitaire_ht"]),
ordre=i
)
db.add(ligne)
db.commit()
return RedirectResponse(f"/devis/{devis.id}", status_code=303)
@router.get("/{devis_id}", response_class=HTMLResponse)
def voir_devis(request: Request, devis_id: int, db: Session = Depends(get_db)):
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
return render(templates, "devis/detail.html", request, {
"devis": devis, "StatutDevis": StatutDevis
})
@router.get("/{devis_id}/modifier", response_class=HTMLResponse)
def modifier_devis_form(request: Request, devis_id: int, db: Session = Depends(get_db)):
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
return render(templates, "devis/form.html", request, {
"devis": devis,
"clients": clients,
"titre": "Modifier le devis",
"date_aujourd_hui": date.today().isoformat(),
"date_validite_defaut": devis.date_validite.isoformat(),
})
@router.post("/{devis_id}/modifier")
def modifier_devis(
devis_id: int,
client_id: int = Form(...),
date_emission: str = Form(...),
date_validite: str = Form(...),
notes: str = Form(""),
conditions: str = Form(""),
lignes_json: str = Form(...),
db: Session = Depends(get_db)
):
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
devis.client_id = client_id
devis.date_emission = date.fromisoformat(date_emission)
devis.date_validite = date.fromisoformat(date_validite)
devis.notes = notes
devis.conditions = conditions
for ligne in devis.lignes:
db.delete(ligne)
db.flush()
lignes = json.loads(lignes_json)
for i, l in enumerate(lignes):
ligne = LigneDevis(
devis_id=devis.id,
description=l["description"],
quantite=float(l["quantite"]),
prix_unitaire_ht=float(l["prix_unitaire_ht"]),
ordre=i
)
db.add(ligne)
db.commit()
return RedirectResponse(f"/devis/{devis_id}", status_code=303)
@router.post("/{devis_id}/statut")
def changer_statut_devis(
devis_id: int,
statut: str = Form(...),
db: Session = Depends(get_db)
):
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
devis.statut = StatutDevis(statut)
db.commit()
return RedirectResponse(f"/devis/{devis_id}", status_code=303)
@router.post("/{devis_id}/convertir")
def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)):
from models import Facture, LigneFacture
from numerotation import generer_numero_facture
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
numero = generer_numero_facture(db)
facture = Facture(
numero=numero,
client_id=devis.client_id,
devis_id=devis.id,
date_emission=date.today(),
date_echeance=date.today() + timedelta(days=30),
notes=devis.notes,
)
db.add(facture)
db.flush()
for i, l in enumerate(devis.lignes):
ligne = LigneFacture(
facture_id=facture.id,
description=l.description,
quantite=l.quantite,
prix_unitaire_ht=l.prix_unitaire_ht,
ordre=i
)
db.add(ligne)
devis.statut = StatutDevis.accepte
db.commit()
return RedirectResponse(f"/factures/{facture.id}", status_code=303)

189
routers/factures.py Normal file
View File

@@ -0,0 +1,189 @@
import json
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from database import get_db
from models import Facture, LigneFacture, Client, StatutFacture
from numerotation import generer_numero_facture
from config import settings
from auth import get_current_user
from template_helper import render
router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates")
@router.get("/", response_class=HTMLResponse)
def liste_factures(request: Request, db: Session = Depends(get_db)):
factures = db.query(Facture).order_by(Facture.date_emission.desc()).all()
return render(templates, "factures/liste.html", request, {
"factures": factures
})
@router.get("/nouvelle", response_class=HTMLResponse)
def nouvelle_facture_form(request: Request, db: Session = Depends(get_db)):
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
return render(templates, "factures/form.html", request, {
"facture": None,
"clients": clients,
"titre": "Nouvelle facture",
"date_aujourd_hui": date.today().isoformat(),
"date_echeance_defaut": (date.today() + timedelta(days=30)).isoformat(),
})
@router.post("/nouvelle")
def creer_facture(
client_id: int = Form(...),
date_emission: str = Form(...),
date_echeance: str = Form(...),
notes: str = Form(""),
conditions_reglement: str = Form("Paiement à réception de facture."),
lignes_json: str = Form(...),
db: Session = Depends(get_db)
):
numero = generer_numero_facture(db)
facture = Facture(
numero=numero,
client_id=client_id,
date_emission=date.fromisoformat(date_emission),
date_echeance=date.fromisoformat(date_echeance),
notes=notes,
conditions_reglement=conditions_reglement,
)
db.add(facture)
db.flush()
lignes = json.loads(lignes_json)
for i, l in enumerate(lignes):
ligne = LigneFacture(
facture_id=facture.id,
description=l["description"],
quantite=float(l["quantite"]),
prix_unitaire_ht=float(l["prix_unitaire_ht"]),
ordre=i
)
db.add(ligne)
db.commit()
return RedirectResponse(f"/factures/{facture.id}", status_code=303)
@router.get("/{facture_id}", response_class=HTMLResponse)
def voir_facture(request: Request, facture_id: int, db: Session = Depends(get_db)):
facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
return render(templates, "factures/detail.html", request, {
"facture": facture, "StatutFacture": StatutFacture
})
@router.get("/{facture_id}/modifier", response_class=HTMLResponse)
def modifier_facture_form(request: Request, facture_id: int, db: Session = Depends(get_db)):
facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
if facture.statut != StatutFacture.emise:
raise HTTPException(status_code=400, detail="Seules les factures émises peuvent être modifiées.")
clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all()
return render(templates, "factures/form.html", request, {
"facture": facture,
"clients": clients,
"titre": "Modifier la facture",
"date_aujourd_hui": facture.date_emission.isoformat(),
"date_echeance_defaut": facture.date_echeance.isoformat(),
})
@router.post("/{facture_id}/modifier")
def modifier_facture(
facture_id: int,
client_id: int = Form(...),
date_emission: str = Form(...),
date_echeance: str = Form(...),
notes: str = Form(""),
conditions_reglement: str = Form(""),
lignes_json: str = Form(...),
db: Session = Depends(get_db)
):
facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
facture.client_id = client_id
facture.date_emission = date.fromisoformat(date_emission)
facture.date_echeance = date.fromisoformat(date_echeance)
facture.notes = notes
facture.conditions_reglement = conditions_reglement
for ligne in facture.lignes:
db.delete(ligne)
db.flush()
lignes = json.loads(lignes_json)
for i, l in enumerate(lignes):
ligne = LigneFacture(
facture_id=facture.id,
description=l["description"],
quantite=float(l["quantite"]),
prix_unitaire_ht=float(l["prix_unitaire_ht"]),
ordre=i
)
db.add(ligne)
db.commit()
return RedirectResponse(f"/factures/{facture_id}", status_code=303)
@router.post("/{facture_id}/statut")
def changer_statut_facture(
facture_id: int,
statut: str = Form(...),
date_paiement: str = Form(""),
db: Session = Depends(get_db)
):
facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
facture.statut = StatutFacture(statut)
if statut == "payee" and date_paiement:
facture.date_paiement = date.fromisoformat(date_paiement)
db.commit()
return RedirectResponse(f"/factures/{facture_id}", status_code=303)
@router.get("/{facture_id}/pdf")
def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)):
from weasyprint import HTML
facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
html_content = templates.get_template("pdf/facture.html").render({
"facture": facture,
"settings": settings,
})
pdf_bytes = HTML(string=html_content, base_url=".").write_pdf()
filename = f"facture-{facture.numero}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/{facture_id}/apercu-pdf", response_class=HTMLResponse)
def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)):
facture = db.query(Facture).get(facture_id)
if not facture:
raise HTTPException(status_code=404)
return render(templates, "pdf/facture.html", request, {
"facture": facture,
"settings": settings,
})

192
static/css/style.css Normal file
View File

@@ -0,0 +1,192 @@
:root {
--primary: #2c3e50;
--accent: #3498db;
--danger: #e74c3c;
--success: #27ae60;
--warning: #f39c12;
--bg: #f5f6fa;
--card: #ffffff;
--border: #e0e0e0;
--text: #2c3e50;
--muted: #7f8c8d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
/* NAV */
nav {
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 56px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.nav-brand { font-size: 1.1rem; font-weight: 700; letter-spacing: 0.5px; }
.nav-links { list-style: none; display: flex; gap: 0.5rem; }
.nav-links a {
color: rgba(255,255,255,0.85);
text-decoration: none;
padding: 0.4em 0.9em;
border-radius: 4px;
transition: background 0.15s;
}
.nav-links a:hover { background: rgba(255,255,255,0.15); color: white; }
/* MAIN */
main { max-width: 1000px; margin: 2rem auto; padding: 0 1.5rem; }
/* PAGE HEADER */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.page-header h1 { font-size: 1.6rem; color: var(--primary); }
.btn-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* BUTTONS */
.btn {
display: inline-block;
padding: 0.45em 1em;
border-radius: 5px;
border: 1px solid var(--border);
background: white;
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover { background: var(--bg); border-color: #bbb; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn-primary:hover { background: #2980b9; border-color: #2980b9; }
.btn-danger { background: var(--danger); border-color: var(--danger); color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-sm { padding: 0.25em 0.7em; font-size: 0.8rem; }
/* TABLE */
table { width: 100%; border-collapse: collapse; background: white;
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.07); }
thead { background: var(--primary); color: white; }
th { padding: 0.75em 1em; text-align: left; font-weight: 600; font-size: 0.85rem; }
td { padding: 0.75em 1em; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #f8fafc; }
tfoot td { background: #f8fafc; font-size: 0.9rem; }
.tva-mention { font-size: 0.8rem; color: var(--muted); font-style: italic; }
/* BADGES */
.badge {
display: inline-block;
padding: 0.2em 0.7em;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 600;
text-transform: capitalize;
}
.badge-emise { background: #dbeafe; color: #1e40af; }
.badge-payee { background: #dcfce7; color: #166534; }
.badge-annulee { background: #fee2e2; color: #991b1b; }
.badge-brouillon { background: #f3f4f6; color: #374151; }
.badge-envoye { background: #fef9c3; color: #854d0e; }
.badge-accepte { background: #dcfce7; color: #166534; }
.badge-refuse { background: #fee2e2; color: #991b1b; }
/* CARDS */
.form-card, .detail-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
margin-bottom: 1.5rem;
}
.form-card h2 { font-size: 1.1rem; margin-bottom: 1rem; color: var(--primary);
border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
/* FORM */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: 0.82rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }
input[type="text"], input[type="email"], input[type="date"], input[type="number"],
select, textarea {
padding: 0.5em 0.75em;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 0.9rem;
font-family: inherit;
transition: border-color 0.15s;
background: white;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(52,152,219,0.15);
}
textarea { resize: vertical; }
.form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
/* TABLE LIGNES (formulaire) */
#table-lignes { margin-bottom: 1rem; }
#table-lignes thead { background: #f1f5f9; }
#table-lignes th { color: var(--primary); }
#table-lignes .ligne-desc { width: 100%; min-width: 200px; }
#table-lignes .ligne-qte { width: 70px; }
#table-lignes .ligne-pu { width: 110px; }
.ligne-total { text-align: right; font-weight: 500; }
.total-bloc {
text-align: right;
padding: 0.75rem 1rem;
background: #f8fafc;
border-radius: 5px;
margin: 1rem 0;
font-size: 1rem;
}
.total-bloc small { color: var(--muted); font-style: italic; }
/* DETAIL */
.detail-meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.detail-meta label { font-size: 0.78rem; font-weight: 700; text-transform: uppercase;
color: var(--muted); display: block; margin-bottom: 0.4rem; }
/* MISC */
.empty-state { color: var(--muted); padding: 2rem; text-align: center; background: white;
border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.07); }
.empty-state a { color: var(--accent); }
@media (max-width: 640px) {
.form-grid { grid-template-columns: 1fr; }
.detail-meta { grid-template-columns: 1fr; }
.page-header { flex-direction: column; align-items: flex-start; }
}

19
template_helper.py Normal file
View File

@@ -0,0 +1,19 @@
"""
Helper pour injecter automatiquement current_user dans le contexte Jinja2.
"""
from fastapi import Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
def render(templates: Jinja2Templates, template_name: str,
request: Request, context: dict = None,
status_code: int = 200) -> HTMLResponse:
ctx = context or {}
ctx["request"] = request
if "current_user" not in ctx:
ctx["current_user"] = getattr(request.state, "current_user", None)
# Compatible avec toutes les versions de Starlette/FastAPI
response = templates.TemplateResponse(template_name, ctx)
response.status_code = status_code
return response

62
templates/auth/login.html Normal file
View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion — Facturation</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
body { display: flex; align-items: center; justify-content: center;
min-height: 100vh; background: var(--bg); }
.login-card {
background: white;
border-radius: 10px;
padding: 2.5rem 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 380px;
}
.login-card h1 { font-size: 1.4rem; color: var(--primary);
margin-bottom: 0.25rem; text-align: center; }
.login-card .subtitle { text-align: center; color: var(--muted);
font-size: 0.85rem; margin-bottom: 2rem; }
.erreur { background: #fee2e2; color: #991b1b; padding: 0.7em 1em;
border-radius: 5px; font-size: 0.875rem; margin-bottom: 1rem; }
.form-group { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 1rem; }
label { font-size: 0.82rem; font-weight: 600; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.3px; }
input { padding: 0.6em 0.75em; border: 1px solid var(--border);
border-radius: 5px; font-size: 0.95rem; width: 100%; }
input:focus { outline: none; border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(52,152,219,0.15); }
.btn-login { width: 100%; padding: 0.7em; background: var(--accent);
color: white; border: none; border-radius: 5px;
font-size: 1rem; cursor: pointer; margin-top: 0.5rem; }
.btn-login:hover { background: #2980b9; }
.logo { text-align: center; font-size: 2.5rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="login-card">
<div class="logo">🧾</div>
<h1>Facturation</h1>
<p class="subtitle">Connectez-vous pour continuer</p>
{% if erreur %}
<div class="erreur">{{ erreur }}</div>
{% endif %}
<form method="post" action="/login">
<div class="form-group">
<label>Nom d'utilisateur</label>
<input type="text" name="username" autofocus autocomplete="username" required>
</div>
<div class="form-group">
<label>Mot de passe</label>
<input type="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn-login">Se connecter</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}{{ titre }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ titre }}</h1>
<a href="/admin/utilisateurs" class="btn">← Retour</a>
</div>
<form method="post" class="form-card">
<div class="form-grid">
<div class="form-group">
<label>Nom d'utilisateur *</label>
<input type="text" name="username" required
value="{{ user.username if user else '' }}" autocomplete="off">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email"
value="{{ user.email if user else '' }}">
</div>
<div class="form-group">
<label>Mot de passe {% if user %}(laisser vide = inchangé){% else %}*{% endif %}</label>
<input type="password" name="password"
{% if not user %}required{% endif %} autocomplete="new-password">
</div>
<div class="form-group" style="justify-content: flex-end; padding-top: 1.5rem;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="is_admin" value="true"
{% if user and user.is_admin %}checked{% endif %}
style="width:auto;">
Administrateur
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Enregistrer</button>
<a href="/admin/utilisateurs" class="btn">Annuler</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Utilisateurs{% endblock %}
{% block content %}
<div class="page-header">
<h1>Utilisateurs</h1>
<a href="/admin/utilisateurs/nouveau" class="btn btn-primary">+ Nouvel utilisateur</a>
</div>
<table>
<thead>
<tr>
<th>Nom d'utilisateur</th>
<th>Email</th>
<th>Rôle</th>
<th>Statut</th>
<th>Créé le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
<strong>{{ u.username }}</strong>
{% if u.id == current_user.id %}<em>(vous)</em>{% endif %}
</td>
<td>{{ u.email or "—" }}</td>
<td>
{% if u.is_admin %}
<span class="badge badge-accepte">Admin</span>
{% else %}
<span class="badge badge-brouillon">Utilisateur</span>
{% endif %}
</td>
<td>
{% if u.actif %}
<span class="badge badge-payee">Actif</span>
{% else %}
<span class="badge badge-annulee">Inactif</span>
{% endif %}
</td>
<td>{{ u.created_at | date_fr }}</td>
<td>
<a href="/admin/utilisateurs/{{ u.id }}/modifier" class="btn btn-sm">Modifier</a>
{% if u.id != current_user.id %}
<form method="post" action="/admin/utilisateurs/{{ u.id }}/desactiver" style="display:inline">
<button type="submit" class="btn btn-sm {% if u.actif %}btn-danger{% endif %}">
{{ "Désactiver" if u.actif else "Réactiver" }}
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

33
templates/base.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Facturation{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav>
<div class="nav-brand">🧾 Facturation</div>
<ul class="nav-links">
<li><a href="/factures/">Factures</a></li>
<li><a href="/devis/">Devis</a></li>
<li><a href="/clients/">Clients</a></li>
{% if current_user is defined and current_user %}
{% if current_user.is_admin %}
<li><a href="/admin/utilisateurs">Utilisateurs</a></li>
{% endif %}
<li class="nav-user">{{ current_user.username }}</li>
<li>
<form method="post" action="/logout" style="display:inline">
<button type="submit" class="btn-nav-logout">Déconnexion</button>
</form>
</li>
{% endif %}
</ul>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}{{ titre }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ titre }}</h1>
<a href="/clients/" class="btn">← Retour</a>
</div>
<form method="post" class="form-card">
<div class="form-grid">
<div class="form-group full">
<label>Nom *</label>
<input type="text" name="nom" required value="{{ client.nom if client else '' }}">
</div>
<div class="form-group full">
<label>Adresse *</label>
<input type="text" name="adresse" required value="{{ client.adresse if client else '' }}">
</div>
<div class="form-group">
<label>Code postal *</label>
<input type="text" name="code_postal" required value="{{ client.code_postal if client else '' }}">
</div>
<div class="form-group">
<label>Ville *</label>
<input type="text" name="ville" required value="{{ client.ville if client else '' }}">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="{{ client.email if client else '' }}">
</div>
<div class="form-group">
<label>Téléphone</label>
<input type="text" name="telephone" value="{{ client.telephone if client else '' }}">
</div>
<div class="form-group">
<label>SIRET</label>
<input type="text" name="siret" maxlength="14" value="{{ client.siret if client else '' }}">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Enregistrer</button>
<a href="/clients/" class="btn">Annuler</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Clients{% endblock %}
{% block content %}
<div class="page-header">
<h1>Clients</h1>
<a href="/clients/nouveau" class="btn btn-primary">+ Nouveau client</a>
</div>
{% if clients %}
<table>
<thead>
<tr>
<th>Nom</th>
<th>Ville</th>
<th>Email</th>
<th>SIRET</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><strong>{{ c.nom }}</strong></td>
<td>{{ c.code_postal }} {{ c.ville }}</td>
<td>{{ c.email or "—" }}</td>
<td>{{ c.siret or "—" }}</td>
<td>
<a href="/clients/{{ c.id }}/modifier" class="btn btn-sm">Modifier</a>
<form method="post" action="/clients/{{ c.id }}/supprimer" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Supprimer ce client ?')">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">Aucun client. <a href="/clients/nouveau">Créer le premier</a>.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Devis {{ devis.numero }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>Devis {{ devis.numero }}</h1>
<div class="btn-group">
<a href="/devis/" class="btn">← Retour</a>
{% if devis.statut.value == 'brouillon' %}
<a href="/devis/{{ devis.id }}/modifier" class="btn">Modifier</a>
{% endif %}
{% if devis.statut.value in ['envoye', 'accepte'] %}
<form method="post" action="/devis/{{ devis.id }}/convertir" style="display:inline">
<button type="submit" class="btn btn-primary"
onclick="return confirm('Convertir ce devis en facture ?')">
→ Convertir en facture
</button>
</form>
{% endif %}
</div>
</div>
<div class="detail-card">
<div class="detail-meta">
<div>
<label>Client</label>
<strong>{{ devis.client.nom }}</strong><br>
{{ devis.client.adresse }}<br>
{{ devis.client.code_postal }} {{ devis.client.ville }}
</div>
<div>
<label>Dates</label>
Émission : {{ devis.date_emission | date_fr }}<br>
Validité : {{ devis.date_validite | date_fr }}
</div>
<div>
<label>Statut</label>
<span class="badge badge-{{ devis.statut.value }}">{{ devis.statut.value }}</span>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Qté</th>
<th>PU HT</th>
<th>Total HT</th>
</tr>
</thead>
<tbody>
{% for l in devis.lignes %}
<tr>
<td>{{ l.description }}</td>
<td>{{ l.quantite | int if l.quantite == l.quantite | int else l.quantite }}</td>
<td>{{ l.prix_unitaire_ht | montant }}</td>
<td>{{ l.total_ht | montant }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total HT</strong></td>
<td><strong>{{ devis.total_ht | montant }}</strong></td>
</tr>
<tr>
<td colspan="4" class="tva-mention">TVA non applicable — art. 293B du CGI</td>
</tr>
</tfoot>
</table>
{% if devis.notes %}
<p><strong>Notes :</strong> {{ devis.notes }}</p>
{% endif %}
{% if devis.conditions %}
<p><strong>Conditions :</strong> {{ devis.conditions }}</p>
{% endif %}
</div>
<div class="form-card">
<h2>Changer le statut</h2>
<form method="post" action="/devis/{{ devis.id }}/statut" style="display:flex; gap:1rem; align-items:flex-end;">
<div class="form-group">
<label>Nouveau statut</label>
<select name="statut">
<option value="brouillon" {% if devis.statut.value == 'brouillon' %}selected{% endif %}>Brouillon</option>
<option value="envoye" {% if devis.statut.value == 'envoye' %}selected{% endif %}>Envoyé</option>
<option value="accepte" {% if devis.statut.value == 'accepte' %}selected{% endif %}>Accepté</option>
<option value="refuse" {% if devis.statut.value == 'refuse' %}selected{% endif %}>Refusé</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Mettre à jour</button>
</form>
</div>
{% endblock %}

131
templates/devis/form.html Normal file
View File

@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}{{ titre }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ titre }}</h1>
<a href="/devis/" class="btn">← Retour</a>
</div>
<form method="post" class="form-card" id="form-devis">
<div class="form-grid">
<div class="form-group">
<label>Client *</label>
<select name="client_id" required>
<option value="">— Sélectionner —</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if devis and devis.client_id == c.id %}selected{% endif %}>
{{ c.nom }} — {{ c.ville }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Date d'émission *</label>
<input type="date" name="date_emission" required value="{{ date_aujourd_hui }}">
</div>
<div class="form-group">
<label>Date de validité *</label>
<input type="date" name="date_validite" required value="{{ date_validite_defaut }}">
</div>
<div class="form-group full">
<label>Conditions</label>
<textarea name="conditions" rows="2">{{ devis.conditions if devis else '' }}</textarea>
</div>
<div class="form-group full">
<label>Notes internes</label>
<textarea name="notes" rows="2">{{ devis.notes if devis else '' }}</textarea>
</div>
</div>
<h2>Lignes</h2>
<table id="table-lignes">
<thead>
<tr>
<th>Description</th>
<th>Qté</th>
<th>PU HT (€)</th>
<th>Total HT</th>
<th></th>
</tr>
</thead>
<tbody id="lignes-body">
{% if devis and devis.lignes %}
{% for l in devis.lignes %}
<tr class="ligne-row">
<td><input type="text" class="ligne-desc" placeholder="Description" value="{{ l.description }}" required></td>
<td><input type="number" class="ligne-qte" step="0.01" min="0.01" value="{{ l.quantite }}" required></td>
<td><input type="number" class="ligne-pu" step="0.01" min="0" value="{{ l.prix_unitaire_ht }}" required></td>
<td class="ligne-total">{{ (l.quantite * l.prix_unitaire_ht) | round(2) }} €</td>
<td><button type="button" class="btn btn-sm btn-danger btn-suppr-ligne"></button></td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<button type="button" id="btn-ajouter-ligne" class="btn">+ Ajouter une ligne</button>
<div class="total-bloc">
<strong>Total HT : <span id="total-ht">0,00 €</span></strong>
<br><small>TVA non applicable — art. 293B du CGI</small>
</div>
<input type="hidden" name="lignes_json" id="lignes_json">
<div class="form-actions">
<button type="submit" class="btn btn-primary">Enregistrer</button>
<a href="/devis/" class="btn">Annuler</a>
</div>
</form>
<script>
function recalculer() {
let total = 0;
document.querySelectorAll('.ligne-row').forEach(row => {
const qte = parseFloat(row.querySelector('.ligne-qte').value) || 0;
const pu = parseFloat(row.querySelector('.ligne-pu').value) || 0;
const t = qte * pu;
row.querySelector('.ligne-total').textContent = t.toFixed(2).replace('.', ',') + ' €';
total += t;
});
document.getElementById('total-ht').textContent = total.toFixed(2).replace('.', ',') + ' €';
}
function ajouterLigne() {
const tbody = document.getElementById('lignes-body');
const tr = document.createElement('tr');
tr.className = 'ligne-row';
tr.innerHTML = `
<td><input type="text" class="ligne-desc" placeholder="Description" required></td>
<td><input type="number" class="ligne-qte" step="0.01" min="0.01" value="1" required></td>
<td><input type="number" class="ligne-pu" step="0.01" min="0" value="0" required></td>
<td class="ligne-total">0,00 €</td>
<td><button type="button" class="btn btn-sm btn-danger btn-suppr-ligne">✕</button></td>
`;
tbody.appendChild(tr);
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalculer));
tr.querySelector('.btn-suppr-ligne').addEventListener('click', () => { tr.remove(); recalculer(); });
}
document.getElementById('btn-ajouter-ligne').addEventListener('click', ajouterLigne);
document.querySelectorAll('.ligne-row').forEach(row => {
row.querySelectorAll('input').forEach(i => i.addEventListener('input', recalculer));
row.querySelector('.btn-suppr-ligne').addEventListener('click', () => { row.remove(); recalculer(); });
});
document.getElementById('form-devis').addEventListener('submit', function(e) {
const lignes = [];
let valide = true;
document.querySelectorAll('.ligne-row').forEach(row => {
const desc = row.querySelector('.ligne-desc').value.trim();
const qte = parseFloat(row.querySelector('.ligne-qte').value);
const pu = parseFloat(row.querySelector('.ligne-pu').value);
if (!desc || isNaN(qte) || isNaN(pu)) { valide = false; return; }
lignes.push({ description: desc, quantite: qte, prix_unitaire_ht: pu });
});
if (!valide || lignes.length === 0) {
e.preventDefault();
alert('Veuillez saisir au moins une ligne valide.');
return;
}
document.getElementById('lignes_json').value = JSON.stringify(lignes);
});
recalculer();
</script>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Devis{% endblock %}
{% block content %}
<div class="page-header">
<h1>Devis</h1>
<a href="/devis/nouveau" class="btn btn-primary">+ Nouveau devis</a>
</div>
{% if devis %}
<table>
<thead>
<tr>
<th>Numéro</th>
<th>Date</th>
<th>Client</th>
<th>Montant HT</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for d in devis %}
<tr>
<td><strong>{{ d.numero }}</strong></td>
<td>{{ d.date_emission | date_fr }}</td>
<td>{{ d.client.nom }}</td>
<td>{{ d.total_ht | montant }}</td>
<td><span class="badge badge-{{ d.statut.value }}">{{ d.statut.value }}</span></td>
<td>
<a href="/devis/{{ d.id }}" class="btn btn-sm">Voir</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">Aucun devis. <a href="/devis/nouveau">Créer le premier</a>.</p>
{% endif %}
{% endblock %}

10
templates/erreur.html Normal file
View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Erreur {{ status_code }}{% endblock %}
{% block content %}
<div class="form-card" style="text-align:center; padding: 3rem;">
<p style="font-size: 3rem;">⚠️</p>
<h1 style="margin: 1rem 0 0.5rem;">Erreur {{ status_code }}</h1>
<p style="color: var(--muted);">{{ detail or "Une erreur est survenue." }}</p>
<a href="/" class="btn btn-primary" style="margin-top: 1.5rem;">Retour à l'accueil</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Facture {{ facture.numero }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>Facture {{ facture.numero }}</h1>
<div class="btn-group">
<a href="/factures/" class="btn">← Retour</a>
{% if facture.statut.value == 'emise' %}
<a href="/factures/{{ facture.id }}/modifier" class="btn">Modifier</a>
{% endif %}
<a href="/factures/{{ facture.id }}/apercu-pdf" class="btn" target="_blank">Aperçu PDF</a>
<a href="/factures/{{ facture.id }}/pdf" class="btn btn-primary">⬇ Télécharger PDF</a>
</div>
</div>
<div class="detail-card">
<div class="detail-meta">
<div>
<label>Client</label>
<strong>{{ facture.client.nom }}</strong><br>
{{ facture.client.adresse }}<br>
{{ facture.client.code_postal }} {{ facture.client.ville }}
{% if facture.client.siret %}<br>SIRET : {{ facture.client.siret }}{% endif %}
</div>
<div>
<label>Dates</label>
Émission : {{ facture.date_emission | date_fr }}<br>
Échéance : {{ facture.date_echeance | date_fr }}<br>
{% if facture.date_paiement %}
Paiement : {{ facture.date_paiement | date_fr }}
{% endif %}
</div>
<div>
<label>Statut</label>
<span class="badge badge-{{ facture.statut.value }}">{{ facture.statut.value }}</span>
{% if facture.devis_origine %}
<br><small>Issu du devis {{ facture.devis_origine.numero }}</small>
{% endif %}
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Qté</th>
<th>PU HT</th>
<th>Total HT</th>
</tr>
</thead>
<tbody>
{% for l in facture.lignes %}
<tr>
<td>{{ l.description }}</td>
<td>{{ l.quantite | int if l.quantite == l.quantite | int else l.quantite }}</td>
<td>{{ l.prix_unitaire_ht | montant }}</td>
<td>{{ l.total_ht | montant }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total HT</strong></td>
<td><strong>{{ facture.total_ht | montant }}</strong></td>
</tr>
<tr>
<td colspan="4" class="tva-mention">TVA non applicable — art. 293B du CGI</td>
</tr>
</tfoot>
</table>
{% if facture.conditions_reglement %}
<p><strong>Conditions de règlement :</strong> {{ facture.conditions_reglement }}</p>
{% endif %}
{% if facture.notes %}
<p><strong>Notes :</strong> {{ facture.notes }}</p>
{% endif %}
</div>
<div class="form-card">
<h2>Changer le statut</h2>
<form method="post" action="/factures/{{ facture.id }}/statut" style="display:flex; gap:1rem; align-items:flex-end; flex-wrap:wrap;">
<div class="form-group">
<label>Nouveau statut</label>
<select name="statut">
<option value="emise" {% if facture.statut.value == 'emise' %}selected{% endif %}>Émise</option>
<option value="payee" {% if facture.statut.value == 'payee' %}selected{% endif %}>Payée</option>
<option value="annulee" {% if facture.statut.value == 'annulee' %}selected{% endif %}>Annulée</option>
</select>
</div>
<div class="form-group" id="champ-date-paiement">
<label>Date de paiement</label>
<input type="date" name="date_paiement" value="{{ facture.date_paiement.isoformat() if facture.date_paiement else '' }}">
</div>
<button type="submit" class="btn btn-primary">Mettre à jour</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}{{ titre }}{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ titre }}</h1>
<a href="/factures/" class="btn">← Retour</a>
</div>
<form method="post" class="form-card" id="form-facture">
<div class="form-grid">
<div class="form-group">
<label>Client *</label>
<select name="client_id" required>
<option value="">— Sélectionner —</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if facture and facture.client_id == c.id %}selected{% endif %}>
{{ c.nom }} — {{ c.ville }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Date d'émission *</label>
<input type="date" name="date_emission" required value="{{ date_aujourd_hui }}">
</div>
<div class="form-group">
<label>Date d'échéance *</label>
<input type="date" name="date_echeance" required value="{{ date_echeance_defaut }}">
</div>
<div class="form-group full">
<label>Conditions de règlement</label>
<input type="text" name="conditions_reglement"
value="{{ facture.conditions_reglement if facture else 'Paiement à réception de facture.' }}">
</div>
<div class="form-group full">
<label>Notes internes</label>
<textarea name="notes" rows="2">{{ facture.notes if facture else '' }}</textarea>
</div>
</div>
<h2>Lignes</h2>
<div id="lignes-container">
<table id="table-lignes">
<thead>
<tr>
<th>Description</th>
<th>Qté</th>
<th>PU HT (€)</th>
<th>Total HT</th>
<th></th>
</tr>
</thead>
<tbody id="lignes-body">
{% if facture and facture.lignes %}
{% for l in facture.lignes %}
<tr class="ligne-row">
<td><input type="text" class="ligne-desc" placeholder="Description" value="{{ l.description }}" required></td>
<td><input type="number" class="ligne-qte" step="0.01" min="0.01" value="{{ l.quantite }}" required></td>
<td><input type="number" class="ligne-pu" step="0.01" min="0" value="{{ l.prix_unitaire_ht }}" required></td>
<td class="ligne-total">{{ (l.quantite * l.prix_unitaire_ht) | round(2) }} €</td>
<td><button type="button" class="btn btn-sm btn-danger btn-suppr-ligne"></button></td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<button type="button" id="btn-ajouter-ligne" class="btn">+ Ajouter une ligne</button>
</div>
<div class="total-bloc">
<strong>Total HT : <span id="total-ht">0,00 €</span></strong>
<br><small>TVA non applicable — art. 293B du CGI</small>
</div>
<input type="hidden" name="lignes_json" id="lignes_json">
<div class="form-actions">
<button type="submit" class="btn btn-primary">Enregistrer</button>
<a href="/factures/" class="btn">Annuler</a>
</div>
</form>
<script>
function recalculer() {
let total = 0;
document.querySelectorAll('.ligne-row').forEach(row => {
const qte = parseFloat(row.querySelector('.ligne-qte').value) || 0;
const pu = parseFloat(row.querySelector('.ligne-pu').value) || 0;
const t = qte * pu;
row.querySelector('.ligne-total').textContent = t.toFixed(2).replace('.', ',') + ' €';
total += t;
});
document.getElementById('total-ht').textContent = total.toFixed(2).replace('.', ',') + ' €';
}
function ajouterLigne() {
const tbody = document.getElementById('lignes-body');
const tr = document.createElement('tr');
tr.className = 'ligne-row';
tr.innerHTML = `
<td><input type="text" class="ligne-desc" placeholder="Description" required></td>
<td><input type="number" class="ligne-qte" step="0.01" min="0.01" value="1" required></td>
<td><input type="number" class="ligne-pu" step="0.01" min="0" value="0" required></td>
<td class="ligne-total">0,00 €</td>
<td><button type="button" class="btn btn-sm btn-danger btn-suppr-ligne">✕</button></td>
`;
tbody.appendChild(tr);
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalculer));
tr.querySelector('.btn-suppr-ligne').addEventListener('click', () => { tr.remove(); recalculer(); });
}
document.getElementById('btn-ajouter-ligne').addEventListener('click', ajouterLigne);
document.querySelectorAll('.ligne-row').forEach(row => {
row.querySelectorAll('input').forEach(i => i.addEventListener('input', recalculer));
row.querySelector('.btn-suppr-ligne').addEventListener('click', () => { row.remove(); recalculer(); });
});
document.getElementById('form-facture').addEventListener('submit', function(e) {
const lignes = [];
let valide = true;
document.querySelectorAll('.ligne-row').forEach(row => {
const desc = row.querySelector('.ligne-desc').value.trim();
const qte = parseFloat(row.querySelector('.ligne-qte').value);
const pu = parseFloat(row.querySelector('.ligne-pu').value);
if (!desc || isNaN(qte) || isNaN(pu)) { valide = false; return; }
lignes.push({ description: desc, quantite: qte, prix_unitaire_ht: pu });
});
if (!valide || lignes.length === 0) {
e.preventDefault();
alert('Veuillez saisir au moins une ligne valide.');
return;
}
document.getElementById('lignes_json').value = JSON.stringify(lignes);
});
recalculer();
</script>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Factures{% endblock %}
{% block content %}
<div class="page-header">
<h1>Factures</h1>
<a href="/factures/nouvelle" class="btn btn-primary">+ Nouvelle facture</a>
</div>
{% if factures %}
<table>
<thead>
<tr>
<th>Numéro</th>
<th>Date</th>
<th>Client</th>
<th>Montant HT</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for f in factures %}
<tr>
<td><strong>{{ f.numero }}</strong></td>
<td>{{ f.date_emission | date_fr }}</td>
<td>{{ f.client.nom }}</td>
<td>{{ f.total_ht | montant }}</td>
<td><span class="badge badge-{{ f.statut.value }}">{{ f.statut.value }}</span></td>
<td>
<a href="/factures/{{ f.id }}" class="btn btn-sm">Voir</a>
<a href="/factures/{{ f.id }}/pdf" class="btn btn-sm btn-primary">PDF</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">Aucune facture. <a href="/factures/nouvelle">Créer la première</a>.</p>
{% endif %}
{% endblock %}

297
templates/pdf/facture.html Normal file
View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 1.5cm 1.8cm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2em;
padding-bottom: 1.5em;
border-bottom: 2px solid #2c3e50;
}
.emetteur h1 {
font-size: 16pt;
color: #2c3e50;
margin-bottom: 0.3em;
}
.emetteur p { font-size: 9pt; color: #555; line-height: 1.6; }
.facture-titre {
text-align: right;
}
.facture-titre h2 {
font-size: 22pt;
color: #2c3e50;
letter-spacing: 1px;
text-transform: uppercase;
}
.facture-titre .numero {
font-size: 13pt;
font-weight: bold;
color: #e74c3c;
margin-top: 0.2em;
}
.facture-titre .dates {
font-size: 9pt;
color: #555;
margin-top: 0.5em;
line-height: 1.8;
}
.parties {
display: flex;
justify-content: space-between;
margin-bottom: 2em;
gap: 2em;
}
.partie {
flex: 1;
padding: 1em;
border: 1px solid #ddd;
border-radius: 4px;
}
.partie h3 {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 0.7em;
border-bottom: 1px solid #eee;
padding-bottom: 0.4em;
}
.partie p { font-size: 9.5pt; line-height: 1.7; }
.partie .nom { font-weight: bold; font-size: 11pt; }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5em;
}
thead tr {
background-color: #2c3e50;
color: white;
}
thead th {
padding: 0.6em 0.8em;
text-align: left;
font-size: 9pt;
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.5px;
}
thead th:last-child { text-align: right; }
tbody tr:nth-child(even) { background: #f8f9fa; }
tbody td {
padding: 0.6em 0.8em;
font-size: 9.5pt;
border-bottom: 1px solid #eee;
vertical-align: top;
}
tbody td:last-child { text-align: right; font-weight: 500; }
td.qte { text-align: center; }
td.pu { text-align: right; }
.totaux {
display: flex;
justify-content: flex-end;
margin-bottom: 2em;
}
.totaux table {
width: auto;
min-width: 280px;
}
.totaux td {
padding: 0.4em 0.8em;
font-size: 10pt;
border: none;
}
.totaux tr.total-ht td {
font-size: 12pt;
font-weight: bold;
color: #2c3e50;
border-top: 2px solid #2c3e50;
padding-top: 0.6em;
}
.totaux td:last-child { text-align: right; }
.tva-mention {
font-size: 8.5pt;
color: #666;
font-style: italic;
text-align: right;
margin-bottom: 1.5em;
}
.footer-info {
border-top: 1px solid #ddd;
padding-top: 1em;
font-size: 8.5pt;
color: #555;
line-height: 1.7;
}
.footer-info .conditions {
margin-bottom: 0.5em;
}
.footer-info .penalites {
font-size: 8pt;
color: #888;
}
.footer-info .iban {
margin-top: 0.5em;
font-weight: bold;
}
.devis-ref {
font-size: 8.5pt;
color: #888;
margin-bottom: 1.5em;
}
.notes {
background: #f8f9fa;
border-left: 3px solid #2c3e50;
padding: 0.7em 1em;
font-size: 9pt;
margin-bottom: 1.5em;
color: #444;
}
</style>
</head>
<body>
<!-- EN-TÊTE -->
<div class="header">
<div class="emetteur">
<h1>{{ settings.asso_nom }}</h1>
<p>
{{ settings.asso_adresse }}<br>
{{ settings.asso_code_postal }} {{ settings.asso_ville }}<br>
{% if settings.asso_email %}{{ settings.asso_email }}<br>{% endif %}
{% if settings.asso_telephone %}{{ settings.asso_telephone }}<br>{% endif %}
{% if settings.asso_rna %}RNA : {{ settings.asso_rna }}<br>{% endif %}
{% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %}
</p>
</div>
<div class="facture-titre">
<h2>Facture</h2>
<div class="numero">N° {{ facture.numero }}</div>
<div class="dates">
Date d'émission : {{ facture.date_emission.strftime('%d/%m/%Y') }}<br>
Date d'échéance : {{ facture.date_echeance.strftime('%d/%m/%Y') }}
{% if facture.statut.value == 'payee' and facture.date_paiement %}
<br>Date de paiement : {{ facture.date_paiement.strftime('%d/%m/%Y') }}
{% endif %}
</div>
</div>
</div>
<!-- PARTIES -->
<div class="parties">
<div class="partie">
<h3>Émetteur</h3>
<p>
<span class="nom">{{ settings.asso_nom }}</span><br>
{{ settings.asso_adresse }}<br>
{{ settings.asso_code_postal }} {{ settings.asso_ville }}<br>
{% if settings.asso_rna %}RNA : {{ settings.asso_rna }}<br>{% endif %}
{% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %}
</p>
</div>
<div class="partie">
<h3>Destinataire</h3>
<p>
<span class="nom">{{ facture.client.nom }}</span><br>
{{ facture.client.adresse }}<br>
{{ facture.client.code_postal }} {{ facture.client.ville }}
{% if facture.client.siret %}<br>SIRET : {{ facture.client.siret }}{% endif %}
{% if facture.client.email %}<br>{{ facture.client.email }}{% endif %}
</p>
</div>
</div>
{% if facture.devis_origine %}
<p class="devis-ref">Facture établie suite au devis {{ facture.devis_origine.numero }}</p>
{% endif %}
<!-- LIGNES -->
<table>
<thead>
<tr>
<th style="width:55%">Désignation</th>
<th style="width:10%" class="qte">Qté</th>
<th style="width:17%">Prix unitaire HT</th>
<th style="width:18%">Total HT</th>
</tr>
</thead>
<tbody>
{% for l in facture.lignes %}
<tr>
<td>{{ l.description }}</td>
<td class="qte">
{% if l.quantite == l.quantite | int %}{{ l.quantite | int }}{% else %}{{ l.quantite }}{% endif %}
</td>
<td class="pu">
{{ "%.2f"|format(l.prix_unitaire_ht) | replace('.', ',') }} €
</td>
<td>{{ "%.2f"|format(l.total_ht) | replace('.', ',') }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- TOTAUX -->
<div class="totaux">
<table>
<tr>
<td>Montant HT</td>
<td>{{ "%.2f"|format(facture.total_ht) | replace('.', ',') }} €</td>
</tr>
<tr>
<td>TVA</td>
<td>0,00 €</td>
</tr>
<tr class="total-ht">
<td>Total TTC</td>
<td>{{ "%.2f"|format(facture.total_ht) | replace('.', ',') }} €</td>
</tr>
</table>
</div>
<p class="tva-mention">TVA non applicable — art. 293B du CGI</p>
{% if facture.notes %}
<div class="notes">{{ facture.notes }}</div>
{% endif %}
<!-- PIED DE PAGE LÉGAL -->
<div class="footer-info">
<div class="conditions">
<strong>Conditions de règlement :</strong>
{{ facture.conditions_reglement or "Paiement à réception de facture." }}
</div>
<div class="penalites">
En cas de retard de paiement, des pénalités de retard au taux de 3 fois le taux d'intérêt légal
seront exigibles (art. L.441-10 du Code de commerce), ainsi qu'une indemnité forfaitaire de
recouvrement de 40 € (décret n° 2012-1115).
</div>
{% if settings.asso_iban %}
<div class="iban">
Coordonnées bancaires — IBAN : {{ settings.asso_iban }}
{% if settings.asso_bic %} — BIC : {{ settings.asso_bic }}{% endif %}
</div>
{% endif %}
</div>
</body>
</html>