feat: GarageManager v2.1 - véhicules, interventions, pièces

- Application FastAPI avec SQLite et Jinja2
- Gestion des véhicules (VIN, immatriculation, infos libres)
- Gestion des interventions avec statut (en_attente/en_cours/termine)
- Gestion des pièces détachées avec photos
- Tableau de bord avec statistiques
- Déploiement Docker + Traefik (HTTPS)
- README complet en français
This commit is contained in:
2026-03-15 12:40:50 +01:00
commit 97ae64caf0
18 changed files with 1304 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
data/
__pycache__/
*.pyc
.env
*.db
venv/
.venv/
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY templates/ ./templates/
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+224
View File
@@ -0,0 +1,224 @@
# 🔧 GarageManager
> Application web de gestion de garage automobile — véhicules, interventions, pièces détachées.
[![FastAPI](https://img.shields.io/badge/FastAPI-0.109-009688?style=flat&logo=fastapi)](https://fastapi.tiangolo.com)
[![Python](https://img.shields.io/badge/Python-3.12-3776AB?style=flat&logo=python)](https://python.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://docker.com)
[![SQLite](https://img.shields.io/badge/SQLite-léger-003B57?style=flat&logo=sqlite)](https://sqlite.org)
🌐 **URL de production** : [https://garage.nas.percolouco.com](https://garage.nas.percolouco.com)
---
## 📋 Description
**GarageManager** est une application web légère permettant de gérer un garage automobile personnel ou professionnel. Elle permet de suivre les véhicules, leurs interventions mécaniques et les pièces utilisées, avec un tableau de bord synthétique.
Pensée pour être self-hostée, elle tourne dans un conteneur Docker derrière un reverse proxy Traefik.
---
## 🛠️ Stack technique
| Composant | Technologie | Rôle |
|-----------|-------------|------|
| **Backend** | [FastAPI](https://fastapi.tiangolo.com) 0.109 | API REST + rendu SSR |
| **Base de données** | [SQLite](https://sqlite.org) | Stockage persistant, fichier unique |
| **Templates** | [Jinja2](https://jinja.palletsprojects.com) 3.1 | Rendu HTML côté serveur |
| **CSS** | [Tailwind CSS](https://tailwindcss.com) (CDN) | Interface responsive moderne |
| **Serveur ASGI** | [Uvicorn](https://www.uvicorn.org) | Serveur HTTP haute performance |
| **Conteneur** | [Docker](https://docker.com) + Compose | Déploiement clé en main |
| **Reverse proxy** | [Traefik](https://traefik.io) | HTTPS automatique (Let's Encrypt) |
---
## ✨ Fonctionnalités
### 🚗 Gestion des véhicules
- Ajout de véhicules avec les informations clés :
- **Immatriculation** (clé unique, mise en majuscules automatiquement)
- **Marque**, **modèle**, **année**, **kilométrage**
- **Numéro VIN** (Vehicle Identification Number)
- **Infos libres** par véhicule : notes texte, liens URL, photos — système extensible
- Recherche par immatriculation, marque, modèle ou VIN
- Compteur d'interventions par véhicule
- Suppression en cascade (interventions + pièces supprimées automatiquement)
### 🔩 Gestion des interventions
- Création d'interventions liées à un véhicule
- Champs disponibles :
- **Description** de l'intervention
- **Statut** : `En attente` / `En cours` / `Terminé`
- **Coût main-d'œuvre**
- **Technicien** responsable
- **Notes** internes
- Suivi du coût total (main-d'œuvre + pièces)
- Filtrage par statut
### 🔧 Gestion des pièces détachées
- Association de pièces à chaque intervention :
- **Nom** de la pièce
- **Référence** constructeur
- **Marque** de la pièce
- **Prix unitaire** et **quantité**
- **Photo** (upload d'image)
- Calcul automatique du coût total par intervention
### 📊 Tableau de bord
- Vue synthétique en temps réel :
- Nombre de véhicules enregistrés
- Interventions par statut (en attente / en cours / terminées)
- Coût total des interventions terminées (pièces + main-d'œuvre)
- 5 dernières interventions avec accès rapide
---
## 📁 Structure du projet
```
garagemanager/
├── app/
│ ├── __init__.py
│ └── main.py # Application FastAPI (routes, DB, logique)
├── templates/
│ ├── base.html # Template de base (nav, layout)
│ ├── dashboard.html # Tableau de bord
│ ├── vehicules.html # Liste des véhicules
│ ├── vehicule_form.html # Formulaire ajout/édition véhicule
│ ├── vehicule_detail.html # Détail véhicule + interventions + infos
│ ├── interventions.html # Liste des interventions
│ ├── intervention_form.html # Formulaire ajout/édition intervention
│ └── intervention_detail.html # Détail intervention + pièces
├── data/ # Volume persistant (ignoré par git)
│ └── garage.db # Base SQLite
├── Dockerfile # Image Docker Python 3.12-slim
├── docker-compose.yml # Déploiement avec Traefik
├── requirements.txt # Dépendances Python
└── README.md
```
---
## 🚀 Installation
### Prérequis
- Docker & Docker Compose installés
- (Optionnel) Traefik configuré pour HTTPS automatique
### 1. Cloner le dépôt
```bash
git clone http://192.168.1.29:3500/perco/garagemanager.git
cd garagemanager
```
### 2. Démarrer avec Docker Compose
**Avec Traefik (production)** :
```bash
docker compose up -d
```
L'application sera accessible via `https://garage.nas.percolouco.com` si Traefik est configuré avec le réseau `proxy`.
**Sans Traefik (local/dev)** :
Modifier `docker-compose.yml` pour exposer un port directement :
```yaml
ports:
- "8000:8000"
```
Puis :
```bash
docker compose up -d
```
Accessible sur : `http://localhost:8000`
### 3. Développement local (sans Docker)
```bash
# Créer un environnement virtuel
python3 -m venv venv
source venv/bin/activate
# Installer les dépendances
pip install -r requirements.txt
# Lancer le serveur avec rechargement automatique
DB_PATH=./data/garage.db uvicorn app.main:app --reload --port 8000
```
Accessible sur : `http://localhost:8000`
---
## 🗄️ Base de données
La base SQLite est initialisée automatiquement au démarrage. Le schéma comprend 4 tables :
| Table | Description |
|-------|-------------|
| `vehicules` | Véhicules (immat, marque, modèle, VIN, km) |
| `vehicule_infos` | Infos libres par véhicule (texte/url/image) |
| `interventions` | Interventions mécaniques avec statut et coûts |
| `pieces` | Pièces détachées liées aux interventions |
Les suppressions en cascade sont gérées par des contraintes SQLite (`ON DELETE CASCADE`).
---
## 🐳 Variables d'environnement
| Variable | Défaut | Description |
|----------|--------|-------------|
| `DB_PATH` | `/data/garage.db` | Chemin de la base SQLite |
| `UPLOAD_DIR` | `/data/uploads` | Dossier de stockage des photos |
---
## 🔄 Mise à jour
```bash
# Arrêter le conteneur
docker compose down
# Récupérer les dernières modifications
git pull
# Reconstruire l'image et redémarrer
docker compose up -d --build
```
---
## 📝 Notes
- Les données sont persistées dans le dossier `./data/` (volume Docker)
- Les photos uploadées sont stockées dans `./data/uploads/`
- Le dossier `data/` est exclu du dépôt git (`.gitignore`)
- L'application ne gère pas d'authentification — à protéger via Traefik middleware si nécessaire
---
## 🏷️ Versions
| Version | Notes |
|---------|-------|
| v2.1 | Infos libres véhicule (texte/url/image), upload photos pièces |
| v2.0 | Ajout gestion des pièces détachées |
| v1.0 | Gestion véhicules + interventions |
---
*Développé avec ❤️ pour la gestion de garage automobile*
View File
+288
View File
@@ -0,0 +1,288 @@
from fastapi import FastAPI, Request, Form, HTTPException, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
import sqlite3, os, shutil, uuid
from typing import Optional, List
app = FastAPI(title="GarageManager")
templates = Jinja2Templates(directory="templates")
DB_PATH = os.environ.get("DB_PATH", "/data/garage.db")
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS vehicules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
immatriculation TEXT NOT NULL UNIQUE,
marque TEXT NOT NULL,
modele TEXT NOT NULL,
annee INTEGER,
vin TEXT,
kilometrage INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS vehicule_infos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vehicule_id INTEGER NOT NULL REFERENCES vehicules(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'texte' CHECK(type IN ('texte','url','image')),
titre TEXT,
contenu TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS interventions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vehicule_id INTEGER NOT NULL REFERENCES vehicules(id) ON DELETE CASCADE,
description TEXT NOT NULL,
statut TEXT NOT NULL DEFAULT 'en_attente' CHECK(statut IN ('en_attente','en_cours','termine')),
cout_main_oeuvre REAL DEFAULT 0,
technicien TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pieces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
intervention_id INTEGER NOT NULL REFERENCES interventions(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
reference TEXT,
marque TEXT,
prix REAL DEFAULT 0,
quantite INTEGER DEFAULT 1,
photo TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.commit()
conn.close()
@app.on_event("startup")
async def startup():
init_db()
# DASHBOARD
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
conn = get_db()
stats = {
"nb_vehicules": conn.execute("SELECT COUNT(*) FROM vehicules").fetchone()[0],
"nb_en_attente": conn.execute("SELECT COUNT(*) FROM interventions WHERE statut='en_attente'").fetchone()[0],
"nb_en_cours": conn.execute("SELECT COUNT(*) FROM interventions WHERE statut='en_cours'").fetchone()[0],
"nb_termine": conn.execute("SELECT COUNT(*) FROM interventions WHERE statut='termine'").fetchone()[0],
"cout_pieces": conn.execute("SELECT COALESCE(SUM(p.prix*p.quantite),0) FROM pieces p JOIN interventions i ON i.id=p.intervention_id WHERE i.statut='termine'").fetchone()[0],
"cout_mo": conn.execute("SELECT COALESCE(SUM(cout_main_oeuvre),0) FROM interventions WHERE statut='termine'").fetchone()[0],
}
stats["cout_total"] = stats["cout_pieces"] + stats["cout_mo"]
recent = conn.execute("""
SELECT i.id, i.description, i.statut, i.created_at, i.cout_main_oeuvre,
v.immatriculation, v.marque, v.modele, v.id as vehicule_id,
COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces
FROM interventions i
JOIN vehicules v ON i.vehicule_id = v.id
LEFT JOIN pieces p ON p.intervention_id = i.id
GROUP BY i.id
ORDER BY i.created_at DESC LIMIT 5
""").fetchall()
conn.close()
return templates.TemplateResponse("dashboard.html", {"request": request, "stats": stats, "recent": recent, "page": "dashboard"})
# VEHICULES
@app.get("/vehicules", response_class=HTMLResponse)
async def vehicules_list(request: Request, q: str = ""):
conn = get_db()
if q:
rows = conn.execute(
"SELECT v.*, COUNT(i.id) as nb_interventions FROM vehicules v LEFT JOIN interventions i ON i.vehicule_id=v.id WHERE v.immatriculation LIKE ? OR v.marque LIKE ? OR v.modele LIKE ? OR v.vin LIKE ? GROUP BY v.id ORDER BY v.marque",
(f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%")
).fetchall()
else:
rows = conn.execute("SELECT v.*, COUNT(i.id) as nb_interventions FROM vehicules v LEFT JOIN interventions i ON i.vehicule_id=v.id GROUP BY v.id ORDER BY v.marque").fetchall()
conn.close()
return templates.TemplateResponse("vehicules.html", {"request": request, "vehicules": rows, "q": q, "page": "vehicules"})
@app.get("/vehicules/new", response_class=HTMLResponse)
async def vehicule_new(request: Request):
return templates.TemplateResponse("vehicule_form.html", {"request": request, "vehicule": None, "page": "vehicules"})
@app.post("/vehicules/new")
async def vehicule_create(immatriculation: str = Form(...), marque: str = Form(...), modele: str = Form(...), annee: Optional[int] = Form(None), vin: str = Form(""), kilometrage: Optional[int] = Form(None)):
conn = get_db()
conn.execute("INSERT INTO vehicules (immatriculation, marque, modele, annee, vin, kilometrage) VALUES (?,?,?,?,?,?)",
(immatriculation.upper().strip(), marque.strip(), modele.strip(), annee, vin.strip(), kilometrage))
conn.commit(); conn.close()
return RedirectResponse("/vehicules", status_code=303)
@app.get("/vehicules/{id}", response_class=HTMLResponse)
async def vehicule_detail(request: Request, id: int):
conn = get_db()
vehicule = conn.execute("SELECT * FROM vehicules WHERE id=?", (id,)).fetchone()
if not vehicule: raise HTTPException(404)
interventions = conn.execute("""
SELECT i.*, COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces, COUNT(p.id) as nb_pieces
FROM interventions i LEFT JOIN pieces p ON p.intervention_id=i.id
WHERE i.vehicule_id=? GROUP BY i.id ORDER BY i.created_at DESC
""", (id,)).fetchall()
infos = conn.execute("SELECT * FROM vehicule_infos WHERE vehicule_id=? ORDER BY created_at DESC", (id,)).fetchall()
conn.close()
return templates.TemplateResponse("vehicule_detail.html", {"request": request, "vehicule": vehicule, "interventions": interventions, "infos": infos, "page": "vehicules"})
@app.get("/vehicules/{id}/edit", response_class=HTMLResponse)
async def vehicule_edit(request: Request, id: int):
conn = get_db()
vehicule = conn.execute("SELECT * FROM vehicules WHERE id=?", (id,)).fetchone()
conn.close()
return templates.TemplateResponse("vehicule_form.html", {"request": request, "vehicule": vehicule, "page": "vehicules"})
@app.post("/vehicules/{id}/edit")
async def vehicule_update(id: int, immatriculation: str = Form(...), marque: str = Form(...), modele: str = Form(...), annee: Optional[int] = Form(None), vin: str = Form(""), kilometrage: Optional[int] = Form(None)):
conn = get_db()
conn.execute("UPDATE vehicules SET immatriculation=?,marque=?,modele=?,annee=?,vin=?,kilometrage=? WHERE id=?",
(immatriculation.upper().strip(), marque.strip(), modele.strip(), annee, vin.strip(), kilometrage, id))
conn.commit(); conn.close()
return RedirectResponse(f"/vehicules/{id}", status_code=303)
@app.post("/vehicules/{id}/delete")
async def vehicule_delete(id: int):
conn = get_db()
conn.execute("DELETE FROM vehicules WHERE id=?", (id,))
conn.commit(); conn.close()
return RedirectResponse("/vehicules", status_code=303)
# VEHICULE INFOS
@app.post("/vehicules/{id}/infos/add")
async def vehicule_info_add(id: int, type: str = Form("texte"), titre: str = Form(""), contenu: str = Form(...), photo: UploadFile = File(None)):
conn = get_db()
contenu_final = contenu.strip()
if type == "image" and photo and photo.filename:
ext = os.path.splitext(photo.filename)[1]
filename = f"{uuid.uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, filename)
with open(path, "wb") as f:
shutil.copyfileobj(photo.file, f)
contenu_final = f"/uploads/{filename}"
conn.execute("INSERT INTO vehicule_infos (vehicule_id, type, titre, contenu) VALUES (?,?,?,?)",
(id, type, titre.strip(), contenu_final))
conn.commit(); conn.close()
return RedirectResponse(f"/vehicules/{id}", status_code=303)
@app.post("/vehicules/{vid}/infos/{iid}/delete")
async def vehicule_info_delete(vid: int, iid: int):
conn = get_db()
conn.execute("DELETE FROM vehicule_infos WHERE id=? AND vehicule_id=?", (iid, vid))
conn.commit(); conn.close()
return RedirectResponse(f"/vehicules/{vid}", status_code=303)
# INTERVENTIONS
@app.get("/interventions", response_class=HTMLResponse)
async def interventions_list(request: Request, statut: str = ""):
conn = get_db()
if statut:
rows = conn.execute("""
SELECT i.*, v.immatriculation, v.marque, v.modele,
COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces
FROM interventions i JOIN vehicules v ON v.id=i.vehicule_id
LEFT JOIN pieces p ON p.intervention_id=i.id
WHERE i.statut=? GROUP BY i.id ORDER BY i.created_at DESC
""", (statut,)).fetchall()
else:
rows = conn.execute("""
SELECT i.*, v.immatriculation, v.marque, v.modele,
COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces
FROM interventions i JOIN vehicules v ON v.id=i.vehicule_id
LEFT JOIN pieces p ON p.intervention_id=i.id
GROUP BY i.id ORDER BY i.created_at DESC
""").fetchall()
conn.close()
return templates.TemplateResponse("interventions.html", {"request": request, "interventions": rows, "statut": statut, "page": "interventions"})
@app.get("/interventions/new", response_class=HTMLResponse)
async def intervention_new(request: Request, vehicule_id: Optional[int] = None):
conn = get_db()
vehicules = conn.execute("SELECT id, immatriculation||' - '||marque||' '||modele as label FROM vehicules ORDER BY marque").fetchall()
conn.close()
return templates.TemplateResponse("intervention_form.html", {"request": request, "intervention": None, "vehicules": vehicules, "preselect_vehicule": vehicule_id, "page": "interventions"})
@app.post("/interventions/new")
async def intervention_create(vehicule_id: int = Form(...), description: str = Form(...), statut: str = Form("en_attente"), cout_main_oeuvre: float = Form(0), technicien: str = Form(""), notes: str = Form("")):
conn = get_db()
conn.execute("INSERT INTO interventions (vehicule_id, description, statut, cout_main_oeuvre, technicien, notes) VALUES (?,?,?,?,?,?)",
(vehicule_id, description.strip(), statut, cout_main_oeuvre, technicien.strip(), notes.strip()))
conn.commit()
iid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.close()
return RedirectResponse(f"/interventions/{iid}", status_code=303)
@app.get("/interventions/{id}", response_class=HTMLResponse)
async def intervention_detail(request: Request, id: int):
conn = get_db()
intervention = conn.execute("""
SELECT i.*, v.immatriculation, v.marque, v.modele, v.id as vid,
COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces
FROM interventions i JOIN vehicules v ON v.id=i.vehicule_id
LEFT JOIN pieces p ON p.intervention_id=i.id
WHERE i.id=? GROUP BY i.id
""", (id,)).fetchone()
if not intervention: raise HTTPException(404)
pieces = conn.execute("SELECT * FROM pieces WHERE intervention_id=? ORDER BY created_at", (id,)).fetchall()
conn.close()
return templates.TemplateResponse("intervention_detail.html", {"request": request, "intervention": intervention, "pieces": pieces, "page": "interventions"})
@app.get("/interventions/{id}/edit", response_class=HTMLResponse)
async def intervention_edit(request: Request, id: int):
conn = get_db()
intervention = conn.execute("SELECT * FROM interventions WHERE id=?", (id,)).fetchone()
vehicules = conn.execute("SELECT id, immatriculation||' - '||marque||' '||modele as label FROM vehicules ORDER BY marque").fetchall()
conn.close()
return templates.TemplateResponse("intervention_form.html", {"request": request, "intervention": intervention, "vehicules": vehicules, "preselect_vehicule": None, "page": "interventions"})
@app.post("/interventions/{id}/edit")
async def intervention_update(id: int, vehicule_id: int = Form(...), description: str = Form(...), statut: str = Form("en_attente"), cout_main_oeuvre: float = Form(0), technicien: str = Form(""), notes: str = Form("")):
conn = get_db()
conn.execute("UPDATE interventions SET vehicule_id=?,description=?,statut=?,cout_main_oeuvre=?,technicien=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",
(vehicule_id, description.strip(), statut, cout_main_oeuvre, technicien.strip(), notes.strip(), id))
conn.commit(); conn.close()
return RedirectResponse(f"/interventions/{id}", status_code=303)
@app.post("/interventions/{id}/delete")
async def intervention_delete(id: int):
conn = get_db()
row = conn.execute("SELECT vehicule_id FROM interventions WHERE id=?", (id,)).fetchone()
vid = row["vehicule_id"] if row else None
conn.execute("DELETE FROM interventions WHERE id=?", (id,))
conn.commit(); conn.close()
return RedirectResponse(f"/vehicules/{vid}" if vid else "/vehicules", status_code=303)
# PIECES
@app.post("/interventions/{id}/pieces/add")
async def piece_add(id: int, nom: str = Form(...), reference: str = Form(""), marque: str = Form(""), prix: float = Form(0), quantite: int = Form(1), photo: UploadFile = File(None)):
conn = get_db()
photo_path = None
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1]
filename = f"{uuid.uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, filename)
with open(path, "wb") as f:
shutil.copyfileobj(photo.file, f)
photo_path = f"/uploads/{filename}"
conn.execute("INSERT INTO pieces (intervention_id, nom, reference, marque, prix, quantite, photo) VALUES (?,?,?,?,?,?,?)",
(id, nom.strip(), reference.strip(), marque.strip(), prix, quantite, photo_path))
conn.commit(); conn.close()
return RedirectResponse(f"/interventions/{id}", status_code=303)
@app.post("/interventions/{iid}/pieces/{pid}/delete")
async def piece_delete(iid: int, pid: int):
conn = get_db()
conn.execute("DELETE FROM pieces WHERE id=? AND intervention_id=?", (pid, iid))
conn.commit(); conn.close()
return RedirectResponse(f"/interventions/{iid}", status_code=303)
+22
View File
@@ -0,0 +1,22 @@
version: "3.8"
services:
garagemanager:
build: .
container_name: garagemanager
restart: unless-stopped
volumes:
- ./data:/data
environment:
- DB_PATH=/data/garage.db
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.garage.rule=Host(`garage.nas.percolouco.com`)"
- "traefik.http.routers.garage.entrypoints=websecure"
- "traefik.http.routers.garage.tls=true"
- "traefik.http.routers.garage.tls.certresolver=letsencrypt"
- "traefik.http.services.garage.loadbalancer.server.port=8000"
networks:
proxy:
external: true
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
jinja2==3.1.3
python-multipart==0.0.9
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GarageManager{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body class="bg-gray-100 min-h-screen flex">
<div class="w-64 bg-slate-800 min-h-screen flex flex-col fixed left-0 top-0">
<div class="p-6 border-b border-slate-700">
<h1 class="text-white text-xl font-bold flex items-center gap-2">
<i class="fas fa-wrench text-orange-400"></i> GarageManager
</h1>
</div>
<nav class="flex-1 p-4 space-y-1">
<a href="/" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition {% if page == 'dashboard' %}bg-orange-500 text-white{% else %}text-slate-300 hover:bg-slate-700 hover:text-white{% endif %}">
<i class="fas fa-chart-line w-5"></i> Dashboard
</a>
<a href="/vehicules" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition {% if page == 'vehicules' %}bg-orange-500 text-white{% else %}text-slate-300 hover:bg-slate-700 hover:text-white{% endif %}">
<i class="fas fa-car w-5"></i> Véhicules
</a>
<a href="/interventions" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition {% if page == 'interventions' %}bg-orange-500 text-white{% else %}text-slate-300 hover:bg-slate-700 hover:text-white{% endif %}">
<i class="fas fa-tools w-5"></i> Interventions
</a>
</nav>
<div class="p-4 border-t border-slate-700 text-xs text-slate-500 text-center">GarageManager v2.1</div>
</div>
<div class="ml-64 flex-1 p-8">
{% block content %}{% endblock %}
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}{{ client.nom }} {{ client.prenom }} - GarageManager{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/clients" class="text-slate-500 hover:text-slate-700 text-sm flex items-center gap-1 mb-4"><i class="fas fa-arrow-left"></i> Retour aux clients</a>
<div class="flex justify-between items-start">
<div>
<h2 class="text-2xl font-bold text-slate-800">{{ client.nom }} {{ client.prenom }}</h2>
<div class="flex flex-wrap gap-4 mt-2 text-sm text-slate-500">
{% if client.telephone %}<span><i class="fas fa-phone mr-1"></i>{{ client.telephone }}</span>{% endif %}
{% if client.email %}<span><i class="fas fa-envelope mr-1"></i>{{ client.email }}</span>{% endif %}
{% if client.adresse %}<span><i class="fas fa-map-marker-alt mr-1"></i>{{ client.adresse }}</span>{% endif %}
</div>
</div>
<div class="flex gap-2">
<a href="/clients/{{ client.id }}/edit" class="border border-orange-300 text-orange-500 px-4 py-2 rounded-lg text-sm hover:bg-orange-50">Modifier</a>
<form method="post" action="/clients/{{ client.id }}/delete" onsubmit="return confirm('Supprimer ce client et tous ses véhicules ?')">
<button class="border border-red-300 text-red-500 px-4 py-2 rounded-lg text-sm hover:bg-red-50">Supprimer</button>
</form>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow">
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-800">Véhicules ({{ vehicules|length }})</h3>
<a href="/vehicules/new" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600">+ Ajouter un véhicule</a>
</div>
{% if vehicules %}
<div class="divide-y divide-gray-100">
{% for v in vehicules %}
<div class="px-6 py-4 flex justify-between items-center hover:bg-gray-50">
<div>
<span class="font-medium text-slate-800">{{ v.immatriculation }}</span>
<span class="text-slate-500 ml-2">{{ v.marque }} {{ v.modele }}</span>
{% if v.annee %}<span class="text-slate-400 text-sm ml-2">({{ v.annee }})</span>{% endif %}
{% if v.kilometrage %}<span class="text-slate-400 text-sm ml-2">— {{ v.kilometrage }} km</span>{% endif %}
</div>
<div class="flex items-center gap-3">
<span class="bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-xs">{{ v.nb_interventions }} intervention(s)</span>
<a href="/vehicules/{{ v.id }}" class="text-blue-500 hover:underline text-sm">Voir →</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-slate-400 text-sm">Aucun véhicule enregistré pour ce client.</div>
{% endif %}
</div>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}{% if client %}Modifier{% else %}Nouveau{% endif %} client - GarageManager{% endblock %}
{% block content %}
<div class="mb-8">
<a href="/clients" class="text-slate-500 hover:text-slate-700 text-sm flex items-center gap-1 mb-4"><i class="fas fa-arrow-left"></i> Retour</a>
<h2 class="text-2xl font-bold text-slate-800">{% if client %}Modifier le client{% else %}Nouveau client{% endif %}</h2>
</div>
<div class="bg-white rounded-xl shadow p-8 max-w-2xl">
<form method="post" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Nom *</label>
<input name="nom" value="{{ client.nom if client else '' }}" required class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Prénom *</label>
<input name="prenom" value="{{ client.prenom if client else '' }}" required class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Téléphone</label>
<input name="telephone" value="{{ client.telephone if client else '' }}" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Email</label>
<input name="email" type="email" value="{{ client.email if client else '' }}" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Adresse</label>
<textarea name="adresse" rows="2" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">{{ client.adresse if client else '' }}</textarea>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="bg-orange-500 text-white px-6 py-2 rounded-lg hover:bg-orange-600 transition text-sm font-medium">Enregistrer</button>
<a href="/clients" class="text-slate-500 px-6 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-sm">Annuler</a>
</div>
</form>
</div>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Clients - GarageManager{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-8">
<div><h2 class="text-2xl font-bold text-slate-800">Clients</h2><p class="text-slate-500 mt-1">{{ clients|length }} client(s)</p></div>
<a href="/clients/new" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600 transition flex items-center gap-2"><i class="fas fa-plus"></i> Nouveau client</a>
</div>
<div class="bg-white rounded-xl shadow">
<div class="p-4 border-b border-gray-100">
<form method="get" class="flex gap-2">
<input name="q" value="{{ q }}" placeholder="Rechercher par nom, téléphone..." class="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
<button type="submit" class="bg-slate-700 text-white px-4 py-2 rounded-lg text-sm hover:bg-slate-800">Rechercher</button>
{% if q %}<a href="/clients" class="text-slate-500 px-3 py-2 text-sm hover:text-slate-700 flex items-center"></a>{% endif %}
</form>
</div>
<div class="overflow-x-auto">
{% if clients %}
<table class="w-full text-sm">
<thead class="bg-gray-50 text-slate-600 text-xs uppercase">
<tr>
<th class="px-6 py-3 text-left">Nom</th>
<th class="px-6 py-3 text-left">Téléphone</th>
<th class="px-6 py-3 text-left">Email</th>
<th class="px-6 py-3 text-left">Véhicules</th>
<th class="px-6 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for c in clients %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4"><a href="/clients/{{ c.id }}" class="font-medium text-slate-800 hover:text-orange-500">{{ c.nom }} {{ c.prenom }}</a></td>
<td class="px-6 py-4 text-slate-600">{{ c.telephone or '-' }}</td>
<td class="px-6 py-4 text-slate-600">{{ c.email or '-' }}</td>
<td class="px-6 py-4"><span class="bg-purple-100 text-purple-700 px-2 py-1 rounded-full text-xs">{{ c.nb_vehicules }}</span></td>
<td class="px-6 py-4 flex gap-3">
<a href="/clients/{{ c.id }}" class="text-blue-500 hover:underline">Voir</a>
<a href="/clients/{{ c.id }}/edit" class="text-orange-500 hover:underline">Modifier</a>
<form method="post" action="/clients/{{ c.id }}/delete" class="inline" onsubmit="return confirm('Supprimer ce client ?')">
<button type="submit" class="text-red-500 hover:underline">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center text-slate-400">
<i class="fas fa-users text-3xl mb-3"></i><p>Aucun client trouvé</p>
<a href="/clients/new" class="text-orange-500 hover:underline text-sm mt-2 inline-block">Ajouter le premier →</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Dashboard - GarageManager{% endblock %}
{% block content %}
<div class="mb-8">
<h2 class="text-2xl font-bold text-slate-800">Dashboard</h2>
<p class="text-slate-500 mt-1">Vue d'ensemble de votre garage</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-xl shadow p-6">
<div class="flex items-center justify-between">
<div><p class="text-sm text-slate-500">Véhicules</p><p class="text-3xl font-bold text-slate-800 mt-1">{{ stats.nb_vehicules }}</p></div>
<div class="bg-purple-100 p-3 rounded-full"><i class="fas fa-car text-purple-500 text-xl"></i></div>
</div>
<a href="/vehicules" class="text-xs text-purple-500 hover:underline mt-3 inline-block">Voir tous →</a>
</div>
<div class="bg-white rounded-xl shadow p-6">
<div class="flex items-center justify-between">
<div><p class="text-sm text-slate-500">En attente</p><p class="text-3xl font-bold text-yellow-500 mt-1">{{ stats.nb_en_attente }}</p></div>
<div class="bg-yellow-100 p-3 rounded-full"><i class="fas fa-clock text-yellow-500 text-xl"></i></div>
</div>
<a href="/interventions?statut=en_attente" class="text-xs text-yellow-500 hover:underline mt-3 inline-block">Voir →</a>
</div>
<div class="bg-white rounded-xl shadow p-6">
<div class="flex items-center justify-between">
<div><p class="text-sm text-slate-500">En cours</p><p class="text-3xl font-bold text-blue-500 mt-1">{{ stats.nb_en_cours }}</p></div>
<div class="bg-blue-100 p-3 rounded-full"><i class="fas fa-spinner text-blue-500 text-xl"></i></div>
</div>
<a href="/interventions?statut=en_cours" class="text-xs text-blue-500 hover:underline mt-3 inline-block">Voir →</a>
</div>
<div class="bg-white rounded-xl shadow p-6">
<div class="flex items-center justify-between">
<div><p class="text-sm text-slate-500">Terminées</p><p class="text-3xl font-bold text-green-500 mt-1">{{ stats.nb_termine }}</p></div>
<div class="bg-green-100 p-3 rounded-full"><i class="fas fa-check-circle text-green-500 text-xl"></i></div>
</div>
<a href="/interventions?statut=termine" class="text-xs text-green-500 hover:underline mt-3 inline-block">Voir →</a>
</div>
<div class="bg-white rounded-xl shadow p-6">
<div class="flex items-center justify-between">
<div><p class="text-sm text-slate-500">Coût pièces</p><p class="text-3xl font-bold text-slate-800 mt-1">{{ "%.0f"|format(stats.cout_pieces) }} €</p></div>
<div class="bg-red-100 p-3 rounded-full"><i class="fas fa-cog text-red-400 text-xl"></i></div>
</div>
</div>
<div class="bg-white rounded-xl shadow p-6">
<div class="flex items-center justify-between">
<div><p class="text-sm text-slate-500">Total dépensé</p><p class="text-3xl font-bold text-orange-500 mt-1">{{ "%.0f"|format(stats.cout_total) }} €</p></div>
<div class="bg-orange-100 p-3 rounded-full"><i class="fas fa-euro-sign text-orange-500 text-xl"></i></div>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow">
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-800">Interventions récentes</h3>
<a href="/interventions/new" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600 transition">+ Nouvelle</a>
</div>
{% if recent %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-slate-600 text-xs uppercase">
<tr>
<th class="px-6 py-3 text-left">Véhicule</th>
<th class="px-6 py-3 text-left">Description</th>
<th class="px-6 py-3 text-left">Statut</th>
<th class="px-6 py-3 text-left">Coût total</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for i in recent %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium text-slate-800"><a href="/vehicules/{{ i.vehicule_id }}" class="hover:text-orange-500">{{ i.immatriculation }}<br><span class="text-xs font-normal text-slate-400">{{ i.marque }} {{ i.modele }}</span></a></td>
<td class="px-6 py-4 text-slate-600"><a href="/interventions/{{ i.id }}" class="hover:text-orange-500">{{ i.description[:60] }}{% if i.description|length > 60 %}...{% endif %}</a></td>
<td class="px-6 py-4">
{% if i.statut == 'en_attente' %}<span class="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs">En attente</span>
{% elif i.statut == 'en_cours' %}<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded-full text-xs">En cours</span>
{% else %}<span class="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">Terminé</span>{% endif %}
</td>
<td class="px-6 py-4 font-semibold text-slate-700">{{ "%.0f"|format(i.cout_main_oeuvre + i.cout_pieces) }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-8 text-center text-slate-400">
<i class="fas fa-tools text-3xl mb-3"></i><p>Aucune intervention pour l'instant</p>
<a href="/interventions/new" class="text-orange-500 hover:underline text-sm mt-2 inline-block">Créer la première →</a>
</div>
{% endif %}
</div>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Intervention #{{ intervention.id }} - GarageManager{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/vehicules/{{ intervention.vid }}" class="text-slate-500 hover:text-slate-700 text-sm flex items-center gap-1 mb-4"><i class="fas fa-arrow-left"></i> Retour au véhicule</a>
<div class="flex justify-between items-start">
<div>
<h2 class="text-2xl font-bold text-slate-800">{{ intervention.description }}</h2>
<p class="text-slate-500 mt-1">{{ intervention.immatriculation }} — {{ intervention.marque }} {{ intervention.modele }}</p>
<p class="text-xs text-slate-400 mt-1">{{ intervention.created_at[:10] }}{% if intervention.technicien %} • <i class="fas fa-user-cog mr-1"></i>{{ intervention.technicien }}{% endif %}</p>
</div>
<div class="flex gap-2 items-center">
{% if intervention.statut == 'en_attente' %}<span class="bg-yellow-100 text-yellow-700 px-3 py-1 rounded-full text-sm">⏳ En attente</span>
{% elif intervention.statut == 'en_cours' %}<span class="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm">🔧 En cours</span>
{% else %}<span class="bg-green-100 text-green-700 px-3 py-1 rounded-full text-sm">✅ Terminé</span>{% endif %}
<a href="/interventions/{{ intervention.id }}/edit" class="border border-orange-300 text-orange-500 px-4 py-2 rounded-lg text-sm hover:bg-orange-50">Modifier</a>
<form method="post" action="/interventions/{{ intervention.id }}/delete" onsubmit="return confirm('Supprimer cette intervention ?')">
<button class="border border-red-300 text-red-500 px-4 py-2 rounded-lg text-sm hover:bg-red-50">Supprimer</button>
</form>
</div>
</div>
</div>
<!-- Résumé coûts -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl shadow p-5">
<p class="text-xs text-slate-500">Main d'œuvre</p>
<p class="text-2xl font-bold text-slate-800 mt-1">{{ "%.2f"|format(intervention.cout_main_oeuvre) }} €</p>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-xs text-slate-500">Pièces</p>
<p class="text-2xl font-bold text-slate-800 mt-1">{{ "%.2f"|format(intervention.cout_pieces) }} €</p>
</div>
<div class="bg-orange-50 rounded-xl shadow p-5 border border-orange-200">
<p class="text-xs text-orange-600">Total</p>
<p class="text-2xl font-bold text-orange-600 mt-1">{{ "%.2f"|format(intervention.cout_main_oeuvre + intervention.cout_pieces) }} €</p>
</div>
</div>
{% if intervention.notes %}
<div class="bg-white rounded-xl shadow p-5 mb-6">
<h3 class="font-semibold text-slate-800 mb-2">📋 Notes</h3>
<p class="text-sm text-slate-600 whitespace-pre-wrap">{{ intervention.notes }}</p>
</div>
{% endif %}
<!-- Pièces -->
<div class="bg-white rounded-xl shadow">
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-800">🔩 Pièces utilisées ({{ pieces|length }})</h3>
<button onclick="document.getElementById('add-piece-form').classList.toggle('hidden')" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600">+ Ajouter une pièce</button>
</div>
<!-- Formulaire ajout pièce -->
<div id="add-piece-form" class="hidden p-5 border-b border-gray-100 bg-gray-50">
<form method="post" action="/interventions/{{ intervention.id }}/pieces/add" enctype="multipart/form-data">
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Nom de la pièce *</label>
<input name="nom" required placeholder="Ex: Filtre à huile, Courroie..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Référence</label>
<input name="reference" placeholder="Ex: OC90, 06A115561B..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
</div>
<div class="grid grid-cols-3 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Marque</label>
<input name="marque" placeholder="Ex: Bosch, NGK, Valeo..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Prix unitaire (€)</label>
<input name="prix" type="number" step="0.01" min="0" value="0" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Quantité</label>
<input name="quantite" type="number" min="1" value="1" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-slate-600 mb-1">Photo (optionnel)</label>
<input type="file" name="photo" accept="image/*" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
</div>
<button type="submit" class="bg-orange-500 text-white px-5 py-2 rounded-lg text-sm hover:bg-orange-600">Ajouter la pièce</button>
</form>
</div>
<!-- Liste des pièces -->
{% if pieces %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-slate-600 text-xs uppercase">
<tr>
<th class="px-5 py-3 text-left">Photo</th>
<th class="px-5 py-3 text-left">Pièce</th>
<th class="px-5 py-3 text-left">Référence</th>
<th class="px-5 py-3 text-left">Marque</th>
<th class="px-5 py-3 text-left">Qté</th>
<th class="px-5 py-3 text-left">Prix unit.</th>
<th class="px-5 py-3 text-left">Total</th>
<th class="px-5 py-3 text-left"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for p in pieces %}
<tr class="hover:bg-gray-50">
<td class="px-5 py-3">
{% if p.photo %}
<img src="{{ p.photo }}" alt="{{ p.nom }}" class="w-12 h-12 object-cover rounded-lg cursor-pointer border" onclick="window.open('{{ p.photo }}','_blank')">
{% else %}
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center text-slate-300"><i class="fas fa-cog"></i></div>
{% endif %}
</td>
<td class="px-5 py-3 font-medium text-slate-800">{{ p.nom }}</td>
<td class="px-5 py-3 text-slate-500 font-mono text-xs">{{ p.reference or '-' }}</td>
<td class="px-5 py-3 text-slate-600">{{ p.marque or '-' }}</td>
<td class="px-5 py-3 text-slate-600">{{ p.quantite }}</td>
<td class="px-5 py-3 text-slate-600">{{ "%.2f"|format(p.prix) }} €</td>
<td class="px-5 py-3 font-semibold text-slate-800">{{ "%.2f"|format(p.prix * p.quantite) }} €</td>
<td class="px-5 py-3">
<form method="post" action="/interventions/{{ intervention.id }}/pieces/{{ p.id }}/delete" onsubmit="return confirm('Supprimer cette pièce ?')">
<button class="text-red-400 hover:text-red-600 text-xs hover:underline">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
<tr class="bg-orange-50">
<td colspan="6" class="px-5 py-3 text-right font-semibold text-slate-700">Total pièces</td>
<td colspan="2" class="px-5 py-3 font-bold text-orange-600">{{ "%.2f"|format(intervention.cout_pieces) }} €</td>
</tr>
</tbody>
</table>
</div>
{% else %}
<div class="p-8 text-center text-slate-400 text-sm">
Aucune pièce enregistrée.<br>
<button onclick="document.getElementById('add-piece-form').classList.remove('hidden')" class="text-orange-500 hover:underline mt-1 inline-block">Ajouter la première →</button>
</div>
{% endif %}
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{% if intervention %}Modifier{% else %}Nouvelle{% endif %} intervention - GarageManager{% endblock %}
{% block content %}
<div class="mb-8">
<a href="/interventions" class="text-slate-500 hover:text-slate-700 text-sm flex items-center gap-1 mb-4"><i class="fas fa-arrow-left"></i> Retour</a>
<h2 class="text-2xl font-bold text-slate-800">{% if intervention %}Modifier l'intervention{% else %}Nouvelle intervention{% endif %}</h2>
</div>
<div class="bg-white rounded-xl shadow p-8 max-w-2xl">
<form method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Véhicule *</label>
<select name="vehicule_id" required class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
<option value="">-- Sélectionner un véhicule --</option>
{% for v in vehicules %}
<option value="{{ v.id }}" {% if (intervention and intervention.vehicule_id == v.id) or preselect_vehicule == v.id %}selected{% endif %}>{{ v.label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Description *</label>
<textarea name="description" rows="2" required placeholder="Ex: Vidange + filtre à huile, Changement courroie de distribution..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">{{ intervention.description if intervention else '' }}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Statut</label>
<select name="statut" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
<option value="en_attente" {% if not intervention or intervention.statut == 'en_attente' %}selected{% endif %}>⏳ En attente</option>
<option value="en_cours" {% if intervention and intervention.statut == 'en_cours' %}selected{% endif %}>🔧 En cours</option>
<option value="termine" {% if intervention and intervention.statut == 'termine' %}selected{% endif %}>✅ Terminé</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Main d'œuvre (€)</label>
<input name="cout_main_oeuvre" type="number" step="0.01" min="0" value="{{ intervention.cout_main_oeuvre if intervention else '0' }}" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Technicien</label>
<input name="technicien" value="{{ intervention.technicien if intervention else '' }}" placeholder="Nom du technicien / garage" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Notes</label>
<textarea name="notes" rows="3" placeholder="Observations, prochaine révision prévue, problèmes restants..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">{{ intervention.notes if intervention else '' }}</textarea>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="bg-orange-500 text-white px-6 py-2 rounded-lg hover:bg-orange-600 transition text-sm font-medium">Enregistrer</button>
<a href="/interventions" class="text-slate-500 px-6 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-sm">Annuler</a>
</div>
</form>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Interventions - GarageManager{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-8">
<div><h2 class="text-2xl font-bold text-slate-800">Interventions</h2><p class="text-slate-500 mt-1">{{ interventions|length }} intervention(s)</p></div>
<a href="/interventions/new" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600 flex items-center gap-2"><i class="fas fa-plus"></i> Nouvelle intervention</a>
</div>
<div class="flex gap-2 mb-6 flex-wrap">
<a href="/interventions" class="px-4 py-2 rounded-full text-sm {% if not statut %}bg-slate-800 text-white{% else %}bg-white text-slate-600 border hover:bg-gray-50{% endif %}">Toutes</a>
<a href="/interventions?statut=en_attente" class="px-4 py-2 rounded-full text-sm {% if statut == 'en_attente' %}bg-yellow-500 text-white{% else %}bg-white text-slate-600 border hover:bg-gray-50{% endif %}">⏳ En attente</a>
<a href="/interventions?statut=en_cours" class="px-4 py-2 rounded-full text-sm {% if statut == 'en_cours' %}bg-blue-500 text-white{% else %}bg-white text-slate-600 border hover:bg-gray-50{% endif %}">🔧 En cours</a>
<a href="/interventions?statut=termine" class="px-4 py-2 rounded-full text-sm {% if statut == 'termine' %}bg-green-500 text-white{% else %}bg-white text-slate-600 border hover:bg-gray-50{% endif %}">✅ Terminées</a>
</div>
<div class="bg-white rounded-xl shadow overflow-x-auto">
{% if interventions %}
<table class="w-full text-sm">
<thead class="bg-gray-50 text-slate-600 text-xs uppercase">
<tr>
<th class="px-6 py-3 text-left">Véhicule</th>
<th class="px-6 py-3 text-left">Description</th>
<th class="px-6 py-3 text-left">Statut</th>
<th class="px-6 py-3 text-left">Pièces</th>
<th class="px-6 py-3 text-left">Total</th>
<th class="px-6 py-3 text-left">Date</th>
<th class="px-6 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for i in interventions %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium text-slate-800">
<a href="/vehicules/{{ i.vehicule_id }}" class="hover:text-orange-500">{{ i.immatriculation }}<br><span class="text-xs font-normal text-slate-400">{{ i.marque }} {{ i.modele }}</span></a>
</td>
<td class="px-6 py-4 text-slate-600 max-w-xs">
<a href="/interventions/{{ i.id }}" class="hover:text-orange-500">{{ i.description[:60] }}{% if i.description|length > 60 %}...{% endif %}</a>
</td>
<td class="px-6 py-4">
{% if i.statut == 'en_attente' %}<span class="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs">En attente</span>
{% elif i.statut == 'en_cours' %}<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded-full text-xs">En cours</span>
{% else %}<span class="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">Terminé</span>{% endif %}
</td>
<td class="px-6 py-4 text-slate-500 text-xs">{{ "%.2f"|format(i.cout_pieces) }} €</td>
<td class="px-6 py-4 font-semibold text-slate-700">{{ "%.0f"|format(i.cout_main_oeuvre + i.cout_pieces) }} €</td>
<td class="px-6 py-4 text-slate-400 text-xs">{{ i.created_at[:10] }}</td>
<td class="px-6 py-4 flex gap-3">
<a href="/interventions/{{ i.id }}" class="text-blue-500 hover:underline">Voir</a>
<a href="/interventions/{{ i.id }}/edit" class="text-orange-500 hover:underline">Modifier</a>
<form method="post" action="/interventions/{{ i.id }}/delete" class="inline" onsubmit="return confirm('Supprimer ?')">
<button class="text-red-500 hover:underline">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center text-slate-400">
<i class="fas fa-tools text-3xl mb-3"></i><p>Aucune intervention trouvée</p>
<a href="/interventions/new" class="text-orange-500 hover:underline text-sm mt-2 inline-block">Créer la première →</a>
</div>
{% endif %}
</div>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}{{ vehicule.immatriculation }} - GarageManager{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/vehicules" class="text-slate-500 hover:text-slate-700 text-sm flex items-center gap-1 mb-4"><i class="fas fa-arrow-left"></i> Retour aux véhicules</a>
<div class="flex justify-between items-start">
<div>
<h2 class="text-2xl font-bold text-slate-800">{{ vehicule.immatriculation }}</h2>
<p class="text-slate-500 mt-1">{{ vehicule.marque }} {{ vehicule.modele }}{% if vehicule.annee %} ({{ vehicule.annee }}){% endif %}{% if vehicule.kilometrage %} • {{ vehicule.kilometrage }} km{% endif %}</p>
{% if vehicule.vin %}<p class="text-xs font-mono text-slate-400 mt-1">VIN : {{ vehicule.vin }}</p>{% endif %}
</div>
<div class="flex gap-2">
<a href="/interventions/new?vehicule_id={{ vehicule.id }}" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600">+ Intervention</a>
<a href="/vehicules/{{ vehicule.id }}/edit" class="border border-orange-300 text-orange-500 px-4 py-2 rounded-lg text-sm hover:bg-orange-50">Modifier</a>
<form method="post" action="/vehicules/{{ vehicule.id }}/delete" onsubmit="return confirm('Supprimer ce véhicule ?')">
<button class="border border-red-300 text-red-500 px-4 py-2 rounded-lg text-sm hover:bg-red-50">Supprimer</button>
</form>
</div>
</div>
</div>
<!-- Infos / Notes -->
<div class="bg-white rounded-xl shadow mb-6">
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-800">📎 Infos & Notes</h3>
<button onclick="document.getElementById('add-info-form').classList.toggle('hidden')" class="text-orange-500 text-sm hover:underline">+ Ajouter</button>
</div>
<div id="add-info-form" class="hidden p-5 border-b border-gray-100 bg-gray-50">
<form method="post" action="/vehicules/{{ vehicule.id }}/infos/add" enctype="multipart/form-data" class="space-y-3">
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Type</label>
<select name="type" id="info-type" onchange="toggleInfoType()" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
<option value="texte">📝 Texte</option>
<option value="url">🔗 URL / Lien</option>
<option value="image">🖼️ Image</option>
</select>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-slate-600 mb-1">Titre (optionnel)</label>
<input name="titre" placeholder="Ex: Manuel d'entretien, Lien forum..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
</div>
</div>
<div id="input-texte">
<label class="block text-xs font-medium text-slate-600 mb-1">Contenu *</label>
<textarea name="contenu" rows="3" placeholder="Notes libres, infos techniques..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"></textarea>
</div>
<div id="input-url" class="hidden">
<label class="block text-xs font-medium text-slate-600 mb-1">URL *</label>
<input name="contenu" placeholder="https://..." class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
</div>
<div id="input-image" class="hidden">
<label class="block text-xs font-medium text-slate-600 mb-1">Photo *</label>
<input type="file" name="photo" accept="image/*" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
<input type="hidden" name="contenu" value="photo">
</div>
<button type="submit" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600">Enregistrer</button>
</form>
</div>
{% if infos %}
<div class="divide-y divide-gray-100">
{% for info in infos %}
<div class="px-5 py-4 flex justify-between items-start hover:bg-gray-50">
<div class="flex-1">
{% if info.titre %}<p class="text-xs font-semibold text-slate-500 uppercase mb-1">{{ info.titre }}</p>{% endif %}
{% if info.type == 'texte' %}
<p class="text-sm text-slate-700 whitespace-pre-wrap">{{ info.contenu }}</p>
{% elif info.type == 'url' %}
<a href="{{ info.contenu }}" target="_blank" class="text-sm text-blue-500 hover:underline break-all">🔗 {{ info.contenu }}</a>
{% elif info.type == 'image' %}
<img src="{{ info.contenu }}" alt="{{ info.titre or 'image' }}" class="max-h-48 rounded-lg mt-1 cursor-pointer" onclick="window.open('{{ info.contenu }}','_blank')">
{% endif %}
<p class="text-xs text-slate-400 mt-1">{{ info.created_at[:10] }}</p>
</div>
<form method="post" action="/vehicules/{{ vehicule.id }}/infos/{{ info.id }}/delete" class="ml-4" onsubmit="return confirm('Supprimer ?')">
<button class="text-red-400 hover:text-red-600 text-xs"></button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-6 text-center text-slate-400 text-sm">Aucune info pour l'instant.</div>
{% endif %}
</div>
<!-- Interventions -->
<div class="bg-white rounded-xl shadow">
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-800">🔧 Interventions ({{ interventions|length }})</h3>
<a href="/interventions/new?vehicule_id={{ vehicule.id }}" class="text-orange-500 text-sm hover:underline">+ Nouvelle intervention</a>
</div>
{% if interventions %}
<div class="divide-y divide-gray-100">
{% for i in interventions %}
<div class="px-5 py-4 hover:bg-gray-50">
<div class="flex justify-between items-start">
<div class="flex-1">
<a href="/interventions/{{ i.id }}" class="font-medium text-slate-800 hover:text-orange-500">{{ i.description }}</a>
{% if i.technicien %}<p class="text-xs text-slate-400 mt-1"><i class="fas fa-user-cog mr-1"></i>{{ i.technicien }}</p>{% endif %}
<p class="text-xs text-slate-400 mt-1">{{ i.created_at[:10] }} • {{ i.nb_pieces }} pièce(s)</p>
</div>
<div class="flex items-center gap-3 ml-4 flex-shrink-0">
<span class="text-sm font-semibold text-slate-700">{{ "%.0f"|format(i.cout_main_oeuvre + i.cout_pieces) }} €</span>
{% if i.statut == 'en_attente' %}<span class="bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full text-xs">En attente</span>
{% elif i.statut == 'en_cours' %}<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded-full text-xs">En cours</span>
{% else %}<span class="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs">Terminé</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-6 text-center text-slate-400 text-sm">
Aucune intervention.<br>
<a href="/interventions/new?vehicule_id={{ vehicule.id }}" class="text-orange-500 hover:underline mt-1 inline-block">Créer la première →</a>
</div>
{% endif %}
</div>
<script>
function toggleInfoType() {
const type = document.getElementById('info-type').value;
document.getElementById('input-texte').classList.toggle('hidden', type !== 'texte');
document.getElementById('input-url').classList.toggle('hidden', type !== 'url');
document.getElementById('input-image').classList.toggle('hidden', type !== 'image');
}
</script>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{% if vehicule %}Modifier{% else %}Nouveau{% endif %} véhicule - GarageManager{% endblock %}
{% block content %}
<div class="mb-8">
<a href="/vehicules" class="text-slate-500 hover:text-slate-700 text-sm flex items-center gap-1 mb-4"><i class="fas fa-arrow-left"></i> Retour</a>
<h2 class="text-2xl font-bold text-slate-800">{% if vehicule %}Modifier le véhicule{% else %}Nouveau véhicule{% endif %}</h2>
</div>
<div class="bg-white rounded-xl shadow p-8 max-w-2xl">
<form method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Immatriculation *</label>
<input name="immatriculation" value="{{ vehicule.immatriculation if vehicule else '' }}" required placeholder="AB-123-CD" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300 uppercase">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Marque *</label>
<input name="marque" value="{{ vehicule.marque if vehicule else '' }}" required class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Modèle *</label>
<input name="modele" value="{{ vehicule.modele if vehicule else '' }}" required class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Année</label>
<input name="annee" type="number" value="{{ vehicule.annee if vehicule else '' }}" min="1900" max="2030" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Kilométrage</label>
<input name="kilometrage" type="number" value="{{ vehicule.kilometrage if vehicule else '' }}" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">VIN (numéro de châssis)</label>
<input name="vin" value="{{ vehicule.vin if vehicule else '' }}" placeholder="17 caractères" maxlength="17" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-orange-300 uppercase">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="bg-orange-500 text-white px-6 py-2 rounded-lg hover:bg-orange-600 transition text-sm font-medium">Enregistrer</button>
<a href="/vehicules" class="text-slate-500 px-6 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 text-sm">Annuler</a>
</div>
</form>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Véhicules - GarageManager{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-8">
<div><h2 class="text-2xl font-bold text-slate-800">Véhicules</h2><p class="text-slate-500 mt-1">{{ vehicules|length }} véhicule(s)</p></div>
<a href="/vehicules/new" class="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm hover:bg-orange-600 transition flex items-center gap-2"><i class="fas fa-plus"></i> Nouveau véhicule</a>
</div>
<div class="bg-white rounded-xl shadow">
<div class="p-4 border-b border-gray-100">
<form method="get" class="flex gap-2">
<input name="q" value="{{ q }}" placeholder="Immatriculation, marque, modèle, VIN..." class="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-300">
<button type="submit" class="bg-slate-700 text-white px-4 py-2 rounded-lg text-sm">Rechercher</button>
{% if q %}<a href="/vehicules" class="text-slate-500 px-3 py-2 text-sm flex items-center"></a>{% endif %}
</form>
</div>
<div class="overflow-x-auto">
{% if vehicules %}
<table class="w-full text-sm">
<thead class="bg-gray-50 text-slate-600 text-xs uppercase">
<tr>
<th class="px-6 py-3 text-left">Immatriculation</th>
<th class="px-6 py-3 text-left">Marque / Modèle</th>
<th class="px-6 py-3 text-left">Année</th>
<th class="px-6 py-3 text-left">VIN</th>
<th class="px-6 py-3 text-left">Kilométrage</th>
<th class="px-6 py-3 text-left">Interventions</th>
<th class="px-6 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for v in vehicules %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-bold text-slate-800">{{ v.immatriculation }}</td>
<td class="px-6 py-4 font-medium text-slate-700">{{ v.marque }} {{ v.modele }}</td>
<td class="px-6 py-4 text-slate-500">{{ v.annee or '-' }}</td>
<td class="px-6 py-4 text-slate-400 text-xs font-mono">{{ v.vin or '-' }}</td>
<td class="px-6 py-4 text-slate-600">{{ (v.kilometrage|string + ' km') if v.kilometrage else '-' }}</td>
<td class="px-6 py-4"><span class="bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-xs">{{ v.nb_interventions }}</span></td>
<td class="px-6 py-4 flex gap-3">
<a href="/vehicules/{{ v.id }}" class="text-blue-500 hover:underline">Voir</a>
<a href="/vehicules/{{ v.id }}/edit" class="text-orange-500 hover:underline">Modifier</a>
<form method="post" action="/vehicules/{{ v.id }}/delete" class="inline" onsubmit="return confirm('Supprimer ce véhicule ?')">
<button class="text-red-500 hover:underline">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center text-slate-400">
<i class="fas fa-car text-3xl mb-3"></i><p>Aucun véhicule trouvé</p>
<a href="/vehicules/new" class="text-orange-500 hover:underline text-sm mt-2 inline-block">Ajouter le premier →</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}