Checkpoint: AAPDashboard mis à jour avec les 4 mêmes évolutions que VeilleDashboard :

1. Mode vignette par défaut
2. Alternance de couleurs en mode liste (pair=blanc, impair=slate-50)
3. Couleur de sélection au clic sur une ligne (bg-primary/10, titre en primary)
4. Bouton segmenté Non lus / Tous / Lus — Non lus par défaut
5. Message vide adapté selon le filtre actif
0 erreur TypeScript.
This commit is contained in:
Manus
2026-06-26 10:32:19 -04:00
parent 1c425f25ad
commit 6b8b3ce9d7
3 changed files with 263 additions and 106 deletions

View File

@@ -1,4 +1,4 @@
{
"version": "c7ed64d2",
"timestamp": 1782483733259
"version": "d5ca5837",
"timestamp": 1782484339948
}

View File

@@ -11,6 +11,7 @@ import {
LayoutGrid,
List,
Eye,
EyeOff,
ExternalLink,
Calendar,
MapPin,
@@ -39,6 +40,7 @@ import { format, isPast, differenceInDays } from "date-fns";
import { fr } from "date-fns/locale";
type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
type ReadFilter = "unread" | "all" | "read";
interface AAPItem {
id: number;
@@ -136,10 +138,17 @@ export default function AAPDashboard() {
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<AAPCategorie | "all">("all");
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
// 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.aap.filters.useQuery();
@@ -168,10 +177,17 @@ export default function AAPDashboard() {
setPage(1);
};
const items = (itemsQuery.data?.items ?? []) as AAPItem[];
const allItems = (itemsQuery.data?.items ?? []) as AAPItem[];
const total = itemsQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
// Filtrage Lu/Non lu côté client
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 = [
{ key: "region", label: "Région", options: filtersQuery.data?.regions ?? [] },
{ key: "departement", label: "Département", options: filtersQuery.data?.departements ?? [] },
@@ -183,6 +199,13 @@ export default function AAPDashboard() {
const categories: AAPCategorie[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
unread: "Non lus",
all: "Tous",
read: "Lus",
};
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
return (
<div className="p-6 space-y-6 animate-fade-up">
{/* En-tête */}
@@ -191,20 +214,22 @@ 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>
)}
{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">
<div className="flex items-center gap-2 flex-wrap">
{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>
)}
{/* Boutons mode d'affichage */}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste
</Button>
@@ -227,8 +252,8 @@ export default function AAPDashboard() {
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets (Handicap, PA, Enfance, Précarité, Sanitaire et Autre).</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>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets.</p>
<p className="text-destructive font-medium">Cette opération est irréversible.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -248,15 +273,40 @@ export default function AAPDashboard() {
</div>
</div>
{/* Onglets */}
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}>
<TabsList className="bg-muted/50 flex-wrap h-auto gap-1">
<TabsTrigger value="all">Tous</TabsTrigger>
{categories.map((c) => (
<TabsTrigger key={c} value={c}>{c}</TabsTrigger>
{/* Onglets catégories + bouton Lu/Non lu */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}>
<TabsList className="bg-muted/50 flex-wrap h-auto gap-1">
<TabsTrigger value="all">Tous</TabsTrigger>
{categories.map((c) => (
<TabsTrigger key={c} value={c}>{c}</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>
</Tabs>
</div>
</div>
{/* Filtres */}
<FilterBar
@@ -276,11 +326,23 @@ export default function AAPDashboard() {
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Target size={48} className="text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun appel à projets trouvé</p>
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
<p className="text-muted-foreground font-medium">
{readFilter === "unread" ? "Aucun appel à projets non lu" : "Aucun appel à projets trouvé"}
</p>
<p className="text-muted-foreground/60 text-sm mt-1">
{readFilter === "unread"
? "Tous les appels à projets ont été lus, ou modifiez le filtre Lu/Non lu"
: "Modifiez vos filtres ou importez des données"}
</p>
</div>
) : viewMode === "list" ? (
<AAPListView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
<AAPListView
items={items}
readIds={readIds}
onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })}
selectedRowId={selectedRowId}
onSelectRow={setSelectedRowId}
/>
) : (
<AAPGridView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
)}
@@ -303,13 +365,25 @@ export default function AAPDashboard() {
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
function AAPListView({
items,
readIds,
onMarkRead,
selectedRowId,
onSelectRow,
}: {
items: AAPItem[];
readIds: Set<number>;
onMarkRead: (id: number) => void;
selectedRowId: number | null;
onSelectRow: (id: number | null) => void;
}) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<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">Titre</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Catégorie</th>
@@ -320,46 +394,85 @@ function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-16">Lien</th>
</tr>
</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", !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="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>}
<tbody>
{items.map((item, idx) => {
const isSelected = selectedRowId === item.id;
const isRead = readIds.has(item.id);
const isEven = idx % 2 === 0;
const catKey = item.iaCategorie || item.categorie;
return (
<tr
key={item.id}
onClick={() => onSelectRow(isSelected ? null : item.id)}
className={cn(
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
CAT_ACCENT[catKey] || "border-l-transparent",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: 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="flex items-start gap-2 max-w-sm">
{!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 && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>
)}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[item.iaCategorie || item.categorie])}>
{item.iaCategorie || item.categorie}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.region || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.departement || "—"}</td>
<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">
<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>
))}
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[catKey])}>
{catKey}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.region || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.departement || "—"}</td>
<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" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{!isRead && (
<button
onClick={() => onMarkRead(item.id)}
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="Marquer comme lu"
>
<Eye size={13} />
</button>
)}
{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} />
</a>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -369,52 +482,96 @@ function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function AAPGridView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
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] || "", !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])}>
{item.iaCategorie || item.categorie}
</Badge>
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors flex-shrink-0">
<ExternalLink size={14} />
</a>
{items.map((item) => {
const catKey = item.iaCategorie || item.categorie;
return (
<Card
key={item.id}
className={cn(
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
CAT_ACCENT[catKey] || "",
!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[catKey])}>
{catKey}
</Badge>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!readIds.has(item.id) && (
<button
onClick={() => onMarkRead(item.id)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Marquer comme lu"
>
<Eye size={14} />
</button>
)}
{item.lien && (
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-accent transition-colors"
>
<ExternalLink size={14} />
</a>
)}
</div>
</div>
<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.iaResume && (
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume}</p>
)}
</div>
<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">
{item.region && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-200">
<MapPin size={10} />{item.region}
</span>
)}
{item.departement && (
<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.departement}
</span>
)}
</div>
<div className="flex items-center justify-between pt-1 border-t border-border/50 flex-wrap gap-1">
{item.datePublication && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
<Calendar size={10} />
{formatDate(item.datePublication)}
</span>
)}
<ClotureStatus date={item.dateCloture} />
</div>
</CardContent>
</Card>
))}
<div className="flex flex-wrap gap-1.5">
{item.region && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-200">
<MapPin size={10} />{item.region}
</span>
)}
{item.departement && (
<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.departement}
</span>
)}
</div>
<div className="flex items-center justify-between pt-1 border-t border-border/50 flex-wrap gap-1">
{item.datePublication && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
<Calendar size={10} />
{formatDate(item.datePublication)}
</span>
)}
<ClotureStatus date={item.dateCloture} />
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -169,7 +169,7 @@
- [x] rssEngine.ts : même filtre avant insertion aap_items
- [x] 21 tests passés, 0 erreur TypeScript
- [x] Déployer en recette
- [ ] Déployer en production
- [ ] Déployer en production (en attente validation recette)
- [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)