From 34d89c521625be815b6732510dab4f2467300eb7 Mon Sep 17 00:00:00 2001 From: perco Date: Mon, 4 May 2026 11:08:36 +0200 Subject: [PATCH] Add listings/annonces feature: matrix view, ad content storage, copy-paste --- app/database.py | 15 ++++++ app/main.py | 3 +- app/routers/listings.py | 112 ++++++++++++++++++++++++++++++++++++++++ static/app.js | 106 +++++++++++++++++++++++++++++++++++++ static/style.css | 15 ++++++ templates/index.html | 38 ++++++++++++++ 6 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 app/routers/listings.py 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 = '
Aucun outil
'; return; } + if (!platforms.length) { document.getElementById('listings-matrix').innerHTML = '
Aucune plateforme configurée (allez dans ⚙️)
'; return; } + + let html = `
+ `; + platforms.forEach(p => { html += ``; }); + html += ``; + + tools.forEach(t => { + html += ``; + 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 += ``; + }); + html += `
Outil${esc(p.name)}
${esc(t.name)}
${fmtEuro(t.daily_price)}/j
`; + document.getElementById('listings-matrix').innerHTML = html; +} + +async function openListingModal(toolId, platformId) { + const { tools, platforms, listings } = _matrix; + const tool = tools.find(t => t.id === toolId); + const platform = platforms.find(p => p.id === platformId); + const key = `${toolId},${platformId}`; + const l = listings[key] || null; + + document.getElementById('listing-id').value = l?.id || ''; + document.getElementById('listing-tool-id').value = toolId; + document.getElementById('listing-platform-id').value = platformId; + document.getElementById('modal-listing-title').textContent = `Annonce — ${tool?.name}`; + document.getElementById('listing-meta').textContent = `Plateforme : ${platform?.name}`; + document.getElementById('listing-title').value = l?.title || (tool ? `${tool.name} à louer — ${tool.daily_price}€/jour` : ''); + document.getElementById('listing-desc').value = l?.description || ''; + document.getElementById('listing-price').value = l?.price ?? tool?.daily_price ?? ''; + document.getElementById('listing-url').value = l?.url || ''; + document.getElementById('listing-notes').value = l?.notes || ''; + document.getElementById('listing-active').checked = l ? !!l.is_active : true; + + const delBtn = document.getElementById('btn-listing-delete'); + const copyBtn = document.getElementById('btn-listing-copy'); + if (l) { + delBtn.style.display = ''; + delBtn.onclick = () => deleteListing(l.id); + copyBtn.style.display = ''; + } else { + delBtn.style.display = 'none'; + copyBtn.style.display = 'none'; + } + document.getElementById('modal-listing').classList.remove('hidden'); +} + +async function saveListing() { + const data = { + tool_id: parseInt(document.getElementById('listing-tool-id').value), + platform_id: parseInt(document.getElementById('listing-platform-id').value), + is_active: document.getElementById('listing-active').checked, + title: document.getElementById('listing-title').value.trim(), + description: document.getElementById('listing-desc').value.trim(), + price: parseFloat(document.getElementById('listing-price').value) || null, + url: document.getElementById('listing-url').value.trim(), + notes: document.getElementById('listing-notes').value.trim() + }; + await api.post('/api/listings', data); + closeModal('modal-listing'); + _matrix = await api.get('/api/listings/matrix'); + renderListingsMatrix(); +} + +async function deleteListing(id) { + if (!confirm('Supprimer cette annonce ?')) return; + await api.del(`/api/listings/${id}`); + closeModal('modal-listing'); + _matrix = await api.get('/api/listings/matrix'); + renderListingsMatrix(); +} + +function copyListingText() { + const title = document.getElementById('listing-title').value; + const desc = document.getElementById('listing-desc').value; + const price = document.getElementById('listing-price').value; + const text = [title, price ? `Prix : ${price}€/jour` : '', '', desc].filter(Boolean).join('\n'); + navigator.clipboard.writeText(text).then(() => { + const btn = document.getElementById('btn-listing-copy'); + btn.textContent = '✅ Copié !'; + setTimeout(() => btn.textContent = '📋 Copier', 2000); + }); +} + // ─── Lightbox ─────────────────────────────────────────────────────────────── let _lbImages = [], _lbIdx = 0; diff --git a/static/style.css b/static/style.css index cee7474..7db33b1 100644 --- a/static/style.css +++ b/static/style.css @@ -181,6 +181,21 @@ input[type="file"] { padding: 6px; font-size: 0.8rem; } .logo { display: none; } } +/* Listings matrix */ +.listings-matrix { overflow-x: auto; } +.listings-table { border-collapse: collapse; width: 100%; min-width: 500px; } +.listings-table th { padding: 10px 14px; text-align: left; font-size: .8rem; color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; } +.listings-table th.platform-col { text-align: center; min-width: 110px; } +.listings-table td { padding: 8px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; } +.listings-table tr:hover td { background: var(--surface2); } +.listings-table .tool-name { font-weight: 600; font-size: .9rem; } +.listing-cell { display: flex; align-items: center; justify-content: center; } +.listing-btn { width: 36px; height: 36px; border-radius: 50%; border: 2px solid var(--border); background: transparent; cursor: pointer; font-size: 1rem; display: flex; align-items: center; justify-content: center; transition: all .15s; } +.listing-btn:hover { border-color: var(--primary); transform: scale(1.1); } +.listing-btn.active { background: rgba(34,197,94,.15); border-color: var(--success); } +.listing-btn.inactive { background: rgba(239,68,68,.1); border-color: var(--danger); } +.listing-btn.empty { border-style: dashed; } + /* Lightbox */ .lightbox { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; } .lightbox.hidden { display: none; } diff --git a/templates/index.html b/templates/index.html index efc13a8..128cd84 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,6 +16,7 @@ 👥 Clients 📋 Locations 📅 Calendrier + 📢 Annonces ⚙️ Paramètres @@ -78,6 +79,15 @@
+ + +