Files
btpxpress-frontend/app/(main)/factures/payees/page.tsx

613 lines
27 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Toolbar } from 'primereact/toolbar';
import { Tag } from 'primereact/tag';
import { Dialog } from 'primereact/dialog';
import { Calendar } from 'primereact/calendar';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { Chip } from 'primereact/chip';
import { Divider } from 'primereact/divider';
import { factureService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Facture } from '../../../../types/btp';
const FacturesPayeesPage = () => {
const [factures, setFactures] = useState<Facture[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedFactures, setSelectedFactures] = useState<Facture[]>([]);
const [detailDialog, setDetailDialog] = useState(false);
const [selectedFacture, setSelectedFacture] = useState<Facture | null>(null);
const [filterPeriod, setFilterPeriod] = useState('ALL');
const [dateRange, setDateRange] = useState<Date[]>([]);
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Facture[]>>(null);
const periodOptions = [
{ label: 'Toutes les factures', value: 'ALL' },
{ label: 'Ce mois-ci', value: 'THIS_MONTH' },
{ label: 'Mois dernier', value: 'LAST_MONTH' },
{ label: 'Ce trimestre', value: 'THIS_QUARTER' },
{ label: 'Cette année', value: 'THIS_YEAR' },
{ label: 'Période personnalisée', value: 'CUSTOM' }
];
useEffect(() => {
loadFactures();
}, [filterPeriod, dateRange]);
const loadFactures = async () => {
try {
setLoading(true);
const data = await factureService.getAll();
// Filtrer les factures payées
let facturesPayees = data.filter(facture =>
facture.statut === 'PAYEE' && facture.datePaiement
);
// Appliquer le filtre de période
facturesPayees = applyPeriodFilter(facturesPayees);
setFactures(facturesPayees);
} catch (error) {
console.error('Erreur lors du chargement des factures:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les factures payées',
life: 3000
});
} finally {
setLoading(false);
}
};
const applyPeriodFilter = (facturesList: Facture[]) => {
const now = new Date();
switch (filterPeriod) {
case 'THIS_MONTH':
return facturesList.filter(f => {
const paymentDate = new Date(f.datePaiement!);
return paymentDate.getMonth() === now.getMonth() &&
paymentDate.getFullYear() === now.getFullYear();
});
case 'LAST_MONTH':
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1);
return facturesList.filter(f => {
const paymentDate = new Date(f.datePaiement!);
return paymentDate.getMonth() === lastMonth.getMonth() &&
paymentDate.getFullYear() === lastMonth.getFullYear();
});
case 'THIS_QUARTER':
const quarterStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
return facturesList.filter(f => {
const paymentDate = new Date(f.datePaiement!);
return paymentDate >= quarterStart && paymentDate <= now;
});
case 'THIS_YEAR':
return facturesList.filter(f => {
const paymentDate = new Date(f.datePaiement!);
return paymentDate.getFullYear() === now.getFullYear();
});
case 'CUSTOM':
if (dateRange.length === 2) {
return facturesList.filter(f => {
const paymentDate = new Date(f.datePaiement!);
return paymentDate >= dateRange[0] && paymentDate <= dateRange[1];
});
}
return facturesList;
default:
return facturesList;
}
};
const getDaysToPayment = (dateEmission: string | Date, datePaiement: string | Date) => {
const emissionDate = new Date(dateEmission);
const paymentDate = new Date(datePaiement);
const diffTime = paymentDate.getTime() - emissionDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
const getPaymentPerformance = (dateEcheance: string | Date, datePaiement: string | Date) => {
const dueDate = new Date(dateEcheance);
const paymentDate = new Date(datePaiement);
const diffTime = paymentDate.getTime() - dueDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { status: 'EN AVANCE', days: Math.abs(diffDays), severity: 'success' as const };
if (diffDays === 0) return { status: 'À L\'ÉCHÉANCE', days: 0, severity: 'info' as const };
return { status: 'EN RETARD', days: diffDays, severity: 'warning' as const };
};
const viewDetails = (facture: Facture) => {
setSelectedFacture(facture);
setDetailDialog(true);
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const generatePaymentReport = () => {
const totalReceived = factures.reduce((sum, f) => sum + (f.montantTTC || 0), 0);
const avgPaymentTime = factures.length > 0 ?
factures.reduce((sum, f) => sum + getDaysToPayment(f.dateEmission, f.datePaiement!), 0) / factures.length : 0;
const onTimePayments = factures.filter(f => {
const perf = getPaymentPerformance(f.dateEcheance, f.datePaiement!);
return perf.status !== 'EN RETARD';
});
const earlyPayments = factures.filter(f => {
const perf = getPaymentPerformance(f.dateEcheance, f.datePaiement!);
return perf.status === 'EN AVANCE';
});
const report = `
=== RAPPORT ENCAISSEMENTS ===
Période: ${getPeriodLabel()}
Date du rapport: ${new Date().toLocaleDateString('fr-FR')}
STATISTIQUES GÉNÉRALES:
- Nombre de factures payées: ${factures.length}
- Montant total encaissé: ${formatCurrency(totalReceived)}
- Montant moyen par facture: ${formatCurrency(totalReceived / (factures.length || 1))}
- Délai moyen de paiement: ${Math.round(avgPaymentTime)} jours
PERFORMANCE DE PAIEMENT:
- Paiements à l'heure: ${onTimePayments.length} (${Math.round((onTimePayments.length / factures.length) * 100)}%)
- Paiements en avance: ${earlyPayments.length} (${Math.round((earlyPayments.length / factures.length) * 100)}%)
- Taux de ponctualité: ${Math.round((onTimePayments.length / factures.length) * 100)}%
RÉPARTITION PAR MOIS:
${getMonthlyBreakdown()}
ANALYSE PAR CLIENT:
${getClientPaymentAnalysis()}
TOP 5 PLUS GROSSES FACTURES:
${factures
.sort((a, b) => (b.montantTTC || 0) - (a.montantTTC || 0))
.slice(0, 5)
.map(f => `- ${f.numero}: ${formatCurrency(f.montantTTC || 0)} - ${f.client ? `${f.client.prenom} ${f.client.nom}` : 'N/A'} - ${formatDate(f.datePaiement!)}`)
.join('\n')}
CLIENTS LES PLUS PONCTUELS:
${getBestPayingClients()}
RECOMMANDATIONS:
- Maintenir les bonnes relations avec les clients ponctuels
- Analyser les facteurs de succès pour améliorer les délais globaux
- Proposer des remises pour paiement anticipé
- Utiliser cette base pour évaluer la solvabilité des clients
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `rapport_encaissements_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
toast.current?.show({
severity: 'success',
summary: 'Rapport généré',
detail: 'Le rapport d\'encaissements a été téléchargé',
life: 3000
});
};
const getPeriodLabel = () => {
switch (filterPeriod) {
case 'THIS_MONTH': return 'Ce mois-ci';
case 'LAST_MONTH': return 'Mois dernier';
case 'THIS_QUARTER': return 'Ce trimestre';
case 'THIS_YEAR': return 'Cette année';
case 'CUSTOM':
return dateRange.length === 2 ?
`Du ${formatDate(dateRange[0])} au ${formatDate(dateRange[1])}` :
'Période personnalisée';
default: return 'Toutes les factures';
}
};
const getMonthlyBreakdown = () => {
const months = {};
factures.forEach(f => {
const month = new Date(f.datePaiement!).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long' });
if (!months[month]) {
months[month] = { count: 0, value: 0 };
}
months[month].count++;
months[month].value += f.montantTTC || 0;
});
return Object.entries(months)
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
.map(([month, data]: [string, any]) => `- ${month}: ${data.count} factures, ${formatCurrency(data.value)}`)
.join('\n');
};
const getClientPaymentAnalysis = () => {
const clientStats = {};
factures.forEach(f => {
if (f.client) {
const clientKey = `${f.client.prenom} ${f.client.nom}`;
if (!clientStats[clientKey]) {
clientStats[clientKey] = { count: 0, value: 0, totalDays: 0 };
}
clientStats[clientKey].count++;
clientStats[clientKey].value += f.montantTTC || 0;
clientStats[clientKey].totalDays += getDaysToPayment(f.dateEmission, f.datePaiement!);
}
});
return Object.entries(clientStats)
.sort((a: [string, any], b: [string, any]) => b[1].value - a[1].value)
.slice(0, 5)
.map(([client, data]: [string, any]) => `- ${client}: ${data.count} factures, ${formatCurrency(data.value)}, délai moyen: ${Math.round(data.totalDays / data.count)} jours`)
.join('\n');
};
const getBestPayingClients = () => {
const clientStats = {};
factures.forEach(f => {
if (f.client) {
const clientKey = `${f.client.prenom} ${f.client.nom}`;
if (!clientStats[clientKey]) {
clientStats[clientKey] = { onTime: 0, total: 0 };
}
clientStats[clientKey].total++;
const perf = getPaymentPerformance(f.dateEcheance, f.datePaiement!);
if (perf.status !== 'EN RETARD') {
clientStats[clientKey].onTime++;
}
}
});
return Object.entries(clientStats)
.filter(([_, data]: [string, any]) => data.total >= 2) // Au moins 2 factures
.sort((a: [string, any], b: [string, any]) => (b[1].onTime / b[1].total) - (a[1].onTime / a[1].total))
.slice(0, 5)
.map(([client, data]: [string, any]) => `- ${client}: ${Math.round((data.onTime / data.total) * 100)}% ponctuel (${data.onTime}/${data.total})`)
.join('\n');
};
const leftToolbarTemplate = () => {
return (
<div className="my-2 flex gap-2 align-items-center">
<h5 className="m-0 flex align-items-center text-green-600">
<i className="pi pi-check-circle mr-2"></i>
Factures payées ({factures.length})
</h5>
<Chip
label={`Total encaissé: ${formatCurrency(factures.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}`}
className="bg-green-100 text-green-800"
/>
<Dropdown
value={filterPeriod}
options={periodOptions}
onChange={(e) => setFilterPeriod(e.value)}
className="w-12rem"
/>
{filterPeriod === 'CUSTOM' && (
<Calendar
value={dateRange}
onChange={(e) => setDateRange(e.value as Date[])}
selectionMode="range"
placeholder="Sélectionner la période"
className="w-15rem"
/>
)}
<Button
label="Rapport d'encaissements"
icon="pi pi-chart-bar"
severity="success"
size="small"
onClick={generatePaymentReport}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Facture) => {
return (
<div className="flex gap-1">
<Button
icon="pi pi-eye"
rounded
severity="info"
size="small"
tooltip="Voir détails du paiement"
onClick={() => viewDetails(rowData)}
/>
<Button
icon="pi pi-print"
rounded
severity="help"
size="small"
tooltip="Imprimer reçu"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Impression',
detail: `Impression du reçu pour ${rowData.numero}`,
life: 3000
});
}}
/>
<Button
icon="pi pi-file-pdf"
rounded
severity={"secondary" as any}
size="small"
tooltip="Générer attestation de paiement"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Attestation',
detail: `Attestation générée pour ${rowData.numero}`,
life: 3000
});
}}
/>
</div>
);
};
const statusBodyTemplate = (rowData: Facture) => {
const performance = getPaymentPerformance(rowData.dateEcheance, rowData.datePaiement!);
return (
<div className="flex align-items-center gap-2">
<Tag value="Payée" severity="success" />
<Tag
value={performance.status}
severity={performance.severity}
className="text-xs"
/>
</div>
);
};
const paymentBodyTemplate = (rowData: Facture) => {
const performance = getPaymentPerformance(rowData.dateEcheance, rowData.datePaiement!);
const paymentDays = getDaysToPayment(rowData.dateEmission, rowData.datePaiement!);
return (
<div>
<div className="font-bold text-green-600">{formatDate(rowData.datePaiement!)}</div>
<small className="text-600">
Payée en {paymentDays} jour{paymentDays > 1 ? 's' : ''}
</small>
{performance.status === 'EN AVANCE' && (
<div className="text-green-600 text-xs">
<i className="pi pi-check mr-1"></i>
{performance.days} jour{performance.days > 1 ? 's' : ''} en avance
</div>
)}
{performance.status === 'EN RETARD' && (
<div className="text-orange-600 text-xs">
<i className="pi pi-clock mr-1"></i>
{performance.days} jour{performance.days > 1 ? 's' : ''} de retard
</div>
)}
</div>
);
};
const clientBodyTemplate = (rowData: Facture) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const performanceBodyTemplate = (rowData: Facture) => {
const performance = getPaymentPerformance(rowData.dateEcheance, rowData.datePaiement!);
const paymentDays = getDaysToPayment(rowData.dateEmission, rowData.datePaiement!);
// Score de performance (0-100)
let score = 100;
if (performance.status === 'EN RETARD') {
score = Math.max(0, 100 - (performance.days * 5)); // -5 points par jour de retard
} else if (performance.status === 'EN AVANCE') {
score = 100 + Math.min(20, performance.days * 2); // +2 points par jour d'avance (max +20)
}
let scoreColor = '#22c55e'; // green
if (score < 70) scoreColor = '#ef4444'; // red
else if (score < 85) scoreColor = '#f59e0b'; // orange
return (
<div className="text-center">
<div className="text-2xl font-bold mb-1" style={{ color: scoreColor }}>
{Math.round(score)}
</div>
<small className="text-600">Score de ponctualité</small>
</div>
);
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Factures payées - Suivi des encaissements</h5>
<span className="block mt-2 md:mt-0 p-input-icon-left">
<i className="pi pi-search" />
<InputText
type="search"
placeholder="Rechercher..."
onInput={(e) => setGlobalFilter(e.currentTarget.value)}
/>
</span>
</div>
);
const detailDialogFooter = (
<Button
label="Fermer"
icon="pi pi-times"
text
onClick={() => setDetailDialog(false)}
/>
);
return (
<div className="grid">
<div className="col-12">
<Card>
<Toast ref={toast} />
<Toolbar className="mb-4" left={leftToolbarTemplate} right={rightToolbarTemplate} />
<DataTable
ref={dt}
value={factures}
selection={selectedFactures}
onSelectionChange={(e) => setSelectedFactures(e.value)}
dataKey="id"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
className="datatable-responsive"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Affichage de {first} à {last} sur {totalRecords} factures"
globalFilter={globalFilter}
emptyMessage="Aucune facture payée trouvée."
header={header}
responsiveLayout="scroll"
loading={loading}
sortField="datePaiement"
sortOrder={-1}
>
<Column selectionMode="multiple" headerStyle={{ width: '4rem' }} />
<Column field="numero" header="Numéro" sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="objet" header="Objet" sortable headerStyle={{ minWidth: '15rem' }} />
<Column field="client" header="Client" body={clientBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="dateEmission" header="Date émission" body={(rowData) => formatDate(rowData.dateEmission)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="dateEcheance" header="Échéance" body={(rowData) => formatDate(rowData.dateEcheance)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="datePaiement" header="Paiement" body={paymentBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="montantTTC" header="Montant TTC" body={(rowData) => formatCurrency(rowData.montantTTC)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="performance" header="Performance" body={performanceBodyTemplate} headerStyle={{ minWidth: '10rem' }} />
<Column field="statut" header="Statut" body={statusBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
</DataTable>
<Dialog
visible={detailDialog}
style={{ width: '600px' }}
header="Détails du paiement"
modal
className="p-fluid"
footer={detailDialogFooter}
onHide={() => setDetailDialog(false)}
>
{selectedFacture && (
<div className="formgrid grid">
<div className="field col-12">
<h6>Informations de la facture</h6>
<p><strong>Numéro:</strong> {selectedFacture.numero}</p>
<p><strong>Objet:</strong> {selectedFacture.objet}</p>
<p><strong>Client:</strong> {selectedFacture.client ? `${selectedFacture.client.prenom} ${selectedFacture.client.nom}` : 'N/A'}</p>
<p><strong>Date d'émission:</strong> {formatDate(selectedFacture.dateEmission)}</p>
<p><strong>Date d'échéance:</strong> {formatDate(selectedFacture.dateEcheance)}</p>
</div>
<Divider />
<div className="field col-12">
<h6>Détails du paiement</h6>
<p><strong>Date de paiement:</strong> {formatDate(selectedFacture.datePaiement!)}</p>
<p><strong>Montant payé:</strong> {formatCurrency(selectedFacture.montantTTC || 0)}</p>
<p><strong>Délai de paiement:</strong> {getDaysToPayment(selectedFacture.dateEmission, selectedFacture.datePaiement!)} jours</p>
{(() => {
const perf = getPaymentPerformance(selectedFacture.dateEcheance, selectedFacture.datePaiement!);
return (
<p>
<strong>Performance:</strong>
<Tag
value={perf.status}
severity={perf.severity}
className="ml-2"
/>
{perf.days > 0 && (
<span className="ml-2">
({perf.days} jour{perf.days > 1 ? 's' : ''})
</span>
)}
</p>
);
})()}
</div>
<Divider />
<div className="field col-12">
<h6>Actions disponibles</h6>
<div className="flex gap-2">
<Button
label="Imprimer reçu"
icon="pi pi-print"
size="small"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Impression',
detail: `Impression du reçu pour ${selectedFacture.numero}`,
life: 3000
});
}}
/>
<Button
label="Attestation"
icon="pi pi-file-pdf"
severity={"secondary" as any}
size="small"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Attestation',
detail: `Attestation générée pour ${selectedFacture.numero}`,
life: 3000
});
}}
/>
</div>
</div>
</div>
)}
</Dialog>
</Card>
</div>
</div>
);
};
export default FacturesPayeesPage;