diff --git a/client/src/components/RouterProduction.tsx b/client/src/components/RouterProduction.tsx index f65bd7f..421731e 100644 --- a/client/src/components/RouterProduction.tsx +++ b/client/src/components/RouterProduction.tsx @@ -25,6 +25,7 @@ import Avoirs from "@/pages/Avoirs"; import WeatherSettings from "@/pages/WeatherSettings"; import Analytics from "@/pages/Analytics"; import SalesAnalysisPage from "@/pages/SalesAnalysisPage"; +import PaymentSchedulePage from "@/pages/PaymentSchedulePage"; import Layout from "@/components/Layout"; function RouterProduction() { @@ -106,6 +107,7 @@ function RouterProduction() { + {/* Routes de compatibilité - redirection vers utilities */} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 44c798b..d141104 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -35,7 +35,8 @@ import { FileUp, Send, Receipt, - TrendingUp + TrendingUp, + CreditCard } from "lucide-react"; export default function Sidebar() { @@ -270,6 +271,12 @@ export default function Sidebar() { icon: TrendingUp, roles: ["admin", "directeur"] }, + { + path: "/payment-schedule", + label: "Échéance", + icon: CreditCard, + roles: ["admin", "directeur"] + }, { path: "/avoirs", label: "Avoirs", diff --git a/client/src/pages/Groups.tsx b/client/src/pages/Groups.tsx index c99de45..8b7b776 100644 --- a/client/src/pages/Groups.tsx +++ b/client/src/pages/Groups.tsx @@ -51,6 +51,7 @@ export default function Groups() { nocodbBlColumnName: "", nocodbAmountColumnName: "", nocodbSupplierColumnName: "", + nocodbDueDateColumnName: "", webhookUrl: "", }); @@ -101,9 +102,13 @@ export default function Groups() { name: "", color: "#1976D2", nocodbConfigId: "", - nocodbTableId: "", nocodbTableName: "", - invoiceColumnName: "Ref Facture", + invoiceColumnName: "", + nocodbBlColumnName: "", + nocodbAmountColumnName: "", + nocodbSupplierColumnName: "", + nocodbDueDateColumnName: "", + webhookUrl: "", }); }, onError: (error) => { @@ -142,9 +147,13 @@ export default function Groups() { name: "", color: "#1976D2", nocodbConfigId: "", - nocodbTableId: "", nocodbTableName: "", - invoiceColumnName: "Ref Facture", + invoiceColumnName: "", + nocodbBlColumnName: "", + nocodbAmountColumnName: "", + nocodbSupplierColumnName: "", + nocodbDueDateColumnName: "", + webhookUrl: "", }); }, onError: (error) => { @@ -251,6 +260,7 @@ export default function Groups() { nocodbBlColumnName: "", nocodbAmountColumnName: "", nocodbSupplierColumnName: "", + nocodbDueDateColumnName: "", webhookUrl: "", }); setShowCreateModal(true); @@ -267,6 +277,7 @@ export default function Groups() { nocodbBlColumnName: group.nocodbBlColumnName || "", nocodbAmountColumnName: group.nocodbAmountColumnName || "", nocodbSupplierColumnName: group.nocodbSupplierColumnName || "", + nocodbDueDateColumnName: group.nocodbDueDateColumnName || "", webhookUrl: group.webhookUrl || "", }); setShowEditModal(true); @@ -631,6 +642,16 @@ export default function Groups() { placeholder="ex: Fournisseur" /> + +
+ + handleChange('nocodbDueDateColumnName', e.target.value)} + placeholder="ex: Date_Echeance" + /> +
@@ -693,6 +714,7 @@ export default function Groups() { nocodbBlColumnName: "", nocodbAmountColumnName: "", nocodbSupplierColumnName: "", + nocodbDueDateColumnName: "", webhookUrl: "", }); }} diff --git a/client/src/pages/PaymentSchedulePage.tsx b/client/src/pages/PaymentSchedulePage.tsx new file mode 100644 index 0000000..6334206 --- /dev/null +++ b/client/src/pages/PaymentSchedulePage.tsx @@ -0,0 +1,288 @@ +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useStore } from "@/contexts/StoreContext"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Loader2, Calendar, DollarSign, Building, CreditCard, AlertCircle } from "lucide-react"; +import { format, parseISO, startOfMonth, endOfMonth, eachMonthOfInterval, isWithinInterval } from "date-fns"; +import { fr } from "date-fns/locale"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface PaymentSchedule { + id: string | number; + invoiceReference: string; + dueDate: string; + amount: number; + supplierName: string; + paymentMethod: string | null; + groupId: number; + groupName: string; +} + +interface PaymentScheduleResponse { + schedules: PaymentSchedule[]; + message?: string; +} + +export default function PaymentSchedulePage() { + const { selectedStoreId } = useStore(); + const [selectedMonth, setSelectedMonth] = useState(() => { + const now = new Date(); + return format(now, 'yyyy-MM'); + }); + + // Récupérer les échéances + const { data, isLoading, error } = useQuery({ + queryKey: ['/api/payment-schedule', selectedStoreId], + queryFn: async () => { + const response = await fetch(`/api/payment-schedule?groupId=${selectedStoreId}`, { + credentials: 'include' + }); + if (!response.ok) { + throw new Error(`Erreur ${response.status}: ${response.statusText}`); + } + return response.json(); + }, + enabled: !!selectedStoreId, + }); + + // Calculer les mois disponibles (12 derniers mois + 12 prochains mois) + const availableMonths = useMemo(() => { + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1); + const endDate = new Date(now.getFullYear(), now.getMonth() + 12, 1); + + return eachMonthOfInterval({ start: startDate, end: endDate }) + .map(date => ({ + value: format(date, 'yyyy-MM'), + label: format(date, 'MMMM yyyy', { locale: fr }) + })); + }, []); + + // Filtrer les échéances par mois sélectionné + const filteredSchedules = useMemo(() => { + if (!data?.schedules) return []; + + const monthStart = parseISO(`${selectedMonth}-01`); + const monthEnd = endOfMonth(monthStart); + + return data.schedules + .filter(schedule => { + try { + const dueDate = parseISO(schedule.dueDate); + return isWithinInterval(dueDate, { start: monthStart, end: monthEnd }); + } catch { + return false; + } + }) + .sort((a, b) => { + const dateA = parseISO(a.dueDate); + const dateB = parseISO(b.dueDate); + return dateA.getTime() - dateB.getTime(); + }); + }, [data?.schedules, selectedMonth]); + + // Calculer le total du mois + const monthTotal = useMemo(() => { + return filteredSchedules.reduce((sum, schedule) => sum + schedule.amount, 0); + }, [filteredSchedules]); + + // Grouper par mode de paiement + const paymentMethodTotals = useMemo(() => { + const totals: Record = {}; + filteredSchedules.forEach(schedule => { + const method = schedule.paymentMethod || 'Non défini'; + totals[method] = (totals[method] || 0) + schedule.amount; + }); + return totals; + }, [filteredSchedules]); + + if (!selectedStoreId) { + return ( +
+ + + + Sélectionner un magasin + + Veuillez sélectionner un magasin pour afficher l'échéancier + + + +
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + + + Erreur + + Une erreur est survenue lors du chargement des échéances + + + +
+ ); + } + + if (data?.message) { + return ( +
+ + + + Configuration requise + {data.message} + + +
+ ); + } + + return ( +
+ {/* En-tête */} +
+
+

Échéancier des Paiements

+

Gestion des échéances fournisseurs

+
+
+ +
+
+ + {/* Statistiques du mois */} +
+ + + Total du Mois + + + +
+ {monthTotal.toFixed(2)} € +
+

+ {filteredSchedules.length} échéance{filteredSchedules.length > 1 ? 's' : ''} +

+
+
+ + + + Modes de Paiement + + + +
+ {Object.entries(paymentMethodTotals).map(([method, total]) => ( +
+ {method}: + {total.toFixed(2)} € +
+ ))} +
+
+
+ + + + Période + + + +
+ {format(parseISO(`${selectedMonth}-01`), 'MMMM', { locale: fr })} +
+

+ {format(parseISO(`${selectedMonth}-01`), 'yyyy', { locale: fr })} +

+
+
+
+ + {/* Liste des échéances */} + + + Échéances du Mois + + Liste détaillée des paiements à effectuer pour le mois sélectionné + + + + {filteredSchedules.length === 0 ? ( +
+ Aucune échéance pour ce mois +
+ ) : ( +
+ {filteredSchedules.map((schedule, index) => ( +
+
+
+ + + {format(parseISO(schedule.dueDate), 'dd/MM/yyyy', { locale: fr })} + +
+
+
+ + {schedule.supplierName} +
+
Facture: {schedule.invoiceReference}
+ {schedule.paymentMethod && ( +
+ + {schedule.paymentMethod} +
+ )} +
+
+
+
+ {schedule.amount.toFixed(2)} € +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/Suppliers.tsx b/client/src/pages/Suppliers.tsx index 5e2b093..bea80f4 100644 --- a/client/src/pages/Suppliers.tsx +++ b/client/src/pages/Suppliers.tsx @@ -5,6 +5,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; @@ -39,6 +46,7 @@ export default function Suppliers() { hasDlc: false, automaticReconciliation: false, requiresControl: false, + paymentMethod: "", }); const { data: suppliers = [], isLoading } = useQuery({ @@ -193,6 +201,7 @@ export default function Suppliers() { hasDlc: false, automaticReconciliation: false, requiresControl: false, + paymentMethod: "", }); }; @@ -230,6 +239,7 @@ export default function Suppliers() { hasDlc: supplier.hasDlc || false, automaticReconciliation: supplier.automaticReconciliation || false, requiresControl: supplier.requiresControl || false, + paymentMethod: supplier.paymentMethod || "", }); setShowEditModal(true); }; @@ -399,6 +409,13 @@ export default function Suppliers() { {supplier.phone} )} + {supplier.paymentMethod && ( +
+ + Mode de paiement: + {supplier.paymentMethod} +
+ )}
{supplier.hasDlc && ( @@ -511,6 +528,25 @@ export default function Suppliers() { />
+
+ + +
+
{ } }); + // Route pour récupérer l'échéancier des paiements fournisseurs + app.get('/api/payment-schedule', isAuthenticated, async (req: any, res) => { + try { + const userId = req.user?.claims?.sub || req.user?.id; + if (!userId) { + return res.status(401).json({ error: 'Utilisateur non authentifié' }); + } + + const user = await storage.getUser(userId); + if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { + return res.status(403).json({ error: 'Accès refusé - Admin ou Directeur uniquement' }); + } + + // Validation du groupId avec Zod + const groupIdSchema = z.coerce.number().int().positive(); + const validation = groupIdSchema.safeParse(req.query.groupId); + + if (!validation.success) { + return res.status(400).json({ error: 'groupId invalide - doit être un entier positif' }); + } + + const groupId = validation.data; + + // Vérifier l'autorisation : directeur ne peut voir que ses groupes + if (user.role === 'directeur') { + const userGroupIds = await storage.getUserGroups(user.id); + if (!userGroupIds.includes(groupId)) { + return res.status(403).json({ error: 'Accès refusé - Vous ne pouvez accéder qu\'aux données de votre groupe' }); + } + } + + // Récupérer le groupe avec la configuration NocoDB + const group = await storage.getGroup(groupId); + if (!group || !group.nocodbConfigId || !group.nocodbTableId) { + return res.json({ schedules: [], message: 'Pas de configuration NocoDB pour ce groupe' }); + } + + // Récupérer la configuration NocoDB + const nocodbConfig = await storage.getNocodbConfig(group.nocodbConfigId); + if (!nocodbConfig) { + return res.json({ schedules: [], message: 'Configuration NocoDB non trouvée' }); + } + + // Vérifier que les colonnes nécessaires sont configurées + if (!group.nocodbDueDateColumnName || !group.invoiceColumnName || !group.nocodbAmountColumnName) { + return res.json({ + schedules: [], + message: 'Configuration incomplète: colonnes date échéance, facture ou montant manquantes' + }); + } + + // Récupérer tous les enregistrements de NocoDB + const url = `${nocodbConfig.baseUrl}/api/v1/db/data/noco/${nocodbConfig.projectId}/${group.nocodbTableId}`; + + console.log('📅 Récupération échéances NocoDB:', { + url, + groupId, + dueDateColumn: group.nocodbDueDateColumnName + }); + + const response = await fetch(url, { + method: "GET", + headers: { + "xc-token": nocodbConfig.apiToken, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + console.error('❌ Erreur NocoDB:', response.status, response.statusText); + return res.status(500).json({ error: 'Erreur lors de la récupération des données NocoDB' }); + } + + const data = await response.json(); + + // Récupérer tous les fournisseurs pour le mapping + const allSuppliers = await storage.getSuppliers(); + + // Fonction de normalisation pour comparer les noms de fournisseurs + const normalizeName = (name: string) => { + return name + .toLowerCase() + .trim() + .normalize("NFD") // Décompose les accents + .replace(/[\u0300-\u036f]/g, "") // Supprime les diacritiques + .replace(/\s+/g, ' '); // Normalise les espaces multiples + }; + + // Créer une map avec noms normalisés + const supplierMap = new Map(allSuppliers.map(s => [normalizeName(s.name), s])); + + // Traiter les données et filtrer les enregistrements avec une date d'échéance + const schedules = (data.list || []) + .map((record: any) => { + const dueDate = record[group.nocodbDueDateColumnName!]; + const invoiceRef = record[group.invoiceColumnName!]; + const amount = record[group.nocodbAmountColumnName!]; + const supplierName = record[group.nocodbSupplierColumnName || 'Fournisseur']; + + if (!dueDate) return null; + + // Chercher le fournisseur correspondant avec normalisation + const normalizedSupplierName = normalizeName(supplierName || ''); + const supplier = supplierMap.get(normalizedSupplierName); + + return { + id: record.Id || record.id, + invoiceReference: invoiceRef, + dueDate: dueDate, + amount: amount ? parseFloat(amount) : 0, + supplierName: supplierName, + paymentMethod: supplier?.paymentMethod || null, + groupId: group.id, + groupName: group.name + }; + }) + .filter((s: any) => s !== null); + + res.json({ schedules }); + + } catch (error: any) { + console.error('❌ Erreur récupération échéances:', error); + res.status(500).json({ error: 'Erreur serveur', details: error.message }); + } + }); + // Route BAP pour envoi webhook n8n app.post('/api/bap/send-webhook', isAuthenticated, async (req: any, res) => { try { diff --git a/shared/schema.ts b/shared/schema.ts index 9ed9b97..5a5136a 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -58,6 +58,7 @@ export const groups = pgTable("groups", { nocodbBlColumnName: varchar("nocodb_bl_column_name"), // Nom de la colonne BL dans leur table nocodbAmountColumnName: varchar("nocodb_amount_column_name"), // Nom de la colonne montant dans leur table nocodbSupplierColumnName: varchar("nocodb_supplier_column_name"), // Nom de la colonne fournisseur dans leur table + nocodbDueDateColumnName: varchar("nocodb_due_date_column_name"), // Nom de la colonne date d'échéance dans leur table webhookUrl: varchar("webhook_url", { length: 500 }), // URL de webhook pour notifications par magasin createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), @@ -79,6 +80,7 @@ export const suppliers = pgTable("suppliers", { contact: varchar("contact"), phone: varchar("phone"), hasDlc: boolean("has_dlc").default(false), // Coche DLC pour la gestion DLC + paymentMethod: varchar("payment_method"), // Mode de paiement: Virement, Traite, Traite Magnétique, Chèque createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), automaticReconciliation: boolean("automatic_reconciliation").default(false), // Rapprochement automatique BL/Factures