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>
</>
);
}