Checkpoint: Bouton œil (Eye) ajouté sur chaque ligne du tableau Veille (vue liste et vignettes) ouvrant une boîte de dialogue avec titre complet, badge type coloré, métadonnées en grille (catégorie, niveau, territoire, source, passage en vigueur), résumé intégral et lien externe

This commit is contained in:
Manus
2026-03-16 15:00:15 -04:00
parent b7aa274921
commit 2065786052
2 changed files with 205 additions and 16 deletions

View File

@@ -5,6 +5,12 @@ 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
LayoutGrid,
List,
@@ -17,6 +23,9 @@ import {
Loader2,
ChevronLeft,
ChevronRight,
Eye,
Globe,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
@@ -32,6 +41,8 @@ interface VeilleItem {
niveau: string | null;
territoire: string | null;
resume: string | null;
source: string | null;
passage: string | null;
lien: string | null;
datePublication: Date | null;
importedAt: Date;
@@ -58,19 +69,157 @@ const TYPE_ACCENT: Record<string, string> = {
generale: "border-l-amber-500",
};
const TYPE_HEADER_BG: Record<string, string> = {
reglementaire: "from-blue-50 to-blue-100/30 border-blue-200",
concurrentielle: "from-purple-50 to-purple-100/30 border-purple-200",
technologique: "from-emerald-50 to-emerald-100/30 border-emerald-200",
generale: "from-amber-50 to-amber-100/30 border-amber-200",
};
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 }); }
try { return format(new Date(d), "d MMMM yyyy", { locale: fr }); }
catch { return null; }
}
// ─── Boîte de dialogue Détail ─────────────────────────────────────────────────
function VeilleDetailDialog({
item,
open,
onClose,
}: {
item: VeilleItem | null;
open: boolean;
onClose: () => void;
}) {
if (!item) return null;
const typeKey = item.typeVeille as TypeVeille;
const headerBg = TYPE_HEADER_BG[typeKey] || "from-muted to-muted/30 border-border";
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto p-0">
{/* En-tête coloré */}
<div className={cn("bg-gradient-to-br border-b px-6 py-5", headerBg)}>
<DialogHeader>
<div className="flex items-start gap-3">
<Badge
variant="outline"
className={cn("text-xs flex-shrink-0 mt-0.5", TYPE_COLORS[typeKey])}
>
{TYPE_LABELS[typeKey] || item.typeVeille}
</Badge>
{item.datePublication && (
<span className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5 ml-auto">
<Calendar size={11} />
{formatDate(item.datePublication)}
</span>
)}
</div>
<DialogTitle className="text-lg font-bold text-foreground leading-snug mt-2 pr-6">
{item.titre}
</DialogTitle>
</DialogHeader>
</div>
{/* Corps */}
<div className="px-6 py-5 space-y-5">
{/* Métadonnées en grille */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{item.categorie && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/40 border border-border/50">
<Tag size={14} className="text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">Catégorie</p>
<p className="text-sm font-medium text-foreground">{item.categorie}</p>
</div>
</div>
)}
{item.niveau && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/40 border border-border/50">
<Layers size={14} className="text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">Niveau</p>
<p className="text-sm font-medium text-foreground">{item.niveau}</p>
</div>
</div>
)}
{item.territoire && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/40 border border-border/50">
<MapPin size={14} className="text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">Territoire</p>
<p className="text-sm font-medium text-foreground">{item.territoire}</p>
</div>
</div>
)}
{item.source && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/40 border border-border/50">
<Globe size={14} className="text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">Source</p>
<p className="text-sm font-medium text-foreground">{item.source}</p>
</div>
</div>
)}
{item.passage && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/40 border border-border/50 col-span-2">
<Calendar size={14} className="text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">Passage en vigueur</p>
<p className="text-sm font-medium text-foreground">{item.passage}</p>
</div>
</div>
)}
</div>
{/* Résumé complet */}
{item.resume && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<BookOpen size={14} className="text-primary" />
<h3 className="text-sm font-semibold text-foreground">Résumé</h3>
</div>
<div className="p-4 rounded-lg bg-muted/20 border border-border/50">
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">{item.resume}</p>
</div>
</div>
)}
{/* Lien externe */}
{item.lien && (
<div className="pt-1 border-t border-border/50">
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary/80 transition-colors group"
>
<ExternalLink size={14} className="group-hover:translate-x-0.5 transition-transform" />
Accéder à la source originale
</a>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Composant principal ──────────────────────────────────────────────────────
export default function VeilleDashboard() {
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<TypeVeille | "all">("all");
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [selectedItem, setSelectedItem] = useState<VeilleItem | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const filtersQuery = trpc.veille.filters.useQuery();
@@ -98,6 +247,11 @@ export default function VeilleDashboard() {
setPage(1);
};
const openDetail = (item: VeilleItem) => {
setSelectedItem(item);
setDialogOpen(true);
};
const items = (itemsQuery.data?.items ?? []) as VeilleItem[];
const total = itemsQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
@@ -165,9 +319,9 @@ export default function VeilleDashboard() {
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
</div>
) : viewMode === "list" ? (
<VeilleListView items={items} />
<VeilleListView items={items} onDetail={openDetail} />
) : (
<VeilleGridView items={items} />
<VeilleGridView items={items} onDetail={openDetail} />
)}
{/* Pagination */}
@@ -182,13 +336,20 @@ export default function VeilleDashboard() {
</Button>
</div>
)}
{/* Boîte de dialogue détail */}
<VeilleDetailDialog
item={selectedItem}
open={dialogOpen}
onClose={() => setDialogOpen(false)}
/>
</div>
);
}
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function VeilleListView({ items }: { items: VeilleItem[] }) {
function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
@@ -202,7 +363,7 @@ function VeilleListView({ items }: { items: VeilleItem[] }) {
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Niveau</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-36">Territoire</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Date</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-16">Lien</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -225,11 +386,28 @@ function VeilleListView({ items }: { items: VeilleItem[] }) {
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{/* Bouton Détail */}
<button
onClick={() => onDetail(item)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Voir le détail complet"
>
<Eye size={15} />
</button>
{/* Bouton Lien externe */}
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors" title="Ouvrir">
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-accent transition-colors"
title="Ouvrir la source"
>
<ExternalLink size={15} />
</a>
)}
</div>
</td>
</tr>
))}
@@ -242,7 +420,7 @@ function VeilleListView({ items }: { items: VeilleItem[] }) {
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function VeilleGridView({ items }: { items: VeilleItem[] }) {
function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => 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) => (
@@ -252,12 +430,22 @@ function VeilleGridView({ items }: { items: VeilleItem[] }) {
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
</Badge>
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* Bouton Détail */}
<button
onClick={() => onDetail(item)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Voir le détail complet"
>
<Eye size={14} />
</button>
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors flex-shrink-0">
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors">
<ExternalLink size={14} />
</a>
)}
</div>
</div>
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">

View File

@@ -38,3 +38,4 @@
- [x] Frontend : zone de dépôt (drag & drop) dans la page Paramètres pour les deux fichiers
- [x] Frontend : afficher le résultat de l'import (nouvelles entrées, erreurs) après upload
- [x] Page Paramètres : afficher les zones d'upload (drag & drop) quand la source "local" est sélectionnée
- [x] Veille : bouton "Détail" sur chaque ligne ouvrant une boîte de dialogue avec toutes les infos complètes