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:
284
client/src/components/AppLayout.tsx
Normal file
284
client/src/components/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
client/src/components/FilterBar.tsx
Normal file
149
client/src/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user