diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index 0bd5d11..b48bedd 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -2,7 +2,8 @@ La Voix du Peuple — Backend Flask ================================== Plateforme démocratique citoyenne. -Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000) +Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000), + Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024. Sécurité : - Rate limiting (flask-limiter) @@ -10,21 +11,28 @@ Sécurité : - CORS restreint - En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.) - Protection contre l'injection via requêtes paramétrées (psycopg2) +- Panel admin protégé par ADMIN_SECRET (Bearer token) - Aucun secret exposé dans les réponses d'erreur """ +import csv +import io import os import logging import threading -from datetime import datetime, timezone +from functools import wraps import bleach -from flask import Flask, jsonify, request, g +from flask import Flask, jsonify, request, Response from flask_cors import CORS from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from database import init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, get_synthesis, get_all_ideas +from database import ( + init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, + get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas, + override_idea, flag_idea, unflag_idea, +) from ai_agent import filter_idea, synthesize_ideas # ─── Logging ──────────────────────────────────────────────────────────────── @@ -106,6 +114,23 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]: return {"content": content, "author": author}, None +# ─── Authentification Admin ────────────────────────────────────────────────── + +def _get_admin_secret() -> str | None: + return os.environ.get("ADMIN_SECRET") + +def require_admin(f): + @wraps(f) + def decorated(*args, **kwargs): + secret = _get_admin_secret() + if not secret: + return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503 + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or auth[7:] != secret: + return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401 + return f(*args, **kwargs) + return decorated + # ─── Gestion des erreurs ───────────────────────────────────────────────────── @app.errorhandler(400) @@ -220,6 +245,160 @@ def get_synthesis_route(): "updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None, }) +# ─── Route publique : signalement ──────────────────────────────────────────── + +@app.post("/api/ideas//flag") +@limiter.limit("3 per minute; 10 per hour") +def flag_idea_route(idea_id: int): + """Signale une contribution pour examen par l'administrateur.""" + result = flag_idea(idea_id) + if not result: + return jsonify({"error": "not_found", "message": "Contribution introuvable ou non publiée."}), 404 + return jsonify({"ok": True, "flagCount": result.get("flag_count", 1)}) + +# ─── Routes Admin ───────────────────────────────────────────────────────────── + +@app.post("/api/admin/login") +@limiter.limit("10 per minute") +def admin_login(): + """Vérifie le mot de passe admin. Retourne ok si correct.""" + secret = _get_admin_secret() + if not secret: + return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503 + data = request.get_json(silent=True) or {} + password = data.get("password", "") + if password != secret: + logger.warning("Tentative de connexion admin échouée") + return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401 + logger.info("Connexion admin réussie") + return jsonify({"ok": True, "token": secret}) + + +@app.get("/api/admin/stats") +@require_admin +def admin_stats(): + """Statistiques détaillées pour l'administrateur.""" + stats = get_stats() + return jsonify(stats) + + +@app.get("/api/admin/ideas") +@require_admin +@limiter.limit("120 per minute") +def admin_list_ideas(): + """Liste toutes les contributions avec filtres et pagination.""" + status = request.args.get("status", "all") + page = max(1, int(request.args.get("page", 1))) + per_page = min(100, max(10, int(request.args.get("per_page", 50)))) + search = request.args.get("q", "").strip() + + if status not in ("all", "accepted", "rejected", "flagged"): + status = "all" + + ideas, total = get_ideas_admin(status=status, page=page, per_page=per_page, search=search) + return jsonify({ + "ideas": [serialize_idea_admin(i) for i in ideas], + "total": total, + "page": page, + "perPage": per_page, + "pages": max(1, -(-total // per_page)), + }) + + +@app.delete("/api/admin/ideas/") +@require_admin +def admin_delete_idea(idea_id: int): + """Supprime une contribution et régénère la synthèse.""" + deleted = delete_idea(idea_id) + if not deleted: + return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 + threading.Thread(target=_update_synthesis_background, daemon=True).start() + logger.info("Admin — contribution #%d supprimée", idea_id) + return jsonify({"ok": True, "synthesisUpdating": True}) + + +@app.post("/api/admin/ideas/bulk-delete") +@require_admin +def admin_bulk_delete(): + """Suppression en masse de contributions.""" + data = request.get_json(silent=True) or {} + ids = data.get("ids", []) + if not isinstance(ids, list) or not ids: + return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400 + ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()] + count = bulk_delete_ideas(ids) + if count > 0: + threading.Thread(target=_update_synthesis_background, daemon=True).start() + logger.info("Admin — %d contribution(s) supprimée(s) en masse", count) + return jsonify({"ok": True, "deleted": count, "synthesisUpdating": count > 0}) + + +@app.post("/api/admin/ideas//override") +@require_admin +def admin_override_idea(idea_id: int): + """Modifie manuellement le statut d'une contribution (accepter/rejeter).""" + data = request.get_json(silent=True) or {} + accepted = bool(data.get("accepted", False)) + reason = sanitize_text(data.get("reason", "") or "") + note = sanitize_text(data.get("note", "") or "") + result = override_idea(idea_id, accepted, reason or None, note or None) + if not result: + return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 + threading.Thread(target=_update_synthesis_background, daemon=True).start() + logger.info("Admin — contribution #%d override → accepted=%s", idea_id, accepted) + return jsonify({"ok": True, "idea": serialize_idea_admin(result), "synthesisUpdating": True}) + + +@app.post("/api/admin/ideas//unflag") +@require_admin +def admin_unflag_idea(idea_id: int): + """Retire le signalement d'une contribution.""" + result = unflag_idea(idea_id) + if not result: + return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 + return jsonify({"ok": True, "idea": serialize_idea_admin(result)}) + + +@app.post("/api/admin/synthesis/regenerate") +@require_admin +@limiter.limit("5 per minute") +def admin_regenerate_synthesis(): + """Force la régénération complète de la synthèse.""" + threading.Thread(target=_update_synthesis_background, daemon=True).start() + logger.info("Admin — régénération manuelle de la synthèse déclenchée") + return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."}) + + +@app.get("/api/admin/export/csv") +@require_admin +def admin_export_csv(): + """Exporte toutes les contributions en CSV.""" + ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000) + output = io.StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_ALL) + writer.writerow(["id", "content", "author", "accepted", "flagged", + "flag_count", "rejection_reason", "legal_basis", + "admin_note", "created_at"]) + for idea in ideas: + writer.writerow([ + idea.get("id"), + idea.get("content", ""), + idea.get("author", ""), + "oui" if idea.get("accepted") else "non", + "oui" if idea.get("flagged") else "non", + idea.get("flag_count", 0), + idea.get("rejection_reason", ""), + idea.get("legal_basis", ""), + idea.get("admin_note", ""), + idea.get("created_at").isoformat() if idea.get("created_at") else "", + ]) + csv_bytes = output.getvalue().encode("utf-8-sig") + return Response( + csv_bytes, + mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=contributions.csv"}, + ) + # ─── Helpers ───────────────────────────────────────────────────────────────── def serialize_idea(idea: dict) -> dict: @@ -228,12 +407,20 @@ def serialize_idea(idea: dict) -> dict: "content": idea["content"], "author": idea.get("author"), "accepted": idea["accepted"], + "flagged": idea.get("flagged", False), + "flagCount": idea.get("flag_count", 0), "rejectionReason": idea.get("rejection_reason"), "legalBasis": idea.get("legal_basis"), "createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None, } +def serialize_idea_admin(idea: dict) -> dict: + base = serialize_idea(idea) + base["adminNote"] = idea.get("admin_note") + return base + + def _update_synthesis_background(): try: ideas = get_accepted_ideas() diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index 31d0f58..25253ba 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -46,10 +46,10 @@ def init_db(): created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """) - cur.execute(""" - ALTER TABLE ideas - ADD COLUMN IF NOT EXISTS legal_basis TEXT - """) + cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT") + cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE") + cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0") + cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT") cur.execute(""" CREATE TABLE IF NOT EXISTS synthesis ( id SERIAL PRIMARY KEY, @@ -91,6 +91,97 @@ def get_all_ideas(limit: int = 50) -> list[dict]: return [dict(row) for row in cur.fetchall()] +def get_ideas_admin(status: str = "all", page: int = 1, + per_page: int = 50, search: str = "") -> tuple[list[dict], int]: + offset = (page - 1) * per_page + conditions = [] + params: list = [] + + if status == "accepted": + conditions.append("accepted = TRUE") + elif status == "rejected": + conditions.append("accepted = FALSE AND flagged = FALSE") + elif status == "flagged": + conditions.append("flagged = TRUE") + + if search: + conditions.append("content ILIKE %s") + params.append(f"%{search}%") + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + + with db_cursor() as cur: + cur.execute(f"SELECT COUNT(*) as total FROM ideas {where}", params) + total = cur.fetchone()["total"] + cur.execute( + f"SELECT * FROM ideas {where} ORDER BY flagged DESC, created_at DESC LIMIT %s OFFSET %s", + params + [per_page, offset], + ) + rows = [dict(row) for row in cur.fetchall()] + return rows, total + + +def delete_idea(idea_id: int) -> bool: + with db_cursor() as cur: + cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,)) + return cur.fetchone() is not None + + +def bulk_delete_ideas(idea_ids: list[int]) -> int: + if not idea_ids: + return 0 + with db_cursor() as cur: + cur.execute( + "DELETE FROM ideas WHERE id = ANY(%s) RETURNING id", + (idea_ids,), + ) + return len(cur.fetchall()) + + +def override_idea(idea_id: int, accepted: bool, + reason: str | None, note: str | None) -> dict | None: + with db_cursor() as cur: + cur.execute( + """ + UPDATE ideas + SET accepted = %s, + rejection_reason = %s, + flagged = FALSE, + admin_note = %s + WHERE id = %s + RETURNING * + """, + (accepted, reason, note, idea_id), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def flag_idea(idea_id: int) -> dict | None: + with db_cursor() as cur: + cur.execute( + """ + UPDATE ideas + SET flagged = TRUE, flag_count = flag_count + 1 + WHERE id = %s AND accepted = TRUE + RETURNING * + """, + (idea_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def unflag_idea(idea_id: int) -> dict | None: + with db_cursor() as cur: + cur.execute( + "UPDATE ideas SET flagged = FALSE WHERE id = %s RETURNING *", + (idea_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_stats() -> dict: with db_cursor() as cur: cur.execute("SELECT COUNT(*) as total FROM ideas") @@ -99,7 +190,9 @@ def get_stats() -> dict: accepted = cur.fetchone()["accepted"] cur.execute("SELECT COUNT(*) as rejected FROM ideas WHERE accepted = FALSE") rejected = cur.fetchone()["rejected"] - return {"total": total, "accepted": accepted, "rejected": rejected} + cur.execute("SELECT COUNT(*) as flagged FROM ideas WHERE flagged = TRUE") + flagged = cur.fetchone()["flagged"] + return {"total": total, "accepted": accepted, "rejected": rejected, "flagged": flagged} def upsert_synthesis(text: str, idea_count: int) -> dict: diff --git a/artifacts/voix-du-peuple/src/App.tsx b/artifacts/voix-du-peuple/src/App.tsx index c8b1699..6e414ca 100644 --- a/artifacts/voix-du-peuple/src/App.tsx +++ b/artifacts/voix-du-peuple/src/App.tsx @@ -7,6 +7,7 @@ import Home from "@/pages/home"; import About from "@/pages/about"; import Transparence from "@/pages/transparence"; import Flyer from "@/pages/flyer"; +import Admin from "@/pages/admin"; import { AccessibilityProvider } from "@/hooks/use-accessibility"; import { AccessibilityPanel } from "@/components/accessibility-panel"; @@ -67,6 +68,7 @@ function Router() { + diff --git a/artifacts/voix-du-peuple/src/pages/admin.tsx b/artifacts/voix-du-peuple/src/pages/admin.tsx new file mode 100644 index 0000000..53082d0 --- /dev/null +++ b/artifacts/voix-du-peuple/src/pages/admin.tsx @@ -0,0 +1,652 @@ +import { useState, useEffect, useCallback } from "react"; +import { useToast } from "@/hooks/use-toast"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Trash2, RefreshCw, Download, LogOut, Check, X, Flag, + ChevronLeft, ChevronRight, Search, ShieldCheck, Eye, Loader2, +} from "lucide-react"; + +const API_BASE = import.meta.env.VITE_API_URL ?? ""; + +type Idea = { + id: number; + content: string; + author: string | null; + accepted: boolean; + flagged: boolean; + flagCount: number; + rejectionReason: string | null; + legalBasis: string | null; + adminNote: string | null; + createdAt: string | null; +}; + +type Stats = { + total: number; + accepted: number; + rejected: number; + flagged: number; +}; + +type IdeaList = { + ideas: Idea[]; + total: number; + page: number; + pages: number; + perPage: number; +}; + +function useAdminAuth() { + const [token, setToken] = useState(() => + sessionStorage.getItem("admin_token") + ); + const login = (t: string) => { + sessionStorage.setItem("admin_token", t); + setToken(t); + }; + const logout = () => { + sessionStorage.removeItem("admin_token"); + setToken(null); + }; + const headers = token + ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } + : { "Content-Type": "application/json" }; + return { token, login, logout, headers }; +} + +function LoginPanel({ onLogin }: { onLogin: (t: string) => void }) { + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const { toast } = useToast(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await fetch(`${API_BASE}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.message || "Mot de passe incorrect."); + return; + } + onLogin(data.token); + toast({ title: "Connecté", description: "Bienvenue dans le panel admin." }); + } catch { + setError("Erreur réseau. Vérifiez que l'API est accessible."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +
+

Panel Admin

+

La Voix du Peuple

+
+
+
+
+ + setPassword(e.target.value)} + placeholder="ADMIN_SECRET" + autoFocus + required + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+
+ ); +} + +function StatsBadge({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +export default function Admin() { + const { token, login, logout, headers } = useAdminAuth(); + const { toast } = useToast(); + + const [stats, setStats] = useState(null); + const [list, setList] = useState(null); + const [status, setStatus] = useState("all"); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [deleteTarget, setDeleteTarget] = useState(null); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [overrideTarget, setOverrideTarget] = useState(null); + const [overrideAccepted, setOverrideAccepted] = useState(false); + const [overrideReason, setOverrideReason] = useState(""); + const [overrideNote, setOverrideNote] = useState(""); + const [regenLoading, setRegenLoading] = useState(false); + const [flaggedIds, setFlaggedIds] = useState>(new Set()); + + const fetchStats = useCallback(async () => { + if (!token) return; + try { + const res = await fetch(`${API_BASE}/api/admin/stats`, { headers }); + if (res.ok) setStats(await res.json()); + } catch { /* ignore */ } + }, [token, headers]); + + const fetchList = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const params = new URLSearchParams({ + status, + page: String(page), + per_page: "50", + ...(search ? { q: search } : {}), + }); + const res = await fetch(`${API_BASE}/api/admin/ideas?${params}`, { headers }); + if (res.ok) { + const data: IdeaList = await res.json(); + setList(data); + setSelected(new Set()); + } + } catch { /* ignore */ } + finally { setLoading(false); } + }, [token, headers, status, page, search]); + + useEffect(() => { if (token) { fetchStats(); fetchList(); } }, [token, fetchStats, fetchList]); + + const deleteOne = async (id: number) => { + const res = await fetch(`${API_BASE}/api/admin/ideas/${id}`, { method: "DELETE", headers }); + if (res.ok) { + toast({ title: "Contribution supprimée", description: "La synthèse est en cours de mise à jour." }); + fetchStats(); fetchList(); + } else { + toast({ title: "Erreur", description: "Suppression échouée.", variant: "destructive" }); + } + setDeleteTarget(null); + }; + + const bulkDelete = async () => { + const ids = Array.from(selected); + const res = await fetch(`${API_BASE}/api/admin/ideas/bulk-delete`, { + method: "POST", headers, body: JSON.stringify({ ids }), + }); + if (res.ok) { + const data = await res.json(); + toast({ title: `${data.deleted} contribution(s) supprimée(s)`, description: "Synthèse en cours de mise à jour." }); + fetchStats(); fetchList(); + } else { + toast({ title: "Erreur", description: "Suppression en masse échouée.", variant: "destructive" }); + } + setBulkDeleteOpen(false); + }; + + const override = async () => { + if (!overrideTarget) return; + const res = await fetch(`${API_BASE}/api/admin/ideas/${overrideTarget.id}/override`, { + method: "POST", headers, + body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }), + }); + if (res.ok) { + toast({ title: "Statut modifié", description: `Contribution ${overrideAccepted ? "acceptée" : "rejetée"} manuellement.` }); + fetchStats(); fetchList(); + } else { + toast({ title: "Erreur", description: "Modification échouée.", variant: "destructive" }); + } + setOverrideTarget(null); + }; + + const unflag = async (id: number) => { + const res = await fetch(`${API_BASE}/api/admin/ideas/${id}/unflag`, { method: "POST", headers }); + if (res.ok) { + setFlaggedIds((prev) => { const s = new Set(prev); s.delete(id); return s; }); + toast({ title: "Signalement retiré" }); + fetchList(); + } + }; + + const regenerate = async () => { + setRegenLoading(true); + const res = await fetch(`${API_BASE}/api/admin/synthesis/regenerate`, { method: "POST", headers }); + if (res.ok) { + toast({ title: "Régénération lancée", description: "La synthèse sera mise à jour dans quelques secondes." }); + } + setRegenLoading(false); + }; + + const exportCsv = () => { + const url = `${API_BASE}/api/admin/export/csv`; + const a = document.createElement("a"); + a.href = url; + a.setAttribute("download", "contributions.csv"); + const req = new XMLHttpRequest(); + req.open("GET", url); + req.setRequestHeader("Authorization", `Bearer ${token}`); + req.responseType = "blob"; + req.onload = () => { + const blob = req.response; + const objUrl = URL.createObjectURL(blob); + a.href = objUrl; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(objUrl); + }; + req.send(); + }; + + const toggleSelect = (id: number) => { + setSelected((prev) => { + const s = new Set(prev); + s.has(id) ? s.delete(id) : s.add(id); + return s; + }); + }; + + const toggleAll = () => { + if (!list) return; + if (selected.size === list.ideas.length) { + setSelected(new Set()); + } else { + setSelected(new Set(list.ideas.map((i) => i.id))); + } + }; + + const openOverride = (idea: Idea) => { + setOverrideTarget(idea); + setOverrideAccepted(!idea.accepted); + setOverrideReason(idea.rejectionReason || ""); + setOverrideNote(idea.adminNote || ""); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearch(searchInput); + setPage(1); + }; + + if (!token) return ; + + const TABS = [ + { key: "all", label: "Toutes" }, + { key: "accepted", label: "Acceptées" }, + { key: "rejected", label: "Rejetées" }, + { key: "flagged", label: "Signalées" }, + ]; + + return ( +
+ {/* Header */} +
+ + Administration + La Voix du Peuple +
+ + + +
+
+ +
+ + {/* Stats */} + {stats && ( +
+ + + + +
+ )} + + {/* Toolbar */} +
+ {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Search */} +
+ setSearchInput(e.target.value)} + placeholder="Rechercher..." + className="h-8 text-sm w-48" + /> + + {search && ( + + )} +
+
+ + {/* Bulk actions */} + {selected.size > 0 && ( +
+ {selected.size} sélectionnée(s) + + +
+ )} + + {/* Table */} +
+ {loading ? ( +
+ Chargement... +
+ ) : !list || list.ideas.length === 0 ? ( +
+ Aucune contribution dans cette catégorie. +
+ ) : ( + <> + {/* Header row */} +
+ 0} + onChange={toggleAll} + /> + Contribution + Statut + Date + Actions +
+ + {/* Rows */} + {list.ideas.map((idea) => ( +
+ toggleSelect(idea.id)} + /> +
+

{idea.content}

+
+ {idea.author && ( + {idea.author} + )} + {idea.flagged && ( + + {idea.flagCount}× + + )} + {idea.rejectionReason && ( + + ✗ {idea.rejectionReason} + + )} + {idea.adminNote && ( + + Note: {idea.adminNote} + + )} +
+
+ +
+ + {idea.accepted ? "Acceptée" : "Rejetée"} + +
+ +
+ {idea.createdAt ? ( + + {new Date(idea.createdAt).toLocaleDateString("fr-FR", { + day: "2-digit", month: "2-digit", year: "2-digit", + hour: "2-digit", minute: "2-digit", + })} + + ) : null} +
+ +
+ {idea.flagged && ( + + )} + + +
+
+ ))} + + )} +
+ + {/* Pagination */} + {list && list.pages > 1 && ( +
+ + + {page} / {list.pages} + + +
+ )} +
+ + {/* Dialog suppression unique */} + setDeleteTarget(null)}> + + + Supprimer cette contribution ? + + Cette action est irréversible. La contribution sera définitivement supprimée + et la synthèse collective sera régénérée automatiquement. + + + + Annuler + deleteTarget !== null && deleteOne(deleteTarget)} + > + Supprimer + + + + + + {/* Dialog suppression en masse */} + + + + Supprimer {selected.size} contribution(s) ? + + Cette action est irréversible. La synthèse sera régénérée après suppression. + + + + Annuler + + Supprimer + + + + + + {/* Dialog override */} + setOverrideTarget(null)}> + + + Modifier le statut manuellement + + {overrideTarget && ( +
+

+ {overrideTarget.content} +

+
+ + +
+ {!overrideAccepted && ( +
+ + setOverrideReason(e.target.value)} + placeholder="Raison de la modération manuelle" + /> +
+ )} +
+ +