Initial commit: locoutil — gestion de location d'outils
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
uploads/
|
||||
*.db
|
||||
.env
|
||||
+17
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
+61
@@ -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
|
||||
}
|
||||
@@ -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,))
|
||||
@@ -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,))
|
||||
@@ -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,))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
jinja2==3.1.4
|
||||
python-multipart==0.0.12
|
||||
+513
@@ -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 = `
|
||||
<div class="stat-card primary"><div class="stat-val">${d.active_rentals}</div><div class="stat-label">Location(s) en cours</div></div>
|
||||
<div class="stat-card success"><div class="stat-val">${fmt€(d.revenue_month)}</div><div class="stat-label">Revenus ce mois</div></div>
|
||||
<div class="stat-card info"><div class="stat-val">${fmt€(d.revenue_total)}</div><div class="stat-label">Revenus total</div></div>
|
||||
<div class="stat-card"><div class="stat-val">${d.tools_count}</div><div class="stat-label">Outils</div></div>
|
||||
<div class="stat-card"><div class="stat-val">${d.clients_count}</div><div class="stat-label">Clients</div></div>
|
||||
<div class="stat-card warning"><div class="stat-val">${d.pending_deposit_return}</div><div class="stat-label">Cautions à rendre</div></div>
|
||||
`;
|
||||
const ret = document.getElementById('dash-returning');
|
||||
if (!d.returning_soon.length) { ret.innerHTML = '<div class="empty-state">Aucun retour prévu dans les 7 jours</div>'; return; }
|
||||
ret.innerHTML = d.returning_soon.map(r => `
|
||||
<div class="return-row" onclick="openRentalDetail(${r.id})">
|
||||
<span>🔨 <strong>${r.tool_name}</strong></span>
|
||||
<span>👤 ${r.client_name}</span>
|
||||
<span>📅 Retour le ${fmtDate(r.end_date)}</span>
|
||||
<span class="badge badge-${r.status}">${statusLabel(r.status)}</span>
|
||||
</div>
|
||||
`).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 = '<div class="empty-state">Aucun outil trouvé</div>'; return; }
|
||||
grid.innerHTML = filtered.map(t => {
|
||||
const img = t.main_image ? `<img class="tool-img" src="/uploads/tools/${t.main_image}" loading="lazy"/>` : `<div class="tool-img">🔧</div>`;
|
||||
return `<div class="tool-card" onclick="openToolDetail(${t.id})">
|
||||
${img}
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">${esc(t.name)}</div>
|
||||
<div class="tool-cat">${esc(t.category||'')}</div>
|
||||
<div class="tool-prices">
|
||||
<span>${fmt€(t.daily_price)}/j</span>
|
||||
${t.weekend_price ? `<span>${fmt€(t.weekend_price)}/wk</span>` : ''}
|
||||
${t.deposit ? `<span>🔒 ${fmt€(t.deposit)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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 += `<div class="img-thumb">
|
||||
<img src="/uploads/tools/${img.filename}"/>
|
||||
${img.is_main ? '<span class="img-main">★</span>' : `<button class="img-main" onclick="setMainImage(${tool.id},${img.id})">★</button>`}
|
||||
<button class="img-del" onclick="deleteToolImage(${tool.id},${img.id})">×</button>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
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 =>
|
||||
`<img src="/uploads/tools/${img.filename}" class="${img.is_main?'main-img':''}" title="${img.is_main?'Principale':''}" onclick="setMainImage(${id},${img.id})"/>`
|
||||
).join('') : '<span style="color:var(--muted)">Aucune photo</span>';
|
||||
|
||||
document.getElementById('tool-detail-content').innerHTML = `
|
||||
<h2>${esc(t.name)} ${t.category ? `<span style="font-size:.8rem;color:var(--muted)">${esc(t.category)}</span>` : ''}</h2>
|
||||
<div class="tool-detail-gallery">${gallery}</div>
|
||||
<div class="tool-detail-info">
|
||||
<div class="detail-field"><label>Prix / jour</label><div class="val price">${fmt€(t.daily_price)}</div></div>
|
||||
<div class="detail-field"><label>Prix week-end</label><div class="val price">${t.weekend_price ? fmt€(t.weekend_price) : '—'}</div></div>
|
||||
<div class="detail-field"><label>Caution</label><div class="val">${t.deposit ? fmt€(t.deposit) : '—'}</div></div>
|
||||
<div class="detail-field"><label>Description</label><div class="val">${esc(t.description)||'—'}</div></div>
|
||||
${t.notes ? `<div class="detail-field"><label>Notes</label><div class="val">${esc(t.notes)}</div></div>` : ''}
|
||||
</div>
|
||||
<h3 style="margin-top:12px">Historique locations (${rents.length})</h3>
|
||||
${rents.slice(0,5).map(r => `<div class="rental-row" onclick="closeModal('modal-tool-detail');openRentalDetail(${r.id})">
|
||||
<div class="rental-info">
|
||||
<div class="rental-title">👤 ${esc(r.client?.name||'')}</div>
|
||||
<div class="rental-meta"><span>${fmtDate(r.start_date)} → ${fmtDate(r.end_date)}</span><span>${fmt€(r.price)}</span><span class="badge badge-${r.status}">${statusLabel(r.status)}</span></div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
${rents.length > 5 ? `<div style="color:var(--muted);font-size:.8rem">… et ${rents.length-5} autres</div>` : ''}
|
||||
`;
|
||||
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 = '<div class="empty-state">Aucun client trouvé</div>'; return; }
|
||||
list.innerHTML = filtered.map(c => `
|
||||
<div class="client-row" onclick="openClientDetail(${c.id})">
|
||||
<div class="client-avatar">${c.name[0].toUpperCase()}</div>
|
||||
<div class="client-info">
|
||||
<div class="client-name">${esc(c.name)}</div>
|
||||
<div class="client-meta">
|
||||
${c.phone ? `<span>📞 ${esc(c.phone)}</span>` : ''}
|
||||
${c.email ? `<span>✉️ ${esc(c.email)}</span>` : ''}
|
||||
<span>📋 ${c.rental_count} location(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).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 `<div class="doc-item">
|
||||
${isImg ? `<img src="/uploads/clients/${d.filename}"/>` : `<div class="doc-file">📄</div>`}
|
||||
<div class="doc-label">${esc(d.label)}</div>
|
||||
<button class="doc-del" onclick="deleteDoc(${id},${d.id})">×</button>
|
||||
</div>`;
|
||||
}).join('') : '';
|
||||
|
||||
document.getElementById('client-detail-content').innerHTML = `
|
||||
<h2>${esc(c.name)}</h2>
|
||||
<div class="tool-detail-info" style="margin-bottom:12px">
|
||||
${c.phone ? `<div class="detail-field"><label>Téléphone</label><div class="val">${esc(c.phone)}</div></div>` : ''}
|
||||
${c.email ? `<div class="detail-field"><label>Email</label><div class="val">${esc(c.email)}</div></div>` : ''}
|
||||
${c.address ? `<div class="detail-field"><label>Adresse</label><div class="val">${esc(c.address)}</div></div>` : ''}
|
||||
${c.notes ? `<div class="detail-field"><label>Notes</label><div class="val">${esc(c.notes)}</div></div>` : ''}
|
||||
</div>
|
||||
<h3>Documents (${c.documents.length})</h3>
|
||||
<div class="doc-grid">${docsHtml}</div>
|
||||
<div class="upload-zone" onclick="document.getElementById('doc-upload-${id}').click()">
|
||||
📎 Ajouter un document
|
||||
<input type="file" id="doc-upload-${id}" accept="image/*,.pdf" style="display:none" onchange="uploadDoc(${id},this)"/>
|
||||
</div>
|
||||
<h3 style="margin-top:12px">Historique locations (${c.rentals.length})</h3>
|
||||
${c.rentals.slice(0,5).map(r => `<div class="rental-row" onclick="closeModal('modal-client-detail');openRentalDetail(${r.id})">
|
||||
<div class="rental-info">
|
||||
<div class="rental-title">🔨 ${esc(r.tool_name)}</div>
|
||||
<div class="rental-meta"><span>${fmtDate(r.start_date)} → ${fmtDate(r.end_date)}</span><span>${fmt€(r.price)}</span><span class="badge badge-${r.status}">${statusLabel(r.status)}</span></div>
|
||||
</div>
|
||||
</div>`).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 = '<div class="empty-state">Aucune location trouvée</div>'; return; }
|
||||
list.innerHTML = rentals.map(r => `
|
||||
<div class="rental-row" onclick="openRentalDetail(${r.id})">
|
||||
<div class="rental-info">
|
||||
<div class="rental-title">🔨 ${esc(r.tool?.name||'')} — 👤 ${esc(r.client?.name||'')}</div>
|
||||
<div class="rental-meta">
|
||||
<span>📅 ${fmtDate(r.start_date)} → ${fmtDate(r.end_date)}</span>
|
||||
<span>💶 ${fmt€(r.price)}</span>
|
||||
${r.platform ? `<span>🔗 ${esc(r.platform.name)}</span>` : ''}
|
||||
<span class="badge badge-${r.status}">${statusLabel(r.status)}</span>
|
||||
${r.deposit_collected && !r.deposit_returned ? '<span style="color:var(--warning)">🔒 Caution à rendre</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).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 => `<option value="${t.id}" ${rental?.tool_id===t.id?'selected':''}>${esc(t.name)}</option>`).join('');
|
||||
cSel.innerHTML = clients.map(c => `<option value="${c.id}" ${rental?.client_id===c.id?'selected':''}>${esc(c.name)}</option>`).join('');
|
||||
pSel.innerHTML = `<option value="">—</option>` + platforms.map(p => `<option value="${p.id}" ${rental?.platform_id===p.id?'selected':''}>${esc(p.name)}</option>`).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 = `<table class="cal-table"><thead><tr>`;
|
||||
['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'].forEach(d => html += `<th>${d}</th>`);
|
||||
html += '</tr></thead><tbody><tr>';
|
||||
|
||||
for (let i=0; i<startOffset; i++) html += '<td></td>';
|
||||
let col = startOffset;
|
||||
|
||||
for (let day=1; day<=daysInMonth; day++) {
|
||||
const dateStr = `${calYear}-${String(calMonth).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
|
||||
const dayRentals = rentals.filter(r => r.start_date <= dateStr && r.end_date >= dateStr);
|
||||
const isToday = dateStr === today;
|
||||
const numEl = isToday ? `<div class="cal-day-num today">${day}</div>` : `<div class="cal-day-num">${day}</div>`;
|
||||
const events = dayRentals.map(r =>
|
||||
`<div class="cal-event" onclick="openRentalDetail(${r.id})" title="${esc(r.tool_name)} — ${esc(r.client_name)} (${esc(r.platform_name||'Direct')})">
|
||||
🔨 ${esc(r.tool_name)}
|
||||
</div>`
|
||||
).join('');
|
||||
html += `<td>${numEl}${events}</td>`;
|
||||
col++;
|
||||
if (col % 7 === 0 && day < daysInMonth) html += '</tr><tr>';
|
||||
}
|
||||
while (col % 7 !== 0) { html += '<td></td>'; col++; }
|
||||
html += '</tr></tbody></table>';
|
||||
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 => `
|
||||
<div class="platform-row">
|
||||
<span>${esc(p.name)}</span>
|
||||
<button class="btn-icon" onclick="deletePlatform(${p.id})">🗑️</button>
|
||||
</div>
|
||||
`).join('') : '<div style="color:var(--muted);padding:8px">Aucune plateforme</div>';
|
||||
}
|
||||
|
||||
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,'>').replace(/"/g,'"'); }
|
||||
function statusLabel(s) {
|
||||
return {confirmed:'Confirmée', ongoing:'En cours', returned:'Retournée', cancelled:'Annulée'}[s] || s;
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────────────────────────────────────────
|
||||
loadDashboard();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<title>LocOutil</title>
|
||||
<link rel="stylesheet" href="/static/style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="logo">🔧 LocOutil</div>
|
||||
<a class="nav-item active" data-section="dashboard">📊 Dashboard</a>
|
||||
<a class="nav-item" data-section="tools">🔨 Outils</a>
|
||||
<a class="nav-item" data-section="clients">👥 Clients</a>
|
||||
<a class="nav-item" data-section="rentals">📋 Locations</a>
|
||||
<a class="nav-item" data-section="calendar">📅 Calendrier</a>
|
||||
<a class="nav-item" data-section="settings">⚙️ Paramètres</a>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<section id="section-dashboard" class="section">
|
||||
<h2>Dashboard</h2>
|
||||
<div class="stats-grid" id="dash-stats"></div>
|
||||
<h3 style="margin-top:24px">Retours dans les 7 jours</h3>
|
||||
<div id="dash-returning"></div>
|
||||
</section>
|
||||
|
||||
<!-- OUTILS -->
|
||||
<section id="section-tools" class="section hidden">
|
||||
<div class="section-header">
|
||||
<h2>Outils</h2>
|
||||
<button class="btn btn-primary" onclick="openToolModal()">+ Ajouter</button>
|
||||
</div>
|
||||
<div class="search-bar"><input type="text" id="tools-search" placeholder="Rechercher…" oninput="renderTools()"/></div>
|
||||
<div id="tools-grid" class="tools-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- CLIENTS -->
|
||||
<section id="section-clients" class="section hidden">
|
||||
<div class="section-header">
|
||||
<h2>Clients</h2>
|
||||
<button class="btn btn-primary" onclick="openClientModal()">+ Ajouter</button>
|
||||
</div>
|
||||
<div class="search-bar"><input type="text" id="clients-search" placeholder="Rechercher…" oninput="renderClients()"/></div>
|
||||
<div id="clients-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- LOCATIONS -->
|
||||
<section id="section-rentals" class="section hidden">
|
||||
<div class="section-header">
|
||||
<h2>Locations</h2>
|
||||
<button class="btn btn-primary" onclick="openRentalModal()">+ Ajouter</button>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<button class="filter-btn active" data-status="">Toutes</button>
|
||||
<button class="filter-btn" data-status="confirmed">Confirmées</button>
|
||||
<button class="filter-btn" data-status="ongoing">En cours</button>
|
||||
<button class="filter-btn" data-status="returned">Retournées</button>
|
||||
<button class="filter-btn" data-status="cancelled">Annulées</button>
|
||||
</div>
|
||||
<div id="rentals-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- CALENDRIER -->
|
||||
<section id="section-calendar" class="section hidden">
|
||||
<div class="section-header">
|
||||
<h2>Calendrier</h2>
|
||||
<div class="cal-nav">
|
||||
<button class="btn btn-ghost" onclick="calPrev()">‹</button>
|
||||
<span id="cal-title"></span>
|
||||
<button class="btn btn-ghost" onclick="calNext()">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="calendar-wrap"></div>
|
||||
</section>
|
||||
|
||||
<!-- PARAMÈTRES -->
|
||||
<section id="section-settings" class="section hidden">
|
||||
<h2>Paramètres — Plateformes</h2>
|
||||
<div class="card" style="max-width:500px">
|
||||
<div id="platforms-list"></div>
|
||||
<div class="form-row" style="margin-top:12px">
|
||||
<input type="text" id="new-platform" placeholder="Nouvelle plateforme…"/>
|
||||
<button class="btn btn-primary" onclick="addPlatform()">Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- MODAL OUTIL -->
|
||||
<div id="modal-tool" class="modal hidden">
|
||||
<div class="modal-box card">
|
||||
<h2 id="modal-tool-title">Ajouter un outil</h2>
|
||||
<input type="hidden" id="tool-id"/>
|
||||
<div class="form-grid">
|
||||
<div class="form-col">
|
||||
<label>Nom *</label>
|
||||
<input type="text" id="tool-name" placeholder="Ex: Perceuse Bosch"/>
|
||||
<label>Catégorie</label>
|
||||
<input type="text" id="tool-category" placeholder="Ex: Électroportatif"/>
|
||||
<label>Prix / jour (€) *</label>
|
||||
<input type="number" id="tool-daily" min="0" step="0.5" value="0"/>
|
||||
<label>Prix week-end (€)</label>
|
||||
<input type="number" id="tool-weekend" min="0" step="0.5" value="0"/>
|
||||
<label>Caution (€)</label>
|
||||
<input type="number" id="tool-deposit" min="0" step="1" value="0"/>
|
||||
</div>
|
||||
<div class="form-col">
|
||||
<label>Description</label>
|
||||
<textarea id="tool-desc" rows="3" placeholder="État, accessoires inclus…"></textarea>
|
||||
<label>Notes internes</label>
|
||||
<textarea id="tool-notes" rows="2" placeholder="Notes privées…"></textarea>
|
||||
<label>Photos</label>
|
||||
<input type="file" id="tool-images" accept="image/*" multiple/>
|
||||
<div id="tool-images-preview" class="images-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" onclick="closeModal('modal-tool')">Annuler</button>
|
||||
<button class="btn btn-primary" onclick="saveTool()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL OUTIL DÉTAIL -->
|
||||
<div id="modal-tool-detail" class="modal hidden">
|
||||
<div class="modal-box card wide">
|
||||
<div id="tool-detail-content"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" onclick="closeModal('modal-tool-detail')">Fermer</button>
|
||||
<button class="btn btn-danger" id="btn-delete-tool">Supprimer</button>
|
||||
<button class="btn btn-primary" id="btn-edit-tool">Modifier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL CLIENT -->
|
||||
<div id="modal-client" class="modal hidden">
|
||||
<div class="modal-box card">
|
||||
<h2 id="modal-client-title">Ajouter un client</h2>
|
||||
<input type="hidden" id="client-id"/>
|
||||
<label>Nom *</label>
|
||||
<input type="text" id="client-name"/>
|
||||
<label>Téléphone</label>
|
||||
<input type="text" id="client-phone"/>
|
||||
<label>Email</label>
|
||||
<input type="email" id="client-email"/>
|
||||
<label>Adresse</label>
|
||||
<input type="text" id="client-address"/>
|
||||
<label>Notes</label>
|
||||
<textarea id="client-notes" rows="2"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" onclick="closeModal('modal-client')">Annuler</button>
|
||||
<button class="btn btn-primary" onclick="saveClient()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL CLIENT DÉTAIL -->
|
||||
<div id="modal-client-detail" class="modal hidden">
|
||||
<div class="modal-box card wide">
|
||||
<div id="client-detail-content"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" onclick="closeModal('modal-client-detail')">Fermer</button>
|
||||
<button class="btn btn-danger" id="btn-delete-client">Supprimer</button>
|
||||
<button class="btn btn-primary" id="btn-edit-client">Modifier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL LOCATION -->
|
||||
<div id="modal-rental" class="modal hidden">
|
||||
<div class="modal-box card">
|
||||
<h2 id="modal-rental-title">Nouvelle location</h2>
|
||||
<input type="hidden" id="rental-id"/>
|
||||
<div class="form-grid">
|
||||
<div class="form-col">
|
||||
<label>Outil *</label>
|
||||
<select id="rental-tool"></select>
|
||||
<label>Client *</label>
|
||||
<select id="rental-client"></select>
|
||||
<label>Plateforme</label>
|
||||
<select id="rental-platform"></select>
|
||||
<label>Statut</label>
|
||||
<select id="rental-status">
|
||||
<option value="confirmed">Confirmée</option>
|
||||
<option value="ongoing">En cours</option>
|
||||
<option value="returned">Retournée</option>
|
||||
<option value="cancelled">Annulée</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-col">
|
||||
<label>Date début *</label>
|
||||
<input type="date" id="rental-start" oninput="calcRentalPrice()"/>
|
||||
<label>Date fin *</label>
|
||||
<input type="date" id="rental-end" oninput="calcRentalPrice()"/>
|
||||
<label>Prix total (€) *</label>
|
||||
<input type="number" id="rental-price" min="0" step="0.5"/>
|
||||
<div class="price-hint" id="rental-price-hint"></div>
|
||||
<label><input type="checkbox" id="rental-deposit-collected"/> Caution encaissée</label>
|
||||
<label><input type="checkbox" id="rental-deposit-returned"/> Caution rendue</label>
|
||||
<label>Notes de retour</label>
|
||||
<textarea id="rental-notes" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" onclick="closeModal('modal-rental')">Annuler</button>
|
||||
<button class="btn btn-primary" onclick="saveRental()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user