mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
Add functionality to export payment schedules to Excel
Introduces an API endpoint and frontend components for exporting payment schedule data to an Excel file, with filtering options for payment methods and tax inclusion. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 1e4433b5-47ab-464c-b663-fea2e53367dd Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 09286923-d43e-46ea-831c-611649677d67 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/1e4433b5-47ab-464c-b663-fea2e53367dd/vfXE6SO
This commit is contained in:
1 parent
a4582fefe2
commit
01dde7b7c1
4 files changed
+560
-56
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+104
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user