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

@@ -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">