Checkpoint: 4 évolutions UI sur VeilleDashboard :

1. Mode vignette par défaut (viewMode initialisé à "grid")
2. Alternance de couleurs en mode liste : lignes paires = blanc, lignes impaires = gris clair (slate-50)
3. Couleur de sélection au clic sur une ligne : bg-primary/10 avec titre en couleur primaire
4. Bouton segmenté Lu/Non lu/Tous au niveau des onglets — Non lu par défaut
5. Message d'état vide adapté selon le filtre actif
Planification RSS vérifiée : scheduleRssFetch() lit bien rss_settings (fetchMode, fetchIntervalMinutes, scheduledTime) depuis la BDD.
This commit is contained in:
Manus
2026-06-26 10:22:13 -04:00
parent a7cf918fa8
commit 1c425f25ad
3 changed files with 221 additions and 86 deletions

View File

@@ -1,4 +1,4 @@
{ {
"version": "f01773b1", "version": "c7ed64d2",
"timestamp": 1782481858153 "timestamp": 1782483733259
} }

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo } from "react";
import { useLocalAuth } from "@/contexts/LocalAuthContext"; import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -19,7 +19,6 @@ import {
ExternalLink, ExternalLink,
Calendar, Calendar,
MapPin, MapPin,
Tag,
Layers, Layers,
FileSearch, FileSearch,
Loader2, Loader2,
@@ -30,6 +29,7 @@ import {
BookOpen, BookOpen,
Trash2, Trash2,
AlertTriangle, AlertTriangle,
EyeOff,
} from "lucide-react"; } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
@@ -47,6 +47,7 @@ import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"; type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
type ReadFilter = "unread" | "all" | "read";
interface VeilleItem { interface VeilleItem {
id: number; id: number;
@@ -150,7 +151,6 @@ function VeilleDetailDialog({
{/* Métadonnées en grille */} {/* Métadonnées en grille */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{item.niveau && ( {item.niveau && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200"> <div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200">
<Layers size={14} className="text-violet-500 mt-0.5 shrink-0" /> <Layers size={14} className="text-violet-500 mt-0.5 shrink-0" />
@@ -205,7 +205,6 @@ function VeilleDetailDialog({
</div> </div>
)} )}
{/* Lien externe */} {/* Lien externe */}
{item.lien && ( {item.lien && (
<div className="pt-1 border-t border-border/50"> <div className="pt-1 border-t border-border/50">
@@ -258,12 +257,19 @@ export default function VeilleDashboard() {
toast.error(`Erreur lors de la purge : ${err.message}`); toast.error(`Erreur lors de la purge : ${err.message}`);
}, },
}); });
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ── État UI ─────────────────────────────────────────────────────────────────
// Mode vignette par défaut
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
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 [selectedItem, setSelectedItem] = useState<VeilleItem | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
// 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.veille.filters.useQuery(); const filtersQuery = trpc.veille.filters.useQuery();
@@ -294,16 +300,22 @@ export default function VeilleDashboard() {
const openDetail = (item: VeilleItem) => { const openDetail = (item: VeilleItem) => {
setSelectedItem(item); setSelectedItem(item);
setDialogOpen(true); setDialogOpen(true);
// Marquer comme lu automatiquement à l'ouverture du détail
if (!readIds.has(item.id)) { if (!readIds.has(item.id)) {
markAsReadMutation.mutate({ articleId: item.id }); markAsReadMutation.mutate({ articleId: item.id });
} }
}; };
const items = (itemsQuery.data?.items ?? []) as VeilleItem[]; const allItems = (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);
// Filtrage Lu/Non lu côté client (readIds est local à la session)
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 = [ const filterOptions = [
{ key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] }, { key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] },
{ key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] }, { key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] },
@@ -311,6 +323,14 @@ export default function VeilleDashboard() {
{ key: "dateTo", label: "Date jusqu'à", type: "date" as const }, { key: "dateTo", label: "Date jusqu'à", type: "date" as const },
]; ];
// Labels et styles du bouton Lu/Non lu
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
unread: "Non lus",
all: "Tous",
read: "Lus",
};
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
return ( return (
<div className="p-6 space-y-6 animate-fade-up"> <div className="p-6 space-y-6 animate-fade-up">
{/* En-tête */} {/* En-tête */}
@@ -319,21 +339,22 @@ export default function VeilleDashboard() {
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<FileSearch size={22} className="text-primary" /> <FileSearch size={22} className="text-primary" />
<h1 className="text-2xl font-bold text-foreground">Veille Stratégique</h1> <h1 className="text-2xl font-bold text-foreground">Veille Stratégique</h1>
{unreadCount > 0 && ( {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> <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> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Suivi réglementaire, concurrentiel, technologique et informationnel Suivi réglementaire, concurrentiel, technologique et informationnel
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{unreadCount > 0 && ( {unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground"> <Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
<Eye size={15} /> <Eye size={15} />
Tout marquer comme lu Tout marquer comme lu
</Button> </Button>
)} )}
{/* Boutons mode d'affichage */}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2"> <Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste <List size={15} />Liste
</Button> </Button>
@@ -341,7 +362,6 @@ export default function VeilleDashboard() {
<LayoutGrid size={15} />Vignettes <LayoutGrid size={15} />Vignettes
</Button> </Button>
{isAdmin && ( {isAdmin && (
<>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2"> <Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2">
@@ -357,8 +377,8 @@ export default function VeilleDashboard() {
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription asChild> <AlertDialogDescription asChild>
<div className="space-y-2"> <div className="space-y-2">
<p>Cette action va <strong>supprimer définitivement</strong> toutes les entrées de la veille stratégique (réglementaire, concurrentielle, technologique et générale).</p> <p>Cette action va <strong>supprimer définitivement</strong> toutes les entrées de la veille stratégique.</p>
<p className="text-destructive font-medium">Cette opération est irréversible. Les données ne pourront pas être récupérées.</p> <p className="text-destructive font-medium">Cette opération est irréversible.</p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
@@ -374,19 +394,44 @@ export default function VeilleDashboard() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</>
)} )}
</div> </div>
</div> </div>
{/* Onglets */}
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as TypeVeille | "all"); setPage(1); }}> {/* Onglets type de veille + bouton Lu/Non lu */}
<TabsList className="bg-muted/50"> <div className="flex items-center justify-between gap-4 flex-wrap">
<TabsTrigger value="all">Tous</TabsTrigger> <Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as TypeVeille | "all"); setPage(1); }}>
{(Object.keys(TYPE_LABELS) as TypeVeille[]).map((t) => ( <TabsList className="bg-muted/50">
<TabsTrigger key={t} value={t}>{TYPE_LABELS[t]}</TabsTrigger> <TabsTrigger value="all">Tous</TabsTrigger>
{(Object.keys(TYPE_LABELS) as TypeVeille[]).map((t) => (
<TabsTrigger key={t} value={t}>{TYPE_LABELS[t]}</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>
))} ))}
</TabsList> </div>
</Tabs> </div>
{/* Filtres */} {/* Filtres */}
<FilterBar <FilterBar
@@ -406,11 +451,23 @@ export default function VeilleDashboard() {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center"> <div className="flex flex-col items-center justify-center py-24 text-center">
<FileSearch size={48} className="text-muted-foreground/30 mb-4" /> <FileSearch size={48} className="text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun résultat trouvé</p> <p className="text-muted-foreground font-medium">
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p> {readFilter === "unread" ? "Aucun article non lu" : "Aucun résultat trouvé"}
</p>
<p className="text-muted-foreground/60 text-sm mt-1">
{readFilter === "unread"
? "Tous les articles ont é lus, ou modifiez le filtre Lu/Non lu"
: "Modifiez vos filtres ou importez des données"}
</p>
</div> </div>
) : viewMode === "list" ? ( ) : viewMode === "list" ? (
<VeilleListView items={items} onDetail={openDetail} readIds={readIds} /> <VeilleListView
items={items}
onDetail={openDetail}
readIds={readIds}
selectedRowId={selectedRowId}
onSelectRow={setSelectedRowId}
/>
) : ( ) : (
<VeilleGridView items={items} onDetail={openDetail} readIds={readIds} /> <VeilleGridView items={items} onDetail={openDetail} readIds={readIds} />
)} )}
@@ -440,13 +497,25 @@ export default function VeilleDashboard() {
// ─── Vue Liste ──────────────────────────────────────────────────────────────── // ─── Vue Liste ────────────────────────────────────────────────────────────────
function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) { function VeilleListView({
items,
onDetail,
readIds,
selectedRowId,
onSelectRow,
}: {
items: VeilleItem[];
onDetail: (item: VeilleItem) => void;
readIds: Set<number>;
selectedRowId: number | null;
onSelectRow: (id: number | null) => 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">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-muted/50 border-b border-border"> <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 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">Titre</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th> <th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th>
@@ -456,55 +525,86 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-20">Actions</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>
{items.map((item, idx) => ( {items.map((item, idx) => {
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", TYPE_ACCENT[item.typeVeille] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}> const isSelected = selectedRowId === item.id;
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td> const isRead = readIds.has(item.id);
<td className="px-4 py-3"> // Alternance de couleurs : pair = blanc, impair = gris très léger
<div className="max-w-md flex items-start gap-2"> const isEven = idx % 2 === 0;
{!readIds.has(item.id) && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" /> return (
)} <tr
<div> key={item.id}
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p> onClick={() => onSelectRow(isSelected ? null : item.id)}
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume || item.resume}</p>} className={cn(
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
TYPE_ACCENT[item.typeVeille] || "border-l-transparent",
// Ligne sélectionnée — priorité maximale
isSelected
? "bg-primary/10 hover:bg-primary/15"
// Non sélectionnée : alternance pair/impair
: 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="max-w-md flex items-start gap-2">
{!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 || item.resume) && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{item.iaResume || item.resume}
</p>
)}
</div>
</div> </div>
</div> </td>
</td> <td className="px-4 py-3">
<td className="px-4 py-3"> <Badge variant="outline" className={cn("text-xs", TYPE_COLORS[item.typeVeille])}>
<Badge variant="outline" className={cn("text-xs", TYPE_COLORS[item.typeVeille])}> {TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille} </Badge>
</Badge> </td>
</td> <td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || ""}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || "—"}</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">{item.territoire || "—"}</td> <td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td> {formatDate(item.datePublication) || ""}
<td className="px-4 py-3"> </td>
<div className="flex items-center gap-1.5"> <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
{/* Bouton Détail — bleu */} <div className="flex items-center gap-1.5">
<button <button
onClick={() => onDetail(item)} onClick={() => onDetail(item)}
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" 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="Voir le détail complet" title="Voir le détail complet"
>
<Eye size={13} />
</button>
{/* Bouton Lien externe — vert */}
{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} /> <Eye size={13} />
</a> </button>
)} {item.lien && (
</div> <a
</td> href={item.lien}
</tr> 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> </tbody>
</table> </table>
</div> </div>
@@ -514,18 +614,32 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
// ─── Vue Vignettes ──────────────────────────────────────────────────────────── // ─── Vue Vignettes ────────────────────────────────────────────────────────────
function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) { function VeilleGridView({
items,
onDetail,
readIds,
}: {
items: VeilleItem[];
onDetail: (item: VeilleItem) => void;
readIds: Set<number>;
}) {
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) => (
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", TYPE_ACCENT[item.typeVeille] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}> <Card
key={item.id}
className={cn(
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
TYPE_ACCENT[item.typeVeille] || "",
!readIds.has(item.id) && "ring-1 ring-primary/20"
)}
>
<CardHeader className="pb-2 pt-4 px-4"> <CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<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"> <div className="flex items-center gap-1.5 flex-shrink-0">
{/* Bouton Détail */}
<button <button
onClick={() => onDetail(item)} onClick={() => onDetail(item)}
className="text-muted-foreground hover:text-primary transition-colors" className="text-muted-foreground hover:text-primary transition-colors"
@@ -534,21 +648,37 @@ function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
<Eye size={14} /> <Eye size={14} />
</button> </button>
{item.lien && ( {item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors"> <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> </div>
<div className="flex items-start gap-1.5 mt-2"> <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" />} {!readIds.has(item.id) && (
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3> <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> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4 space-y-2"> <CardContent className="px-4 pb-4 space-y-2">
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume || item.resume}</p>} {(item.iaResume || item.resume) && (
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
{item.iaResume || item.resume}
</p>
)}
<div className="flex flex-wrap gap-1.5 pt-1"> <div className="flex flex-wrap gap-1.5 pt-1">
{item.territoire && ( {item.territoire && (
<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"> <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.territoire} <MapPin size={10} />{item.territoire}
</span> </span>

View File

@@ -168,5 +168,10 @@
- [x] rssEngine.ts : ajouter filtre `if (!aiResult.relevant) { skip; continue; }` avant insertion veille_items - [x] rssEngine.ts : ajouter filtre `if (!aiResult.relevant) { skip; continue; }` avant insertion veille_items
- [x] rssEngine.ts : même filtre avant insertion aap_items - [x] rssEngine.ts : même filtre avant insertion aap_items
- [x] 21 tests passés, 0 erreur TypeScript - [x] 21 tests passés, 0 erreur TypeScript
- [ ] Déployer en recette - [x] Déployer en recette
- [ ] Déployer en production - [ ] Déployer en production
- [x] Mode vignette par défaut
- [x] Alternance de couleurs en mode liste (pair=blanc, impair=gris clair)
- [x] Couleur de sélection au clic sur une ligne (bg-primary/10)
- [x] Filtre Lu/Non lu avec bouton segmenté (Non lu par défaut)
- [x] Message vide adapté selon le filtre actif