Author SHA1 Message Date
Claude 1a64a73d5a feat(api): gérer les clés de l'API externe depuis Paramètres
Les clés de l'API de rapprochement n'étaient configurables que par la
variable d'environnement EXTERNAL_API_KEYS. Nouvel onglet Paramètres >
API externe (admin) :

- état de l'API, adresse à copier, rappel des principales routes
- création d'une clé nommée par outil, affichée une seule fois
- liste des clés avec préfixe, date de création et de dernière
  utilisation ; révocation immédiate, sans redémarrage

Côté serveur :
- table external_api_keys (empreinte SHA-256 uniquement), créée par
  migrations.production.ts et init.sql
- server/externalApiKeys.ts : création, liste, révocation, vérification
  (repli en mémoire sans base)
- l'API accepte les clés de Paramètres et toujours celles de
  EXTERNAL_API_KEYS ; 503 seulement si aucune clé active
- routes /api/external-api/keys (session + admin)
- documentation et .env.example mis à jour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
2026-10-10 07:52:43 +00:00
LogiFlowandClaude Opus 5.5 b964e137bf feat(ui) : refonte du menu latéral et de la navigation téléphone (#569)
Menu PC et navigation téléphone tirés d'une configuration unique
(client/src/lib/navigation.ts), selon docs/PLAN-OPTIMISATION-WEBUI.md
(P1 § 2.1 et § 2.6).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
2026-10-10 09:35:35 +02:00
LogiFlowandClaude bd3bb5e64a feat(api): compléter la facture des fournisseurs en rapprochement automatique (#568)
Les livraisons d'un fournisseur en rapprochement automatique sont validées
d'office à la saisie du BL ; PATCH /api/ext/v1/deliveries/:id les refusait
donc (409) sauf à les dévalider. Pour ces fournisseurs, la référence, les
montants HT/TTC et l'échéance s'écrivent désormais directement, la livraison
restant validée.

- automaticReconciliation exposé sur chaque livraison renvoyée par l'API
- documentation mise à jour avec un exemple


Claude-Session: https://claude.ai/code/session_01LEhR8Cok79VpsxixhrKChM

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 09:11:08 +02:00
MichaelandClaude Opus 5.5 6f1a39e4b2 feat(api): API externe de rapprochement BL / factures
Nouvelle API sous /api/ext/v1, authentifiée par clé (EXTERNAL_API_KEYS,
en-tête X-API-Key ou Authorization: Bearer), pour qu'un outil tiers
lise magasins, fournisseurs et livraisons livrées (n° BL) puis écrive
la référence, les montants et l'échéance de la facture, avec
validation/dévalidation optionnelle du rapprochement.

- normalizeDateString extrait dans server/dateUtils.ts pour être partagé
- /api/ext/ exempté du CSRF (pas de cookie de session)
- EXTERNAL_API_KEYS ajouté à .env.example et docker-compose.yml
- documentation : docs/API-RAPPROCHEMENT.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:14:45 +02:00
MichaelandClaude Opus 5.5 2274c9bb4a fix(reconciliation): auto-reconcile deliveries from automatic suppliers on validation
POST /api/deliveries/:id/validate never set reconciled=true for suppliers in
automatic reconciliation mode, so their deliveries showed in neither the
Manuels tab (auto suppliers excluded) nor Validées (reconciled=false).

- validate endpoint now marks reconciled + validatedAt for auto suppliers
- Validées tab includes all deliveries from auto suppliers
- migration backfills reconciled for existing delivered deliveries with BL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:43 +02:00
24 changed files with 2024 additions and 1280 deletions

No files matched your search

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