diff --git a/client/src/pages/PaymentSchedulePage.tsx b/client/src/pages/PaymentSchedulePage.tsx index 3a86198..042fa8b 100644 --- a/client/src/pages/PaymentSchedulePage.tsx +++ b/client/src/pages/PaymentSchedulePage.tsx @@ -1,8 +1,9 @@ import { useState, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useStore } from "@/contexts/StoreContext"; +import { useToast } from "@/hooks/use-toast"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2, Calendar, DollarSign, Building, CreditCard, AlertCircle } from "lucide-react"; +import { Loader2, Calendar, DollarSign, Building, CreditCard, AlertCircle, FileSpreadsheet, Download } from "lucide-react"; import { format, parseISO, startOfMonth, endOfMonth, eachMonthOfInterval, isWithinInterval } from "date-fns"; import { fr } from "date-fns/locale"; import { @@ -12,6 +13,25 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; interface PaymentSchedule { id: string | number; @@ -32,10 +52,18 @@ interface PaymentScheduleResponse { export default function PaymentSchedulePage() { const { selectedStoreId } = useStore(); + const { toast } = useToast(); const [selectedMonth, setSelectedMonth] = useState(() => { const now = new Date(); return format(now, 'yyyy-MM'); }); + + // États pour la modale d'export + const [showExportModal, setShowExportModal] = useState(false); + const [selectedPaymentMethods, setSelectedPaymentMethods] = useState([]); + const [includeHT, setIncludeHT] = useState(true); + const [includeTTC, setIncludeTTC] = useState(true); + const [isExporting, setIsExporting] = useState(false); // Récupérer les échéances const { data, isLoading, error } = useQuery({ @@ -118,6 +146,95 @@ export default function PaymentSchedulePage() { return totals; }, [filteredSchedules]); + // Récupérer tous les modes de paiement disponibles + const availablePaymentMethods = useMemo(() => { + const methods = new Set(); + filteredSchedules.forEach(schedule => { + methods.add(schedule.paymentMethod || 'Non défini'); + }); + return Array.from(methods).sort(); + }, [filteredSchedules]); + + // Handler pour ouvrir la modale d'export + const handleOpenExportModal = () => { + // Pré-sélectionner tous les modes de paiement par défaut + setSelectedPaymentMethods(availablePaymentMethods); + setShowExportModal(true); + }; + + // Handler pour basculer la sélection d'un mode de paiement + const togglePaymentMethod = (method: string) => { + setSelectedPaymentMethods(prev => + prev.includes(method) + ? prev.filter(m => m !== method) + : [...prev, method] + ); + }; + + // Handler pour sélectionner/désélectionner tous les modes de paiement + const toggleAllPaymentMethods = () => { + if (selectedPaymentMethods.length === availablePaymentMethods.length) { + setSelectedPaymentMethods([]); + } else { + setSelectedPaymentMethods(availablePaymentMethods); + } + }; + + // Handler pour exporter vers Excel + const handleExport = async () => { + if (selectedPaymentMethods.length === 0) { + return; + } + + setIsExporting(true); + try { + const response = await fetch('/api/payment-schedule/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + groupId: selectedStoreId, + month: selectedMonth, + paymentMethods: selectedPaymentMethods, + includeHT, + includeTTC, + }), + }); + + if (!response.ok) { + throw new Error('Erreur lors de l\'export'); + } + + // Télécharger le fichier + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `echeancier_${selectedMonth}.xlsx`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + setShowExportModal(false); + toast({ + title: "Export réussi", + description: "Le fichier Excel a été téléchargé avec succès.", + }); + } catch (error) { + console.error('Erreur export:', error); + toast({ + title: "Erreur d'export", + description: "Une erreur est survenue lors de l'export du fichier Excel.", + variant: "destructive", + }); + } finally { + setIsExporting(false); + } + }; + if (!selectedStoreId) { return (
@@ -180,19 +297,31 @@ export default function PaymentSchedulePage() {

Échéancier des Paiements

Gestion des échéances fournisseurs

-
- +
+ +
+ +
@@ -266,7 +395,7 @@ export default function PaymentSchedulePage() { - {/* Liste des échéances */} + {/* Tableau des échéances */} Échéances du Mois @@ -280,54 +409,175 @@ export default function PaymentSchedulePage() { Aucune échéance pour ce mois ) : ( -
- {filteredSchedules.map((schedule, index) => ( -
-
-
- - +
+ + + + Date + Fournisseur + Facture + Mode de paiement + Montant HT + Montant TTC + + + + {filteredSchedules.map((schedule, index) => ( + + {format(parseISO(schedule.dueDate), 'dd/MM/yyyy', { locale: fr })} - - -
-
- - {schedule.supplierName} -
-
Facture: {schedule.invoiceReference}
- {schedule.paymentMethod && ( -
- - {schedule.paymentMethod} -
- )} -
- -
-
- HT: - + + {schedule.supplierName} + {schedule.invoiceReference} + + + {schedule.paymentMethod || 'Non défini'} + + + {schedule.amount.toFixed(2)} € - -
-
- TTC: - + + {schedule.amountTTC.toFixed(2)} € - -
-
- - ))} +
+
+ ))} +
+
)} + + {/* Modale d'export Excel */} + + + + + + Exporter vers Excel + + + Sélectionnez les modes de paiement et les colonnes à inclure dans l'export + + + +
+ {/* Sélection des modes de paiement */} +
+
+ + +
+
+ {availablePaymentMethods.map((method) => ( +
+ togglePaymentMethod(method)} + data-testid={`checkbox-payment-method-${method}`} + /> + +
+ ))} +
+
+ + {/* Options de colonnes */} +
+ +
+
+ setIncludeHT(!!checked)} + data-testid="checkbox-include-ht" + /> + +
+
+ setIncludeTTC(!!checked)} + data-testid="checkbox-include-ttc" + /> + +
+
+
+
+ + + + + +
+
); } diff --git a/package-lock.json b/package-lock.json index b28f359..0adb9fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "vaul": "^1.1.2", "wouter": "^3.3.5", "ws": "^8.18.0", + "xlsx": "^0.18.5", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" }, @@ -3941,6 +3942,15 @@ "node": ">= 0.6" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -4277,6 +4287,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4360,6 +4383,15 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4454,6 +4486,18 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5944,6 +5988,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -8408,6 +8461,18 @@ "node": ">= 10.x" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -9482,6 +9547,24 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wouter": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz", @@ -9608,6 +9691,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 3dd692d..7acdf08 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "vaul": "^1.1.2", "wouter": "^3.3.5", "ws": "^8.18.0", + "xlsx": "^0.18.5", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" }, diff --git a/server/routes.ts b/server/routes.ts index 72a58e7..9a950e5 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -500,6 +500,155 @@ export async function registerRoutes(app: Express): Promise { } }); + // Route pour exporter l'échéancier vers Excel + app.post('/api/payment-schedule/export', 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' }); + } + + // Récupérer les paramètres d'export + const { groupId, month, paymentMethods, includeHT, includeTTC } = req.body; + + // Validation + const groupIdSchema = z.coerce.number().int().positive(); + const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); + const paymentMethodsSchema = z.array(z.string()).min(1); + + const validatedGroupId = groupIdSchema.parse(groupId); + const validatedMonth = monthSchema.parse(month); + const validatedPaymentMethods = paymentMethodsSchema.parse(paymentMethods); + + // Vérifier l'autorisation pour les directeurs + if (user.role === 'directeur') { + const userGroupIds = await storage.getUserGroups(user.id); + if (!userGroupIds.includes(validatedGroupId)) { + 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 + const group = await storage.getGroup(validatedGroupId); + if (!group) { + return res.status(404).json({ error: 'Groupe non trouvé' }); + } + + // Récupérer toutes les livraisons du groupe avec échéance + const allDeliveries = await storage.getDeliveries(); + const groupDeliveries = allDeliveries.filter((d: any) => + d.groupId === validatedGroupId && + d.invoiceReference && + d.dueDate + ); + + // Filtrer par mois + const { parseISO, startOfMonth, endOfMonth, isWithinInterval, format } = await import('date-fns'); + const { fr } = await import('date-fns/locale'); + const monthStart = parseISO(`${validatedMonth}-01`); + const monthEnd = endOfMonth(monthStart); + + const filteredDeliveries = groupDeliveries.filter((d: any) => { + try { + const dueDate = new Date(d.dueDate); + return isWithinInterval(dueDate, { start: monthStart, end: monthEnd }); + } catch { + return false; + } + }); + + // Récupérer les fournisseurs + const allSuppliers = await storage.getSuppliers(); + const supplierMap = new Map(allSuppliers.map((s: any) => [s.id, s])); + + // Filtrer par modes de paiement sélectionnés + const schedules = filteredDeliveries + .map((delivery: any) => { + const supplier = supplierMap.get(delivery.supplierId); + return { + dueDate: new Date(delivery.dueDate), + supplierName: supplier?.name || 'Fournisseur inconnu', + invoiceReference: delivery.invoiceReference, + paymentMethod: supplier?.paymentMethod || 'Non défini', + amountHT: delivery.invoiceAmount ? parseFloat(delivery.invoiceAmount) : 0, + amountTTC: delivery.invoiceAmountTTC ? parseFloat(delivery.invoiceAmountTTC) : 0, + }; + }) + .filter((schedule: any) => validatedPaymentMethods.includes(schedule.paymentMethod)) + .sort((a: any, b: any) => a.dueDate.getTime() - b.dueDate.getTime()); + + // Générer le fichier Excel + const XLSX = await import('xlsx'); + const workbook = XLSX.utils.book_new(); + + // Préparer les données pour Excel + const headers = ['Date d\'échéance', 'Fournisseur', 'Facture', 'Mode de paiement']; + if (includeHT) headers.push('Montant HT'); + if (includeTTC) headers.push('Montant TTC'); + + const data = schedules.map((schedule: any) => { + const row: any = { + 'Date d\'échéance': format(schedule.dueDate, 'dd/MM/yyyy', { locale: fr }), + 'Fournisseur': schedule.supplierName, + 'Facture': schedule.invoiceReference, + 'Mode de paiement': schedule.paymentMethod, + }; + if (includeHT) row['Montant HT'] = schedule.amountHT; + if (includeTTC) row['Montant TTC'] = schedule.amountTTC; + return row; + }); + + // Calculer les totaux + const totalHT = schedules.reduce((sum: number, s: any) => sum + s.amountHT, 0); + const totalTTC = schedules.reduce((sum: number, s: any) => sum + s.amountTTC, 0); + + // Ajouter une ligne de total + const totalRow: any = { + 'Date d\'échéance': '', + 'Fournisseur': '', + 'Facture': '', + 'Mode de paiement': 'TOTAL', + }; + if (includeHT) totalRow['Montant HT'] = totalHT; + if (includeTTC) totalRow['Montant TTC'] = totalTTC; + data.push(totalRow); + + // Créer la feuille Excel + const worksheet = XLSX.utils.json_to_sheet(data, { header: headers }); + + // Définir la largeur des colonnes + worksheet['!cols'] = [ + { wch: 15 }, // Date + { wch: 25 }, // Fournisseur + { wch: 15 }, // Facture + { wch: 18 }, // Mode de paiement + { wch: 12 }, // Montant HT + { wch: 12 }, // Montant TTC + ]; + + XLSX.utils.book_append_sheet(workbook, worksheet, 'Échéancier'); + + // Générer le buffer Excel + const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + + // Envoyer le fichier + res.setHeader('Content-Disposition', `attachment; filename="echeancier_${validatedMonth}.xlsx"`); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(excelBuffer); + + console.log(`📊 Export Excel généré: ${schedules.length} échéances pour ${group.name}`); + + } catch (error: any) { + console.error('❌ Erreur export Excel:', error); + res.status(500).json({ error: 'Erreur lors de l\'export', details: error.message }); + } + }); + // Route BAP pour envoi webhook n8n app.post('/api/bap/send-webhook', isAuthenticated, async (req: any, res) => { try {