From 54b39dd669b036523d693e2150dff95bda62b212 Mon Sep 17 00:00:00 2001 From: perco Date: Thu, 30 Apr 2026 21:19:11 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20locoutil=20=E2=80=94=20gest?= =?UTF-8?q?ion=20de=20location=20d'outils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + Dockerfile | 17 ++ README.md | 74 ++++++ app/__init__.py | 0 app/database.py | 83 +++++++ app/main.py | 61 +++++ app/routers/__init__.py | 0 app/routers/clients.py | 110 +++++++++ app/routers/platforms.py | 36 +++ app/routers/rentals.py | 111 +++++++++ app/routers/tools.py | 128 ++++++++++ requirements.txt | 4 + static/app.js | 513 +++++++++++++++++++++++++++++++++++++++ static/style.css | 182 ++++++++++++++ templates/index.html | 221 +++++++++++++++++ 15 files changed, 1546 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/clients.py create mode 100644 app/routers/platforms.py create mode 100644 app/routers/rentals.py create mode 100644 app/routers/tools.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6b56f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +data/ +uploads/ +*.db +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..872ed81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY static/ ./static/ +COPY templates/ ./templates/ + +VOLUME ["/data"] +VOLUME ["/uploads"] + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d26081 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# LocOutil — Gestion de location d'outils + +Application web de gestion de location d'outils. Permet de gérer le catalogue d'outils, les clients, les réservations et d'avoir une vue calendrier de la disponibilité. + +## Fonctionnalités + +### Outils +- Fiche outil : nom, catégorie, description, photos multiples +- Tarifs : prix à la journée, prix week-end, caution +- Notes internes +- Historique des locations par outil + +### Clients +- Fiche client : nom, téléphone, email, adresse, notes +- Upload de documents (pièces d'identité, etc.) avec libellé personnalisable +- Historique des locations par client + +### Locations +- Lien outil + client + plateforme + dates +- Calcul automatique du prix selon la durée +- Suivi caution (encaissée / rendue) +- Statuts : Confirmée / En cours / Retournée / Annulée +- Notes de retour (état de l'outil à la restitution) + +### Calendrier +- Vue mensuelle avec toutes les locations actives +- Clic sur un événement pour voir le détail + +### Plateformes +- Liste éditable (Leboncoin, Direct, etc.) +- Associée à chaque location pour le suivi des sources + +### Dashboard +- Locations en cours, revenus du mois, revenus total +- Cautions en attente de retour +- Retours prévus dans les 7 jours + +## Stack + +- **Backend** : Python FastAPI + SQLite +- **Frontend** : HTML/CSS/JS vanilla +- **Déploiement** : Docker + Traefik + +## Déploiement + +```bash +cd /opt/container/locoutil +docker compose up -d --build +``` + +Accessible sur **https://locoutil.nas.percolouco.com** + +## Structure + +``` +locoutil/ +├── app/ +│ ├── main.py # App FastAPI + dashboard +│ ├── database.py # SQLite + schéma +│ └── routers/ +│ ├── tools.py # CRUD outils + images +│ ├── clients.py # CRUD clients + documents +│ ├── rentals.py # CRUD locations + calendrier +│ └── platforms.py # CRUD plateformes +├── static/ +│ ├── style.css +│ └── app.js +├── templates/ +│ └── index.html +├── Dockerfile +└── requirements.txt +``` + +Les données sont persistées dans `/opt/container/locoutil/data/` (SQLite) et `/opt/container/locoutil/uploads/` (fichiers). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..89dc138 --- /dev/null +++ b/app/database.py @@ -0,0 +1,83 @@ +import sqlite3 +import os +from contextlib import contextmanager + +DB_PATH = os.environ.get("DB_PATH", "/data/locoutil.db") + +SCHEMA = """ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS tools ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + category TEXT DEFAULT '', + daily_price REAL NOT NULL DEFAULT 0, + weekend_price REAL NOT NULL DEFAULT 0, + deposit REAL DEFAULT 0, + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now', 'localtime')) +); + +CREATE TABLE IF NOT EXISTS tool_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + is_main INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT DEFAULT '', + email TEXT DEFAULT '', + address TEXT DEFAULT '', + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now', 'localtime')) +); + +CREATE TABLE IF NOT EXISTS client_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + label TEXT DEFAULT 'Document' +); + +CREATE TABLE IF NOT EXISTS platforms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS rentals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_id INTEGER NOT NULL REFERENCES tools(id), + client_id INTEGER NOT NULL REFERENCES clients(id), + platform_id INTEGER REFERENCES platforms(id), + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + price REAL NOT NULL DEFAULT 0, + deposit_collected INTEGER DEFAULT 0, + deposit_returned INTEGER DEFAULT 0, + status TEXT DEFAULT 'confirmed', + return_notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now', 'localtime')) +); + +INSERT OR IGNORE INTO platforms (name) VALUES ('Direct'); +INSERT OR IGNORE INTO platforms (name) VALUES ('Leboncoin'); +""" + +@contextmanager +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + finally: + conn.close() + +def init_db(): + with get_db() as conn: + conn.executescript(SCHEMA) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..88e74c3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,61 @@ +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from datetime import datetime, date +import os + +from .database import init_db, get_db +from .routers import tools, clients, rentals, platforms + +app = FastAPI(title="LocOutil") + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/uploads") + +app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") +app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") +templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) + +app.include_router(tools.router) +app.include_router(clients.router) +app.include_router(rentals.router) +app.include_router(platforms.router) + +@app.on_event("startup") +def startup(): + os.makedirs(os.path.join(UPLOAD_DIR, "tools"), exist_ok=True) + os.makedirs(os.path.join(UPLOAD_DIR, "clients"), exist_ok=True) + init_db() + +@app.get("/", response_class=HTMLResponse) +def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + +@app.get("/api/dashboard") +def dashboard(): + today = date.today().isoformat() + month = today[:7] + with get_db() as conn: + active = conn.execute("SELECT COUNT(*) FROM rentals WHERE status IN ('confirmed','ongoing') AND end_date >= ?", (today,)).fetchone()[0] + revenue_month = conn.execute("SELECT COALESCE(SUM(price),0) FROM rentals WHERE status != 'cancelled' AND start_date LIKE ?", (f"{month}%",)).fetchone()[0] + revenue_total = conn.execute("SELECT COALESCE(SUM(price),0) FROM rentals WHERE status != 'cancelled'").fetchone()[0] + returning_soon = conn.execute(""" + SELECT r.*, t.name as tool_name, c.name as client_name + FROM rentals r JOIN tools t ON t.id=r.tool_id JOIN clients c ON c.id=r.client_id + WHERE r.status IN ('confirmed','ongoing') AND r.end_date >= ? AND r.end_date <= date(?, '+7 days') + ORDER BY r.end_date + """, (today, today)).fetchall() + tools_count = conn.execute("SELECT COUNT(*) FROM tools").fetchone()[0] + clients_count = conn.execute("SELECT COUNT(*) FROM clients").fetchone()[0] + pending_deposit = conn.execute("SELECT COUNT(*) FROM rentals WHERE status != 'cancelled' AND deposit_collected=1 AND deposit_returned=0 AND end_date < ?", (today,)).fetchone()[0] + return { + "active_rentals": active, + "revenue_month": revenue_month, + "revenue_total": revenue_total, + "returning_soon": [dict(r) for r in returning_soon], + "tools_count": tools_count, + "clients_count": clients_count, + "pending_deposit_return": pending_deposit, + "current_month": month + } diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/clients.py b/app/routers/clients.py new file mode 100644 index 0000000..184121e --- /dev/null +++ b/app/routers/clients.py @@ -0,0 +1,110 @@ +import os, uuid, shutil +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional +from ..database import get_db + +router = APIRouter(prefix="/api/clients", tags=["clients"]) +UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/uploads") + +class ClientUpdate(BaseModel): + name: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + address: Optional[str] = None + notes: Optional[str] = None + +def client_full(conn, row): + c = dict(row) + docs = conn.execute("SELECT * FROM client_documents WHERE client_id = ? ORDER BY id", (c["id"],)).fetchall() + c["documents"] = [dict(d) for d in docs] + rentals = conn.execute(""" + SELECT r.*, t.name as tool_name FROM rentals r + JOIN tools t ON t.id = r.tool_id + WHERE r.client_id = ? ORDER BY r.start_date DESC + """, (c["id"],)).fetchall() + c["rentals"] = [dict(r) for r in rentals] + return c + +@router.get("") +def list_clients(): + with get_db() as conn: + rows = conn.execute("SELECT * FROM clients ORDER BY name").fetchall() + result = [] + for r in rows: + c = dict(r) + c["rental_count"] = conn.execute("SELECT COUNT(*) FROM rentals WHERE client_id = ?", (r["id"],)).fetchone()[0] + result.append(c) + return result + +@router.get("/{client_id}") +def get_client(client_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone() + if not row: + raise HTTPException(404, "Client introuvable") + return client_full(conn, row) + +@router.post("", status_code=201) +def create_client( + name: str = Form(...), + phone: str = Form(""), + email: str = Form(""), + address: str = Form(""), + notes: str = Form("") +): + with get_db() as conn: + cur = conn.execute( + "INSERT INTO clients (name, phone, email, address, notes) VALUES (?,?,?,?,?)", + (name.strip(), phone.strip(), email.strip(), address.strip(), notes.strip()) + ) + row = conn.execute("SELECT * FROM clients WHERE id = ?", (cur.lastrowid,)).fetchone() + return client_full(conn, row) + +@router.put("/{client_id}") +def update_client(client_id: int, data: ClientUpdate): + with get_db() as conn: + if not conn.execute("SELECT id FROM clients WHERE id = ?", (client_id,)).fetchone(): + raise HTTPException(404, "Client introuvable") + fields, values = [], [] + for field, val in data.model_dump(exclude_none=True).items(): + fields.append(f"{field} = ?") + values.append(val.strip() if isinstance(val, str) else val) + if fields: + values.append(client_id) + conn.execute(f"UPDATE clients SET {', '.join(fields)} WHERE id = ?", values) + return client_full(conn, conn.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone()) + +@router.delete("/{client_id}", status_code=204) +def delete_client(client_id: int): + with get_db() as conn: + docs = conn.execute("SELECT filename FROM client_documents WHERE client_id = ?", (client_id,)).fetchall() + for doc in docs: + path = os.path.join(UPLOAD_DIR, "clients", doc["filename"]) + if os.path.exists(path): + os.remove(path) + conn.execute("DELETE FROM clients WHERE id = ?", (client_id,)) + +@router.post("/{client_id}/documents", status_code=201) +async def add_document(client_id: int, file: UploadFile = File(...), label: str = Form("Document")): + with get_db() as conn: + if not conn.execute("SELECT id FROM clients WHERE id = ?", (client_id,)).fetchone(): + raise HTTPException(404, "Client introuvable") + ext = os.path.splitext(file.filename)[1].lower() + fname = f"{uuid.uuid4().hex}{ext}" + path = os.path.join(UPLOAD_DIR, "clients", fname) + with open(path, "wb") as f: + shutil.copyfileobj(file.file, f) + cur = conn.execute("INSERT INTO client_documents (client_id, filename, label) VALUES (?,?,?)", (client_id, fname, label.strip())) + return dict(conn.execute("SELECT * FROM client_documents WHERE id = ?", (cur.lastrowid,)).fetchone()) + +@router.delete("/{client_id}/documents/{doc_id}", status_code=204) +def delete_document(client_id: int, doc_id: int): + with get_db() as conn: + doc = conn.execute("SELECT * FROM client_documents WHERE id = ? AND client_id = ?", (doc_id, client_id)).fetchone() + if not doc: + raise HTTPException(404, "Document introuvable") + path = os.path.join(UPLOAD_DIR, "clients", doc["filename"]) + if os.path.exists(path): + os.remove(path) + conn.execute("DELETE FROM client_documents WHERE id = ?", (doc_id,)) diff --git a/app/routers/platforms.py b/app/routers/platforms.py new file mode 100644 index 0000000..3f08339 --- /dev/null +++ b/app/routers/platforms.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from ..database import get_db + +router = APIRouter(prefix="/api/platforms", tags=["platforms"]) + +class PlatformCreate(BaseModel): + name: str + +@router.get("") +def list_platforms(): + with get_db() as conn: + rows = conn.execute("SELECT * FROM platforms ORDER BY name").fetchall() + return [dict(r) for r in rows] + +@router.post("", status_code=201) +def create_platform(data: PlatformCreate): + with get_db() as conn: + try: + cur = conn.execute("INSERT INTO platforms (name) VALUES (?)", (data.name.strip(),)) + return dict(conn.execute("SELECT * FROM platforms WHERE id = ?", (cur.lastrowid,)).fetchone()) + except Exception: + raise HTTPException(409, "Plateforme déjà existante") + +@router.put("/{platform_id}") +def update_platform(platform_id: int, data: PlatformCreate): + with get_db() as conn: + if not conn.execute("SELECT id FROM platforms WHERE id = ?", (platform_id,)).fetchone(): + raise HTTPException(404, "Plateforme introuvable") + conn.execute("UPDATE platforms SET name = ? WHERE id = ?", (data.name.strip(), platform_id)) + return dict(conn.execute("SELECT * FROM platforms WHERE id = ?", (platform_id,)).fetchone()) + +@router.delete("/{platform_id}", status_code=204) +def delete_platform(platform_id: int): + with get_db() as conn: + conn.execute("DELETE FROM platforms WHERE id = ?", (platform_id,)) diff --git a/app/routers/rentals.py b/app/routers/rentals.py new file mode 100644 index 0000000..95242ac --- /dev/null +++ b/app/routers/rentals.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +from ..database import get_db + +router = APIRouter(prefix="/api/rentals", tags=["rentals"]) + +class RentalCreate(BaseModel): + tool_id: int + client_id: int + platform_id: Optional[int] = None + start_date: str + end_date: str + price: float + deposit_collected: bool = False + return_notes: str = "" + status: str = "confirmed" + +class RentalUpdate(BaseModel): + platform_id: Optional[int] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + price: Optional[float] = None + deposit_collected: Optional[bool] = None + deposit_returned: Optional[bool] = None + status: Optional[str] = None + return_notes: Optional[str] = None + +def rental_full(conn, row): + r = dict(row) + tool = conn.execute("SELECT id, name, daily_price, weekend_price, deposit FROM tools WHERE id = ?", (r["tool_id"],)).fetchone() + client = conn.execute("SELECT id, name, phone, email FROM clients WHERE id = ?", (r["client_id"],)).fetchone() + platform = conn.execute("SELECT id, name FROM platforms WHERE id = ?", (r["platform_id"],)).fetchone() if r.get("platform_id") else None + r["tool"] = dict(tool) if tool else None + r["client"] = dict(client) if client else None + r["platform"] = dict(platform) if platform else None + return r + +@router.get("") +def list_rentals(status: Optional[str] = None, tool_id: Optional[int] = None, month: Optional[str] = None): + with get_db() as conn: + q = "SELECT * FROM rentals WHERE 1=1" + params = [] + if status: + q += " AND status = ?"; params.append(status) + if tool_id: + q += " AND tool_id = ?"; params.append(tool_id) + if month: + q += " AND (start_date LIKE ? OR end_date LIKE ?)"; params += [f"{month}%", f"{month}%"] + q += " ORDER BY start_date DESC" + rows = conn.execute(q, params).fetchall() + return [rental_full(conn, r) for r in rows] + +@router.get("/calendar") +def calendar_rentals(year: int, month: int): + with get_db() as conn: + month_str = f"{year}-{month:02d}" + rows = conn.execute(""" + SELECT r.*, t.name as tool_name, c.name as client_name, p.name as platform_name + FROM rentals r + JOIN tools t ON t.id = r.tool_id + JOIN clients c ON c.id = r.client_id + LEFT JOIN platforms p ON p.id = r.platform_id + WHERE r.status != 'cancelled' + AND r.start_date <= ? AND r.end_date >= ? + ORDER BY r.start_date + """, (f"{year}-{month:02d}-31", f"{year}-{month:02d}-01")).fetchall() + return [dict(r) for r in rows] + +@router.get("/{rental_id}") +def get_rental(rental_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM rentals WHERE id = ?", (rental_id,)).fetchone() + if not row: + raise HTTPException(404, "Location introuvable") + return rental_full(conn, row) + +@router.post("", status_code=201) +def create_rental(data: RentalCreate): + with get_db() as conn: + if not conn.execute("SELECT id FROM tools WHERE id = ?", (data.tool_id,)).fetchone(): + raise HTTPException(404, "Outil introuvable") + if not conn.execute("SELECT id FROM clients WHERE id = ?", (data.client_id,)).fetchone(): + raise HTTPException(404, "Client introuvable") + cur = conn.execute( + "INSERT INTO rentals (tool_id, client_id, platform_id, start_date, end_date, price, deposit_collected, status, return_notes) VALUES (?,?,?,?,?,?,?,?,?)", + (data.tool_id, data.client_id, data.platform_id, data.start_date, data.end_date, data.price, 1 if data.deposit_collected else 0, data.status, data.return_notes) + ) + return rental_full(conn, conn.execute("SELECT * FROM rentals WHERE id = ?", (cur.lastrowid,)).fetchone()) + +@router.put("/{rental_id}") +def update_rental(rental_id: int, data: RentalUpdate): + with get_db() as conn: + if not conn.execute("SELECT id FROM rentals WHERE id = ?", (rental_id,)).fetchone(): + raise HTTPException(404, "Location introuvable") + fields, values = [], [] + d = data.model_dump(exclude_none=True) + for field, val in d.items(): + if field in ("deposit_collected", "deposit_returned"): + fields.append(f"{field} = ?"); values.append(1 if val else 0) + else: + fields.append(f"{field} = ?"); values.append(val) + if fields: + values.append(rental_id) + conn.execute(f"UPDATE rentals SET {', '.join(fields)} WHERE id = ?", values) + return rental_full(conn, conn.execute("SELECT * FROM rentals WHERE id = ?", (rental_id,)).fetchone()) + +@router.delete("/{rental_id}", status_code=204) +def delete_rental(rental_id: int): + with get_db() as conn: + conn.execute("DELETE FROM rentals WHERE id = ?", (rental_id,)) diff --git a/app/routers/tools.py b/app/routers/tools.py new file mode 100644 index 0000000..df999aa --- /dev/null +++ b/app/routers/tools.py @@ -0,0 +1,128 @@ +import os, uuid, shutil +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Optional +from ..database import get_db + +router = APIRouter(prefix="/api/tools", tags=["tools"]) +UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/uploads") + +class ToolUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + daily_price: Optional[float] = None + weekend_price: Optional[float] = None + deposit: Optional[float] = None + notes: Optional[str] = None + +def tool_with_images(conn, row): + t = dict(row) + imgs = conn.execute("SELECT * FROM tool_images WHERE tool_id = ? ORDER BY is_main DESC, id", (t["id"],)).fetchall() + t["images"] = [dict(i) for i in imgs] + t["main_image"] = next((i["filename"] for i in t["images"] if i["is_main"]), (t["images"][0]["filename"] if t["images"] else None)) + return t + +@router.get("") +def list_tools(): + with get_db() as conn: + rows = conn.execute("SELECT * FROM tools ORDER BY name").fetchall() + return [tool_with_images(conn, r) for r in rows] + +@router.get("/{tool_id}") +def get_tool(tool_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM tools WHERE id = ?", (tool_id,)).fetchone() + if not row: + raise HTTPException(404, "Outil introuvable") + return tool_with_images(conn, row) + +@router.post("", status_code=201) +async def create_tool( + name: str = Form(...), + description: str = Form(""), + category: str = Form(""), + daily_price: float = Form(0), + weekend_price: float = Form(0), + deposit: float = Form(0), + notes: str = Form(""), + images: list[UploadFile] = File(default=[]) +): + with get_db() as conn: + cur = conn.execute( + "INSERT INTO tools (name, description, category, daily_price, weekend_price, deposit, notes) VALUES (?,?,?,?,?,?,?)", + (name.strip(), description.strip(), category.strip(), daily_price, weekend_price, deposit, notes.strip()) + ) + tool_id = cur.lastrowid + for i, img in enumerate(images): + if img.filename: + ext = os.path.splitext(img.filename)[1].lower() + fname = f"{uuid.uuid4().hex}{ext}" + path = os.path.join(UPLOAD_DIR, "tools", fname) + with open(path, "wb") as f: + shutil.copyfileobj(img.file, f) + conn.execute("INSERT INTO tool_images (tool_id, filename, is_main) VALUES (?,?,?)", (tool_id, fname, 1 if i == 0 else 0)) + row = conn.execute("SELECT * FROM tools WHERE id = ?", (tool_id,)).fetchone() + return tool_with_images(conn, row) + +@router.put("/{tool_id}") +def update_tool(tool_id: int, data: ToolUpdate): + with get_db() as conn: + row = conn.execute("SELECT * FROM tools WHERE id = ?", (tool_id,)).fetchone() + if not row: + raise HTTPException(404, "Outil introuvable") + fields, values = [], [] + for field, val in data.model_dump(exclude_none=True).items(): + fields.append(f"{field} = ?") + values.append(val.strip() if isinstance(val, str) else val) + if fields: + values.append(tool_id) + conn.execute(f"UPDATE tools SET {', '.join(fields)} WHERE id = ?", values) + return tool_with_images(conn, conn.execute("SELECT * FROM tools WHERE id = ?", (tool_id,)).fetchone()) + +@router.delete("/{tool_id}", status_code=204) +def delete_tool(tool_id: int): + with get_db() as conn: + imgs = conn.execute("SELECT filename FROM tool_images WHERE tool_id = ?", (tool_id,)).fetchall() + for img in imgs: + path = os.path.join(UPLOAD_DIR, "tools", img["filename"]) + if os.path.exists(path): + os.remove(path) + conn.execute("DELETE FROM tools WHERE id = ?", (tool_id,)) + +@router.post("/{tool_id}/images", status_code=201) +async def add_image(tool_id: int, image: UploadFile = File(...), is_main: bool = Form(False)): + with get_db() as conn: + if not conn.execute("SELECT id FROM tools WHERE id = ?", (tool_id,)).fetchone(): + raise HTTPException(404, "Outil introuvable") + ext = os.path.splitext(image.filename)[1].lower() + fname = f"{uuid.uuid4().hex}{ext}" + path = os.path.join(UPLOAD_DIR, "tools", fname) + with open(path, "wb") as f: + shutil.copyfileobj(image.file, f) + if is_main: + conn.execute("UPDATE tool_images SET is_main = 0 WHERE tool_id = ?", (tool_id,)) + conn.execute("INSERT INTO tool_images (tool_id, filename, is_main) VALUES (?,?,?)", (tool_id, fname, 1 if is_main else 0)) + return {"filename": fname} + +@router.delete("/{tool_id}/images/{image_id}", status_code=204) +def delete_image(tool_id: int, image_id: int): + with get_db() as conn: + img = conn.execute("SELECT * FROM tool_images WHERE id = ? AND tool_id = ?", (image_id, tool_id)).fetchone() + if not img: + raise HTTPException(404, "Image introuvable") + path = os.path.join(UPLOAD_DIR, "tools", img["filename"]) + if os.path.exists(path): + os.remove(path) + conn.execute("DELETE FROM tool_images WHERE id = ?", (image_id,)) + if img["is_main"]: + first = conn.execute("SELECT id FROM tool_images WHERE tool_id = ? LIMIT 1", (tool_id,)).fetchone() + if first: + conn.execute("UPDATE tool_images SET is_main = 1 WHERE id = ?", (first["id"],)) + +@router.post("/{tool_id}/images/{image_id}/main", status_code=204) +def set_main_image(tool_id: int, image_id: int): + with get_db() as conn: + conn.execute("UPDATE tool_images SET is_main = 0 WHERE tool_id = ?", (tool_id,)) + conn.execute("UPDATE tool_images SET is_main = 1 WHERE id = ? AND tool_id = ?", (image_id, tool_id)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43b9597 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +jinja2==3.1.4 +python-multipart==0.0.12 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..86e1a64 --- /dev/null +++ b/static/app.js @@ -0,0 +1,513 @@ +// ─── API helpers ─────────────────────────────────────────────────────────── +const api = { + get: (u) => fetch(u).then(r => r.json()), + post: (u, b) => fetch(u, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(b)}).then(r => r.status===204?null:r.json()), + put: (u, b) => fetch(u, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(b)}).then(r => r.json()), + del: (u) => fetch(u, {method:'DELETE'}), + form: (u, fd, method='POST') => fetch(u, {method, body:fd}).then(r => r.status===204?null:r.json()), +}; + +// ─── State ────────────────────────────────────────────────────────────────── +let tools=[], clients=[], platforms=[], rentals=[]; +let calYear = new Date().getFullYear(), calMonth = new Date().getMonth()+1; +let rentalFilter = ''; + +// ─── Navigation ───────────────────────────────────────────────────────────── +document.querySelectorAll('.nav-item').forEach(el => { + el.addEventListener('click', () => { + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + document.querySelectorAll('.section').forEach(s => s.classList.add('hidden')); + el.classList.add('active'); + const sec = 'section-' + el.dataset.section; + document.getElementById(sec).classList.remove('hidden'); + if (el.dataset.section === 'dashboard') loadDashboard(); + if (el.dataset.section === 'tools') loadTools(); + if (el.dataset.section === 'clients') loadClients(); + if (el.dataset.section === 'rentals') loadRentals(); + if (el.dataset.section === 'calendar') loadCalendar(); + if (el.dataset.section === 'settings') loadPlatforms(); + }); +}); + +function closeModal(id) { document.getElementById(id).classList.add('hidden'); } +document.querySelectorAll('.modal').forEach(m => m.addEventListener('click', e => { if(e.target===m) m.classList.add('hidden'); })); + +// ─── Dashboard ────────────────────────────────────────────────────────────── +async function loadDashboard() { + const d = await api.get('/api/dashboard'); + const stats = document.getElementById('dash-stats'); + stats.innerHTML = ` +
${d.active_rentals}
Location(s) en cours
+
${fmt€(d.revenue_month)}
Revenus ce mois
+
${fmt€(d.revenue_total)}
Revenus total
+
${d.tools_count}
Outils
+
${d.clients_count}
Clients
+
${d.pending_deposit_return}
Cautions à rendre
+ `; + const ret = document.getElementById('dash-returning'); + if (!d.returning_soon.length) { ret.innerHTML = '
Aucun retour prévu dans les 7 jours
'; return; } + ret.innerHTML = d.returning_soon.map(r => ` +
+ 🔨 ${r.tool_name} + 👤 ${r.client_name} + 📅 Retour le ${fmtDate(r.end_date)} + ${statusLabel(r.status)} +
+ `).join(''); +} + +// ─── Tools ────────────────────────────────────────────────────────────────── +async function loadTools() { + tools = await api.get('/api/tools'); + renderTools(); +} + +function renderTools() { + const q = document.getElementById('tools-search').value.toLowerCase(); + const grid = document.getElementById('tools-grid'); + const filtered = tools.filter(t => t.name.toLowerCase().includes(q) || (t.category||'').toLowerCase().includes(q)); + if (!filtered.length) { grid.innerHTML = '
Aucun outil trouvé
'; return; } + grid.innerHTML = filtered.map(t => { + const img = t.main_image ? `` : `
🔧
`; + return `
+ ${img} +
+
${esc(t.name)}
+
${esc(t.category||'')}
+
+ ${fmt€(t.daily_price)}/j + ${t.weekend_price ? `${fmt€(t.weekend_price)}/wk` : ''} + ${t.deposit ? `🔒 ${fmt€(t.deposit)}` : ''} +
+
+
`; + }).join(''); +} + +function openToolModal(tool=null) { + document.getElementById('tool-id').value = tool?.id || ''; + document.getElementById('modal-tool-title').textContent = tool ? 'Modifier l\'outil' : 'Ajouter un outil'; + document.getElementById('tool-name').value = tool?.name || ''; + document.getElementById('tool-category').value = tool?.category || ''; + document.getElementById('tool-daily').value = tool?.daily_price ?? 0; + document.getElementById('tool-weekend').value = tool?.weekend_price ?? 0; + document.getElementById('tool-deposit').value = tool?.deposit ?? 0; + document.getElementById('tool-desc').value = tool?.description || ''; + document.getElementById('tool-notes').value = tool?.notes || ''; + document.getElementById('tool-images').value = ''; + const preview = document.getElementById('tool-images-preview'); + preview.innerHTML = ''; + if (tool?.images) { + tool.images.forEach(img => { + preview.innerHTML += `
+ + ${img.is_main ? '' : ``} + +
`; + }); + } + document.getElementById('modal-tool').classList.remove('hidden'); +} + +async function saveTool() { + const id = document.getElementById('tool-id').value; + const name = document.getElementById('tool-name').value.trim(); + if (!name) return; + if (id) { + // update fields + await api.put(`/api/tools/${id}`, { + name, description: document.getElementById('tool-desc').value, + category: document.getElementById('tool-category').value, + daily_price: parseFloat(document.getElementById('tool-daily').value)||0, + weekend_price: parseFloat(document.getElementById('tool-weekend').value)||0, + deposit: parseFloat(document.getElementById('tool-deposit').value)||0, + notes: document.getElementById('tool-notes').value + }); + // upload new images if any + const files = document.getElementById('tool-images').files; + for (const file of files) { + const fd = new FormData(); fd.append('image', file); + await api.form(`/api/tools/${id}/images`, fd); + } + } else { + const fd = new FormData(); + fd.append('name', name); + fd.append('description', document.getElementById('tool-desc').value); + fd.append('category', document.getElementById('tool-category').value); + fd.append('daily_price', document.getElementById('tool-daily').value||0); + fd.append('weekend_price', document.getElementById('tool-weekend').value||0); + fd.append('deposit', document.getElementById('tool-deposit').value||0); + fd.append('notes', document.getElementById('tool-notes').value); + const files = document.getElementById('tool-images').files; + for (const file of files) fd.append('images', file); + await api.form('/api/tools', fd); + } + closeModal('modal-tool'); + await loadTools(); +} + +async function openToolDetail(id) { + const t = await api.get(`/api/tools/${id}`); + const rents = await api.get(`/api/rentals?tool_id=${id}`); + const gallery = t.images.length ? t.images.map(img => + `` + ).join('') : 'Aucune photo'; + + document.getElementById('tool-detail-content').innerHTML = ` +

