Add listings/annonces feature: matrix view, ad content storage, copy-paste

This commit is contained in:
2026-05-04 11:08:36 +02:00
parent 095f448c10
commit 34d89c5216
6 changed files with 288 additions and 1 deletions
+15
View File
@@ -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');
"""
+2 -1
View File
@@ -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():
+112
View File
@@ -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())
+106
View File
@@ -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 = '<div class="empty-state">Aucun outil</div>'; return; }
if (!platforms.length) { document.getElementById('listings-matrix').innerHTML = '<div class="empty-state">Aucune plateforme configurée (allez dans ⚙️)</div>'; return; }
let html = `<div class="listings-matrix"><table class="listings-table"><thead><tr>
<th>Outil</th>`;
platforms.forEach(p => { html += `<th class="platform-col">${esc(p.name)}</th>`; });
html += `</tr></thead><tbody>`;
tools.forEach(t => {
html += `<tr><td><div class="tool-name">${esc(t.name)}</div><div style="font-size:.75rem;color:var(--muted)">${fmtEuro(t.daily_price)}/j</div></td>`;
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 += `<td><div class="listing-cell"><button class="listing-btn ${cls}" title="${title}" onclick="openListingModal(${t.id},${p.id})">${icon}</button></div></td>`;
});
html += `</tr>`;
});
html += `</tbody></table></div>`;
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;
+15
View File
@@ -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; }
+38
View File
@@ -16,6 +16,7 @@
<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="listings">📢 Annonces</a>
<a class="nav-item" data-section="settings">⚙️ Paramètres</a>
</nav>
@@ -78,6 +79,15 @@
<div id="calendar-wrap"></div>
</section>
<!-- ANNONCES -->
<section id="section-listings" class="section hidden">
<div class="section-header">
<h2>Annonces par plateforme</h2>
<div style="font-size:.8rem;color:var(--muted)">Cliquez sur une cellule pour gérer l'annonce</div>
</div>
<div id="listings-matrix"></div>
</section>
<!-- PARAMÈTRES -->
<section id="section-settings" class="section hidden">
<h2>Paramètres — Plateformes</h2>
@@ -216,6 +226,34 @@
</div>
</div>
<!-- MODAL ANNONCE -->
<div id="modal-listing" class="modal hidden">
<div class="modal-box card">
<h2 id="modal-listing-title">Annonce</h2>
<input type="hidden" id="listing-id"/>
<input type="hidden" id="listing-tool-id"/>
<input type="hidden" id="listing-platform-id"/>
<div class="listing-meta" id="listing-meta" style="color:var(--muted);font-size:.85rem;margin-bottom:4px"></div>
<label>Titre de l'annonce</label>
<input type="text" id="listing-title" placeholder="Ex: Perceuse Bosch à louer — 20€/j"/>
<label>Description</label>
<textarea id="listing-desc" rows="5" placeholder="Texte de l'annonce à copier-coller sur la plateforme…"></textarea>
<label>Prix affiché (€)</label>
<input type="number" id="listing-price" min="0" step="0.5" placeholder="Laisser vide = prix standard"/>
<label>Lien de l'annonce</label>
<input type="text" id="listing-url" placeholder="https://www.leboncoin.fr/…"/>
<label>Notes</label>
<input type="text" id="listing-notes" placeholder="Ex: expire le 15/06, renouveler chaque mois…"/>
<label><input type="checkbox" id="listing-active"/> Annonce active (en ligne)</label>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('modal-listing')">Annuler</button>
<button class="btn btn-danger" id="btn-listing-delete" style="display:none">Supprimer</button>
<button class="btn btn-ghost" id="btn-listing-copy" style="display:none" onclick="copyListingText()">📋 Copier</button>
<button class="btn btn-primary" onclick="saveListing()">Enregistrer</button>
</div>
</div>
</div>
<!-- LIGHTBOX -->
<div id="lightbox" class="lightbox hidden">
<div class="lightbox-overlay" onclick="closeLightbox()"></div>