Checkpoint: Application complète : deux tableaux de bord (Veille Stratégique + AAP), import Excel quotidien avec déduplication, sources multiples (local/OneDrive/FTP/SharePoint), affichage liste/vignettes, filtres multi-critères, gestion utilisateurs, logs d'import, page paramètres, authentification locale, tâche cron 06h00, 13 tests Vitest passants.

This commit is contained in:
Manus
2026-03-16 10:45:35 -04:00
parent 5000fc555d
commit 8fb71e8bda
27 changed files with 4525 additions and 184 deletions

View File

@@ -0,0 +1,284 @@
import { useState, useEffect } from "react";
import { Link, useLocation } from "wouter";
import {
LayoutDashboard,
FileSearch,
Target,
Settings,
Users,
LogOut,
ChevronLeft,
ChevronRight,
FileText,
Activity,
ChevronDown,
ChevronUp,
RefreshCw,
Menu,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
badge?: string;
adminOnly?: boolean;
}
interface NavGroup {
label: string;
icon: React.ReactNode;
items: NavItem[];
defaultOpen?: boolean;
}
const NAV_GROUPS: NavGroup[] = [
{
label: "Tableaux de bord",
icon: <LayoutDashboard size={18} />,
defaultOpen: true,
items: [
{ label: "Veille Stratégique", href: "/veille", icon: <FileSearch size={16} /> },
{ label: "Appels à Projets", href: "/aap", icon: <Target size={16} /> },
],
},
{
label: "Administration",
icon: <Settings size={18} />,
defaultOpen: false,
items: [
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
{ label: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
{ label: "Paramètres", href: "/admin/settings", icon: <Settings size={16} />, adminOnly: true },
],
},
];
interface AppLayoutProps {
children: React.ReactNode;
user: { name?: string | null; email?: string | null; role?: string } | null;
onLogout: () => void;
}
export function AppLayout({ children, user, onLogout }: AppLayoutProps) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
"Tableaux de bord": true,
"Administration": false,
});
const [location] = useLocation();
const isAdmin = user?.role === "admin";
const importMutation = trpc.import.run.useMutation({
onSuccess: (data) => {
const v = "veille" in data ? data.veille : null;
const a = "aap" in data ? data.aap : null;
const msg = [
v ? `Veille: +${v.newRows} nouvelles entrées` : null,
a ? `AAP: +${a.newRows} nouvelles entrées` : null,
].filter(Boolean).join(" | ");
toast.success("Import terminé", { description: msg || "Aucune nouvelle entrée" });
},
onError: (e) => toast.error("Erreur d'import", { description: e.message }),
});
const toggleGroup = (label: string) => {
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
};
const SidebarContent = () => (
<div className="flex flex-col h-full">
{/* Logo */}
<div className={cn(
"flex items-center gap-3 px-4 py-5 border-b border-sidebar-border",
collapsed && "justify-center px-2"
)}>
<div className="w-9 h-9 rounded-xl bg-sidebar-primary flex items-center justify-center flex-shrink-0 shadow-lg">
<FileText size={18} className="text-sidebar-primary-foreground" />
</div>
{!collapsed && (
<div className="min-w-0">
<p className="text-sm font-bold text-sidebar-foreground leading-tight truncate">Veille Réglementaire</p>
<p className="text-xs text-sidebar-foreground/60 truncate">Direction des Opérations</p>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1">
{NAV_GROUPS.map((group) => {
const isOpen = openGroups[group.label] ?? group.defaultOpen;
const visibleItems = group.items.filter((item) => !item.adminOnly || isAdmin);
if (visibleItems.length === 0) return null;
return (
<div key={group.label}>
{!collapsed && (
<button
onClick={() => toggleGroup(group.label)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wider hover:text-sidebar-foreground transition-colors"
>
<span className="flex items-center gap-2">
{group.icon}
{group.label}
</span>
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
)}
{(isOpen || collapsed) && (
<div className="space-y-0.5 mt-1">
{visibleItems.map((item) => {
const active = location === item.href || location.startsWith(item.href + "/");
return (
<Link key={item.href} href={item.href}>
<div
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer group",
active
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-2"
)}
title={collapsed ? item.label : undefined}
>
<span className={cn("flex-shrink-0", active ? "text-sidebar-primary-foreground" : "text-sidebar-foreground/60 group-hover:text-sidebar-accent-foreground")}>
{item.icon}
</span>
{!collapsed && (
<span className="truncate">{item.label}</span>
)}
{!collapsed && item.badge && (
<Badge variant="secondary" className="ml-auto text-xs">
{item.badge}
</Badge>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
{/* Import rapide */}
{isAdmin && (
<div className={cn("px-2 py-2 border-t border-sidebar-border", collapsed && "flex justify-center")}>
<Button
variant="ghost"
size="sm"
className={cn(
"text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent w-full",
collapsed && "w-10 h-10 p-0"
)}
onClick={() => importMutation.mutate({ type: "all" })}
disabled={importMutation.isPending}
title="Lancer l'import maintenant"
>
<RefreshCw size={16} className={cn(importMutation.isPending && "animate-spin")} />
{!collapsed && <span className="ml-2 text-xs">Importer maintenant</span>}
</Button>
</div>
)}
{/* Utilisateur */}
<div className={cn(
"px-2 py-3 border-t border-sidebar-border",
collapsed && "flex justify-center"
)}>
{!collapsed ? (
<div className="flex items-center gap-2 px-2">
<div className="w-8 h-8 rounded-full bg-sidebar-primary/30 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-sidebar-primary-foreground">
{(user?.name || user?.email || "?")[0].toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-sidebar-foreground truncate">{user?.name || "Utilisateur"}</p>
<p className="text-xs text-sidebar-foreground/50 truncate">{user?.role === "admin" ? "Administrateur" : user?.role === "readonly" ? "Lecture seule" : "Utilisateur"}</p>
</div>
<Button
variant="ghost"
size="icon"
className="w-7 h-7 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent flex-shrink-0"
onClick={onLogout}
title="Se déconnecter"
>
<LogOut size={14} />
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="w-9 h-9 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent"
onClick={onLogout}
title="Se déconnecter"
>
<LogOut size={16} />
</Button>
)}
</div>
</div>
);
return (
<div className="flex h-screen bg-background overflow-hidden">
{/* Sidebar desktop */}
<aside
className={cn(
"hidden md:flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-300 ease-in-out flex-shrink-0",
collapsed ? "w-16" : "w-64"
)}
>
<SidebarContent />
{/* Toggle collapse */}
<button
onClick={() => setCollapsed(!collapsed)}
className="absolute left-0 top-1/2 -translate-y-1/2 translate-x-full w-5 h-10 bg-sidebar border border-sidebar-border rounded-r-md flex items-center justify-center text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors z-10"
style={{ left: collapsed ? "3.5rem" : "15.5rem" }}
>
{collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}
</button>
</aside>
{/* Sidebar mobile overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 bottom-0 w-72 bg-sidebar border-r border-sidebar-border animate-slide-in-left">
<SidebarContent />
</aside>
</div>
)}
{/* Contenu principal */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header mobile */}
<header className="md:hidden flex items-center gap-3 px-4 py-3 bg-card border-b border-border">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu size={20} />
</Button>
<span className="font-semibold text-foreground">Veille Réglementaire</span>
</header>
{/* Zone de contenu scrollable */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useState } from "react";
import { Search, X, Filter, ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
export interface FilterOption {
key: string;
label: string;
options?: string[];
type?: "select" | "date";
}
interface FilterBarProps {
filters: FilterOption[];
values: Record<string, string>;
onChange: (key: string, value: string) => void;
onReset: () => void;
searchKey?: string;
searchPlaceholder?: string;
totalCount?: number;
filteredCount?: number;
}
export function FilterBar({
filters,
values,
onChange,
onReset,
searchKey = "search",
searchPlaceholder = "Rechercher…",
totalCount,
filteredCount,
}: FilterBarProps) {
const [expanded, setExpanded] = useState(false);
const activeCount = Object.values(values).filter((v) => v && v !== "all").length;
return (
<div className="space-y-3">
{/* Barre principale */}
<div className="flex items-center gap-2 flex-wrap">
{/* Recherche */}
<div className="relative flex-1 min-w-48">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={values[searchKey] || ""}
onChange={(e) => onChange(searchKey, e.target.value)}
className="pl-9 h-9 bg-background"
/>
{values[searchKey] && (
<button
onClick={() => onChange(searchKey, "")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={14} />
</button>
)}
</div>
{/* Bouton filtres */}
<Button
variant="outline"
size="sm"
onClick={() => setExpanded(!expanded)}
className={cn("gap-2 h-9", activeCount > 0 && "border-primary text-primary")}
>
<Filter size={14} />
Filtres
{activeCount > 0 && (
<Badge variant="default" className="h-4 w-4 p-0 flex items-center justify-center text-xs rounded-full">
{activeCount}
</Badge>
)}
<ChevronDown size={12} className={cn("transition-transform", expanded && "rotate-180")} />
</Button>
{/* Reset */}
{activeCount > 0 && (
<Button variant="ghost" size="sm" onClick={onReset} className="h-9 text-muted-foreground hover:text-foreground gap-1">
<X size={14} />
Réinitialiser
</Button>
)}
{/* Compteur */}
{totalCount !== undefined && (
<span className="text-sm text-muted-foreground ml-auto">
{filteredCount !== undefined && filteredCount !== totalCount
? `${filteredCount} / ${totalCount} résultats`
: `${totalCount} résultat${totalCount !== 1 ? "s" : ""}`}
</span>
)}
</div>
{/* Filtres étendus */}
{expanded && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 p-3 bg-muted/30 rounded-lg border border-border/50 animate-fade-up">
{filters.map((filter) => {
if (filter.type === "date") {
return (
<div key={filter.key} className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{filter.label}</label>
<Input
type="date"
value={values[filter.key] || ""}
onChange={(e) => onChange(filter.key, e.target.value)}
className="h-8 text-sm bg-background"
/>
</div>
);
}
return (
<div key={filter.key} className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{filter.label}</label>
<Select
value={values[filter.key] || "all"}
onValueChange={(v) => onChange(filter.key, v === "all" ? "" : v)}
>
<SelectTrigger className="h-8 text-sm bg-background">
<SelectValue placeholder={`Tous`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous</SelectItem>
{filter.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
</div>
)}
</div>
);
}