Add payment schedule view with filtering and supplier payment methods

Adds a new payment schedule page to display due dates and amounts, incorporating supplier payment methods and date filtering.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 1e4433b5-47ab-464c-b663-fea2e53367dd
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: e9c65355-546d-41f7-885b-728c905f4497
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/1e4433b5-47ab-464c-b663-fea2e53367dd/9uIte90
This commit is contained in:
michaelschal committed 2025-11-03 16:02:12 +00:00
1 parent eeb9b5b34b
commit 6f298c4df7
7 files changed
+488 -5

No files matched your search

@@ -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() {
<Route path="/avoirs" component={Avoirs} />
<Route path="/analytics" component={Analytics} />
<Route path="/sales-analysis" component={SalesAnalysisPage} />
<Route path="/payment-schedule" component={PaymentSchedulePage} />
{/* Routes de compatibilité - redirection vers utilities */}
<Route path="/backup" component={Utilities} />
+8 -1
View File
@@ -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",
+26 -4
View File
@@ -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"
/>
</div>
<div>
<Label htmlFor="nocodbDueDateColumnName">Colonne Date d'Échéance</Label>
<Input
id="nocodbDueDateColumnName"
value={formData.nocodbDueDateColumnName}
onChange={(e) => handleChange('nocodbDueDateColumnName', e.target.value)}
placeholder="ex: Date_Echeance"
/>
</div>
</div>
</div>
@@ -693,6 +714,7 @@ export default function Groups() {
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
}}
+288
View File
@@ -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<string>(() => {
const now = new Date();
return format(now, 'yyyy-MM');
});
// Récupérer les échéances
const { data, isLoading, error } = useQuery<PaymentScheduleResponse>({
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<string, number> = {};
filteredSchedules.forEach(schedule => {
const method = schedule.paymentMethod || 'Non défini';
totals[method] = (totals[method] || 0) + schedule.amount;
});
return totals;
}, [filteredSchedules]);
if (!selectedStoreId) {
return (
<div className="flex-1 flex items-center justify-center">
<Card className="w-96">
<CardHeader className="text-center">
<AlertCircle className="w-12 h-12 text-orange-500 mx-auto mb-4" />
<CardTitle>Sélectionner un magasin</CardTitle>
<CardDescription>
Veuillez sélectionner un magasin pour afficher l'échéancier
</CardDescription>
</CardHeader>
</Card>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (error) {
return (
<div className="flex-1 flex items-center justify-center">
<Card className="w-96">
<CardHeader className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<CardTitle>Erreur</CardTitle>
<CardDescription>
Une erreur est survenue lors du chargement des échéances
</CardDescription>
</CardHeader>
</Card>
</div>
);
}
if (data?.message) {
return (
<div className="flex-1 flex items-center justify-center">
<Card className="w-96">
<CardHeader className="text-center">
<AlertCircle className="w-12 h-12 text-blue-500 mx-auto mb-4" />
<CardTitle>Configuration requise</CardTitle>
<CardDescription>{data.message}</CardDescription>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* En-tête */}
<div className="flex justify-between items-center">
<div>
<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>
</div>
{/* Statistiques du mois */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total du Mois</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold" data-testid="text-month-total">
{monthTotal.toFixed(2)} €
</div>
<p className="text-xs text-muted-foreground">
{filteredSchedules.length} échéance{filteredSchedules.length > 1 ? 's' : ''}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Modes de Paiement</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm space-y-1">
{Object.entries(paymentMethodTotals).map(([method, total]) => (
<div key={method} className="flex justify-between">
<span className="text-gray-600">{method}:</span>
<span className="font-semibold">{total.toFixed(2)} €</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Période</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold capitalize">
{format(parseISO(`${selectedMonth}-01`), 'MMMM', { locale: fr })}
</div>
<p className="text-xs text-muted-foreground">
{format(parseISO(`${selectedMonth}-01`), 'yyyy', { locale: fr })}
</p>
</CardContent>
</Card>
</div>
{/* Liste des échéances */}
<Card>
<CardHeader>
<CardTitle>Échéances du Mois</CardTitle>
<CardDescription>
Liste détaillée des paiements à effectuer pour le mois sélectionné
</CardDescription>
</CardHeader>
<CardContent>
{filteredSchedules.length === 0 ? (
<div className="text-center py-8 text-gray-500">
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">
{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">
<div className="text-xl font-bold text-blue-600">
{schedule.amount.toFixed(2)} €
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+36
View File
@@ -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<Supplier[]>({
@@ -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}
</div>
)}
{supplier.paymentMethod && (
<div className="flex items-center text-sm text-gray-600">
<Package className="w-4 h-4 mr-2" />
<span className="font-medium">Mode de paiement:</span>
<span className="ml-1">{supplier.paymentMethod}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-2">
{supplier.hasDlc && (
@@ -511,6 +528,25 @@ export default function Suppliers() {
/>
</div>
<div>
<Label htmlFor="paymentMethod">Mode de paiement</Label>
<Select
value={formData.paymentMethod}
onValueChange={(value) => handleChange('paymentMethod', value)}
>
<SelectTrigger id="paymentMethod" data-testid="select-payment-method">
<SelectValue placeholder="Sélectionner un mode de paiement" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Aucun</SelectItem>
<SelectItem value="Virement">Virement</SelectItem>
<SelectItem value="Traite">Traite</SelectItem>
<SelectItem value="Traite Magnétique">Traite Magnétique</SelectItem>
<SelectItem value="Chèque">Chèque</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasDlc"
+126
View File
@@ -278,6 +278,132 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// 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 {
+2
View File
@@ -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