${esc(t.name)} ${t.category ? `${esc(t.category)}` : ''}

+ +
+
${fmt€(t.daily_price)}
+
${t.weekend_price ? fmt€(t.weekend_price) : '—'}
+
${t.deposit ? fmt€(t.deposit) : '—'}
+
${esc(t.description)||'—'}
+ ${t.notes ? `
${esc(t.notes)}
` : ''} +
+

Historique locations (${rents.length})

+ ${rents.slice(0,5).map(r => `
+
+
👤 ${esc(r.client?.name||'')}
+
${fmtDate(r.start_date)} → ${fmtDate(r.end_date)}${fmt€(r.price)}${statusLabel(r.status)}
+
+
`).join('')} + ${rents.length > 5 ? `
… et ${rents.length-5} autres
` : ''} + `; + document.getElementById('btn-edit-tool').onclick = () => { closeModal('modal-tool-detail'); openToolModal(t); }; + document.getElementById('btn-delete-tool').onclick = async () => { + if (!confirm('Supprimer cet outil ?')) return; + await api.del(`/api/tools/${id}`); + closeModal('modal-tool-detail'); + loadTools(); + }; + document.getElementById('modal-tool-detail').classList.remove('hidden'); +} + +async function deleteToolImage(toolId, imgId) { + await api.del(`/api/tools/${toolId}/images/${imgId}`); + const t = await api.get(`/api/tools/${toolId}`); + openToolModal(t); +} +async function setMainImage(toolId, imgId) { + await api.form(`/api/tools/${toolId}/images/${imgId}/main`, new FormData(), 'POST'); + const t = await api.get(`/api/tools/${toolId}`); + openToolModal(t); +} + +// ─── Clients ──────────────────────────────────────────────────────────────── +async function loadClients() { + clients = await api.get('/api/clients'); + renderClients(); +} + +function renderClients() { + const q = document.getElementById('clients-search').value.toLowerCase(); + const list = document.getElementById('clients-list'); + const filtered = clients.filter(c => c.name.toLowerCase().includes(q) || (c.phone||'').includes(q) || (c.email||'').toLowerCase().includes(q)); + if (!filtered.length) { list.innerHTML = '
Aucun client trouvé
'; return; } + list.innerHTML = filtered.map(c => ` +
+
${c.name[0].toUpperCase()}
+
+
${esc(c.name)}
+
+ ${c.phone ? `📞 ${esc(c.phone)}` : ''} + ${c.email ? `✉️ ${esc(c.email)}` : ''} + 📋 ${c.rental_count} location(s) +
+
+
+ `).join(''); +} + +function openClientModal(client=null) { + document.getElementById('client-id').value = client?.id || ''; + document.getElementById('modal-client-title').textContent = client ? 'Modifier le client' : 'Ajouter un client'; + document.getElementById('client-name').value = client?.name || ''; + document.getElementById('client-phone').value = client?.phone || ''; + document.getElementById('client-email').value = client?.email || ''; + document.getElementById('client-address').value = client?.address || ''; + document.getElementById('client-notes').value = client?.notes || ''; + document.getElementById('modal-client').classList.remove('hidden'); +} + +async function saveClient() { + const id = document.getElementById('client-id').value; + const name = document.getElementById('client-name').value.trim(); + if (!name) return; + const fd = new FormData(); + fd.append('name', name); + fd.append('phone', document.getElementById('client-phone').value); + fd.append('email', document.getElementById('client-email').value); + fd.append('address', document.getElementById('client-address').value); + fd.append('notes', document.getElementById('client-notes').value); + if (id) { + await api.put(`/api/clients/${id}`, { + name, phone: document.getElementById('client-phone').value, + email: document.getElementById('client-email').value, + address: document.getElementById('client-address').value, + notes: document.getElementById('client-notes').value + }); + } else { + await api.form('/api/clients', fd); + } + closeModal('modal-client'); + loadClients(); +} + +async function openClientDetail(id) { + const c = await api.get(`/api/clients/${id}`); + const docsHtml = c.documents.length ? c.documents.map(d => { + const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(d.filename); + return `
+ ${isImg ? `` : `
📄
`} +
${esc(d.label)}
+ +
`; + }).join('') : ''; + + document.getElementById('client-detail-content').innerHTML = ` +

