Checkpoint: Implémentation du résumé automatique généré par l'IA pour chaque article RSS (veille et AAP), marquage lu/non lu avec point bleu et fond teinté, bouton "Tout marquer comme lu", compteurs non lus dans la sidebar et dans les titres de page. Migration BDD : table article_reads + colonne iaResume dans veille_items et aap_items.

This commit is contained in:
Manus
2026-06-16 03:08:29 -04:00
parent c47c76215e
commit 67dfbb1a13
12 changed files with 1324 additions and 35 deletions

View File

@@ -1,4 +1,4 @@
{
"version": "69e54e0a",
"timestamp": 1781255928989
"version": "8d20c2f9",
"timestamp": 1781593709171
}

View File

@@ -89,6 +89,12 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) {
const isAdmin = user?.role === "admin";
// Compteurs non lus
const veilleUnreadQuery = trpc.veille.unreadCount.useQuery();
const aapUnreadQuery = trpc.aap.unreadCount.useQuery();
const veilleUnread = veilleUnreadQuery.data?.count ?? 0;
const aapUnread = aapUnreadQuery.data?.count ?? 0;
const importMutation = trpc.import.run.useMutation({
onSuccess: (data) => {
const v = "veille" in data ? data.veille : null;
@@ -174,6 +180,16 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) {
{item.badge}
</Badge>
)}
{!collapsed && item.href === "/veille" && veilleUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
{veilleUnread}
</span>
)}
{!collapsed && item.href === "/aap" && aapUnread > 0 && (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
{aapUnread}
</span>
)}
</div>
</Link>
);

View File

@@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
LayoutGrid,
List,
Eye,
ExternalLink,
Calendar,
MapPin,
@@ -53,6 +54,7 @@ interface AAPItem {
iaCategorie: string | null;
iaClassifiedBy: "ia" | "rules" | null;
iaReason: string | null;
iaResume: string | null;
}
const CAT_COLORS: Record<string, string> = {
@@ -110,6 +112,20 @@ 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)`);
@@ -175,12 +191,20 @@ export default function AAPDashboard() {
<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">
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
Tout marquer comme lu
</Button>
)}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste
</Button>
@@ -256,9 +280,9 @@ export default function AAPDashboard() {
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
</div>
) : viewMode === "list" ? (
<AAPListView items={items} />
<AAPListView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
) : (
<AAPGridView items={items} />
<AAPGridView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
)}
{/* Pagination */}
@@ -279,7 +303,7 @@ export default function AAPDashboard() {
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function AAPListView({ items }: { items: AAPItem[] }) {
function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
@@ -298,10 +322,18 @@ function AAPListView({ items }: { items: AAPItem[] }) {
</thead>
<tbody className="divide-y divide-border">
{items.map((item, idx) => (
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "border-l-transparent")}>
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
<td className="px-4 py-3">
<p className="font-medium text-foreground line-clamp-2 max-w-sm leading-snug">{item.titre}</p>
<div className="flex items-start gap-2 max-w-sm">
{!readIds.has(item.id) && (
<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", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{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[item.iaCategorie || item.categorie])}>
@@ -313,11 +345,18 @@ function AAPListView({ items }: { items: AAPItem[] }) {
<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">
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors">
<ExternalLink size={15} />
</a>
)}
<div className="flex items-center gap-1.5">
{!readIds.has(item.id) && (
<button onClick={() => onMarkRead(item.id)} className="text-muted-foreground hover:text-primary transition-colors" title="Marquer comme lu">
<Eye size={13} />
</button>
)}
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors">
<ExternalLink size={15} />
</a>
)}
</div>
</td>
</tr>
))}
@@ -330,11 +369,11 @@ function AAPListView({ items }: { items: AAPItem[] }) {
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function AAPGridView({ items }: { items: AAPItem[] }) {
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) => (
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "")}>
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "", !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[item.iaCategorie || item.categorie])}>
@@ -346,7 +385,10 @@ function AAPGridView({ items }: { items: AAPItem[] }) {
</a>
)}
</div>
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
<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">
<div className="flex flex-wrap gap-1.5">

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
@@ -65,6 +65,7 @@ interface VeilleItem {
iaCategorie: string | null;
iaClassifiedBy: "ia" | "rules" | null;
iaReason: string | null;
iaResume: string | null;
}
const TYPE_LABELS: Record<TypeVeille, string> = {
@@ -197,15 +198,18 @@ function VeilleDetailDialog({
)}
</div>
{/* Résumé complet */}
{item.resume && (
{/* Résumé IA ou résumé brut */}
{(item.iaResume || 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>
{item.iaResume && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-50 text-violet-600 border border-violet-200 font-medium">Généré par l'IA</span>
)}
</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>
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">{item.iaResume || item.resume}</p>
</div>
</div>
)}
@@ -237,6 +241,22 @@ export default function VeilleDashboard() {
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.veille.markAsRead.useMutation({
onSuccess: (_, vars) => {
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
},
});
const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({
onSuccess: () => {
utils.veille.unreadCount.invalidate();
},
});
const unreadCountQuery = trpc.veille.unreadCount.useQuery();
const unreadCount = unreadCountQuery.data?.count ?? 0;
const purgeMutation = trpc.veille.purge.useMutation({
onSuccess: (data) => {
toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`);
@@ -283,6 +303,10 @@ export default function VeilleDashboard() {
const openDetail = (item: VeilleItem) => {
setSelectedItem(item);
setDialogOpen(true);
// Marquer comme lu automatiquement à l'ouverture du détail
if (!readIds.has(item.id)) {
markAsReadMutation.mutate({ articleId: item.id });
}
};
const items = (itemsQuery.data?.items ?? []) as VeilleItem[];
@@ -305,12 +329,21 @@ export default function VeilleDashboard() {
<div className="flex items-center gap-2 mb-1">
<FileSearch size={22} className="text-primary" />
<h1 className="text-2xl font-bold text-foreground">Veille Stratégique</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">
Suivi réglementaire, concurrentiel, technologique et général
</p>
</div>
<div className="flex items-center gap-2">
{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>
)}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste
</Button>
@@ -387,9 +420,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} onDetail={openDetail} />
<VeilleListView items={items} onDetail={openDetail} readIds={readIds} />
) : (
<VeilleGridView items={items} onDetail={openDetail} />
<VeilleGridView items={items} onDetail={openDetail} readIds={readIds} />
)}
{/* Pagination */}
@@ -417,7 +450,7 @@ export default function VeilleDashboard() {
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) {
function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
@@ -436,12 +469,17 @@ function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i
</thead>
<tbody className="divide-y divide-border">
{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")}>
<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")}>
<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">
<p className="font-medium text-foreground line-clamp-2 leading-snug">{item.titre}</p>
{item.resume && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.resume}</p>}
<div className="max-w-md flex items-start gap-2">
{!readIds.has(item.id) && (
<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", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{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>
</td>
<td className="px-4 py-3">
@@ -490,11 +528,11 @@ function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) {
function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{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] || "")}>
<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">
<div className="flex items-start justify-between gap-2">
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
@@ -516,10 +554,13 @@ function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i
)}
</div>
</div>
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
<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.resume && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{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">
{item.categorie && <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Tag size={10} />{item.categorie}</span>}
{item.territoire && (