feat(ui) : refonte du menu latéral et de la navigation téléphone (#569)

Menu PC et navigation téléphone tirés d'une configuration unique
(client/src/lib/navigation.ts), selon docs/PLAN-OPTIMISATION-WEBUI.md
(P1 § 2.1 et § 2.6).

- menu regroupé par usage (Au quotidien, Fournisseurs, Planning et infos,
  Administration), libellés clarifiés, chaque icône une seule fois ;
  droits d'accès PC inchangés
- l'envoi de BAP quitte le menu : bouton « Envoyer un BAP » (admin) dans
  l'en-tête du Rapprochement BL / factures
- téléphone : 4 raccourcis selon le rôle + « Menu » avec tous les modules
  autorisés, magasin, profil et déconnexion ; pages sans version mobile
  affichées dans le cadre téléphone
- titre de l'onglet du navigateur tiré du menu
- onglets du Rapprochement lisibles sur téléphone
- suppression de PhoneBottomNav.tsx (inutilisé)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
This commit is contained in:
LogiFlowandClaude Opus 5.5 authored and GitHub committed 2026-10-10 09:35:35 +02:00
1 parent bd3bb5e64a
commit b964e137bf
9 files changed
+826 -1205

No files matched your search

+7
View File
@@ -12,6 +12,7 @@ import Sidebar from "./Sidebar";
import ErrorBoundary from "./ErrorBoundary";
import WeatherWidget from "./WeatherWidget";
import DateWidget from "./DateWidget";
import { getNavItemForPath } from "@/lib/navigation";
import type { Group } from "@shared/schema";
@@ -63,6 +64,12 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Titre de l'onglet du navigateur tiré du menu : « Livraisons — LogiFlow »
useEffect(() => {
const navItem = getNavItemForPath(location);
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
}, [location]);
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
queryKey: ['/api/groups'],
enabled: !!user,
-148
View File
@@ -1,148 +0,0 @@
import { Home, Package, Truck, ClipboardList, MoreHorizontal } from "lucide-react"
import { Link, useLocation } from "wouter"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import type { User } from "@shared/schema"
interface NavItem {
path: string
label: string
icon: any
primary?: boolean
}
interface MoreMenuItem {
path: string
label: string
icon: any
roles?: string[]
}
const primaryNavItems: NavItem[] = [
{ path: "/", label: "Accueil", icon: Home, primary: true },
{ path: "/orders", label: "Commandes", icon: Package, primary: true },
{ path: "/deliveries", label: "Livraisons", icon: Truck, primary: true },
{ path: "/tasks", label: "Tâches", icon: ClipboardList, primary: true },
]
const moreMenuItems: MoreMenuItem[] = [
{ path: "/bl-reconciliation", label: "Rapprochement", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
{ path: "/publicities", label: "Publicités", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/customer-orders", label: "Commandes Clients", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/avoirs", label: "Avoirs", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/dlc", label: "DLC", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/sav", label: "SAV", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
{ path: "/calendar", label: "Calendrier", icon: ClipboardList },
]
interface PhoneBottomNavProps {
user: User
location: string
}
export default function PhoneBottomNav({ user, location }: PhoneBottomNavProps) {
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
const isActive = (path: string) => {
if (path === "/") return location === "/"
return location.startsWith(path)
}
const hasPermission = (roles?: string[]) => {
if (!roles || !user) return true
return roles.includes(user.role)
}
const filteredMoreItems = moreMenuItems.filter(item => hasPermission(item.roles))
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 safe-area-pb bottom-nav">
<div className="flex items-center justify-around py-2 px-1">
{/* Navigation principale */}
{primaryNavItems.map((item) => {
const Icon = item.icon
const active = isActive(item.path)
return (
<Link key={item.path} href={item.path}>
<div
className={`flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 ${
active
? 'bg-blue-50 text-blue-600'
: 'text-gray-600 hover:bg-gray-50'
}`}
data-testid={`nav-${item.path.replace('/', 'home')}`}
>
<Icon className={`w-5 h-5 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
<span className={`text-xs mt-1 font-medium ${
active ? 'text-blue-600' : 'text-gray-600'
}`}>
{item.label}
</span>
</div>
</Link>
)
})}
{/* Menu "Plus" */}
<Sheet open={moreSheetOpen} onOpenChange={setMoreSheetOpen}>
<SheetTrigger asChild>
<div
className="flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 text-gray-600 hover:bg-gray-50 cursor-pointer"
data-testid="nav-more"
>
<MoreHorizontal className="w-5 h-5 text-gray-600" />
<span className="text-xs mt-1 font-medium text-gray-600">Plus</span>
</div>
</SheetTrigger>
<SheetContent side="bottom" className="h-auto max-h-[60vh] rounded-t-lg">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>
Accédez aux autres sections de l'application
</SheetDescription>
</SheetHeader>
<div className="grid grid-cols-3 gap-4 mt-6 pb-4">
{filteredMoreItems.map((item) => {
const Icon = item.icon
const active = isActive(item.path)
return (
<Link
key={item.path}
href={item.path}
onClick={() => setMoreSheetOpen(false)}
>
<div
className={`flex flex-col items-center justify-center p-4 rounded-lg transition-all duration-200 min-h-[80px] ${
active
? 'bg-blue-50 text-blue-600 border-2 border-blue-200'
: 'bg-gray-50 text-gray-700 hover:bg-gray-100 border-2 border-transparent'
}`}
data-testid={`more-nav-${item.path.replace('/', '')}`}
>
<Icon className={`w-6 h-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
<span className={`text-sm mt-2 font-medium text-center ${
active ? 'text-blue-600' : 'text-gray-700'
}`}>
{item.label}
</span>
</div>
</Link>
)
})}
</div>
</SheetContent>
</Sheet>
</div>
</div>
)
}
+42 -11
View File
@@ -1,4 +1,4 @@
import { lazy, useEffect } from "react";
import { lazy, Suspense, useEffect, type ComponentType } from "react";
import { Switch, Route, Redirect, useLocation } from "wouter";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useScreenSize } from "@/hooks/use-screen-size";
@@ -10,6 +10,7 @@ import Layout from "@/components/Layout";
// Mobile : coque et page d'accueil chargées immédiatement
import MobileApp from "@/pages/mobile/MobileApp";
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
import MobileLayout from "@/pages/mobile/MobileLayout";
// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
@@ -44,6 +45,36 @@ const MobileDlcPage = lazy(() => import("@/pages/mobile/DlcPage"));
const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
// Pages sans version mobile : affichées dans le cadre téléphone (en-tête, barre
// du bas et menu) pour ne jamais laisser une page sans navigation
function withMobileFrame(Page: ComponentType) {
return function MobileFramedPage() {
return (
<MobileLayout>
<div className="p-3">
<Suspense fallback={
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
}>
<Page />
</Suspense>
</div>
</MobileLayout>
);
};
}
const MobileFramedContacts = withMobileFrame(Contacts);
const MobileFramedSuppliers = withMobileFrame(Suppliers);
const MobileFramedGroups = withMobileFrame(Groups);
const MobileFramedUsers = withMobileFrame(Users);
const MobileFramedBLReconciliation = withMobileFrame(BLReconciliation);
const MobileFramedUtilities = withMobileFrame(Utilities);
const MobileFramedAnalytics = withMobileFrame(Analytics);
const MobileFramedPaymentSchedule = withMobileFrame(PaymentSchedulePage);
const MobileFramedNotFound = withMobileFrame(NotFound);
// Helper component to handle redirection
const RedirectToAuth = () => {
const [, setLocation] = useLocation();
@@ -92,20 +123,20 @@ function RouterProduction() {
<Route path="/deliveries" component={MobileDeliveriesPage} />
<Route path="/tasks" component={MobileTasksPage} />
{/* Pages sans version mobile - utiliser version desktop pour l'instant */}
<Route path="/contacts" component={Contacts} />
<Route path="/suppliers" component={Suppliers} />
<Route path="/groups" component={Groups} />
<Route path="/users" component={Users} />
<Route path="/bl-reconciliation" component={BLReconciliation} />
{/* Pages sans version mobile : version PC dans le cadre téléphone */}
<Route path="/contacts" component={MobileFramedContacts} />
<Route path="/suppliers" component={MobileFramedSuppliers} />
<Route path="/groups" component={MobileFramedGroups} />
<Route path="/users" component={MobileFramedUsers} />
<Route path="/bl-reconciliation" component={MobileFramedBLReconciliation} />
<Route path="/publicities" component={MobilePublicitiesPage} />
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
<Route path="/dlc" component={MobileDlcPage} />
<Route path="/utilities" component={Utilities} />
<Route path="/utilities" component={MobileFramedUtilities} />
<Route path="/sav" component={MobileSavPage} />
<Route path="/avoirs" component={MobileAvoirsPage} />
<Route path="/analytics" component={Analytics} />
<Route path="/payment-schedule" component={PaymentSchedulePage} />
<Route path="/analytics" component={MobileFramedAnalytics} />
<Route path="/payment-schedule" component={MobileFramedPaymentSchedule} />
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
<Route path="/auth">
@@ -113,7 +144,7 @@ function RouterProduction() {
</Route>
<Route path="/" component={MobileDashboardPage} />
<Route component={NotFound} />
<Route component={MobileFramedNotFound} />
</Switch>
</MobileApp>
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,314 @@
import { useEffect, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Clock, FileUp, Send } from "lucide-react";
// URL utilisée quand aucun webhook BAP n'est configuré dans les paramètres
const DEFAULT_BAP_WEBHOOK_URL = "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d";
interface BapUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Envoi d'un bon à payer (PDF) au workflow configuré, avec fenêtre d'attente
export default function BapUploadModal({ open, onOpenChange }: BapUploadModalProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedRecipient, setSelectedRecipient] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const [showWaitingModal, setShowWaitingModal] = useState(false);
const [processingSeconds, setProcessingSeconds] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const stopProcessingTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
useEffect(() => stopProcessingTimer, []);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.type === 'application/pdf') {
setSelectedFile(file);
} else {
toast({
title: "Erreur",
description: "Veuillez sélectionner un fichier PDF",
variant: "destructive",
});
}
};
const startProcessingTimer = () => {
stopProcessingTimer();
setProcessingSeconds(0);
timerRef.current = setInterval(() => {
setProcessingSeconds(prev => {
if (prev >= 60) {
stopProcessingTimer();
return 60;
}
return prev + 1;
});
}, 1000);
};
const handleCloseWaitingModal = () => {
setShowWaitingModal(false);
setProcessingSeconds(0);
stopProcessingTimer();
};
const clearSelectedFile = () => {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleSendBap = async () => {
if (!selectedFile || !selectedRecipient) {
toast({
title: "Erreur",
description: "Veuillez sélectionner un fichier PDF et un destinataire",
variant: "destructive",
});
return;
}
// Récupérer l'URL webhook configurée
let webhookUrl = '';
try {
const configResponse = await fetch('/api/webhook-bap-config', {
credentials: 'include'
});
if (configResponse.ok) {
const config = await configResponse.json();
webhookUrl = config?.webhookUrl;
}
} catch (error) {
console.warn('Impossible de récupérer la config webhook, utilisation URL par défaut');
}
if (!webhookUrl) {
webhookUrl = DEFAULT_BAP_WEBHOOK_URL;
}
// Fermer la fenêtre de sélection et ouvrir celle d'attente
onOpenChange(false);
setShowWaitingModal(true);
setIsUploading(true);
startProcessingTimer();
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('recipient', selectedRecipient);
formData.append('type', 'BAP');
formData.append('fileName', selectedFile.name);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 secondes
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
}
handleCloseWaitingModal();
toast({
title: "Succès",
description: "Fichier BAP envoyé avec succès",
});
clearSelectedFile();
setSelectedRecipient('');
} catch (error: any) {
handleCloseWaitingModal();
let errorMessage = "Impossible d'envoyer le fichier BAP";
if (error.name === 'AbortError') {
errorMessage = "Le traitement a pris trop de temps (timeout de 60 secondes)";
} else if (error.message) {
errorMessage = error.message;
}
toast({
title: "Erreur",
description: errorMessage,
variant: "destructive",
});
// Réouvrir la fenêtre de sélection en cas d'erreur
onOpenChange(true);
} finally {
setIsUploading(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileUp className="h-5 w-5 text-blue-600" />
Envoyer un bon à payer (BAP)
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-sm font-semibold text-gray-800">Fichier PDF</Label>
{!selectedFile ? (
<button
type="button"
className="w-full border-2 border-dashed border-blue-300 rounded-xl p-8 bg-blue-50 hover:bg-blue-100 transition-colors group"
onClick={() => fileInputRef.current?.click()}
>
<div className="text-center space-y-4">
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-full flex items-center justify-center group-hover:bg-blue-700 transition-colors">
<FileUp className="h-8 w-8 text-white" />
</div>
<div>
<p className="text-lg font-semibold text-gray-800 mb-2">
Choisir un fichier PDF
</p>
<p className="text-sm text-gray-600">
Cliquez pour parcourir vos fichiers
</p>
</div>
</div>
</button>
) : (
<div className="border-2 border-green-300 rounded-xl p-6 bg-green-50">
<div className="flex items-center space-x-4">
<div className="h-12 w-12 bg-green-600 rounded-full flex items-center justify-center">
<FileUp className="h-6 w-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-green-800 truncate">{selectedFile.name}</p>
<p className="text-sm text-green-700">
{(selectedFile.size / 1024 / 1024).toFixed(2)} Mo • PDF
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={clearSelectedFile}
className="border-green-300 text-green-700 hover:bg-green-100"
>
Changer
</Button>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept=".pdf"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold text-gray-800">Envoyer à</Label>
<Select value={selectedRecipient} onValueChange={setSelectedRecipient}>
<SelectTrigger className="h-12 border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-colors">
<SelectValue placeholder="Choisir le destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Laurie" className="py-3">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-purple-600">L</span>
</div>
<span className="font-medium">Laurie</span>
</div>
</SelectItem>
<SelectItem value="Jeremy" className="py-3">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-blue-600">J</span>
</div>
<span className="font-medium">Jeremy</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 h-12 border-2 border-gray-200 hover:border-gray-300"
>
Annuler
</Button>
<Button
onClick={handleSendBap}
disabled={!selectedFile || !selectedRecipient || isUploading}
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-medium"
>
{isUploading ? (
<>
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2"></div>
Envoi en cours...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Envoyer le BAP
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Fenêtre d'attente pendant l'envoi */}
<Dialog open={showWaitingModal} onOpenChange={() => { }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Traitement en cours...
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center space-y-4 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<div className="text-sm text-gray-600">
Envoi du fichier vers le workflow...
</div>
<div className="text-sm text-gray-500">
{processingSeconds < 60
? `${processingSeconds}s écoulées`
: '60s+ écoulées'
}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+134
View File
@@ -0,0 +1,134 @@
import type { LucideIcon } from "lucide-react";
import {
House,
ListTodo,
CalendarClock,
ShoppingCart,
Wrench,
Package,
Truck,
FileCheck,
Receipt,
CreditCard,
CalendarDays,
Megaphone,
BookUser,
ChartLine,
UserCog,
Store,
Building2,
Settings,
} from "lucide-react";
// Configuration unique du menu de navigation (docs/PLAN-OPTIMISATION-WEBUI.md, P1 § 2.1).
// Chaque icône n'apparaît qu'une fois ; les adresses sont inchangées.
export type Role = "admin" | "directeur" | "manager" | "employee";
const ALL_ROLES: Role[] = ["admin", "directeur", "manager", "employee"];
export interface NavItem {
path: string;
label: string;
icon: LucideIcon;
roles: Role[];
}
export interface NavSection {
// Pas de titre pour l'entrée Accueil, affichée en tête
title?: string;
items: NavItem[];
}
export const NAV_SECTIONS: NavSection[] = [
{
items: [
{ path: "/", label: "Accueil", icon: House, roles: ALL_ROLES },
],
},
{
title: "Au quotidien",
items: [
{ path: "/tasks", label: "Tâches", icon: ListTodo, roles: ALL_ROLES },
{ path: "/dlc", label: "DLC", icon: CalendarClock, roles: ALL_ROLES },
{ path: "/customer-orders", label: "Commandes clients", icon: ShoppingCart, roles: ALL_ROLES },
{ path: "/sav", label: "SAV", icon: Wrench, roles: ALL_ROLES },
],
},
{
title: "Fournisseurs",
items: [
{ path: "/orders", label: "Commandes fournisseurs", icon: Package, roles: ["admin", "directeur", "manager"] },
{ path: "/deliveries", label: "Livraisons", icon: Truck, roles: ["admin", "directeur", "manager"] },
{ path: "/bl-reconciliation", label: "Rapprochement BL / factures", icon: FileCheck, roles: ["admin", "directeur"] },
{ path: "/avoirs", label: "Avoirs", icon: Receipt, roles: ["admin", "directeur", "manager"] },
{ path: "/payment-schedule", label: "Échéancier", icon: CreditCard, roles: ["admin", "directeur"] },
],
},
{
title: "Planning et infos",
items: [
{ path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES },
{ path: "/publicities", label: "Publicités", icon: Megaphone, roles: ALL_ROLES },
{ path: "/contacts", label: "Contacts", icon: BookUser, roles: ALL_ROLES },
{ path: "/analytics", label: "Statistiques", icon: ChartLine, roles: ["admin", "directeur", "manager"] },
],
},
{
title: "Administration",
items: [
{ path: "/users", label: "Utilisateurs", icon: UserCog, roles: ["admin"] },
{ path: "/groups", label: "Magasins", icon: Store, roles: ["admin"] },
{ path: "/suppliers", label: "Fiches fournisseurs", icon: Building2, roles: ["admin"] },
{ path: "/utilities", label: "Paramètres", icon: Settings, roles: ["admin"] },
],
},
];
// Sections visibles pour un rôle, sans les sections devenues vides
export function getNavSections(role?: string): NavSection[] {
if (!role) return [];
return NAV_SECTIONS
.map((section) => ({
...section,
items: section.items.filter((item) => item.roles.includes(role as Role)),
}))
.filter((section) => section.items.length > 0);
}
// Barre du bas sur téléphone : les 4 premières entrées autorisées de cette liste,
// le reste est dans « Menu »
const MOBILE_SHORTCUT_PATHS = ["/", "/deliveries", "/tasks", "/dlc", "/customer-orders"];
const MOBILE_SHORTCUT_COUNT = 4;
export function getMobileShortcuts(role?: string): NavItem[] {
const allowed = getNavSections(role).flatMap((section) => section.items);
return MOBILE_SHORTCUT_PATHS
.map((path) => allowed.find((item) => item.path === path))
.filter((item): item is NavItem => Boolean(item))
.slice(0, MOBILE_SHORTCUT_COUNT);
}
// « / » et « /dashboard » activent tous deux Accueil ; les autres entrées
// restent actives sur leurs sous-adresses
export function isNavItemActive(itemPath: string, location: string): boolean {
if (itemPath === "/") return location === "/" || location === "/dashboard";
return location === itemPath || location.startsWith(`${itemPath}/`);
}
export function getNavItemForPath(location: string): NavItem | undefined {
for (const section of NAV_SECTIONS) {
const item = section.items.find((navItem) => isNavItemActive(navItem.path, location));
if (item) return item;
}
return undefined;
}
export function getRoleLabel(role?: string): string {
switch (role) {
case "admin": return "Administrateur";
case "directeur": return "Directeur";
case "manager": return "Manager";
default: return "Employé";
}
}
+33 -9
View File
@@ -12,13 +12,14 @@ import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { usePermissions } from "@shared/permissions";
import { Pagination, usePagination } from "@/components/ui/pagination";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail } from "lucide-react";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail, FileUp } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import ReconciliationComments from "@/components/ReconciliationComments";
import ReconciliationModal from "@/components/modals/ReconciliationModal";
import BapUploadModal from "@/components/modals/BapUploadModal";
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
// effets et calculs mémoïsés à chaque rendu)
@@ -128,6 +129,7 @@ export default function BLReconciliation() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedDelivery, setSelectedDelivery] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [showBapModal, setShowBapModal] = useState(false);
// État pour le modal d'envoi de facture
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
@@ -917,23 +919,41 @@ export default function BLReconciliation() {
<Search className="w-4 h-4 mr-1" />
Vérifier toutes les factures
</Button>
{user?.role === 'admin' && (
<Button
onClick={() => setShowBapModal(true)}
variant="outline"
size="sm"
className="text-xs sm:text-sm"
>
<FileUp className="w-4 h-4 mr-1" />
Envoyer un BAP
</Button>
)}
</div>
</div>
{/* Onglets */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="manual" className="flex items-center space-x-2">
<Edit className="w-4 h-4" />
<span>Rapprochement Manuel</span>
<Badge variant="secondary" className="ml-2">
{/* Libellés courts sur téléphone : les libellés complets débordaient */}
<TabsTrigger value="manual" className="flex items-center gap-1.5 sm:gap-2 min-w-0 px-2">
<Edit className="hidden sm:block w-4 h-4 shrink-0" />
<span className="truncate">
<span className="sm:hidden">À rapprocher</span>
<span className="hidden sm:inline">Rapprochement Manuel</span>
</span>
<Badge variant="secondary" className="shrink-0">
{manualNotValidatedDeliveries.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="validated" className="flex items-center space-x-2">
<CheckCircle className="w-4 h-4" />
<span>Livraisons Validées</span>
<Badge variant="secondary" className="ml-2">
<TabsTrigger value="validated" className="flex items-center gap-1.5 sm:gap-2 min-w-0 px-2">
<CheckCircle className="hidden sm:block w-4 h-4 shrink-0" />
<span className="truncate">
<span className="sm:hidden">Validées</span>
<span className="hidden sm:inline">Livraisons Validées</span>
</span>
<Badge variant="secondary" className="shrink-0">
{allValidatedDeliveries.length}
</Badge>
</TabsTrigger>
@@ -1738,6 +1758,10 @@ export default function BLReconciliation() {
</DialogFooter>
</DialogContent>
</Dialog>
{user?.role === 'admin' && (
<BapUploadModal open={showBapModal} onOpenChange={setShowBapModal} />
)}
</div>
);
}
+48 -120
View File
@@ -1,135 +1,63 @@
/**
* MobileBottomNav - Navigation bottom fixe pour l'application mobile
* 5 boutons: Accueil, Commandes, Livraisons, Tâches, Plus
* MobileBottomNav - Barre de navigation fixe en bas de l'écran (téléphone)
* 4 raccourcis tirés de la configuration du menu (selon le rôle) + « Menu »
*/
import { Link, useLocation } from "wouter";
import { useState } from "react";
import {
Home,
Package,
Truck,
CheckSquare,
MoreHorizontal,
Calendar,
Megaphone,
ShoppingCart,
Clock,
Wrench,
Receipt
} from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Menu } from "lucide-react";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { getMobileShortcuts, isNavItemActive } from "@/lib/navigation";
interface NavItem {
path: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
interface MobileBottomNavProps {
menuOpen: boolean;
onOpenMenu: () => void;
}
const primaryItems: NavItem[] = [
{ path: "/dashboard", label: "Accueil", icon: Home },
{ path: "/orders", label: "Commandes", icon: Package },
{ path: "/deliveries", label: "Livraisons", icon: Truck },
{ path: "/tasks", label: "Tâches", icon: CheckSquare },
];
const moreItems: NavItem[] = [
{ path: "/calendar", label: "Calendrier", icon: Calendar },
{ path: "/publicities", label: "Publicités", icon: Megaphone },
{ path: "/customer-orders", label: "Cmd Client", icon: ShoppingCart },
{ path: "/dlc", label: "DLC", icon: Clock },
{ path: "/sav", label: "SAV", icon: Wrench },
{ path: "/avoirs", label: "Avoirs", icon: Receipt },
];
export default function MobileBottomNav() {
export default function MobileBottomNav({ menuOpen, onOpenMenu }: MobileBottomNavProps) {
const [location] = useLocation();
const [moreOpen, setMoreOpen] = useState(false);
const { user } = useAuthUnified();
const shortcuts = getMobileShortcuts(user?.role);
const isActive = (path: string) => {
if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true;
return location.startsWith(path);
};
// « Menu » est mis en avant quand la page affichée n'est pas un raccourci
const menuActive = menuOpen || !shortcuts.some((item) => isNavItemActive(item.path, location));
// Check if any "more" item is active
const isMoreActive = moreItems.some(item => isActive(item.path));
const itemClasses = (active: boolean) =>
`flex flex-1 flex-col items-center justify-center gap-0.5 min-w-0 h-full px-1 text-xs font-medium leading-tight text-center ${active ? 'text-blue-700' : 'text-gray-600'}`;
return (
<>
{/* Navigation bottom fixe */}
<nav
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="flex items-center justify-around h-16">
{/* Items principaux */}
{primaryItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
<nav
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
aria-label="Navigation principale"
>
<div className="flex items-stretch h-16">
{shortcuts.map((item) => {
const Icon = item.icon;
const active = isNavItemActive(item.path, location);
return (
<Link key={item.path} href={item.path}>
<div className={`flex flex-col items-center justify-center min-w-[60px] py-1 ${active ? 'text-blue-600' : 'text-gray-500'
}`}>
<Icon className={`h-5 w-5 ${active ? 'text-blue-600' : 'text-gray-500'}`} />
<span className={`text-[10px] mt-0.5 font-medium ${active ? 'text-blue-600' : 'text-gray-500'
}`}>
{item.label}
</span>
</div>
</Link>
);
})}
return (
<Link
key={item.path}
href={item.path}
aria-current={active ? "page" : undefined}
className={itemClasses(active)}
>
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="line-clamp-2">{item.label}</span>
</Link>
);
})}
{/* Bouton "Plus" */}
<div
className={`flex flex-col items-center justify-center min-w-[60px] py-1 cursor-pointer ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
}`}
onClick={() => setMoreOpen(true)}
>
<MoreHorizontal className={`h-5 w-5 ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
}`} />
<span className={`text-[10px] mt-0.5 font-medium ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
}`}>
Plus
</span>
</div>
</div>
</nav>
{/* Sheet "Plus" */}
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetContent side="bottom" className="h-auto max-h-[50vh] rounded-t-xl">
<SheetHeader className="pb-4">
<SheetTitle>Autres pages</SheetTitle>
</SheetHeader>
<div className="grid grid-cols-3 gap-3 pb-6">
{moreItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
href={item.path}
onClick={() => setMoreOpen(false)}
>
<div className={`flex flex-col items-center justify-center p-4 rounded-xl min-h-[80px] ${active
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}>
<Icon className={`h-6 w-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
<span className={`text-xs mt-2 font-medium text-center ${active ? 'text-blue-700' : 'text-gray-700'
}`}>
{item.label}
</span>
</div>
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</>
<button
type="button"
onClick={onOpenMenu}
aria-haspopup="dialog"
aria-expanded={menuOpen}
className={itemClasses(menuActive)}
>
<Menu className="h-5 w-5 shrink-0" aria-hidden="true" />
<span>Menu</span>
</button>
</div>
</nav>
);
}
+91 -63
View File
@@ -1,15 +1,18 @@
/**
* MobileLayout - Layout dédié pour l'application mobile
* Affiche le magasin sélectionné de manière visible et une navigation bottom
* MobileLayout - Cadre de l'application sur téléphone
* En-tête (titre de la page + magasin), contenu défilant, barre du bas et
* menu complet, tous tirés de la même configuration que le menu PC
* Note: Le StoreProvider est fourni par MobileApp au niveau supérieur
*/
import { ReactNode, useState } from "react";
import { Menu, Store, ChevronDown, LogOut } from "lucide-react";
import { ReactNode, useEffect, useState } from "react";
import { Link, useLocation } from "wouter";
import { Boxes, ChevronDown, LogOut } from "lucide-react";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getNavItemForPath, getNavSections, getRoleLabel, isNavItemActive } from "@/lib/navigation";
import MobileBottomNav from "./MobileBottomNav";
import type { Group } from "@shared/schema";
@@ -20,10 +23,16 @@ interface MobileLayoutProps {
export default function MobileLayout({ children, title }: MobileLayoutProps) {
const { user } = useAuthUnified();
const [location] = useLocation();
const { selectedStoreId, setSelectedStoreId, stores } = useStore();
const [menuOpen, setMenuOpen] = useState(false);
// Get selected store name
// Titre de l'onglet du navigateur tiré du menu, comme sur PC
useEffect(() => {
const navItem = getNavItemForPath(location);
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
}, [location]);
const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId);
const storeName = selectedStore?.name || "Tous les magasins";
@@ -47,6 +56,11 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
setSelectedStoreId(newStoreId);
};
const sections = getNavSections(user?.role);
const displayName = user && (user.firstName || user.lastName
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
: user.username);
return (
<div
className="bg-gray-50 flex flex-col"
@@ -62,46 +76,38 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
bottom: 0
}}
>
{/* Header fixe */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="flex items-center justify-between h-14 px-3">
{/* Menu hamburger */}
<Button
variant="ghost"
size="sm"
onClick={() => setMenuOpen(true)}
className="h-10 w-10 p-0"
>
<Menu className="h-5 w-5" />
</Button>
{/* Logo */}
<div className="flex items-center gap-1">
<Store className="h-5 w-5 text-blue-600" />
{/* En-tête : logo + magasin actif (le titre est porté par chaque page) */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="flex items-center justify-between gap-3 h-14 px-4">
<Link href="/" className="flex items-center gap-2 shrink-0">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-600">
<Boxes className="h-4 w-4 text-white" aria-hidden="true" />
</span>
<span className="font-semibold text-gray-900">LogiFlow</span>
</div>
</Link>
{/* Magasin sélectionné - bien visible */}
<div
className="flex items-center gap-1 bg-blue-50 px-2 py-1 rounded-lg cursor-pointer"
<button
type="button"
className="flex items-center gap-1 bg-blue-50 px-2.5 py-1.5 rounded-lg min-w-0 shrink"
onClick={() => setMenuOpen(true)}
aria-label={`Magasin : ${storeName}. Ouvrir le menu`}
>
<span className="text-xs font-medium text-blue-700 truncate max-w-[80px]">
<span className="text-sm font-medium text-blue-700 truncate max-w-[140px]">
{storeName}
</span>
<ChevronDown className="h-3 w-3 text-blue-600" />
</div>
<ChevronDown className="h-4 w-4 text-blue-600 shrink-0" aria-hidden="true" />
</button>
</div>
{/* Titre de page optionnel */}
{title && (
<div className="px-3 pb-2">
<div className="px-4 pb-2">
<h1 className="text-lg font-bold text-gray-900">{title}</h1>
</div>
)}
</header>
{/* Contenu principal - scrollable avec espace pour navbar */}
{/* Contenu principal - défilant, avec la place de la barre du bas */}
<main
className="flex-1 overflow-x-hidden"
style={{
@@ -113,35 +119,50 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
{children}
</main>
{/* Navigation bottom */}
<MobileBottomNav />
<MobileBottomNav menuOpen={menuOpen} onOpenMenu={() => setMenuOpen(true)} />
{/* Menu latéral (Sheet) */}
{/* Menu complet : tous les modules autorisés, magasin, profil */}
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="left" className="w-[280px] p-0">
<SheetHeader className="p-4 border-b">
<SheetTitle className="flex items-center gap-2">
<Store className="h-5 w-5 text-blue-600" />
LogiFlow
</SheetTitle>
<SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-xl p-0">
<SheetHeader className="px-4 pt-4 pb-2 text-left">
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<div className="p-4 space-y-6">
{/* Utilisateur */}
{user && (
<div className="bg-gray-50 rounded-lg p-3">
<p className="font-medium text-gray-900">
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500 capitalize">{user.role}</p>
</div>
)}
<nav className="px-4 space-y-4" aria-label="Menu principal">
{sections.map((section) => (
<div key={section.title ?? 'accueil'}>
{section.title && (
<h2 className="mb-2 text-xs font-semibold text-gray-500">{section.title}</h2>
)}
<div className="grid grid-cols-3 gap-2">
{section.items.map((item) => {
const Icon = item.icon;
const active = isNavItemActive(item.path, location);
{/* Sélecteur de magasin */}
return (
<Link
key={item.path}
href={item.path}
onClick={() => setMenuOpen(false)}
aria-current={active ? "page" : undefined}
className={`flex flex-col items-center justify-center gap-1.5 rounded-xl p-2 min-h-[76px] text-center text-xs font-medium leading-tight ${active
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
: 'bg-gray-100 text-gray-800 active:bg-gray-200'
}`}
>
<Icon className={`h-6 w-6 ${active ? 'text-blue-700' : 'text-gray-600'}`} aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</div>
))}
</nav>
<div className="mt-4 border-t border-gray-200 p-4 space-y-4" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Magasin
</label>
<label className="text-sm font-medium text-gray-700">Magasin</label>
<Select
value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")}
onValueChange={handleStoreChange}
@@ -163,7 +184,7 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: store.color || '#gray' }}
style={{ backgroundColor: store.color || '#9ca3af' }}
/>
{store.name}
</div>
@@ -173,15 +194,22 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
</Select>
</div>
{/* Déconnexion */}
<Button
variant="outline"
className="w-full h-12 text-red-600 border-red-200 hover:bg-red-50"
onClick={handleLogout}
>
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</Button>
{user && (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">{displayName}</p>
<p className="text-sm text-gray-600">{getRoleLabel(user.role)}</p>
</div>
<Button
variant="outline"
className="h-11 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
onClick={handleLogout}
>
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</Button>
</div>
)}
</div>
</SheetContent>
</Sheet>