${esc(c.name)}

+
+ ${c.phone ? `
${esc(c.phone)}
` : ''} + ${c.email ? `
${esc(c.email)}
` : ''} + ${c.address ? `
${esc(c.address)}
` : ''} + ${c.notes ? `
${esc(c.notes)}
` : ''} +
+

Documents (${c.documents.length})

+
${docsHtml}
+
+ 📎 Ajouter un document + +
+

Historique locations (${c.rentals.length})

+ ${c.rentals.slice(0,5).map(r => `
+
+
🔨 ${esc(r.tool_name)}
+
${fmtDate(r.start_date)} → ${fmtDate(r.end_date)}${fmt€(r.price)}${statusLabel(r.status)}
+
+
`).join('')} + `; + document.getElementById('btn-edit-client').onclick = () => { closeModal('modal-client-detail'); openClientModal(c); }; + document.getElementById('btn-delete-client').onclick = async () => { + if (!confirm('Supprimer ce client ?')) return; + await api.del(`/api/clients/${id}`); + closeModal('modal-client-detail'); + loadClients(); + }; + document.getElementById('modal-client-detail').classList.remove('hidden'); +} + +async function uploadDoc(clientId, input) { + const file = input.files[0]; if (!file) return; + const label = prompt('Libellé du document ?', 'Pièce d\'identité') || 'Document'; + const fd = new FormData(); fd.append('file', file); fd.append('label', label); + await api.form(`/api/clients/${clientId}/documents`, fd); + openClientDetail(clientId); +} +async function deleteDoc(clientId, docId) { + if (!confirm('Supprimer ce document ?')) return; + await api.del(`/api/clients/${clientId}/documents/${docId}`); + openClientDetail(clientId); +} + +// ─── Rentals ──────────────────────────────────────────────────────────────── +async function loadRentals() { + [tools, clients, platforms] = await Promise.all([api.get('/api/tools'), api.get('/api/clients'), api.get('/api/platforms')]); + const url = rentalFilter ? `/api/rentals?status=${rentalFilter}` : '/api/rentals'; + rentals = await api.get(url); + renderRentals(); +} + +document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + rentalFilter = btn.dataset.status; + loadRentals(); + }); +}); + +function renderRentals() { + const list = document.getElementById('rentals-list'); + if (!rentals.length) { list.innerHTML = '
Aucune location trouvée
'; return; } + list.innerHTML = rentals.map(r => ` +
+
+
🔨 ${esc(r.tool?.name||'')} — 👤 ${esc(r.client?.name||'')}
+
+ 📅 ${fmtDate(r.start_date)} → ${fmtDate(r.end_date)} + 💶 ${fmt€(r.price)} + ${r.platform ? `🔗 ${esc(r.platform.name)}` : ''} + ${statusLabel(r.status)} + ${r.deposit_collected && !r.deposit_returned ? '🔒 Caution à rendre' : ''} +
+
+
+ `).join(''); +} + +async function openRentalModal(rental=null) { + if (!tools.length) [tools, clients, platforms] = await Promise.all([api.get('/api/tools'), api.get('/api/clients'), api.get('/api/platforms')]); + document.getElementById('rental-id').value = rental?.id || ''; + document.getElementById('modal-rental-title').textContent = rental ? 'Modifier la location' : 'Nouvelle location'; + const tSel = document.getElementById('rental-tool'); + const cSel = document.getElementById('rental-client'); + const pSel = document.getElementById('rental-platform'); + tSel.innerHTML = tools.map(t => ``).join(''); + cSel.innerHTML = clients.map(c => ``).join(''); + pSel.innerHTML = `` + platforms.map(p => ``).join(''); + document.getElementById('rental-start').value = rental?.start_date || ''; + document.getElementById('rental-end').value = rental?.end_date || ''; + document.getElementById('rental-price').value = rental?.price ?? ''; + document.getElementById('rental-deposit-collected').checked = !!rental?.deposit_collected; + document.getElementById('rental-deposit-returned').checked = !!rental?.deposit_returned; + document.getElementById('rental-notes').value = rental?.return_notes || ''; + document.getElementById('rental-status').value = rental?.status || 'confirmed'; + document.getElementById('rental-price-hint').textContent = ''; + document.getElementById('modal-rental').classList.remove('hidden'); +} + +function calcRentalPrice() { + const start = document.getElementById('rental-start').value; + const end = document.getElementById('rental-end').value; + const toolId = parseInt(document.getElementById('rental-tool').value); + if (!start || !end || !toolId) return; + const t = tools.find(t => t.id === toolId); + if (!t) return; + const days = Math.max(1, Math.ceil((new Date(end) - new Date(start)) / 86400000) + 1); + const isWeekend = days <= 3 && [6,0].includes(new Date(start).getDay()); + const price = isWeekend && t.weekend_price ? t.weekend_price : t.daily_price * days; + document.getElementById('rental-price').value = price.toFixed(2); + document.getElementById('rental-price-hint').textContent = `${days} jour(s) × ${fmt€(t.daily_price)} = ${fmt€(price)} (estimé)`; +} + +async function saveRental() { + const id = document.getElementById('rental-id').value; + const data = { + tool_id: parseInt(document.getElementById('rental-tool').value), + client_id: parseInt(document.getElementById('rental-client').value), + platform_id: parseInt(document.getElementById('rental-platform').value)||null, + start_date: document.getElementById('rental-start').value, + end_date: document.getElementById('rental-end').value, + price: parseFloat(document.getElementById('rental-price').value)||0, + deposit_collected: document.getElementById('rental-deposit-collected').checked, + deposit_returned: document.getElementById('rental-deposit-returned').checked, + status: document.getElementById('rental-status').value, + return_notes: document.getElementById('rental-notes').value + }; + if (!data.start_date || !data.end_date) return; + if (id) await api.put(`/api/rentals/${id}`, data); + else await api.post('/api/rentals', data); + closeModal('modal-rental'); + loadRentals(); +} + +async function openRentalDetail(id) { + const r = await api.get(`/api/rentals/${id}`); + const days = Math.max(1, Math.ceil((new Date(r.end_date) - new Date(r.start_date)) / 86400000) + 1); + // reuse rental modal for edit + await openRentalModal(r); + document.getElementById('modal-rental-title').textContent = 'Modifier la location'; + // add delete button + const actions = document.querySelector('#modal-rental .modal-actions'); + if (!document.getElementById('btn-del-rental')) { + const del = document.createElement('button'); + del.className = 'btn btn-danger'; del.id = 'btn-del-rental'; del.textContent = 'Supprimer'; + del.onclick = async () => { + if (!confirm('Supprimer cette location ?')) return; + await api.del(`/api/rentals/${id}`); + closeModal('modal-rental'); + loadRentals(); + }; + actions.prepend(del); + } else { + document.getElementById('btn-del-rental').onclick = async () => { + if (!confirm('Supprimer cette location ?')) return; + await api.del(`/api/rentals/${id}`); + closeModal('modal-rental'); + loadRentals(); + }; + } +} + +// ─── Calendar ──────────────────────────────────────────────────────────────── +async function loadCalendar() { + const data = await api.get(`/api/rentals/calendar?year=${calYear}&month=${calMonth}`); + renderCalendar(data); +} + +function calPrev() { calMonth--; if(calMonth<1){calMonth=12;calYear--;} loadCalendar(); } +function calNext() { calMonth++; if(calMonth>12){calMonth=1;calYear++;} loadCalendar(); } + +function renderCalendar(rentals) { + const title = document.getElementById('cal-title'); + title.textContent = new Date(calYear, calMonth-1, 1).toLocaleDateString('fr-FR', {month:'long', year:'numeric'}); + + const today = new Date().toISOString().slice(0,10); + const firstDay = new Date(calYear, calMonth-1, 1).getDay(); + const daysInMonth = new Date(calYear, calMonth, 0).getDate(); + const startOffset = (firstDay + 6) % 7; // Monday start + + let html = ``; + ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'].forEach(d => html += ``); + html += ''; + + for (let i=0; i r.start_date <= dateStr && r.end_date >= dateStr); + const isToday = dateStr === today; + const numEl = isToday ? `
${day}
` : `
${day}
`; + const events = dayRentals.map(r => + `
+ 🔨 ${esc(r.tool_name)} +
` + ).join(''); + html += `
`; + col++; + if (col % 7 === 0 && day < daysInMonth) html += ''; + } + while (col % 7 !== 0) { html += ''; col++; } + html += '
${d}
${numEl}${events}
'; + document.getElementById('calendar-wrap').innerHTML = html; +} + +// ─── Platforms ─────────────────────────────────────────────────────────────── +async function loadPlatforms() { + platforms = await api.get('/api/platforms'); + const list = document.getElementById('platforms-list'); + list.innerHTML = platforms.length ? platforms.map(p => ` +
+ ${esc(p.name)} + +
+ `).join('') : '
Aucune plateforme
'; +} + +async function addPlatform() { + const name = document.getElementById('new-platform').value.trim(); + if (!name) return; + await api.post('/api/platforms', {name}); + document.getElementById('new-platform').value = ''; + loadPlatforms(); +} +document.getElementById('new-platform').addEventListener('keydown', e => { if(e.key==='Enter') addPlatform(); }); + +async function deletePlatform(id) { + if (!confirm('Supprimer cette plateforme ?')) return; + await api.del(`/api/platforms/${id}`); + loadPlatforms(); +} + +// ─── Utils ────────────────────────────────────────────────────────────────── +function fmt€(v) { return Number(v).toLocaleString('fr-FR', {style:'currency', currency:'EUR'}); } +function fmtDate(d) { if(!d) return '—'; return new Date(d+'T00:00:00').toLocaleDateString('fr-FR'); } +function esc(s) { return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +function statusLabel(s) { + return {confirmed:'Confirmée', ongoing:'En cours', returned:'Retournée', cancelled:'Annulée'}[s] || s; +} + +// ─── Init ─────────────────────────────────────────────────────────────────── +loadDashboard(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..8193236 --- /dev/null +++ b/static/style.css @@ -0,0 +1,182 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface2: #293548; + --border: #334155; + --text: #f1f5f9; + --muted: #94a3b8; + --primary: #6366f1; + --primary-h: #4f46e5; + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; + --info: #38bdf8; + --radius: 10px; + --sidebar: 220px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; } + +.layout { display: flex; height: 100vh; } + +/* Sidebar */ +.sidebar { width: var(--sidebar); background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 16px 0; flex-shrink: 0; } +.logo { font-size: 1.1rem; font-weight: 700; padding: 0 16px 20px; border-bottom: 1px solid var(--border); margin-bottom: 8px; } +.nav-item { display: block; padding: 10px 16px; color: var(--muted); text-decoration: none; border-radius: 8px; margin: 2px 8px; cursor: pointer; font-size: 0.9rem; transition: background 0.15s, color 0.15s; } +.nav-item:hover { background: var(--surface2); color: var(--text); } +.nav-item.active { background: var(--primary); color: #fff; } + +/* Content */ +.content { flex: 1; overflow-y: auto; padding: 24px; } +.section { display: flex; flex-direction: column; gap: 16px; } +.section.hidden { display: none; } +.section-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } +.section-header h2 { font-size: 1.3rem; font-weight: 700; } +h2 { font-size: 1.3rem; font-weight: 700; } +h3 { font-size: 1rem; font-weight: 600; color: var(--muted); } + +/* Cards */ +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; } + +/* Stats grid */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } +.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; } +.stat-card .stat-val { font-size: 1.8rem; font-weight: 700; } +.stat-card .stat-label { font-size: 0.8rem; color: var(--muted); margin-top: 4px; } +.stat-card.primary .stat-val { color: var(--primary); } +.stat-card.success .stat-val { color: var(--success); } +.stat-card.warning .stat-val { color: var(--warning); } +.stat-card.info .stat-val { color: var(--info); } + +/* Tools grid */ +.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } +.tool-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; cursor: pointer; transition: border-color 0.15s, transform 0.1s; } +.tool-card:hover { border-color: var(--primary); transform: translateY(-2px); } +.tool-card .tool-img { width: 100%; height: 150px; object-fit: cover; background: var(--surface2); display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--muted); } +.tool-card img.tool-img { display: block; } +.tool-card .tool-info { padding: 12px; } +.tool-card .tool-name { font-weight: 600; font-size: 0.95rem; } +.tool-card .tool-cat { font-size: 0.75rem; color: var(--muted); margin: 2px 0 8px; } +.tool-card .tool-prices { font-size: 0.82rem; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; } +.tool-card .tool-prices span { background: var(--surface2); padding: 2px 8px; border-radius: 99px; } + +/* Clients list */ +.client-row { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; display: flex; align-items: center; gap: 12px; cursor: pointer; transition: border-color 0.15s; margin-bottom: 8px; } +.client-row:hover { border-color: var(--primary); } +.client-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--primary); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1rem; flex-shrink: 0; } +.client-info { flex: 1; } +.client-name { font-weight: 600; } +.client-meta { font-size: 0.8rem; color: var(--muted); display: flex; gap: 12px; margin-top: 2px; } + +/* Rentals */ +.rental-row { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; display: flex; align-items: flex-start; gap: 12px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.15s; } +.rental-row:hover { border-color: var(--primary); } +.rental-info { flex: 1; } +.rental-title { font-weight: 600; } +.rental-meta { font-size: 0.8rem; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; } +.badge-confirmed { background: rgba(99,102,241,0.2); color: var(--primary); } +.badge-ongoing { background: rgba(34,197,94,0.2); color: var(--success); } +.badge-returned { background: rgba(148,163,184,0.15); color: var(--muted); } +.badge-cancelled { background: rgba(239,68,68,0.15); color: var(--danger); } + +/* Filter bar */ +.filter-bar { display: flex; gap: 6px; flex-wrap: wrap; } +.filter-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 99px; background: transparent; color: var(--muted); cursor: pointer; font-size: 0.85rem; transition: all 0.15s; } +.filter-btn.active, .filter-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* Search */ +.search-bar input { width: 100%; max-width: 400px; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 8px 12px; font-size: 0.9rem; outline: none; } +.search-bar input:focus { border-color: var(--primary); } + +/* Calendar */ +.cal-nav { display: flex; align-items: center; gap: 12px; } +.cal-nav span { font-weight: 600; min-width: 140px; text-align: center; } +.cal-table { width: 100%; border-collapse: collapse; } +.cal-table th { padding: 8px; text-align: center; font-size: 0.8rem; color: var(--muted); border-bottom: 1px solid var(--border); } +.cal-table td { padding: 4px; vertical-align: top; min-width: 80px; min-height: 80px; border: 1px solid var(--border); border-radius: 4px; } +.cal-day-num { font-size: 0.8rem; color: var(--muted); padding: 2px 4px; } +.cal-day-num.today { background: var(--primary); color: #fff; border-radius: 50%; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; } +.cal-event { font-size: 0.7rem; padding: 2px 5px; border-radius: 4px; margin-bottom: 2px; cursor: pointer; background: rgba(99,102,241,0.25); color: var(--primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cal-event:hover { background: rgba(99,102,241,0.45); } + +/* Platforms */ +.platform-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); } +.platform-row:last-child { border-bottom: none; } +.platform-row span { flex: 1; } + +/* Returning soon */ +.return-row { background: var(--surface); border: 1px solid var(--warning); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; cursor: pointer; } +.return-row:hover { border-color: var(--primary); } + +/* Buttons */ +.btn { padding: 8px 16px; border: none; border-radius: 8px; font-size: 0.88rem; font-weight: 600; cursor: pointer; transition: background 0.15s, opacity 0.15s; white-space: nowrap; } +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-h); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { opacity: 0.85; } +.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--surface2); color: var(--text); } +.btn-sm { padding: 4px 10px; font-size: 0.78rem; } +.btn-icon { background: transparent; border: none; cursor: pointer; color: var(--muted); padding: 4px 6px; border-radius: 6px; } +.btn-icon:hover { background: var(--surface2); color: var(--danger); } + +/* Modals */ +.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.65); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 16px; } +.modal.hidden { display: none; } +.modal-box { width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } +.modal-box.wide { max-width: 800px; } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; margin-top: 8px; } + +/* Forms */ +label { font-size: 0.82rem; color: var(--muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; } +input[type="text"], input[type="email"], input[type="number"], input[type="date"], select, textarea { + width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 8px 12px; font-size: 0.9rem; outline: none; margin-top: 2px; font-family: inherit; +} +input:focus, select:focus, textarea:focus { border-color: var(--primary); } +select option { background: var(--surface); } +textarea { resize: vertical; } +input[type="checkbox"] { width: auto; margin: 0; } +input[type="file"] { padding: 6px; font-size: 0.8rem; } +.form-row { display: flex; gap: 10px; align-items: flex-start; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.form-col { display: flex; flex-direction: column; } +.price-hint { font-size: 0.78rem; color: var(--warning); margin-top: 4px; } + +/* Images preview */ +.images-preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.images-preview .img-thumb { position: relative; width: 80px; height: 80px; border-radius: 8px; overflow: hidden; border: 2px solid var(--border); } +.images-preview .img-thumb img { width: 100%; height: 100%; object-fit: cover; } +.images-preview .img-thumb .img-del { position: absolute; top: 2px; right: 2px; background: rgba(0,0,0,0.7); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.7rem; padding: 1px 4px; } +.images-preview .img-thumb .img-main { position: absolute; bottom: 2px; left: 2px; background: var(--primary); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.6rem; padding: 1px 4px; } + +/* Tool detail */ +.tool-detail-gallery { display: flex; gap: 8px; overflow-x: auto; margin-bottom: 12px; } +.tool-detail-gallery img { height: 180px; border-radius: 8px; object-fit: cover; cursor: pointer; border: 2px solid transparent; } +.tool-detail-gallery img.main-img { border-color: var(--primary); } +.tool-detail-info { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.detail-field label { color: var(--muted); font-size: 0.78rem; } +.detail-field .val { font-size: 0.95rem; margin-top: 2px; } +.detail-field .price { font-size: 1.2rem; font-weight: 700; color: var(--success); } + +/* Client detail */ +.doc-grid { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0; } +.doc-item { position: relative; } +.doc-item img, .doc-item .doc-file { width: 100px; height: 100px; border-radius: 8px; object-fit: cover; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 2rem; background: var(--surface2); } +.doc-item .doc-del { position: absolute; top: 2px; right: 2px; background: rgba(0,0,0,0.7); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.7rem; padding: 2px 5px; } +.doc-item .doc-label { font-size: 0.7rem; color: var(--muted); text-align: center; margin-top: 4px; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.upload-zone { border: 2px dashed var(--border); border-radius: 8px; padding: 16px; text-align: center; color: var(--muted); font-size: 0.85rem; cursor: pointer; } +.upload-zone:hover { border-color: var(--primary); color: var(--text); } + +.empty-state { text-align: center; color: var(--muted); padding: 40px; } + +@media (max-width: 600px) { + .form-grid { grid-template-columns: 1fr; } + .tool-detail-info { grid-template-columns: 1fr; } + body { overflow: auto; } + .layout { flex-direction: column; height: auto; } + .sidebar { width: 100%; flex-direction: row; overflow-x: auto; padding: 8px; gap: 4px; border-right: none; border-bottom: 1px solid var(--border); } + .nav-item { white-space: nowrap; } + .logo { display: none; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2dd7fce --- /dev/null +++ b/templates/index.html @@ -0,0 +1,221 @@ + + + + + + LocOutil + + + +
+ + + +
+ + +
+

Dashboard

+
+

Retours dans les 7 jours

+
+
+ + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + +