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 { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
@@ -17,6 +23,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
BookOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -32,6 +41,8 @@ interface VeilleItem {
|
|||||||
niveau: string | null;
|
niveau: string | null;
|
||||||
territoire: string | null;
|
territoire: string | null;
|
||||||
resume: string | null;
|
resume: string | null;
|
||||||
|
source: string | null;
|
||||||
|
passage: string | null;
|
||||||
lien: string | null;
|
lien: string | null;
|
||||||
datePublication: Date | null;
|
datePublication: Date | null;
|
||||||
importedAt: Date;
|
importedAt: Date;
|
||||||
@@ -58,19 +69,157 @@ const TYPE_ACCENT: Record<string, string> = {
|
|||||||
generale: "border-l-amber-500",
|
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;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
function formatDate(d: Date | null | undefined): string | null {
|
function formatDate(d: Date | null | undefined): string | null {
|
||||||
if (!d) return 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; }
|
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() {
|
export default function VeilleDashboard() {
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
const [activeTab, setActiveTab] = useState<TypeVeille | "all">("all");
|
const [activeTab, setActiveTab] = useState<TypeVeille | "all">("all");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
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();
|
const filtersQuery = trpc.veille.filters.useQuery();
|
||||||
|
|
||||||
@@ -98,6 +247,11 @@ export default function VeilleDashboard() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDetail = (item: VeilleItem) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const items = (itemsQuery.data?.items ?? []) as VeilleItem[];
|
const items = (itemsQuery.data?.items ?? []) as VeilleItem[];
|
||||||
const total = itemsQuery.data?.total ?? 0;
|
const total = itemsQuery.data?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
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>
|
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "list" ? (
|
) : viewMode === "list" ? (
|
||||||
<VeilleListView items={items} />
|
<VeilleListView items={items} onDetail={openDetail} />
|
||||||
) : (
|
) : (
|
||||||
<VeilleGridView items={items} />
|
<VeilleGridView items={items} onDetail={openDetail} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@@ -182,13 +336,20 @@ export default function VeilleDashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Boîte de dialogue détail */}
|
||||||
|
<VeilleDetailDialog
|
||||||
|
item={selectedItem}
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Vue Liste ────────────────────────────────────────────────────────────────
|
// ─── Vue Liste ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VeilleListView({ items }: { items: VeilleItem[] }) {
|
function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<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-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-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-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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<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">{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 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
||||||
<td className="px-4 py-3">
|
<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 && (
|
{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} />
|
<ExternalLink size={15} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -242,7 +420,7 @@ function VeilleListView({ items }: { items: VeilleItem[] }) {
|
|||||||
|
|
||||||
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
|
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VeilleGridView({ items }: { items: VeilleItem[] }) {
|
function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{items.map((item) => (
|
{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])}>
|
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
|
||||||
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
||||||
</Badge>
|
</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 && (
|
{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} />
|
<ExternalLink size={14} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
|
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
|||||||
1
todo.md
1
todo.md
@@ -38,3 +38,4 @@
|
|||||||
- [x] Frontend : zone de dépôt (drag & drop) dans la page Paramètres pour les deux fichiers
|
- [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] 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] 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
|
||||||
|
|||||||
Reference in New Issue
Block a user