Files
veille-reglementaire/client/src/pages/AAPDashboard.tsx
Manus 6b8b3ce9d7 Checkpoint: AAPDashboard mis à jour avec les 4 mêmes évolutions que VeilleDashboard :
1. Mode vignette par défaut
2. Alternance de couleurs en mode liste (pair=blanc, impair=slate-50)
3. Couleur de sélection au clic sur une ligne (bg-primary/10, titre en primary)
4. Bouton segmenté Non lus / Tous / Lus — Non lus par défaut
5. Message vide adapté selon le filtre actif
0 erreur TypeScript.
2026-06-26 10:32:19 -04:00

578 lines
24 KiB
TypeScript

import { useState, useMemo } from "react";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { FilterBar } from "@/components/FilterBar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
LayoutGrid,
List,
Eye,
EyeOff,
ExternalLink,
Calendar,
MapPin,
Target,
Loader2,
ChevronLeft,
ChevronRight,
AlertCircle,
Clock,
Trash2,
AlertTriangle,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { format, isPast, differenceInDays } from "date-fns";
import { fr } from "date-fns/locale";
type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
type ReadFilter = "unread" | "all" | "read";
interface AAPItem {
id: number;
titre: string;
categorie: string;
region: string | null;
departement: string | null;
dateCloture: Date | null;
datePublication: Date | null;
lien: string | null;
importedAt: Date;
iaRelevant: boolean | null;
iaCategorie: string | null;
iaClassifiedBy: "ia" | "rules" | null;
iaReason: string | null;
iaResume: string | null;
}
const CAT_COLORS: Record<string, string> = {
Handicap: "bg-violet-100 text-violet-800 border-violet-200",
PA: "bg-sky-100 text-sky-800 border-sky-200",
Enfance: "bg-pink-100 text-pink-800 border-pink-200",
"Précarité": "bg-orange-100 text-orange-800 border-orange-200",
Sanitaire: "bg-teal-100 text-teal-800 border-teal-200",
Autre: "bg-gray-100 text-gray-700 border-gray-200",
};
const CAT_ACCENT: Record<string, string> = {
Handicap: "border-l-violet-500",
PA: "border-l-sky-500",
Enfance: "border-l-pink-500",
"Précarité": "border-l-orange-500",
Sanitaire: "border-l-teal-500",
Autre: "border-l-gray-400",
};
const PAGE_SIZE = 24;
function formatDate(d: Date | null | undefined): string | null {
if (!d) return null;
try { return format(new Date(d), "d MMM yyyy", { locale: fr }); }
catch { return null; }
}
function ClotureStatus({ date }: { date: Date | null | undefined }) {
if (!date) return <span className="text-muted-foreground text-xs"></span>;
const d = new Date(date);
const past = isPast(d);
const daysLeft = differenceInDays(d, new Date());
if (past) {
return (
<span className="inline-flex items-center gap-1 text-xs text-red-600 font-medium">
<AlertCircle size={11} />
Clôturé
</span>
);
}
if (daysLeft <= 7) {
return (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
<Clock size={11} />
{daysLeft}j restants
</span>
);
}
return <span className="text-xs text-muted-foreground">{formatDate(d)}</span>;
}
export default function AAPDashboard() {
const { user } = useLocalAuth();
const isAdmin = user?.role === "admin";
const utils = trpc.useUtils();
// Marquage lu/non lu
const [readIds, setReadIds] = useState<Set<number>>(new Set());
const markAsReadMutation = trpc.aap.markAsRead.useMutation({
onSuccess: (_, vars) => {
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
},
});
const markAllAsReadMutation = trpc.aap.markAllAsRead.useMutation({
onSuccess: () => { utils.aap.unreadCount.invalidate(); },
});
const unreadCountQuery = trpc.aap.unreadCount.useQuery();
const unreadCount = unreadCountQuery.data?.count ?? 0;
const purgeMutation = trpc.aap.purge.useMutation({
onSuccess: (data) => {
toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`);
utils.aap.list.invalidate();
utils.aap.filters.invalidate();
},
onError: (err) => {
toast.error(`Erreur lors de la purge : ${err.message}`);
},
});
// ── État UI ─────────────────────────────────────────────────────────────────
// Mode vignette par défaut
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
const [activeTab, setActiveTab] = useState<AAPCategorie | "all">("all");
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
// Filtre Lu/Non lu — Non lu par défaut
const [readFilter, setReadFilter] = useState<ReadFilter>("unread");
// Ligne sélectionnée en mode liste
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const filtersQuery = trpc.aap.filters.useQuery();
const queryInput = useMemo(() => ({
categorie: activeTab !== "all" ? activeTab : undefined,
region: filterValues.region || undefined,
departement: filterValues.departement || undefined,
search: filterValues.search || undefined,
dateFrom: filterValues.dateFrom ? new Date(filterValues.dateFrom) : undefined,
dateTo: filterValues.dateTo ? new Date(filterValues.dateTo) : undefined,
clotureFrom: filterValues.clotureFrom ? new Date(filterValues.clotureFrom) : undefined,
clotureTo: filterValues.clotureTo ? new Date(filterValues.clotureTo) : undefined,
page,
pageSize: PAGE_SIZE,
}), [activeTab, filterValues, page]);
const itemsQuery = trpc.aap.list.useQuery(queryInput);
const handleFilterChange = (key: string, value: string) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setPage(1);
};
const handleReset = () => {
setFilterValues({});
setPage(1);
};
const allItems = (itemsQuery.data?.items ?? []) as AAPItem[];
const total = itemsQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
// Filtrage Lu/Non lu côté client
const items = useMemo(() => {
if (readFilter === "unread") return allItems.filter((i) => !readIds.has(i.id));
if (readFilter === "read") return allItems.filter((i) => readIds.has(i.id));
return allItems;
}, [allItems, readIds, readFilter]);
const filterOptions = [
{ key: "region", label: "Région", options: filtersQuery.data?.regions ?? [] },
{ key: "departement", label: "Département", options: filtersQuery.data?.departements ?? [] },
{ key: "dateFrom", label: "Publié depuis", type: "date" as const },
{ key: "dateTo", label: "Publié jusqu'à", type: "date" as const },
{ key: "clotureFrom", label: "Clôture depuis", type: "date" as const },
{ key: "clotureTo", label: "Clôture jusqu'à", type: "date" as const },
];
const categories: AAPCategorie[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
unread: "Non lus",
all: "Tous",
read: "Lus",
};
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
return (
<div className="p-6 space-y-6 animate-fade-up">
{/* En-tête */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<Target size={22} className="text-primary" />
<h1 className="text-2xl font-bold text-foreground">Appels à Projets</h1>
{unreadCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-primary-foreground text-[11px] font-bold">{unreadCount}</span>
)}
</div>
<p className="text-muted-foreground text-sm">
Handicap, Personnes Âgées, Enfance, Précarité, Sanitaire et Autre
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
<Eye size={15} />
Tout marquer comme lu
</Button>
)}
{/* Boutons mode d'affichage */}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste
</Button>
<Button variant={viewMode === "grid" ? "default" : "outline"} size="sm" onClick={() => setViewMode("grid")} className="gap-2">
<LayoutGrid size={15} />Vignettes
</Button>
{isAdmin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2">
<Trash2 size={15} />
Purger les données
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle size={20} className="text-destructive" />
Purger tous les appels à projets
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets.</p>
<p className="text-destructive font-medium">Cette opération est irréversible.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => purgeMutation.mutate()}
disabled={purgeMutation.isPending}
>
{purgeMutation.isPending ? "Purge en cours..." : "Oui, purger tous les appels à projets"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
{/* Onglets catégories + bouton Lu/Non lu */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}>
<TabsList className="bg-muted/50 flex-wrap h-auto gap-1">
<TabsTrigger value="all">Tous</TabsTrigger>
{categories.map((c) => (
<TabsTrigger key={c} value={c}>{c}</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Bouton segmenté Lu / Non lu */}
<div className="flex items-center rounded-lg border border-border overflow-hidden shadow-sm">
{READ_FILTER_ORDER.map((f) => (
<button
key={f}
onClick={() => setReadFilter(f)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
readFilter === f
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted/50"
)}
>
{f === "unread" && <EyeOff size={12} />}
{f === "read" && <Eye size={12} />}
{READ_FILTER_LABELS[f]}
{f === "unread" && unreadCount > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-primary-foreground/20 text-[10px] font-bold">{unreadCount}</span>
)}
</button>
))}
</div>
</div>
{/* Filtres */}
<FilterBar
filters={filterOptions}
values={filterValues}
onChange={handleFilterChange}
onReset={handleReset}
searchPlaceholder="Rechercher dans les titres…"
totalCount={total}
/>
{/* Contenu */}
{itemsQuery.isLoading ? (
<div className="flex items-center justify-center py-24">
<Loader2 size={32} className="animate-spin text-primary" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Target size={48} className="text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">
{readFilter === "unread" ? "Aucun appel à projets non lu" : "Aucun appel à projets trouvé"}
</p>
<p className="text-muted-foreground/60 text-sm mt-1">
{readFilter === "unread"
? "Tous les appels à projets ont été lus, ou modifiez le filtre Lu/Non lu"
: "Modifiez vos filtres ou importez des données"}
</p>
</div>
) : viewMode === "list" ? (
<AAPListView
items={items}
readIds={readIds}
onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })}
selectedRowId={selectedRowId}
onSelectRow={setSelectedRowId}
/>
) : (
<AAPGridView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
<ChevronLeft size={14} />
</Button>
<span className="text-sm text-muted-foreground px-2">Page {page} / {totalPages}</span>
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
<ChevronRight size={14} />
</Button>
</div>
)}
</div>
);
}
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function AAPListView({
items,
readIds,
onMarkRead,
selectedRowId,
onSelectRow,
}: {
items: AAPItem[];
readIds: Set<number>;
onMarkRead: (id: number) => void;
selectedRowId: number | null;
onSelectRow: (id: number | null) => void;
}) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/60 border-b border-border">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Catégorie</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-36">Région</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Département</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Publication</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Clôture</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-16">Lien</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => {
const isSelected = selectedRowId === item.id;
const isRead = readIds.has(item.id);
const isEven = idx % 2 === 0;
const catKey = item.iaCategorie || item.categorie;
return (
<tr
key={item.id}
onClick={() => onSelectRow(isSelected ? null : item.id)}
className={cn(
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
CAT_ACCENT[catKey] || "border-l-transparent",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: isEven
? "bg-white hover:bg-primary/5"
: "bg-slate-50/80 hover:bg-primary/5"
)}
>
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
<td className="px-4 py-3">
<div className="flex items-start gap-2 max-w-sm">
{!isRead && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
)}
<div>
<p className={cn(
"font-medium line-clamp-2 leading-snug",
isRead ? "text-muted-foreground" : "text-foreground",
isSelected && "text-primary font-semibold"
)}>
{item.titre}
</p>
{item.iaResume && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[catKey])}>
{catKey}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.region || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.departement || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
{formatDate(item.datePublication) || "—"}
</td>
<td className="px-4 py-3"><ClotureStatus date={item.dateCloture} /></td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{!isRead && (
<button
onClick={() => onMarkRead(item.id)}
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors border border-blue-200"
title="Marquer comme lu"
>
<Eye size={13} />
</button>
)}
{item.lien && (
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:text-emerald-700 transition-colors border border-emerald-200"
title="Ouvrir la source"
>
<ExternalLink size={13} />
</a>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function AAPGridView({
items,
readIds,
onMarkRead,
}: {
items: AAPItem[];
readIds: Set<number>;
onMarkRead: (id: number) => void;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((item) => {
const catKey = item.iaCategorie || item.categorie;
return (
<Card
key={item.id}
className={cn(
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
CAT_ACCENT[catKey] || "",
!readIds.has(item.id) && "ring-1 ring-primary/20"
)}
>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-start justify-between gap-2">
<Badge variant="outline" className={cn("text-xs flex-shrink-0", CAT_COLORS[catKey])}>
{catKey}
</Badge>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!readIds.has(item.id) && (
<button
onClick={() => onMarkRead(item.id)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Marquer comme lu"
>
<Eye size={14} />
</button>
)}
{item.lien && (
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-accent transition-colors"
>
<ExternalLink size={14} />
</a>
)}
</div>
</div>
<div className="flex items-start gap-1.5 mt-2">
{!readIds.has(item.id) && (
<span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
)}
<h3 className={cn(
"font-semibold text-sm leading-snug line-clamp-3",
readIds.has(item.id) ? "text-muted-foreground" : "text-foreground"
)}>
{item.titre}
</h3>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{item.iaResume && (
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume}</p>
)}
<div className="flex flex-wrap gap-1.5">
{item.region && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-200">
<MapPin size={10} />{item.region}
</span>
)}
{item.departement && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-teal-50 text-teal-700 border border-teal-200">
<MapPin size={10} />{item.departement}
</span>
)}
</div>
<div className="flex items-center justify-between pt-1 border-t border-border/50 flex-wrap gap-1">
{item.datePublication && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
<Calendar size={10} />
{formatDate(item.datePublication)}
</span>
)}
<ClotureStatus date={item.dateCloture} />
</div>
</CardContent>
</Card>
);
})}
</div>
);
}