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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user