mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-21 14:08:15 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a64a73d5a | ||
|
|
b964e137bf | ||
|
|
bd3bb5e64a | ||
|
|
6f1a39e4b2 | ||
|
|
2274c9bb4a |
No files matched your search
@@ -30,3 +30,9 @@ ENCRYPTION_KEY=
|
||||
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
||||
# Ne définir qu'en cas de besoin, puis retirer.
|
||||
EMERGENCY_SECRET=
|
||||
|
||||
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
||||
# Les clés se créent de préférence dans Paramètres > API externe. Optionnel :
|
||||
# une ou plusieurs clés supplémentaires ici, séparées par des virgules.
|
||||
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
EXTERNAL_API_KEYS=
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+157
-854
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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é";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -497,8 +499,11 @@ export default function BLReconciliation() {
|
||||
return isManual && isNotValidated;
|
||||
}), [deliveriesWithBL]);
|
||||
|
||||
// Fournisseurs en rapprochement automatique : toujours considérés validés,
|
||||
// même si reconciled n'a pas été posé (livraisons antérieures au passage en mode auto)
|
||||
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||
return delivery.reconciled === true || delivery.reconciled === 1;
|
||||
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||
return isAutomatic || delivery.reconciled === true || delivery.reconciled === 1;
|
||||
}), [deliveriesWithBL]);
|
||||
|
||||
// Fonctions de gestion
|
||||
@@ -914,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>
|
||||
@@ -1735,6 +1758,10 @@ export default function BLReconciliation() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<BapUploadModal open={showBapModal} onOpenChange={setShowBapModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { AlertTriangle, Check, Copy, KeyRound, Plug, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
interface ApiKeysResponse {
|
||||
basePath: string;
|
||||
envKeyCount: number;
|
||||
keys: ApiKey[];
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["/api/external-api/keys"];
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
// Message d'erreur lisible depuis « 400: {"error":"..."} » renvoyé par apiRequest
|
||||
function errorMessage(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const json = raw.slice(raw.indexOf(":") + 1).trim();
|
||||
try {
|
||||
return JSON.parse(json).error || raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast({ title: "Copie impossible", description: "Sélectionnez le texte et copiez-le à la main.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label} className="shrink-0">
|
||||
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
<span className="ml-1.5">{copied ? "Copié" : "Copier"}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalApiSettings() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState<{ name: string; key: string } | null>(null);
|
||||
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery<ApiKeysResponse>({ queryKey: QUERY_KEY });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => apiRequest("/api/external-api/keys", "POST", { name }),
|
||||
onSuccess: (result: { key: string; apiKey: ApiKey }) => {
|
||||
setCreatedKey({ name: result.apiKey.name, key: result.key });
|
||||
setNewKeyName("");
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
onError: (err) => toast({ title: "Création impossible", description: errorMessage(err), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/external-api/keys/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Clé révoquée", description: "Les outils qui l'utilisaient n'ont plus accès à l'API." });
|
||||
setKeyToRevoke(null);
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
onError: (err) => toast({ title: "Révocation impossible", description: errorMessage(err), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const apiUrl = `${window.location.origin}${data?.basePath ?? "/api/ext/v1"}`;
|
||||
const activeKeys = data?.keys.filter((k) => !k.revokedAt) ?? [];
|
||||
const revokedKeys = data?.keys.filter((k) => k.revokedAt) ?? [];
|
||||
const apiEnabled = activeKeys.length > 0 || (data?.envKeyCount ?? 0) > 0;
|
||||
|
||||
const handleCreate = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = newKeyName.trim();
|
||||
if (name) createMutation.mutate(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto space-y-6">
|
||||
{/* Présentation et adresse */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plug className="w-5 h-5 text-blue-600" />
|
||||
API externe de rapprochement
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Permet à un outil externe (n8n, comptabilité, script…) de lire les fournisseurs et les numéros de BL
|
||||
des livraisons, puis d'écrire le numéro de facture, les montants et l'échéance.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">État :</span>
|
||||
{isLoading ? (
|
||||
<span className="text-sm text-gray-600">Chargement…</span>
|
||||
) : apiEnabled ? (
|
||||
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-orange-300 text-orange-800">Désactivée : aucune clé active</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="api-url">Adresse de l'API</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="api-url" value={apiUrl} readOnly className="font-mono text-sm" />
|
||||
<CopyButton value={apiUrl} label="Copier l'adresse de l'API" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
La clé s'envoie dans l'en-tête <code className="rounded bg-gray-100 px-1">X-API-Key</code>{" "}
|
||||
(ou <code className="rounded bg-gray-100 px-1">Authorization: Bearer …</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-700 space-y-1">
|
||||
<p className="font-medium text-gray-900">Principales routes</p>
|
||||
<ul className="space-y-0.5 font-mono text-xs sm:text-sm">
|
||||
<li>GET /stores — magasins</li>
|
||||
<li>GET /suppliers — fournisseurs</li>
|
||||
<li>GET /deliveries?supplierId=…&hasInvoice=false — livraisons et n° de BL</li>
|
||||
<li>PATCH /deliveries/:id — n° de facture, montants HT/TTC, échéance</li>
|
||||
</ul>
|
||||
<p className="pt-1">Documentation complète : <code className="rounded bg-white px-1">docs/API-RAPPROCHEMENT.md</code></p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Clés */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-blue-600" />
|
||||
Clés d'API
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez une clé par outil, pour pouvoir en révoquer une sans couper les autres.
|
||||
La clé n'est affichée qu'une seule fois, à sa création.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="api-key-name">Nom de la nouvelle clé</Label>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Ex. : n8n comptabilité"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!newKeyName.trim() || createMutation.isPending}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
{createMutation.isPending ? "Création…" : "Créer une clé"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-700">Impossible de charger les clés : {errorMessage(error)}</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-sm text-gray-600">Chargement…</p>
|
||||
) : activeKeys.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">Aucune clé active.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200">
|
||||
{activeKeys.map((apiKey) => (
|
||||
<li key={apiKey.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 truncate">{apiKey.name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code>
|
||||
{" · "}créée le {formatDateTime(apiKey.createdAt)}
|
||||
{" · "}
|
||||
{apiKey.lastUsedAt ? `dernière utilisation le ${formatDateTime(apiKey.lastUsedAt)}` : "jamais utilisée"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start sm:self-auto text-red-700 border-red-200 hover:bg-red-50"
|
||||
onClick={() => setKeyToRevoke(apiKey)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Révoquer
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(data?.envKeyCount ?? 0) > 0 && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{data!.envKeyCount === 1 ? "1 clé est aussi définie" : `${data!.envKeyCount} clés sont aussi définies`} dans
|
||||
la variable d'environnement <code className="rounded bg-gray-100 px-1">EXTERNAL_API_KEYS</code> du serveur
|
||||
(non modifiables ici).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{revokedKeys.length > 0 && (
|
||||
<details className="text-sm text-gray-600">
|
||||
<summary className="cursor-pointer">Clés révoquées ({revokedKeys.length})</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{revokedKeys.map((apiKey) => (
|
||||
<li key={apiKey.id}>
|
||||
{apiKey.name} · <code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code> · révoquée le {formatDateTime(apiKey.revokedAt)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Clé créée : affichée une seule fois */}
|
||||
<Dialog open={!!createdKey} onOpenChange={(open) => { if (!open) setCreatedKey(null); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clé « {createdKey?.name} » créée</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copiez-la maintenant et collez-la dans votre outil : elle ne sera plus jamais affichée.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input value={createdKey?.key ?? ""} readOnly className="font-mono text-sm" onFocus={(e) => e.currentTarget.select()} aria-label="Clé d'API" />
|
||||
{createdKey && <CopyButton value={createdKey.key} label="Copier la clé d'API" />}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-900">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<p>Cette clé donne accès aux livraisons de tous les magasins. Ne la partagez pas ; en cas de doute, révoquez-la.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreatedKey(null)}>J'ai copié la clé</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation de révocation */}
|
||||
<Dialog open={!!keyToRevoke} onOpenChange={(open) => { if (!open) setKeyToRevoke(null); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Révoquer la clé « {keyToRevoke?.name} » ?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Les outils qui l'utilisent perdront immédiatement l'accès à l'API. Cette action est définitive.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setKeyToRevoke(null)}>Annuler</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => keyToRevoke && revokeMutation.mutate(keyToRevoke.id)}
|
||||
>
|
||||
{revokeMutation.isPending ? "Révocation…" : "Révoquer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Cloud,
|
||||
Send,
|
||||
Code,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug";
|
||||
import WeatherSettings from "./WeatherSettings";
|
||||
import WebhookBAPConfig from "./WebhookBAPConfig";
|
||||
import SQLExecutor from "./SQLExecutor";
|
||||
import ExternalApiSettings from "./ExternalApiSettings";
|
||||
|
||||
export default function Utilities() {
|
||||
const { user } = useAuthUnified();
|
||||
@@ -31,6 +33,7 @@ export default function Utilities() {
|
||||
if (path === '/nocodb-config') return 'nocodb';
|
||||
if (path === '/database-debug') return 'debug';
|
||||
if (path === '/weather-settings') return 'weather';
|
||||
if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api';
|
||||
return 'backups';
|
||||
});
|
||||
|
||||
@@ -41,6 +44,7 @@ export default function Utilities() {
|
||||
const canManageWeather = user?.role === 'admin';
|
||||
const canManageWebhookBAP = user?.role === 'admin';
|
||||
const canExecuteSQL = user?.role === 'admin';
|
||||
const canManageExternalApi = user?.role === 'admin';
|
||||
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
|
||||
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
||||
return (
|
||||
@@ -78,7 +82,7 @@ export default function Utilities() {
|
||||
{/* Content avec onglets */}
|
||||
<div className="flex-1 p-6 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-6 mb-6">
|
||||
<TabsList className="flex flex-wrap h-auto w-full justify-start gap-1 mb-6">
|
||||
{canManageBackups && (
|
||||
<TabsTrigger value="backups" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
@@ -109,6 +113,12 @@ export default function Utilities() {
|
||||
Configuration BAP
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canManageExternalApi && (
|
||||
<TabsTrigger value="api" className="flex items-center gap-2">
|
||||
<Plug className="w-4 h-4" />
|
||||
API externe
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canExecuteSQL && (
|
||||
<TabsTrigger value="sql" className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
@@ -158,6 +168,14 @@ export default function Utilities() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canManageExternalApi && (
|
||||
<TabsContent value="api" className="flex-1 overflow-hidden">
|
||||
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||
<ExternalApiSettings />
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canExecuteSQL && (
|
||||
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
||||
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,8 @@ services:
|
||||
POSTGRES_PASSWORD: LogiFlow2025!
|
||||
POSTGRES_DB: logiflow_db
|
||||
BACKUP_DIR: /app/backups
|
||||
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
|
||||
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# API externe — Rapprochement BL / factures
|
||||
|
||||
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
|
||||
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
|
||||
la référence et le montant de la facture, comme dans la page **Rapprochement**.
|
||||
|
||||
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
||||
|
||||
## Activation et authentification
|
||||
|
||||
**Depuis LogiFlow (recommandé)** : *Paramètres > API externe* (admin). On y
|
||||
crée une clé par outil (nommée, ex. « n8n comptabilité »), affichée une seule
|
||||
fois à sa création, avec sa date de dernière utilisation ; une clé se révoque
|
||||
d'un clic, sans redémarrage. Seule l'empreinte SHA-256 de la clé est stockée
|
||||
(table `external_api_keys`). L'onglet s'ouvre directement par
|
||||
`/utilities?tab=api`.
|
||||
|
||||
**Par l'environnement (optionnel)** : des clés peuvent aussi être définies,
|
||||
séparées par des virgules, puis le serveur redémarré :
|
||||
|
||||
```
|
||||
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||
```
|
||||
|
||||
Générer une clé :
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Aucune clé active (ni dans Paramètres, ni dans l'environnement) : l'API répond
|
||||
`503` (désactivée).
|
||||
|
||||
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||
|
||||
```
|
||||
X-API-Key: <clé>
|
||||
Authorization: Bearer <clé>
|
||||
```
|
||||
|
||||
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
|
||||
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
|
||||
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
|
||||
|
||||
## Points d'accès
|
||||
|
||||
### `GET /stores` — magasins
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "Magasin Nancy" }]
|
||||
```
|
||||
|
||||
### `GET /suppliers` — fournisseurs
|
||||
|
||||
```json
|
||||
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
|
||||
```
|
||||
|
||||
`code` = code fournisseur (`codefou`).
|
||||
|
||||
### `GET /deliveries` — livraisons à rapprocher
|
||||
|
||||
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
|
||||
Rapprochement), de la plus récente à la plus ancienne.
|
||||
|
||||
| Paramètre | Exemple | Effet |
|
||||
|--------------|---------------|------------------------------------------------------------|
|
||||
| `storeId` | `1` | Un seul magasin |
|
||||
| `supplierId` | `4` | Un seul fournisseur |
|
||||
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
|
||||
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
|
||||
| `hasBl` | `true` | Avec / sans numéro de BL |
|
||||
| `hasInvoice` | `false` | Avec / sans référence facture |
|
||||
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
|
||||
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
|
||||
| `offset` | `0` | Décalage de pagination |
|
||||
|
||||
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": 812,
|
||||
"storeId": 1,
|
||||
"storeName": "Magasin Nancy",
|
||||
"supplierId": 4,
|
||||
"supplierName": "Lactalis",
|
||||
"supplierCode": "LAC01",
|
||||
"automaticReconciliation": false,
|
||||
"status": "delivered",
|
||||
"scheduledDate": "2026-10-08",
|
||||
"deliveredDate": "2026-10-08T07:42:00.000Z",
|
||||
"blNumber": "BL-4567",
|
||||
"blAmount": 1234.5,
|
||||
"invoiceReference": null,
|
||||
"invoiceAmount": null,
|
||||
"invoiceAmountTTC": null,
|
||||
"dueDate": null,
|
||||
"reconciled": false,
|
||||
"validatedAt": null,
|
||||
"updatedAt": "2026-10-08T07:42:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
|
||||
|
||||
### `GET /deliveries/:id` — une livraison
|
||||
|
||||
Même objet qu'un élément de `items`. Inconnue : `404`.
|
||||
|
||||
### `PATCH /deliveries/:id` — écrire la facture
|
||||
|
||||
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
|
||||
|
||||
| Champ | Type | Effet |
|
||||
|--------------------|-------------------------|------------------------------------------------------------------|
|
||||
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
|
||||
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
|
||||
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
|
||||
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
|
||||
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
|
||||
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
|
||||
```
|
||||
|
||||
Réponse : la livraison mise à jour.
|
||||
|
||||
Règles :
|
||||
|
||||
- livraison non livrée : `409` ;
|
||||
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
|
||||
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
|
||||
- exception, fournisseur en **rapprochement automatique**
|
||||
(`automaticReconciliation: true`) : la livraison est validée d'office dès la
|
||||
saisie du BL, sa facture (référence, montants, échéance) se complète ensuite
|
||||
directement, sans la dévalider ; elle reste validée ;
|
||||
- corps invalide : `400` avec le détail par champ.
|
||||
|
||||
## Exemple : rapprocher une facture reçue
|
||||
|
||||
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
|
||||
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
|
||||
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
|
||||
(ajouter `"reconciled": true` pour valider directement).
|
||||
|
||||
## Exemple : compléter la facture d'un fournisseur automatique
|
||||
|
||||
Les livraisons d'un fournisseur en rapprochement automatique sont déjà
|
||||
validées (`reconciled: true`) ; la facture s'y ajoute sans autre champ :
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/815 \
|
||||
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"invoiceReference":"FAC-2026-0057","invoiceAmount":"842,10","invoiceAmountTTC":"1010,52","dueDate":"2026-11-30"}'
|
||||
```
|
||||
|
||||
Pour les retrouver : `GET /deliveries?supplierId=<id auto>&hasInvoice=false`.
|
||||
@@ -438,6 +438,18 @@ CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
|
||||
-- Clés de l'API externe de rapprochement (empreinte SHA-256, jamais la clé en clair)
|
||||
CREATE TABLE IF NOT EXISTS "external_api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"key_prefix" varchar(16) NOT NULL,
|
||||
"key_hash" varchar(64) NOT NULL UNIQUE,
|
||||
"created_by" varchar,
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" timestamp,
|
||||
"revoked_at" timestamp
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- FOREIGN KEY CONSTRAINTS
|
||||
-- ============================================================================
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Backfill rapprochement automatique
|
||||
--
|
||||
-- Contexte : l'endpoint POST /api/deliveries/:id/validate ne posait pas
|
||||
-- reconciled = true pour les fournisseurs en rapprochement automatique
|
||||
-- (automatic_reconciliation = true). Ces livraisons n'apparaissaient ni dans
|
||||
-- l'onglet "Manuels" (exclues car fournisseur auto) ni dans "Validées"
|
||||
-- (reconciled = false). Même cas pour les livraisons antérieures au passage
|
||||
-- d'un fournisseur en mode automatique.
|
||||
--
|
||||
-- Applique le même critère que l'auto-validation côté serveur :
|
||||
-- livraison livrée + numéro de BL renseigné.
|
||||
|
||||
UPDATE deliveries d
|
||||
SET reconciled = true,
|
||||
validated_at = COALESCE(d.validated_at, NOW())
|
||||
FROM suppliers s
|
||||
WHERE d.supplier_id = s.id
|
||||
AND s.automatic_reconciliation = true
|
||||
AND d.status = 'delivered'
|
||||
AND d.bl_number IS NOT NULL
|
||||
AND TRIM(d.bl_number) <> ''
|
||||
AND (d.reconciled IS NULL OR d.reconciled = false);
|
||||
@@ -0,0 +1,73 @@
|
||||
// Normalisation des dates d'échéance renvoyées par NocoDB (formats variés)
|
||||
// vers YYYY-MM-DD. Partagée par les routes web et l'API externe.
|
||||
export function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// API externe de rapprochement BL / factures.
|
||||
//
|
||||
// Permet à un outil tiers (comptabilité, n8n, script...) de lire les magasins,
|
||||
// les fournisseurs et les livraisons livrées (numéros de BL), puis d'écrire la
|
||||
// référence, les montants et l'échéance de la facture sur une livraison (y
|
||||
// compris déjà validée, pour les fournisseurs en rapprochement automatique).
|
||||
//
|
||||
// Authentification par clé d'API, indépendante des sessions du webUI :
|
||||
// - clés créées par un admin dans Paramètres > API externe (server/externalApiKeys.ts) ;
|
||||
// - et/ou variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||
// séparées par des virgules) ;
|
||||
// - aucune clé active => API désactivée ;
|
||||
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
||||
//
|
||||
// Documentation : docs/API-RAPPROCHEMENT.md
|
||||
|
||||
import type { Express, Request, Response, NextFunction } from "express";
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { z } from "zod";
|
||||
import { storage } from "./storage";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { invoiceVerificationService } from "./invoiceVerification";
|
||||
import {
|
||||
countActiveApiKeys,
|
||||
countEnvApiKeys,
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
verifyStoredApiKey,
|
||||
} from "./externalApiKeys";
|
||||
|
||||
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
||||
|
||||
const MAX_LIMIT = 500;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
// Clés relues à chaque requête : une rotation via l'environnement ne demande
|
||||
// qu'un redémarrage, et aucune clé n'est gardée en clair plus que nécessaire.
|
||||
function configuredKeyDigests(): Buffer[] {
|
||||
return (process.env.EXTERNAL_API_KEYS || "")
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.map(digest);
|
||||
}
|
||||
|
||||
function extractKey(req: Request): string | null {
|
||||
const header = req.headers["x-api-key"];
|
||||
if (typeof header === "string" && header.trim()) return header.trim();
|
||||
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const provided = extractKey(req);
|
||||
if (provided) {
|
||||
// Clés de l'environnement : comparaison à temps constant sur les empreintes
|
||||
const providedDigest = digest(provided);
|
||||
if (configuredKeyDigests().some((k) => timingSafeEqual(k, providedDigest))) return next();
|
||||
// Clés créées dans Paramètres (recherche par empreinte)
|
||||
if (await verifyStoredApiKey(provided)) return next();
|
||||
}
|
||||
|
||||
if (countEnvApiKeys() === 0 && (await countActiveApiKeys()) === 0) {
|
||||
return res.status(503).json({ error: "API externe désactivée : aucune clé d'API active (Paramètres > API externe)" });
|
||||
}
|
||||
|
||||
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
||||
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] vérification de la clé:", error);
|
||||
return res.status(500).json({ error: "Erreur lors de la vérification de la clé d'API" });
|
||||
}
|
||||
}
|
||||
|
||||
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
||||
const amountSchema = z
|
||||
.union([z.number(), z.string()])
|
||||
.nullable()
|
||||
.transform((value, ctx) => {
|
||||
if (value === null) return null;
|
||||
const raw = typeof value === "number" ? String(value) : value.replace(/[\s ]/g, "").replace(",", ".");
|
||||
if (raw === "") return null;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num) || Math.abs(num) >= 1e8) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Montant invalide" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return num.toFixed(2); // colonne decimal(10,2)
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z
|
||||
.object({
|
||||
invoiceReference: z.string().trim().max(100).nullable().optional(),
|
||||
invoiceAmount: amountSchema.optional(),
|
||||
invoiceAmountTTC: amountSchema.optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Format attendu : YYYY-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
reconciled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 0, { message: "Aucun champ à mettre à jour" });
|
||||
|
||||
const INVOICE_FIELDS = ["invoiceReference", "invoiceAmount", "invoiceAmountTTC", "dueDate"] as const;
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
// Format public d'une livraison : champs utiles au rapprochement uniquement
|
||||
function toApiDelivery(d: any) {
|
||||
return {
|
||||
id: d.id,
|
||||
storeId: d.groupId,
|
||||
storeName: d.group?.name ?? null,
|
||||
supplierId: d.supplierId,
|
||||
supplierName: d.supplier?.name ?? null,
|
||||
supplierCode: d.supplier?.codefou ?? null,
|
||||
automaticReconciliation: !!d.supplier?.automaticReconciliation,
|
||||
status: d.status,
|
||||
scheduledDate: toIsoDate(d.scheduledDate),
|
||||
deliveredDate: toIsoDateTime(d.deliveredDate),
|
||||
blNumber: d.blNumber ?? null,
|
||||
blAmount: toNumber(d.blAmount),
|
||||
invoiceReference: d.invoiceReference ?? null,
|
||||
invoiceAmount: toNumber(d.invoiceAmount),
|
||||
invoiceAmountTTC: toNumber(d.invoiceAmountTTC),
|
||||
dueDate: toIsoDate(d.dueDate),
|
||||
reconciled: !!d.reconciled,
|
||||
validatedAt: toIsoDateTime(d.validatedAt),
|
||||
updatedAt: toIsoDateTime(d.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntParam(value: unknown): number | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
const num = Number(value);
|
||||
return Number.isInteger(num) && num > 0 ? num : null; // null = invalide
|
||||
}
|
||||
|
||||
function parseBoolParam(value: unknown): boolean | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateParam(value: unknown): string | undefined | null {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
// Échéance lue dans NocoDB pour une référence facture (même logique que le webUI)
|
||||
async function lookupDueDate(invoiceReference: string, groupId: number, reconciled: boolean): Promise<Date | null> {
|
||||
try {
|
||||
const result = await invoiceVerificationService.verifyInvoice(invoiceReference, groupId, true, reconciled);
|
||||
const normalized = result.exists ? normalizeDateString(result.dueDate) : null;
|
||||
return normalized ? new Date(normalized) : null;
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] Erreur récupération échéance NocoDB:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExternalApi(app: Express) {
|
||||
const base = EXTERNAL_API_PREFIX;
|
||||
app.use(base, requireApiKey);
|
||||
|
||||
// Magasins
|
||||
app.get(`${base}/stores`, async (_req, res) => {
|
||||
try {
|
||||
const groups = await storage.getGroups();
|
||||
res.json(groups.map((g: any) => ({ id: g.id, name: g.name })));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] stores:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des magasins" });
|
||||
}
|
||||
});
|
||||
|
||||
// Fournisseurs
|
||||
app.get(`${base}/suppliers`, async (_req, res) => {
|
||||
try {
|
||||
const suppliers = await storage.getSuppliers();
|
||||
res.json(
|
||||
suppliers.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.codefou ?? null,
|
||||
paymentMethod: s.paymentMethod ?? null,
|
||||
automaticReconciliation: !!s.automaticReconciliation,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] suppliers:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des fournisseurs" });
|
||||
}
|
||||
});
|
||||
|
||||
// Livraisons livrées (périmètre du rapprochement BL / factures)
|
||||
app.get(`${base}/deliveries`, async (req, res) => {
|
||||
const storeId = parseIntParam(req.query.storeId);
|
||||
const supplierId = parseIntParam(req.query.supplierId);
|
||||
const reconciled = parseBoolParam(req.query.reconciled);
|
||||
const hasBl = parseBoolParam(req.query.hasBl);
|
||||
const hasInvoice = parseBoolParam(req.query.hasInvoice);
|
||||
const from = parseDateParam(req.query.from);
|
||||
const to = parseDateParam(req.query.to);
|
||||
const limit = parseIntParam(req.query.limit);
|
||||
const offsetRaw = req.query.offset;
|
||||
const offset = offsetRaw === undefined || offsetRaw === "" ? 0 : Number(offsetRaw);
|
||||
const blNumber = typeof req.query.blNumber === "string" ? req.query.blNumber.trim().toLowerCase() : "";
|
||||
|
||||
const invalid = Object.entries({ storeId, supplierId, reconciled, hasBl, hasInvoice, from, to, limit })
|
||||
.filter(([, v]) => v === null)
|
||||
.map(([k]) => k);
|
||||
if (!Number.isInteger(offset) || offset < 0) invalid.push("offset");
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: `Paramètre(s) invalide(s) : ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
try {
|
||||
let rows: any[] = await storage.getDeliveries(storeId ? [storeId] : undefined, { status: "delivered" });
|
||||
|
||||
rows = rows.filter((d) => {
|
||||
if (supplierId && d.supplierId !== supplierId) return false;
|
||||
if (reconciled !== undefined && !!d.reconciled !== reconciled) return false;
|
||||
if (hasBl !== undefined && !!d.blNumber?.trim() !== hasBl) return false;
|
||||
if (hasInvoice !== undefined && !!d.invoiceReference?.trim() !== hasInvoice) return false;
|
||||
if (blNumber && (d.blNumber || "").trim().toLowerCase() !== blNumber) return false;
|
||||
if (from || to) {
|
||||
// Date de livraison effective, sinon date prévue
|
||||
const day = toIsoDate(d.deliveredDate) ?? toIsoDate(d.scheduledDate);
|
||||
if (!day) return false;
|
||||
if (from && day < from) return false;
|
||||
if (to && day > to) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const da = toIsoDateTime(a.deliveredDate) ?? toIsoDate(a.scheduledDate) ?? "";
|
||||
const db = toIsoDateTime(b.deliveredDate) ?? toIsoDate(b.scheduledDate) ?? "";
|
||||
return da < db ? 1 : da > db ? -1 : b.id - a.id;
|
||||
});
|
||||
|
||||
const pageSize = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
||||
res.json({
|
||||
total: rows.length,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
items: rows.slice(offset, offset + pageSize).map(toApiDelivery),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] deliveries:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des livraisons" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
res.json(toApiDelivery(delivery));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Écriture de la facture (référence, montants, échéance) et validation optionnelle
|
||||
app.patch(`${base}/deliveries/:id`, async (req, res) => {
|
||||
const id = parseIntParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
|
||||
const parsed = updateInvoiceSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: "Corps de requête invalide",
|
||||
details: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
try {
|
||||
const delivery = await storage.getDelivery(id);
|
||||
if (!delivery) return res.status(404).json({ error: "Livraison introuvable" });
|
||||
|
||||
if (delivery.status !== "delivered") {
|
||||
return res.status(409).json({ error: "Seules les livraisons livrées peuvent être rapprochées" });
|
||||
}
|
||||
|
||||
const touchesInvoice = INVOICE_FIELDS.some((f) => body[f] !== undefined);
|
||||
// Une livraison rapprochée est figée, comme dans le webUI : il faut la
|
||||
// dévalider (reconciled: false) pour modifier sa facture. Exception : les
|
||||
// fournisseurs en rapprochement automatique, validés d'office dès la
|
||||
// saisie du BL, dont la facture arrive après et se complète sans dévalider.
|
||||
const isAutomatic = !!delivery.supplier?.automaticReconciliation;
|
||||
if (delivery.reconciled && !isAutomatic && touchesInvoice && body.reconciled !== false) {
|
||||
return res.status(409).json({
|
||||
error: "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture",
|
||||
});
|
||||
}
|
||||
|
||||
const update: Record<string, any> = {};
|
||||
if (body.invoiceReference !== undefined) update.invoiceReference = body.invoiceReference || null;
|
||||
if (body.invoiceAmount !== undefined) update.invoiceAmount = body.invoiceAmount;
|
||||
if (body.invoiceAmountTTC !== undefined) update.invoiceAmountTTC = body.invoiceAmountTTC;
|
||||
|
||||
if (body.dueDate !== undefined) {
|
||||
update.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
} else if (update.invoiceReference !== undefined && update.invoiceReference !== delivery.invoiceReference) {
|
||||
// Échéance non fournie : reprise depuis NocoDB, ou vidée avec la référence
|
||||
update.dueDate = update.invoiceReference
|
||||
? await lookupDueDate(update.invoiceReference, delivery.groupId, !!delivery.reconciled)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (body.reconciled !== undefined) {
|
||||
update.reconciled = body.reconciled;
|
||||
update.validatedAt = body.reconciled ? new Date() : null;
|
||||
}
|
||||
|
||||
await storage.updateDelivery(id, update);
|
||||
console.log(`🔌 [EXT-API] Livraison #${id} mise à jour : ${Object.keys(update).join(", ")}`);
|
||||
|
||||
const updated = await storage.getDelivery(id);
|
||||
res.json(toApiDelivery(updated));
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] update delivery:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la mise à jour de la livraison" });
|
||||
}
|
||||
});
|
||||
|
||||
// Toute autre route sous le préfixe : 404 JSON plutôt que la page du webUI
|
||||
app.use(base, (_req, res) => {
|
||||
res.status(404).json({ error: "Route inconnue" });
|
||||
});
|
||||
}
|
||||
|
||||
// Gestion des clés depuis Paramètres > API externe : session webUI, admin
|
||||
// uniquement. À enregistrer après la mise en place de l'authentification.
|
||||
export function registerExternalApiKeyAdminRoutes(app: Express, requireSessionAdmin: Array<(req: any, res: any, next: any) => void>) {
|
||||
const createKeySchema = z.object({ name: z.string().trim().min(1, "Nom requis").max(100) });
|
||||
|
||||
app.get("/api/external-api/keys", ...requireSessionAdmin, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
basePath: EXTERNAL_API_PREFIX,
|
||||
envKeyCount: countEnvApiKeys(),
|
||||
keys: await listApiKeys(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] list keys:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la lecture des clés d'API" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/external-api/keys", ...requireSessionAdmin, async (req: any, res) => {
|
||||
const parsed = createKeySchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message || "Nom invalide" });
|
||||
}
|
||||
try {
|
||||
const created = await createApiKey(parsed.data.name, req.user?.id ?? null);
|
||||
console.log(`🔑 [EXT-API] Clé « ${parsed.data.name} » créée par ${req.user?.username ?? "?"}`);
|
||||
res.status(201).json(created);
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] create key:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la création de la clé d'API" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/external-api/keys/:id", ...requireSessionAdmin, async (req: any, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: "Identifiant invalide" });
|
||||
try {
|
||||
if (!(await revokeApiKey(id))) return res.status(404).json({ error: "Clé introuvable ou déjà révoquée" });
|
||||
console.log(`🔑 [EXT-API] Clé #${id} révoquée par ${req.user?.username ?? "?"}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("❌ [EXT-API] revoke key:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la révocation de la clé d'API" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Clés de l'API externe de rapprochement créées depuis Paramètres > API externe.
|
||||
//
|
||||
// La clé n'est montrée qu'une fois, à sa création : seule son empreinte SHA-256
|
||||
// est stockée (table external_api_keys), avec un préfixe lisible pour la
|
||||
// reconnaître dans la liste. Une clé révoquée est conservée pour l'historique.
|
||||
// Les clés de la variable d'environnement EXTERNAL_API_KEYS restent acceptées.
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { externalApiKeys, type ExternalApiKey } from "@shared/schema";
|
||||
|
||||
const KEY_PREFIX = "lf_";
|
||||
const DISPLAY_PREFIX_LENGTH = 10; // "lf_" + 7 caractères
|
||||
// last_used_at n'est réécrit qu'au plus une fois par minute et par clé
|
||||
const LAST_USED_THROTTLE_MS = 60_000;
|
||||
|
||||
export type PublicApiKey = Omit<ExternalApiKey, "keyHash">;
|
||||
|
||||
// Sans base (développement), les clés sont gardées en mémoire
|
||||
const hasDatabase = !!process.env.DATABASE_URL;
|
||||
const memoryKeys: ExternalApiKey[] = [];
|
||||
let memoryNextId = 1;
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function toPublic({ keyHash: _keyHash, ...key }: ExternalApiKey): PublicApiKey {
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function listApiKeys(): Promise<PublicApiKey[]> {
|
||||
if (!hasDatabase) return [...memoryKeys].reverse().map(toPublic);
|
||||
const rows: ExternalApiKey[] = await db.select().from(externalApiKeys).orderBy(desc(externalApiKeys.createdAt));
|
||||
return rows.map(toPublic);
|
||||
}
|
||||
|
||||
// Crée une clé et la renvoie en clair : c'est la seule fois où elle est lisible
|
||||
export async function createApiKey(name: string, createdBy: string | null): Promise<{ key: string; apiKey: PublicApiKey }> {
|
||||
const key = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
|
||||
const values = {
|
||||
name,
|
||||
keyPrefix: key.slice(0, DISPLAY_PREFIX_LENGTH),
|
||||
keyHash: hashApiKey(key),
|
||||
createdBy,
|
||||
};
|
||||
|
||||
if (!hasDatabase) {
|
||||
const row: ExternalApiKey = { id: memoryNextId++, ...values, createdAt: new Date(), lastUsedAt: null, revokedAt: null };
|
||||
memoryKeys.push(row);
|
||||
return { key, apiKey: toPublic(row) };
|
||||
}
|
||||
|
||||
const [row]: ExternalApiKey[] = await db.insert(externalApiKeys).values(values).returning();
|
||||
return { key, apiKey: toPublic(row) };
|
||||
}
|
||||
|
||||
// Révoque une clé active ; false si elle n'existe pas ou l'était déjà
|
||||
export async function revokeApiKey(id: number): Promise<boolean> {
|
||||
if (!hasDatabase) {
|
||||
const row = memoryKeys.find((k) => k.id === id && !k.revokedAt);
|
||||
if (!row) return false;
|
||||
row.revokedAt = new Date();
|
||||
return true;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(externalApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(externalApiKeys.id, id), isNull(externalApiKeys.revokedAt)))
|
||||
.returning({ id: externalApiKeys.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function countActiveApiKeys(): Promise<number> {
|
||||
if (!hasDatabase) return memoryKeys.filter((k) => !k.revokedAt).length;
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(externalApiKeys)
|
||||
.where(isNull(externalApiKeys.revokedAt));
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
// Vérifie une clé reçue ; met à jour sa date de dernière utilisation
|
||||
export async function verifyStoredApiKey(key: string): Promise<boolean> {
|
||||
const keyHash = hashApiKey(key);
|
||||
|
||||
if (!hasDatabase) {
|
||||
const row = memoryKeys.find((k) => k.keyHash === keyHash && !k.revokedAt);
|
||||
if (row) row.lastUsedAt = new Date();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
const [row]: ExternalApiKey[] = await db
|
||||
.select()
|
||||
.from(externalApiKeys)
|
||||
.where(and(eq(externalApiKeys.keyHash, keyHash), isNull(externalApiKeys.revokedAt)))
|
||||
.limit(1);
|
||||
if (!row) return false;
|
||||
|
||||
const lastUsed = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
|
||||
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
|
||||
// Sans attendre : une erreur d'écriture ne doit pas refuser la requête
|
||||
db.update(externalApiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(externalApiKeys.id, row.id))
|
||||
.catch((error: any) => console.error("❌ [EXT-API] last_used_at:", error?.message));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Nombre de clés fournies par la variable d'environnement EXTERNAL_API_KEYS
|
||||
export function countEnvApiKeys(): number {
|
||||
return (process.env.EXTERNAL_API_KEYS || "").split(",").filter((k) => k.trim().length > 0).length;
|
||||
}
|
||||
@@ -378,6 +378,22 @@ export async function runProductionMigrations() {
|
||||
`);
|
||||
console.log('✅ MIGRATION: supplier_mail_logs table is present');
|
||||
|
||||
// Clés de l'API externe de rapprochement, gérées depuis Paramètres
|
||||
console.log('🔄 MIGRATION: Ensuring external_api_keys table...');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_by VARCHAR,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP
|
||||
);
|
||||
`);
|
||||
console.log('✅ MIGRATION: external_api_keys table is present');
|
||||
|
||||
// Chiffrement au repos des secrets encore stockés en clair
|
||||
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
|
||||
// les valeurs déjà au format enc:v1: sont ignorées.
|
||||
|
||||
@@ -106,6 +106,21 @@ WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||
`
|
||||
},
|
||||
{
|
||||
filename: '20261010000000_create_external_api_keys.sql',
|
||||
content: `
|
||||
CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_by VARCHAR,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP
|
||||
);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+23
-73
@@ -4,6 +4,8 @@ import { storage } from "./storage";
|
||||
import { setupLocalAuth, requireAuth } from "./localAuth";
|
||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||
import { stripSmtpPassword } from "./sanitize";
|
||||
import { normalizeDateString } from "./dateUtils";
|
||||
import { registerExternalApi, registerExternalApiKeyAdminRoutes } from "./externalApi";
|
||||
|
||||
// Corps de requête sans les champs secrets : pour les logs uniquement
|
||||
function redactBody(body: any): any {
|
||||
@@ -29,79 +31,6 @@ const require = createRequire(import.meta.url);
|
||||
console.log('🔍 Using development storage and authentication');
|
||||
|
||||
|
||||
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
|
||||
function normalizeDateString(dateString: string | null | undefined): string | null {
|
||||
if (!dateString || typeof dateString !== 'string') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY
|
||||
const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/);
|
||||
if (slashMatch) {
|
||||
const [, first, second, year] = slashMatch;
|
||||
const firstNum = parseInt(first);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
// Validation basique des valeurs
|
||||
if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) {
|
||||
console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer le format en fonction des valeurs
|
||||
let day: string, month: string;
|
||||
|
||||
if (firstNum > 12) {
|
||||
// first > 12 → forcément DD/MM (format français/européen)
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
} else if (secondNum > 12) {
|
||||
// second > 12 → forcément MM/DD (format américain)
|
||||
day = second.padStart(2, '0');
|
||||
month = first.padStart(2, '0');
|
||||
} else {
|
||||
// Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut
|
||||
// Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale
|
||||
day = first.padStart(2, '0');
|
||||
month = second.padStart(2, '0');
|
||||
}
|
||||
|
||||
// Validation finale : mois entre 1-12, jour entre 1-31
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) {
|
||||
console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Essayer de parser avec Date (format ISO complet avec heures)
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Si aucun format reconnu, retourner null (ne pas persister une date invalide)
|
||||
console.warn(`⚠️ Format de date non reconnu: ${trimmed}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur normalisation date:', error);
|
||||
return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides
|
||||
}
|
||||
}
|
||||
|
||||
// Simple hash password function using crypto
|
||||
async function hashPasswordSimple(password: string) {
|
||||
const crypto = await import('crypto');
|
||||
@@ -165,6 +94,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
console.log('🌍 Environment detected:', environment);
|
||||
|
||||
// API externe de rapprochement (clé d'API, hors session) : docs/API-RAPPROCHEMENT.md
|
||||
registerExternalApi(app);
|
||||
|
||||
// Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
|
||||
// Les objets "group" sont joints à de nombreuses réponses (livraisons,
|
||||
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
|
||||
@@ -194,6 +126,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
// Auth middleware
|
||||
await setupAuth(app);
|
||||
|
||||
// Clés de l'API externe (Paramètres > API externe), admin uniquement
|
||||
registerExternalApiKeyAdminRoutes(app, [isAuthenticated, requireAdmin]);
|
||||
|
||||
// Routes pour configuration webhook BAP
|
||||
app.get('/api/webhook-bap-config', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
@@ -2556,6 +2491,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
await storage.validateDelivery(id, blData);
|
||||
|
||||
// AUTO-VALIDATION RAPPROCHEMENT : fournisseur en mode automatique → rapprochement validé directement
|
||||
try {
|
||||
// Fournisseur déjà joint à la livraison
|
||||
const supplier = delivery.supplier;
|
||||
if (supplier?.automaticReconciliation) {
|
||||
await storage.updateDelivery(id, {
|
||||
reconciled: true,
|
||||
validatedAt: new Date()
|
||||
});
|
||||
console.log(`✅ Auto-reconciliation: Delivery #${id} automatically validated for supplier ${supplier.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Auto-reconciliation failed for delivery #${id}:`, error);
|
||||
}
|
||||
|
||||
// MISE À JOUR DU CACHE : Marquer le cache comme permanent pour cette livraison validée
|
||||
try {
|
||||
if (delivery.invoiceReference && delivery.invoiceReference.trim()) {
|
||||
|
||||
@@ -50,6 +50,7 @@ export function setupCsrfProtection(app: Express) {
|
||||
const csrfExemptPaths = [
|
||||
'/api/health',
|
||||
'/api/webhook', // External webhook callbacks
|
||||
'/api/ext/', // API externe : authentifiée par clé d'API, sans cookie de session
|
||||
];
|
||||
|
||||
if (csrfExemptPaths.some(path => req.path.startsWith(path))) {
|
||||
|
||||
@@ -1023,6 +1023,21 @@ export const insertWebhookBapConfigSchema = createInsertSchema(webhookBapConfig)
|
||||
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
|
||||
export type InsertWebhookBapConfig = z.infer<typeof insertWebhookBapConfigSchema>;
|
||||
|
||||
// Clés de l'API externe de rapprochement (docs/API-RAPPROCHEMENT.md), créées
|
||||
// depuis Paramètres. Seule l'empreinte SHA-256 de la clé est stockée.
|
||||
export const externalApiKeys = pgTable("external_api_keys", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
keyPrefix: varchar("key_prefix", { length: 16 }).notNull(), // début de la clé, pour la reconnaître
|
||||
keyHash: varchar("key_hash", { length: 64 }).notNull().unique(),
|
||||
createdBy: varchar("created_by"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at"),
|
||||
revokedAt: timestamp("revoked_at"),
|
||||
});
|
||||
|
||||
export type ExternalApiKey = typeof externalApiKeys.$inferSelect;
|
||||
|
||||
// Supplier mail logs
|
||||
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
|
||||
id: true,
|
||||
|
||||
Reference in new issue
Block a user