Merge pull request #502 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-04 12:56:07 +01:00
commit f59862c856
5 files changed
+561 -57

No files matched your search

+306 -56
View File
@@ -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<string>(() => {
const now = new Date();
return format(now, 'yyyy-MM');
});
// États pour la modale d'export
const [showExportModal, setShowExportModal] = useState(false);
const [selectedPaymentMethods, setSelectedPaymentMethods] = useState<string[]>([]);
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<PaymentScheduleResponse>({
@@ -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<string>();
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 (
<div className="flex-1 flex items-center justify-center">
@@ -180,19 +297,31 @@ export default function PaymentSchedulePage() {
<h1 className="text-3xl font-bold">Échéancier des Paiements</h1>
<p className="text-gray-600 mt-1">Gestion des échéances fournisseurs</p>
</div>
<div className="w-64">
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-4">
<Button
onClick={handleOpenExportModal}
variant="outline"
className="gap-2"
disabled={filteredSchedules.length === 0}
data-testid="button-export-excel"
>
<FileSpreadsheet className="h-4 w-4" />
Exporter Excel
</Button>
<div className="w-64">
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
@@ -266,7 +395,7 @@ export default function PaymentSchedulePage() {
</Card>
</div>
{/* Liste des échéances */}
{/* Tableau des échéances */}
<Card>
<CardHeader>
<CardTitle>Échéances du Mois</CardTitle>
@@ -280,54 +409,175 @@ export default function PaymentSchedulePage() {
Aucune échéance pour ce mois
</div>
) : (
<div className="space-y-3">
{filteredSchedules.map((schedule, index) => (
<div
key={`${schedule.id}-${index}`}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors"
data-testid={`schedule-item-${index}`}
>
<div className="flex-1">
<div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-blue-600" />
<span className="font-semibold">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]">Date</TableHead>
<TableHead>Fournisseur</TableHead>
<TableHead>Facture</TableHead>
<TableHead className="w-[140px]">Mode de paiement</TableHead>
<TableHead className="text-right w-[110px]">Montant HT</TableHead>
<TableHead className="text-right w-[110px]">Montant TTC</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSchedules.map((schedule, index) => (
<TableRow
key={`${schedule.id}-${index}`}
data-testid={`schedule-item-${index}`}
className="hover:bg-gray-50"
>
<TableCell className="font-medium">
{format(parseISO(schedule.dueDate), 'dd/MM/yyyy', { locale: fr })}
</span>
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
<div className="flex items-center gap-2">
<Building className="w-3 h-3" />
<span>{schedule.supplierName}</span>
</div>
<div>Facture: {schedule.invoiceReference}</div>
{schedule.paymentMethod && (
<div className="flex items-center gap-2">
<CreditCard className="w-3 h-3" />
<span>{schedule.paymentMethod}</span>
</div>
)}
</div>
</div>
<div className="text-right space-y-1">
<div className="flex items-center gap-2 justify-end">
<span className="text-xs text-gray-500">HT:</span>
<span className="text-lg font-semibold">
</TableCell>
<TableCell>{schedule.supplierName}</TableCell>
<TableCell className="font-mono text-sm">{schedule.invoiceReference}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
schedule.paymentMethod === 'Virement' ? 'bg-blue-100 text-blue-800' :
schedule.paymentMethod === 'Traite' ? 'bg-green-100 text-green-800' :
schedule.paymentMethod === 'Chèque' ? 'bg-yellow-100 text-yellow-800' :
schedule.paymentMethod === 'Traite Magnétique' ? 'bg-purple-100 text-purple-800' :
'bg-gray-100 text-gray-800'
}`}>
{schedule.paymentMethod || 'Non défini'}
</span>
</TableCell>
<TableCell className="text-right font-semibold">
{schedule.amount.toFixed(2)} €
</span>
</div>
<div className="flex items-center gap-2 justify-end">
<span className="text-xs text-gray-500">TTC:</span>
<span className="text-lg font-bold text-blue-600">
</TableCell>
<TableCell className="text-right font-bold text-blue-600">
{schedule.amountTTC.toFixed(2)} €
</span>
</div>
</div>
</div>
))}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Modale d'export Excel */}
<Dialog open={showExportModal} onOpenChange={setShowExportModal}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5 text-green-600" />
Exporter vers Excel
</DialogTitle>
<DialogDescription>
Sélectionnez les modes de paiement et les colonnes à inclure dans l'export
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Sélection des modes de paiement */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">Modes de paiement</Label>
<Button
variant="ghost"
size="sm"
onClick={toggleAllPaymentMethods}
className="h-auto py-1 text-xs"
data-testid="button-toggle-all-methods"
>
{selectedPaymentMethods.length === availablePaymentMethods.length
? 'Tout désélectionner'
: 'Tout sélectionner'}
</Button>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-md p-3">
{availablePaymentMethods.map((method) => (
<div key={method} className="flex items-center space-x-2">
<Checkbox
id={`payment-method-${method}`}
checked={selectedPaymentMethods.includes(method)}
onCheckedChange={() => togglePaymentMethod(method)}
data-testid={`checkbox-payment-method-${method}`}
/>
<label
htmlFor={`payment-method-${method}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{method}
</label>
</div>
))}
</div>
</div>
{/* Options de colonnes */}
<div className="space-y-3">
<Label className="text-base font-medium">Colonnes à inclure</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="include-ht"
checked={includeHT}
onCheckedChange={(checked) => setIncludeHT(!!checked)}
data-testid="checkbox-include-ht"
/>
<label
htmlFor="include-ht"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Montant HT
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="include-ttc"
checked={includeTTC}
onCheckedChange={(checked) => setIncludeTTC(!!checked)}
data-testid="checkbox-include-ttc"
/>
<label
htmlFor="include-ttc"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Montant TTC
</label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowExportModal(false)}
disabled={isExporting}
>
Annuler
</Button>
<Button
onClick={handleExport}
disabled={
isExporting ||
selectedPaymentMethods.length === 0 ||
(!includeHT && !includeTTC)
}
className="gap-2"
data-testid="button-confirm-export"
>
{isExporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Export en cours...
</>
) : (
<>
<Download className="h-4 w-4" />
Exporter
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+104
View File
@@ -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",
+1
View File
@@ -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"
},
+1 -1
View File
@@ -40,7 +40,7 @@ The platform is built with a clear separation of concerns, utilizing a modern fu
- **Reporting:** Statistical reporting, including monthly summaries of deliveries.
- **UI Harmonization:** All detail modals across modules (orders, deliveries, calendar) use a unified component for consistent information display.
- **Analyse Vente (Sales Analysis):** A dedicated module for admins and directors that displays a configurable external URL/IP in an iframe. Configuration is managed via a dedicated tab in the Utilities page, allowing administrators to set or update the sales analysis dashboard URL. The menu is visible only to admin and directeur roles, ensuring proper access control. Includes intelligent error handling with informative messages when the URL is not configured. Features a refresh button to force complete iframe reloads.
- **Échéancier des Paiements (Payment Schedule):** A comprehensive payment deadline tracking system with hybrid architecture for optimal performance. **Architecture Refactorization (November 2025):** Due dates are now stored locally in deliveries table (dueDate column) as the single source of truth, retrieved once during invoice verification from NocoDB and cached locally for fast queries. Features include: role-based access (admin/director), group-based filtering with selectedStoreId, intelligent fallback to NocoDB for historical deliveries without cached dates, automatic date normalization handling multiple formats (ISO, French DD/MM/YYYY, American MM/DD/YYYY), monthly aggregations with payment method breakdowns (Virement, Traite, Traite Magnétique, Chèque), Zod validation for security, and robust supplier name normalization. Requires configuration of nocodbDueDateColumnName in Groups settings and paymentMethod in Suppliers. **Data Flow:** Invoice verification → NocoDB (fetch dueDate) → deliveries (local storage) → payment-schedule (fast local reads with NocoDB fallback). **Security (November 2025)**: Implements strict authorization - directors can only access their assigned groups, preventing cross-group data access.
- **Échéancier des Paiements (Payment Schedule):** A comprehensive payment deadline tracking system with hybrid architecture for optimal performance. **Architecture Refactorization (November 2025):** Due dates are now stored locally in deliveries table (dueDate column) as the single source of truth, retrieved once during invoice verification from NocoDB and cached locally for fast queries. Features include: role-based access (admin/director), group-based filtering with selectedStoreId, intelligent fallback to NocoDB for historical deliveries without cached dates, automatic date normalization handling multiple formats (ISO, French DD/MM/YYYY, American MM/DD/YYYY), monthly aggregations with payment method breakdowns (Virement, Traite, Traite Magnétique, Chèque), Zod validation for security, and robust supplier name normalization. **UI Enhancement (November 2025):** Replaced bulky cards with compact shadcn table displaying Date, Supplier, Invoice, Payment Method, HT, and TTC columns for improved readability. **Excel Export (November 2025):** Modal-based export functionality with customizable payment method filters (multi-select checkboxes), HT/TTC amount selection options, automatic total calculations, and formatted French date output. Export generates professionally formatted XLSX files with proper column widths and totals row. Includes toast notifications for success/error feedback. Requires configuration of nocodbDueDateColumnName in Groups settings and paymentMethod in Suppliers. **Data Flow:** Invoice verification → NocoDB (fetch dueDate) → deliveries (local storage) → payment-schedule (fast local reads with NocoDB fallback). **Security (November 2025)**: Implements strict authorization - directors can only access their assigned groups, preventing cross-group data access.
## External Dependencies
- **API Météo:** Visual Crossing API
+149
View File
@@ -500,6 +500,155 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// 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 {