diff --git a/app/database.py b/app/database.py index 89dc138..52f3517 100644 --- a/app/database.py +++ b/app/database.py @@ -63,6 +63,21 @@ CREATE TABLE IF NOT EXISTS rentals ( created_at TEXT DEFAULT (datetime('now', 'localtime')) ); +CREATE TABLE IF NOT EXISTS listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE, + platform_id INTEGER NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + is_active INTEGER DEFAULT 1, + title TEXT DEFAULT '', + description TEXT DEFAULT '', + price REAL, + url TEXT DEFAULT '', + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')), + UNIQUE(tool_id, platform_id) +); + INSERT OR IGNORE INTO platforms (name) VALUES ('Direct'); INSERT OR IGNORE INTO platforms (name) VALUES ('Leboncoin'); """ diff --git a/app/main.py b/app/main.py index 88e74c3..7dce66d 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from datetime import datetime, date import os from .database import init_db, get_db -from .routers import tools, clients, rentals, platforms +from .routers import tools, clients, rentals, platforms, listings app = FastAPI(title="LocOutil") @@ -21,6 +21,7 @@ app.include_router(tools.router) app.include_router(clients.router) app.include_router(rentals.router) app.include_router(platforms.router) +app.include_router(listings.router) @app.on_event("startup") def startup(): diff --git a/app/routers/listings.py b/app/routers/listings.py new file mode 100644 index 0000000..1d094f5 --- /dev/null +++ b/app/routers/listings.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +from ..database import get_db + +router = APIRouter(prefix="/api/listings", tags=["listings"]) + +class ListingUpsert(BaseModel): + tool_id: int + platform_id: int + is_active: bool = True + title: str = "" + description: str = "" + price: Optional[float] = None + url: str = "" + notes: str = "" + +class ListingUpdate(BaseModel): + is_active: Optional[bool] = None + title: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = None + url: Optional[str] = None + notes: Optional[str] = None + +def listing_full(conn, row): + l = dict(row) + tool = conn.execute("SELECT id, name, daily_price, weekend_price FROM tools WHERE id = ?", (l["tool_id"],)).fetchone() + platform = conn.execute("SELECT id, name FROM platforms WHERE id = ?", (l["platform_id"],)).fetchone() + l["tool"] = dict(tool) if tool else None + l["platform"] = dict(platform) if platform else None + return l + +@router.get("") +def list_listings(tool_id: Optional[int] = None, platform_id: Optional[int] = None): + with get_db() as conn: + q = "SELECT * FROM listings WHERE 1=1" + params = [] + if tool_id: + q += " AND tool_id = ?"; params.append(tool_id) + if platform_id: + q += " AND platform_id = ?"; params.append(platform_id) + q += " ORDER BY tool_id, platform_id" + rows = conn.execute(q, params).fetchall() + return [listing_full(conn, r) for r in rows] + +@router.get("/matrix") +def listings_matrix(): + with get_db() as conn: + tools = conn.execute("SELECT id, name, daily_price, weekend_price FROM tools ORDER BY name").fetchall() + platforms = conn.execute("SELECT id, name FROM platforms ORDER BY name").fetchall() + listings = conn.execute("SELECT * FROM listings").fetchall() + listing_map = {(l["tool_id"], l["platform_id"]): dict(l) for l in listings} + return { + "tools": [dict(t) for t in tools], + "platforms": [dict(p) for p in platforms], + "listings": {f"{k[0]},{k[1]}": v for k, v in listing_map.items()} + } + +@router.post("", status_code=201) +def create_or_update_listing(data: ListingUpsert): + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM listings WHERE tool_id = ? AND platform_id = ?", + (data.tool_id, data.platform_id) + ).fetchone() + if existing: + conn.execute(""" + UPDATE listings SET is_active=?, title=?, description=?, price=?, url=?, notes=?, + updated_at=datetime('now','localtime') WHERE id=? + """, (1 if data.is_active else 0, data.title, data.description, data.price, + data.url, data.notes, existing["id"])) + row = conn.execute("SELECT * FROM listings WHERE id = ?", (existing["id"],)).fetchone() + else: + cur = conn.execute(""" + INSERT INTO listings (tool_id, platform_id, is_active, title, description, price, url, notes) + VALUES (?,?,?,?,?,?,?,?) + """, (data.tool_id, data.platform_id, 1 if data.is_active else 0, + data.title, data.description, data.price, data.url, data.notes)) + row = conn.execute("SELECT * FROM listings WHERE id = ?", (cur.lastrowid,)).fetchone() + return listing_full(conn, row) + +@router.put("/{listing_id}") +def update_listing(listing_id: int, data: ListingUpdate): + with get_db() as conn: + if not conn.execute("SELECT id FROM listings WHERE id = ?", (listing_id,)).fetchone(): + raise HTTPException(404, "Annonce introuvable") + fields, values = ["updated_at = datetime('now','localtime')"], [] + for field, val in data.model_dump(exclude_none=True).items(): + if field == "is_active": + fields.append("is_active = ?"); values.append(1 if val else 0) + else: + fields.append(f"{field} = ?"); values.append(val) + values.append(listing_id) + conn.execute(f"UPDATE listings SET {', '.join(fields)} WHERE id = ?", values) + return listing_full(conn, conn.execute("SELECT * FROM listings WHERE id = ?", (listing_id,)).fetchone()) + +@router.delete("/{listing_id}", status_code=204) +def delete_listing(listing_id: int): + with get_db() as conn: + conn.execute("DELETE FROM listings WHERE id = ?", (listing_id,)) + +@router.post("/{listing_id}/toggle", status_code=200) +def toggle_listing(listing_id: int): + with get_db() as conn: + row = conn.execute("SELECT * FROM listings WHERE id = ?", (listing_id,)).fetchone() + if not row: + raise HTTPException(404, "Annonce introuvable") + new_status = 0 if row["is_active"] else 1 + conn.execute("UPDATE listings SET is_active = ?, updated_at = datetime('now','localtime') WHERE id = ?", + (new_status, listing_id)) + return listing_full(conn, conn.execute("SELECT * FROM listings WHERE id = ?", (listing_id,)).fetchone()) diff --git a/static/app.js b/static/app.js index e689a4e..7606587 100644 --- a/static/app.js +++ b/static/app.js @@ -25,6 +25,7 @@ document.querySelectorAll('.nav-item').forEach(el => { if (el.dataset.section === 'clients') loadClients(); if (el.dataset.section === 'rentals') loadRentals(); if (el.dataset.section === 'calendar') loadCalendar(); + if (el.dataset.section === 'listings') loadListings(); if (el.dataset.section === 'settings') loadPlatforms(); }); }); @@ -510,6 +511,111 @@ function statusLabel(s) { return {confirmed:'Confirmée', ongoing:'En cours', returned:'Retournée', cancelled:'Annulée'}[s] || s; } +// ─── Listings ─────────────────────────────────────────────────────────────── +let _matrix = null; + +async function loadListings() { + _matrix = await api.get('/api/listings/matrix'); + renderListingsMatrix(); +} + +function renderListingsMatrix() { + const { tools, platforms, listings } = _matrix; + if (!tools.length) { document.getElementById('listings-matrix').innerHTML = '
| Outil | `; + platforms.forEach(p => { html += `${esc(p.name)} | `; }); + html += `
|---|---|
${esc(t.name)} ${fmtEuro(t.daily_price)}/j | `;
+ platforms.forEach(p => {
+ const key = `${t.id},${p.id}`;
+ const l = listings[key];
+ let cls = 'empty', icon = '+', title = 'Créer une annonce';
+ if (l) {
+ if (l.is_active) { cls = 'active'; icon = '✓'; title = 'Annonce active — cliquer pour modifier'; }
+ else { cls = 'inactive'; icon = '✗'; title = 'Annonce inactive — cliquer pour modifier'; }
+ }
+ html += ``; + }); + html += ` |