1277 lines
52 KiB
TypeScript
1277 lines
52 KiB
TypeScript
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||
import React, { useState, useEffect, useCallback } from "react";
|
||
import { Link } from "wouter";
|
||
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,
|
||
Plus, Lock, Clock, Users, Building2, ExternalLink, Calendar,
|
||
ShieldBan, ShieldOff,
|
||
} from "lucide-react";
|
||
|
||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||
|
||
/**
|
||
* fetch() vers l'API admin — envoie toujours le cookie de session httpOnly
|
||
* (jamais lu ni stocké côté JS) et pose le Content-Type JSON par défaut.
|
||
*/
|
||
function adminFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||
return fetch(`${API_BASE}${path}`, {
|
||
...init,
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
||
});
|
||
}
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type Idea = {
|
||
id: number;
|
||
content: string;
|
||
author: string | null;
|
||
accepted: boolean;
|
||
flagged: boolean;
|
||
flagCount: number;
|
||
rejectionReason: string | null;
|
||
legalBasis: string | null;
|
||
adminNote: string | null;
|
||
consultationId: number | 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;
|
||
};
|
||
|
||
type ConsultationStats = {
|
||
total: number;
|
||
accepted: number;
|
||
lastUpdated: string | null;
|
||
};
|
||
|
||
type IpBlacklistEntry = {
|
||
ipHash: string;
|
||
rejectionCount: number;
|
||
firstRejectedAt: string | null;
|
||
lastRejectedAt: string | null;
|
||
lastIdeaId: number | null;
|
||
blacklistedAt: string | null;
|
||
expiresAt: string | null;
|
||
};
|
||
|
||
type Consultation = {
|
||
id: number;
|
||
slug: string;
|
||
title: string;
|
||
subject: string;
|
||
introMessage: string | null;
|
||
organizerName: string | null;
|
||
organizerLogoUrl: string | null;
|
||
startsAt: string | null;
|
||
endsAt: string | null;
|
||
closedAt: string | null;
|
||
createdAt: string | null;
|
||
isOpen: boolean;
|
||
stats: ConsultationStats;
|
||
};
|
||
|
||
// ─── Hook auth ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* L'authentification vit dans un cookie httpOnly (invisible à JS, jamais dans
|
||
* sessionStorage) — au chargement, on interroge /api/admin/session pour savoir
|
||
* s'il est encore valide. `authed === null` = vérification en cours.
|
||
*/
|
||
function useAdminAuth() {
|
||
const [authed, setAuthed] = useState<boolean | null>(null);
|
||
|
||
const checkSession = useCallback(async () => {
|
||
try {
|
||
const res = await adminFetch("/api/admin/session");
|
||
setAuthed(res.ok);
|
||
} catch {
|
||
setAuthed(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { checkSession(); }, [checkSession]);
|
||
|
||
const login = () => setAuthed(true);
|
||
const logout = async () => {
|
||
try {
|
||
await adminFetch("/api/admin/logout", { method: "POST" });
|
||
} catch { /* ignore */ }
|
||
setAuthed(false);
|
||
};
|
||
return { authed, login, logout };
|
||
}
|
||
|
||
// ─── Composants utilitaires ───────────────────────────────────────────────────
|
||
|
||
function LoginPanel({ onLogin }: { onLogin: () => 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 adminFetch("/api/admin/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { setError(data.message || "Mot de passe incorrect."); return; }
|
||
onLogin();
|
||
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 (
|
||
<div className="min-h-screen flex items-center justify-center bg-muted/30">
|
||
<div className="w-full max-w-sm space-y-6 p-8 bg-background border border-border rounded-xl shadow-sm">
|
||
<div className="flex items-center gap-3">
|
||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||
<div>
|
||
<h1 className="font-serif text-xl font-bold text-primary">Panel Admin</h1>
|
||
<p className="text-xs text-muted-foreground">La Voix du Peuple</p>
|
||
</div>
|
||
</div>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="text-sm font-medium mb-1 block">Mot de passe</label>
|
||
<Input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="ADMIN_SECRET"
|
||
autoFocus
|
||
required
|
||
/>
|
||
</div>
|
||
{error && (
|
||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||
{error}
|
||
</p>
|
||
)}
|
||
<Button type="submit" className="w-full" disabled={loading}>
|
||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||
Connexion
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatsBadge({ label, value, color }: { label: string; value: number; color: string }) {
|
||
return (
|
||
<div className={`rounded-lg border px-4 py-3 text-center ${color}`}>
|
||
<div className="text-2xl font-bold font-mono">{value}</div>
|
||
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Panel Consultations ──────────────────────────────────────────────────────
|
||
|
||
function ConsultationsPanel() {
|
||
const { toast } = useToast();
|
||
const [consultations, setConsultations] = useState<Consultation[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||
const [closeTarget, setCloseTarget] = useState<Consultation | null>(null);
|
||
const [deleteTarget, setDeleteTarget] = useState<Consultation | null>(null);
|
||
|
||
// Champs du formulaire de création
|
||
const [fSlug, setFSlug] = useState("");
|
||
const [fTitle, setFTitle] = useState("");
|
||
const [fSubject, setFSubject] = useState("");
|
||
const [fIntro, setFIntro] = useState("");
|
||
const [fOrgName, setFOrgName] = useState("");
|
||
const [fOrgLogo, setFOrgLogo] = useState("");
|
||
const [fStartsAt, setFStartsAt] = useState("");
|
||
const [fEndsAt, setFEndsAt] = useState("");
|
||
const [fWebhook, setFWebhook] = useState("");
|
||
const [creating, setCreating] = useState(false);
|
||
const [createError, setCreateError] = useState("");
|
||
|
||
const fetchConsultations = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await adminFetch("/api/admin/consultations");
|
||
if (res.ok) setConsultations(await res.json());
|
||
} catch { /* ignore */ }
|
||
finally { setLoading(false); }
|
||
}, []);
|
||
|
||
useEffect(() => { fetchConsultations(); }, [fetchConsultations]);
|
||
|
||
// Normalise le slug en temps réel (alphanumériques + tirets)
|
||
const handleSlugChange = (v: string) => {
|
||
setFSlug(v.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-"));
|
||
};
|
||
|
||
const resetForm = () => {
|
||
setFSlug(""); setFTitle(""); setFSubject(""); setFIntro("");
|
||
setFOrgName(""); setFOrgLogo(""); setFStartsAt(""); setFEndsAt(""); setFWebhook("");
|
||
setCreateError("");
|
||
};
|
||
|
||
const handleCreate = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setCreating(true);
|
||
setCreateError("");
|
||
try {
|
||
const res = await adminFetch("/api/admin/consultations", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
slug: fSlug, title: fTitle, subject: fSubject,
|
||
introMessage: fIntro || undefined,
|
||
organizerName: fOrgName || undefined,
|
||
organizerLogoUrl: fOrgLogo || undefined,
|
||
startsAt: fStartsAt || undefined,
|
||
endsAt: fEndsAt || undefined,
|
||
webhookUrl: fWebhook || undefined,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
setCreateError(data.message || "Création échouée.");
|
||
return;
|
||
}
|
||
toast({ title: "Consultation créée", description: `Slug : ${data.slug}` });
|
||
resetForm();
|
||
setShowCreateForm(false);
|
||
fetchConsultations();
|
||
} catch {
|
||
setCreateError("Erreur réseau.");
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
const handleClose = async () => {
|
||
if (!closeTarget) return;
|
||
const res = await adminFetch(`/api/admin/consultations/${closeTarget.slug}/close`, {
|
||
method: "POST",
|
||
});
|
||
if (res.ok) {
|
||
toast({ title: "Consultation fermée", description: "Le webhook a été déclenché si configuré." });
|
||
fetchConsultations();
|
||
} else {
|
||
toast({ title: "Erreur", description: "Fermeture échouée.", variant: "destructive" });
|
||
}
|
||
setCloseTarget(null);
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!deleteTarget) return;
|
||
const res = await adminFetch(`/api/admin/consultations/${deleteTarget.slug}`, {
|
||
method: "DELETE",
|
||
});
|
||
if (res.ok) {
|
||
toast({ title: "Consultation supprimée" });
|
||
fetchConsultations();
|
||
} else {
|
||
toast({ title: "Erreur", description: "Suppression échouée.", variant: "destructive" });
|
||
}
|
||
setDeleteTarget(null);
|
||
};
|
||
|
||
const fmtDate = (iso: string | null) =>
|
||
iso ? new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "2-digit" }) : "—";
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="font-serif font-semibold text-lg text-primary">Consultations ciblées</h2>
|
||
<Button size="sm" onClick={() => { setShowCreateForm((v) => !v); resetForm(); }}>
|
||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||
{showCreateForm ? "Annuler" : "Nouvelle consultation"}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Formulaire de création */}
|
||
{showCreateForm && (
|
||
<form onSubmit={handleCreate} className="bg-background border border-border rounded-xl p-5 space-y-4">
|
||
<h3 className="font-medium text-sm text-foreground">Créer une consultation</h3>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">Slug URL *</label>
|
||
<Input
|
||
value={fSlug}
|
||
onChange={(e) => handleSlugChange(e.target.value)}
|
||
placeholder="mairie-paris-budget-2026"
|
||
required
|
||
className="text-sm font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
Sera accessible sur /consultation/{fSlug || "slug"}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">Titre *</label>
|
||
<Input value={fTitle} onChange={(e) => setFTitle(e.target.value)} placeholder="Budget participatif 2026" required className="text-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">Sujet * (affiché publiquement)</label>
|
||
<Textarea
|
||
value={fSubject}
|
||
onChange={(e) => setFSubject(e.target.value)}
|
||
placeholder="Décrivez la thématique et les questions posées aux participants."
|
||
rows={2}
|
||
required
|
||
className="text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">Message d'introduction (facultatif)</label>
|
||
<Textarea
|
||
value={fIntro}
|
||
onChange={(e) => setFIntro(e.target.value)}
|
||
placeholder="Message de bienvenue ou instructions pour les participants."
|
||
rows={2}
|
||
className="text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">Nom de l'organisateur</label>
|
||
<Input value={fOrgName} onChange={(e) => setFOrgName(e.target.value)} placeholder="Mairie de Paris" className="text-sm" />
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">URL logo HTTPS (facultatif)</label>
|
||
<Input value={fOrgLogo} onChange={(e) => setFOrgLogo(e.target.value)} placeholder="https://…" className="text-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block flex items-center gap-1">
|
||
<Calendar className="h-3 w-3" /> Ouverture (UTC)
|
||
</label>
|
||
<Input type="datetime-local" value={fStartsAt} onChange={(e) => setFStartsAt(e.target.value)} className="text-sm" />
|
||
<p className="text-xs text-muted-foreground mt-0.5">Vide = immédiatement</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block flex items-center gap-1">
|
||
<Calendar className="h-3 w-3" /> Fermeture automatique (UTC)
|
||
</label>
|
||
<Input type="datetime-local" value={fEndsAt} onChange={(e) => setFEndsAt(e.target.value)} className="text-sm" />
|
||
<p className="text-xs text-muted-foreground mt-0.5">Vide = pas de fermeture auto</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block">URL Webhook de clôture (facultatif)</label>
|
||
<Input value={fWebhook} onChange={(e) => setFWebhook(e.target.value)} placeholder="https://…/webhook" className="text-sm font-mono" />
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
Reçoit un POST JSON avec la synthèse à la clôture (manuelle ou automatique).
|
||
</p>
|
||
</div>
|
||
|
||
{createError && (
|
||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||
{createError}
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex gap-2 justify-end">
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => { setShowCreateForm(false); resetForm(); }}>
|
||
Annuler
|
||
</Button>
|
||
<Button type="submit" size="sm" disabled={creating}>
|
||
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||
Créer la consultation
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Liste */}
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" /> Chargement…
|
||
</div>
|
||
) : consultations.length === 0 ? (
|
||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border rounded-xl">
|
||
Aucune consultation créée. Utilisez le bouton "Nouvelle consultation" pour commencer.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{consultations.map((c) => (
|
||
<div key={c.id} className="bg-background border border-border rounded-xl p-4 space-y-3">
|
||
<div className="flex items-start gap-3 flex-wrap">
|
||
<div className="flex-1 min-w-0 space-y-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-semibold text-sm text-foreground">{c.title}</span>
|
||
{c.closedAt ? (
|
||
<Badge variant="secondary" className="text-xs gap-1">
|
||
<Lock className="h-2.5 w-2.5" /> Clôturée
|
||
</Badge>
|
||
) : c.isOpen ? (
|
||
<Badge className="bg-green-600 text-xs gap-1">
|
||
<span className="h-1.5 w-1.5 rounded-full bg-white/80 animate-pulse inline-block" />
|
||
En cours
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="outline" className="text-xs gap-1">
|
||
<Clock className="h-2.5 w-2.5" /> À venir
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground font-mono">/consultation/{c.slug}</p>
|
||
{c.organizerName && (
|
||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||
<Building2 className="h-3 w-3" /> {c.organizerName}
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-foreground/70 line-clamp-2">{c.subject}</p>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="flex gap-3 text-xs text-muted-foreground flex-shrink-0">
|
||
<div className="text-center">
|
||
<div className="text-base font-bold font-mono text-foreground">{c.stats.accepted}</div>
|
||
<div>intégrées</div>
|
||
</div>
|
||
<div className="text-center">
|
||
<div className="text-base font-bold font-mono text-foreground">{c.stats.total}</div>
|
||
<div>soumises</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1">
|
||
<Calendar className="h-3 w-3" />
|
||
Ouverture : {fmtDate(c.startsAt)}
|
||
</span>
|
||
{c.endsAt && (
|
||
<span className="flex items-center gap-1">
|
||
<Clock className="h-3 w-3" />
|
||
Fermeture auto : {fmtDate(c.endsAt)}
|
||
</span>
|
||
)}
|
||
{c.closedAt && (
|
||
<span className="flex items-center gap-1 text-orange-600">
|
||
<Lock className="h-3 w-3" />
|
||
Fermée le : {fmtDate(c.closedAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center gap-2 flex-wrap pt-1 border-t border-border/30">
|
||
<Link href={`/consultation/${c.slug}`} target="_blank">
|
||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1">
|
||
<ExternalLink className="h-3 w-3" /> Voir la page
|
||
</Button>
|
||
</Link>
|
||
{!c.closedAt && (
|
||
<Button
|
||
size="sm" variant="outline"
|
||
className="h-7 text-xs gap-1 text-orange-600 border-orange-200 hover:bg-orange-50"
|
||
onClick={() => setCloseTarget(c)}
|
||
>
|
||
<Lock className="h-3 w-3" /> Fermer manuellement
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm" variant="ghost"
|
||
className="h-7 text-xs gap-1 text-red-500 hover:text-red-600 hover:bg-red-50 ml-auto"
|
||
onClick={() => setDeleteTarget(c)}
|
||
>
|
||
<Trash2 className="h-3 w-3" /> Supprimer
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Dialog fermeture */}
|
||
<AlertDialog open={closeTarget !== null} onOpenChange={() => setCloseTarget(null)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Fermer la consultation ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
La consultation « {closeTarget?.title} » sera définitivement fermée aux nouvelles
|
||
contributions. Si un webhook est configuré, il sera déclenché immédiatement.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction className="bg-orange-600 hover:bg-orange-700" onClick={handleClose}>
|
||
Fermer la consultation
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* Dialog suppression */}
|
||
<AlertDialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Supprimer la consultation ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
La consultation « {deleteTarget?.title} » sera supprimée avec toutes ses contributions
|
||
et sa synthèse. Cette action est irréversible.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction className="bg-red-600 hover:bg-red-700" onClick={handleDelete}>
|
||
Supprimer définitivement
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Panel Blacklist IP ───────────────────────────────────────────────────────
|
||
|
||
function BlacklistPanel() {
|
||
const [entries, setEntries] = React.useState<IpBlacklistEntry[]>([]);
|
||
const [loading, setLoading] = React.useState(false);
|
||
const { toast } = useToast();
|
||
|
||
const fetchEntries = React.useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await adminFetch("/api/admin/ip-blacklist");
|
||
if (res.ok) setEntries(await res.json());
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
React.useEffect(() => { fetchEntries(); }, [fetchEntries]);
|
||
|
||
const lift = async (ipHash: string) => {
|
||
const res = await adminFetch(`/api/admin/ip-blacklist/${ipHash}`, {
|
||
method: "DELETE",
|
||
});
|
||
if (res.ok) {
|
||
toast({ title: "Blacklist levée", description: `Hash ${ipHash.slice(0, 8)}… retiré.` });
|
||
fetchEntries();
|
||
} else {
|
||
toast({ title: "Erreur", description: "Impossible de lever le blacklist.", variant: "destructive" });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<ShieldBan className="h-4 w-4 text-destructive" />
|
||
<h2 className="font-serif font-semibold text-lg text-primary">IPs blacklistées</h2>
|
||
<span className="text-xs text-muted-foreground font-mono">({entries.length} active{entries.length !== 1 ? "s" : ""})</span>
|
||
</div>
|
||
<Button size="sm" variant="outline" onClick={fetchEntries} disabled={loading}>
|
||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="text-xs text-muted-foreground border border-border/40 rounded-md p-3 bg-muted/30 space-y-1">
|
||
<p>Les IPs sont stockées sous forme de hash SHA-256 (non réversible — RGPD art. 6(1)(f)).</p>
|
||
<p>Un blacklist est déclenché après <strong>2 contributions illégales</strong>. Durée : <strong>30 jours</strong> par défaut.</p>
|
||
</div>
|
||
|
||
{entries.length === 0 ? (
|
||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||
{loading ? <Loader2 className="h-5 w-5 animate-spin mx-auto" /> : "Aucune IP blacklistée actuellement."}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{entries.map(e => (
|
||
<div key={e.ipHash} className="border border-border rounded-lg p-4 bg-card space-y-2">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 space-y-1">
|
||
<p className="font-mono text-xs text-foreground/80 break-all">
|
||
<span className="text-muted-foreground mr-2">hash</span>{e.ipHash}
|
||
</p>
|
||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||
<span><span className="text-destructive font-semibold">{e.rejectionCount}</span> rejet{e.rejectionCount !== 1 ? "s" : ""}</span>
|
||
{e.lastIdeaId && (
|
||
<span>dernière contribution : <span className="font-mono">#{e.lastIdeaId}</span></span>
|
||
)}
|
||
{e.blacklistedAt && (
|
||
<span>depuis le {new Date(e.blacklistedAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}</span>
|
||
)}
|
||
{e.expiresAt && (
|
||
<span>expire le {new Date(e.expiresAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="flex-shrink-0 text-xs gap-1.5"
|
||
onClick={() => lift(e.ipHash)}
|
||
>
|
||
<ShieldOff className="h-3.5 w-3.5" /> Lever
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AiModelsPanel() {
|
||
const { toast } = useToast();
|
||
const [data, setData] = useState<{ filter: string; synthesis: string; available: string[]; error: string | null } | null>(null);
|
||
const [filter, setFilter] = useState("");
|
||
const [synthesis, setSynthesis] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
const res = await adminFetch("/api/admin/ai/models");
|
||
if (!res.ok) return;
|
||
const d = await res.json();
|
||
setData(d); setFilter(d.filter); setSynthesis(d.synthesis);
|
||
}, []);
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const test = async (model: string) => {
|
||
setBusy(true);
|
||
const r = await adminFetch("/api/admin/ai/test", { method: "POST", body: JSON.stringify({ model }) });
|
||
const d = await r.json().catch(() => ({}));
|
||
setBusy(false);
|
||
toast(d.ok
|
||
? { title: "Modèle OK", description: model }
|
||
: { title: "Échec", description: d.error ?? `Erreur ${r.status}`, variant: "destructive" });
|
||
return !!d.ok;
|
||
};
|
||
|
||
const save = async () => {
|
||
setBusy(true);
|
||
const r = await adminFetch("/api/admin/ai/models", { method: "POST", body: JSON.stringify({ filter, synthesis }) });
|
||
setBusy(false);
|
||
toast(r.ok ? { title: "Modèles enregistrés" } : { title: "Erreur", variant: "destructive" });
|
||
if (r.ok) load();
|
||
};
|
||
|
||
const opts = data?.available ?? [];
|
||
const field = (label: string, value: string, set: (v: string) => void) => (
|
||
<div className="space-y-1">
|
||
<label className="text-xs text-muted-foreground">{label}</label>
|
||
<div className="flex gap-2">
|
||
<Input list="ai-models" value={value} onChange={e => set(e.target.value)} className="font-mono text-xs" />
|
||
<Button size="sm" variant="outline" disabled={busy || !value} onClick={() => test(value)}>Tester</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h2 className="font-serif font-semibold text-lg text-primary">Modèles IA</h2>
|
||
{data?.error && (
|
||
<div className="text-xs border border-destructive/40 rounded-md p-3 bg-destructive/5 text-destructive">
|
||
Clé API inutilisable ou fournisseur injoignable : <span className="font-mono">{data.error}</span>
|
||
<p className="mt-1 text-muted-foreground">La clé se change dans <code>MISTRAL_API_KEY</code> (.env) puis redémarrage de l'API.</p>
|
||
</div>
|
||
)}
|
||
<datalist id="ai-models">{opts.map(m => <option key={m} value={m} />)}</datalist>
|
||
{field("Modèle de filtrage (rapide)", filter, setFilter)}
|
||
{field("Modèle de synthèse (qualité)", synthesis, setSynthesis)}
|
||
<div className="flex items-center gap-3">
|
||
<Button size="sm" onClick={save} disabled={busy}>Enregistrer</Button>
|
||
<span className="text-xs text-muted-foreground">{opts.length} modèle(s) accessible(s) avec la clé actuelle</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Composant principal Admin ────────────────────────────────────────────────
|
||
|
||
export default function Admin() {
|
||
const { authed, login, logout } = useAdminAuth();
|
||
const { toast } = useToast();
|
||
|
||
const [activePanel, setActivePanel] = useState<"contributions" | "consultations" | "blacklist" | "ia">("contributions");
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [list, setList] = useState<IdeaList | null>(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<Set<number>>(new Set());
|
||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||
const [overrideTarget, setOverrideTarget] = useState<Idea | null>(null);
|
||
const [overrideAccepted, setOverrideAccepted] = useState(false);
|
||
const [overrideReason, setOverrideReason] = useState("");
|
||
const [overrideNote, setOverrideNote] = useState("");
|
||
const [regenLoading, setRegenLoading] = useState(false);
|
||
|
||
const fetchStats = useCallback(async () => {
|
||
if (!authed) return;
|
||
try {
|
||
const res = await adminFetch("/api/admin/stats");
|
||
if (res.ok) setStats(await res.json());
|
||
} catch { /* ignore */ }
|
||
}, [authed]);
|
||
|
||
const fetchList = useCallback(async () => {
|
||
if (!authed) return;
|
||
setLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
status, page: String(page), per_page: "50",
|
||
...(search ? { q: search } : {}),
|
||
});
|
||
const res = await adminFetch(`/api/admin/ideas?${params}`);
|
||
if (res.ok) {
|
||
setList(await res.json());
|
||
setSelected(new Set());
|
||
}
|
||
} catch { /* ignore */ }
|
||
finally { setLoading(false); }
|
||
}, [authed, status, page, search]);
|
||
|
||
useEffect(() => {
|
||
if (authed && activePanel === "contributions") { fetchStats(); fetchList(); }
|
||
}, [authed, fetchStats, fetchList, activePanel]);
|
||
|
||
const deleteOne = async (id: number) => {
|
||
const res = await adminFetch(`/api/admin/ideas/${id}`, { method: "DELETE" });
|
||
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 adminFetch("/api/admin/ideas/bulk-delete", {
|
||
method: "POST", body: JSON.stringify({ ids }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
toast({ title: `${data.deleted} contribution(s) supprimée(s)` });
|
||
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 adminFetch(`/api/admin/ideas/${overrideTarget.id}/override`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }),
|
||
});
|
||
if (res.ok) {
|
||
toast({ title: "Statut modifié" });
|
||
fetchStats(); fetchList();
|
||
} else {
|
||
toast({ title: "Erreur", description: "Modification échouée.", variant: "destructive" });
|
||
}
|
||
setOverrideTarget(null);
|
||
};
|
||
|
||
const unflag = async (id: number) => {
|
||
const res = await adminFetch(`/api/admin/ideas/${id}/unflag`, { method: "POST" });
|
||
if (res.ok) { toast({ title: "Signalement retiré" }); fetchList(); }
|
||
};
|
||
|
||
const regenerate = async () => {
|
||
setRegenLoading(true);
|
||
const res = await adminFetch("/api/admin/synthesis/regenerate", { method: "POST" });
|
||
if (res.ok) toast({ title: "Régénération lancée", description: "Mise à jour dans quelques secondes." });
|
||
setRegenLoading(false);
|
||
};
|
||
|
||
const exportCsv = () => {
|
||
const url = `${API_BASE}/api/admin/export/csv`;
|
||
const req = new XMLHttpRequest();
|
||
req.open("GET", url);
|
||
req.withCredentials = true; // envoie le cookie de session httpOnly
|
||
req.responseType = "blob";
|
||
req.onload = () => {
|
||
if (req.status !== 200) {
|
||
toast({ title: "Erreur export", description: `Erreur ${req.status} lors de l'export CSV.`, variant: "destructive" });
|
||
return;
|
||
}
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(req.response);
|
||
a.setAttribute("download", "contributions.csv");
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
};
|
||
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;
|
||
setSelected(selected.size === list.ideas.length ? new Set() : 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 (authed === null) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-muted/30">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
if (!authed) return <LoginPanel onLogin={login} />;
|
||
|
||
const TABS = [
|
||
{ key: "all", label: "Toutes" },
|
||
{ key: "accepted", label: "Acceptées" },
|
||
{ key: "rejected", label: "Rejetées" },
|
||
{ key: "flagged", label: "Signalées" },
|
||
];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-muted/20">
|
||
{/* Header */}
|
||
<header className="sticky top-0 z-40 bg-background border-b border-border/60 px-6 py-3 flex items-center gap-4">
|
||
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0" />
|
||
<span className="font-serif font-bold text-primary">Administration</span>
|
||
<span className="text-muted-foreground text-sm hidden sm:inline">La Voix du Peuple</span>
|
||
|
||
{/* Sélecteur de panneau */}
|
||
<div className="flex gap-1 bg-muted/50 rounded-lg p-1 ml-4">
|
||
<button
|
||
onClick={() => setActivePanel("contributions")}
|
||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||
activePanel === "contributions"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
Contributions
|
||
</button>
|
||
<button
|
||
onClick={() => setActivePanel("consultations")}
|
||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||
activePanel === "consultations"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
Consultations
|
||
</button>
|
||
<button
|
||
onClick={() => setActivePanel("blacklist")}
|
||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 ${
|
||
activePanel === "blacklist"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<ShieldBan className="h-3 w-3" /> Blacklist
|
||
</button>
|
||
<button
|
||
onClick={() => setActivePanel("ia")}
|
||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||
activePanel === "ia"
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
IA
|
||
</button>
|
||
</div>
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{activePanel === "contributions" && (
|
||
<>
|
||
<Button size="sm" variant="outline" onClick={regenerate} disabled={regenLoading}>
|
||
{regenLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||
<span className="hidden sm:inline ml-1.5">Régénérer synthèse</span>
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={exportCsv}>
|
||
<Download className="h-3 w-3" />
|
||
<span className="hidden sm:inline ml-1.5">CSV</span>
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button size="sm" variant="ghost" onClick={logout}>
|
||
<LogOut className="h-3 w-3" />
|
||
<span className="hidden sm:inline ml-1.5">Déconnexion</span>
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
||
|
||
{/* ─── Panel Consultations ─── */}
|
||
{activePanel === "consultations" && (
|
||
<ConsultationsPanel />
|
||
)}
|
||
|
||
{activePanel === "ia" && <AiModelsPanel />}
|
||
|
||
{/* ─── Panel Blacklist IP ─── */}
|
||
{activePanel === "blacklist" && (
|
||
<BlacklistPanel />
|
||
)}
|
||
|
||
{/* ─── Panel Contributions ─── */}
|
||
{activePanel === "contributions" && (
|
||
<>
|
||
{/* Stats */}
|
||
{stats && (
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<StatsBadge label="Total" value={stats.total} color="border-border bg-background" />
|
||
<StatsBadge label="Acceptées" value={stats.accepted} color="border-green-200 bg-green-50 text-green-800" />
|
||
<StatsBadge label="Rejetées" value={stats.rejected} color="border-red-200 bg-red-50 text-red-800" />
|
||
<StatsBadge label="Signalées" value={stats.flagged} color="border-orange-200 bg-orange-50 text-orange-800" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Toolbar */}
|
||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||
<div className="flex gap-1 bg-muted/50 rounded-lg p-1">
|
||
{TABS.map((t) => (
|
||
<button
|
||
key={t.key}
|
||
onClick={() => { setStatus(t.key); setPage(1); setSelected(new Set()); }}
|
||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||
status === t.key
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<form onSubmit={handleSearch} className="flex gap-2 sm:ml-auto">
|
||
<Input
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
placeholder="Rechercher..."
|
||
className="h-8 text-sm w-48"
|
||
/>
|
||
<Button type="submit" size="sm" variant="outline" className="h-8 px-2">
|
||
<Search className="h-3 w-3" />
|
||
</Button>
|
||
{search && (
|
||
<Button size="sm" variant="ghost" className="h-8 px-2" onClick={() => { setSearch(""); setSearchInput(""); setPage(1); }}>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
</form>
|
||
</div>
|
||
|
||
{/* Sélection multiple */}
|
||
{selected.size > 0 && (
|
||
<div className="flex items-center gap-3 bg-primary/5 border border-primary/20 rounded-lg px-4 py-2">
|
||
<span className="text-sm font-medium text-primary">{selected.size} sélectionnée(s)</span>
|
||
<Button size="sm" variant="destructive" onClick={() => setBulkDeleteOpen(true)}>
|
||
<Trash2 className="h-3 w-3 mr-1" /> Supprimer la sélection
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>Annuler</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tableau */}
|
||
<div className="bg-background border border-border rounded-xl overflow-hidden">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-16 text-muted-foreground gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" /> Chargement...
|
||
</div>
|
||
) : !list || list.ideas.length === 0 ? (
|
||
<div className="text-center py-16 text-muted-foreground text-sm">
|
||
Aucune contribution dans cette catégorie.
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/50 bg-muted/30 text-xs text-muted-foreground font-medium">
|
||
<input
|
||
type="checkbox"
|
||
className="rounded"
|
||
checked={selected.size === list.ideas.length && list.ideas.length > 0}
|
||
onChange={toggleAll}
|
||
/>
|
||
<span className="flex-1">Contribution</span>
|
||
<span className="w-20 text-right">Statut</span>
|
||
<span className="w-32 text-right hidden sm:block">Date</span>
|
||
<span className="w-28 text-right">Actions</span>
|
||
</div>
|
||
|
||
{list.ideas.map((idea) => (
|
||
<div
|
||
key={idea.id}
|
||
className={`flex items-start gap-3 px-4 py-3 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors ${
|
||
idea.flagged ? "bg-orange-50/50" : ""
|
||
} ${selected.has(idea.id) ? "bg-primary/5" : ""}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
className="rounded mt-1 flex-shrink-0"
|
||
checked={selected.has(idea.id)}
|
||
onChange={() => toggleSelect(idea.id)}
|
||
/>
|
||
<div className="flex-1 min-w-0 space-y-1">
|
||
<p className="text-sm leading-snug line-clamp-3">{idea.content}</p>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{idea.author && (
|
||
<span className="text-xs text-muted-foreground font-mono">{idea.author}</span>
|
||
)}
|
||
{idea.consultationId && (
|
||
<span className="text-xs text-blue-500 font-mono">consultation #{idea.consultationId}</span>
|
||
)}
|
||
{idea.flagged && (
|
||
<span className="text-xs text-orange-600 font-medium flex items-center gap-0.5">
|
||
<Flag className="h-3 w-3" /> {idea.flagCount}×
|
||
</span>
|
||
)}
|
||
{idea.rejectionReason && (
|
||
<span className="text-xs text-red-600 truncate max-w-xs" title={idea.rejectionReason}>
|
||
✗ {idea.rejectionReason}
|
||
</span>
|
||
)}
|
||
{idea.adminNote && (
|
||
<span className="text-xs text-blue-600 italic truncate max-w-xs" title={idea.adminNote}>
|
||
Note: {idea.adminNote}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-20 flex-shrink-0 text-right pt-0.5">
|
||
<Badge variant={idea.accepted ? "default" : "destructive"} className="text-xs">
|
||
{idea.accepted ? "Acceptée" : "Rejetée"}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="w-32 flex-shrink-0 text-right pt-1 hidden sm:block">
|
||
{idea.createdAt && (
|
||
<span className="text-xs text-muted-foreground font-mono">
|
||
{new Date(idea.createdAt).toLocaleDateString("fr-FR", {
|
||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||
hour: "2-digit", minute: "2-digit",
|
||
})}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="w-28 flex-shrink-0 flex items-center gap-1 justify-end pt-0.5">
|
||
{idea.flagged && (
|
||
<Button
|
||
size="sm" variant="ghost"
|
||
className="h-7 px-1.5 text-orange-600 hover:text-orange-700"
|
||
title="Retirer le signalement"
|
||
onClick={() => unflag(idea.id)}
|
||
>
|
||
<Eye className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm" variant="ghost" className="h-7 px-1.5"
|
||
title={idea.accepted ? "Rejeter manuellement" : "Accepter manuellement"}
|
||
onClick={() => openOverride(idea)}
|
||
>
|
||
{idea.accepted
|
||
? <X className="h-3.5 w-3.5 text-red-500" />
|
||
: <Check className="h-3.5 w-3.5 text-green-600" />
|
||
}
|
||
</Button>
|
||
<Button
|
||
size="sm" variant="ghost"
|
||
className="h-7 px-1.5 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||
title="Supprimer définitivement"
|
||
onClick={() => setDeleteTarget(idea.id)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{list && list.pages > 1 && (
|
||
<div className="flex items-center justify-center gap-3">
|
||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-sm text-muted-foreground font-mono">{page} / {list.pages}</span>
|
||
<Button size="sm" variant="outline" disabled={page >= list.pages} onClick={() => setPage((p) => p + 1)}>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Dialog suppression unique */}
|
||
<AlertDialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Supprimer cette contribution ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Cette action est irréversible. La synthèse collective sera régénérée automatiquement.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
className="bg-red-600 hover:bg-red-700"
|
||
onClick={() => deleteTarget !== null && deleteOne(deleteTarget)}
|
||
>
|
||
Supprimer
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* Dialog suppression en masse */}
|
||
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Supprimer {selected.size} contribution(s) ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Cette action est irréversible. La synthèse sera régénérée après suppression.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction className="bg-red-600 hover:bg-red-700" onClick={bulkDelete}>
|
||
Supprimer
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* Dialog override */}
|
||
<Dialog open={overrideTarget !== null} onOpenChange={() => setOverrideTarget(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Modifier le statut manuellement</DialogTitle>
|
||
</DialogHeader>
|
||
{overrideTarget && (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground bg-muted/40 p-3 rounded-lg line-clamp-4">
|
||
{overrideTarget.content}
|
||
</p>
|
||
<div className="flex gap-3">
|
||
<Button
|
||
variant={overrideAccepted ? "default" : "outline"} size="sm"
|
||
onClick={() => setOverrideAccepted(true)} className="flex-1"
|
||
>
|
||
<Check className="h-4 w-4 mr-1" /> Accepter
|
||
</Button>
|
||
<Button
|
||
variant={!overrideAccepted ? "destructive" : "outline"} size="sm"
|
||
onClick={() => setOverrideAccepted(false)} className="flex-1"
|
||
>
|
||
<X className="h-4 w-4 mr-1" /> Rejeter
|
||
</Button>
|
||
</div>
|
||
{!overrideAccepted && (
|
||
<div>
|
||
<label className="text-sm font-medium mb-1 block">Motif du refus</label>
|
||
<Input
|
||
value={overrideReason}
|
||
onChange={(e) => setOverrideReason(e.target.value)}
|
||
placeholder="Raison de la modération manuelle"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="text-sm font-medium mb-1 block">Note admin (interne)</label>
|
||
<Textarea
|
||
value={overrideNote}
|
||
onChange={(e) => setOverrideNote(e.target.value)}
|
||
placeholder="Note visible uniquement par l'administrateur"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="ghost" onClick={() => setOverrideTarget(null)}>Annuler</Button>
|
||
<Button onClick={override}>Confirmer</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|