Init project
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal 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
132
README.md
Normal 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
73
auth.py
Normal 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
33
config.py
Normal 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
26
database.py
Normal 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
111
main.py
Normal 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
131
models.py
Normal 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
44
numerotation.py
Normal 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
10
requirements.txt
Normal 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
162
routers/auth.py
Normal 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
95
routers/clients.py
Normal 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
186
routers/devis.py
Normal 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
189
routers/factures.py
Normal 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
192
static/css/style.css
Normal 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
19
template_helper.py
Normal 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
62
templates/auth/login.html
Normal 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>
|
||||||
40
templates/auth/user_form.html
Normal file
40
templates/auth/user_form.html
Normal 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 %}
|
||||||
57
templates/auth/utilisateurs.html
Normal file
57
templates/auth/utilisateurs.html
Normal 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
33
templates/base.html
Normal 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>
|
||||||
45
templates/clients/form.html
Normal file
45
templates/clients/form.html
Normal 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 %}
|
||||||
41
templates/clients/liste.html
Normal file
41
templates/clients/liste.html
Normal 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 %}
|
||||||
94
templates/devis/detail.html
Normal file
94
templates/devis/detail.html
Normal 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
131
templates/devis/form.html
Normal 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 %}
|
||||||
39
templates/devis/liste.html
Normal file
39
templates/devis/liste.html
Normal 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
10
templates/erreur.html
Normal 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 %}
|
||||||
98
templates/factures/detail.html
Normal file
98
templates/factures/detail.html
Normal 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 %}
|
||||||
139
templates/factures/form.html
Normal file
139
templates/factures/form.html
Normal 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 %}
|
||||||
40
templates/factures/liste.html
Normal file
40
templates/factures/liste.html
Normal 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
297
templates/pdf/facture.html
Normal 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>
|
||||||
Reference in New Issue
Block a user