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:
@@ -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">
|
||||
{item.lien && (
|
||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors" title="Ouvrir">
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
)}
|
||||
<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-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,11 +430,21 @@ 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>
|
||||
{item.lien && (
|
||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors flex-shrink-0">
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user