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

684 lines
28 KiB
TypeScript
Executable File

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Dialog } from 'primereact/dialog';
import { InputTextarea } from 'primereact/inputtextarea';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Checkbox } from 'primereact/checkbox';
import { ProgressBar } from 'primereact/progressbar';
import { Knob } from 'primereact/knob';
import {
ActionButtonGroup,
ViewButton,
EditButton,
DeleteButton,
ActionButton
} from '../../../../components/ui/ActionButton';
/**
* Page Relances Automatiques Factures BTP Express
* Gestion complète des relances clients avec workflows automatisés
*/
const RelancesFactures = () => {
const [factures, setFactures] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [relanceDialog, setRelanceDialog] = useState(false);
const [configDialog, setConfigDialog] = useState(false);
const [selectedFactures, setSelectedFactures] = useState<any[]>([]);
const [selectedFacture, setSelectedFacture] = useState<any>(null);
const [messageRelance, setMessageRelance] = useState('');
const [typeRelance, setTypeRelance] = useState('');
const [dateRelance, setDateRelance] = useState<Date | null>(null);
const [configRelances, setConfigRelances] = useState<any>({});
const [metriques, setMetriques] = useState<any>({});
const toast = useRef<Toast>(null);
const typesRelance = [
{ label: 'Relance amiable 1', value: 'AMIABLE_1', delai: 30, ferme: false },
{ label: 'Relance amiable 2', value: 'AMIABLE_2', delai: 45, ferme: false },
{ label: 'Mise en demeure', value: 'MISE_EN_DEMEURE', delai: 60, ferme: true },
{ label: 'Procédure contentieux', value: 'CONTENTIEUX', delai: 90, ferme: true }
];
const modeleMessages = {
'AMIABLE_1': `Madame, Monsieur,
Nous vous informons que votre facture n°[NUMERO] d'un montant de [MONTANT]€ émise le [DATE_EMISSION] reste impayée à ce jour.
Le délai de paiement étant dépassé, nous vous remercions de bien vouloir procéder au règlement dans les plus brefs délais.
En cas d'oubli de votre part, vous pouvez procéder au paiement en ligne ou nous contacter.
Cordialement,
L'équipe BTP Express`,
'AMIABLE_2': `Madame, Monsieur,
SECONDE RELANCE - Facture n°[NUMERO]
Malgré notre précédent courrier, votre facture n°[NUMERO] d'un montant de [MONTANT]€ demeure impayée.
Nous vous rappelons que le délai de paiement est largement dépassé. Pour éviter tout désagrément, nous vous demandons de régulariser votre situation sous 8 jours.
En l'absence de règlement, nous nous verrons contraints d'engager une procédure de recouvrement.
Cordialement,
L'équipe BTP Express`,
'MISE_EN_DEMEURE': `MISE EN DEMEURE DE PAYER
Facture n°[NUMERO]
Madame, Monsieur,
Nos précédentes relances étant restées sans effet, nous vous mettons en demeure de procéder au règlement de votre facture n°[NUMERO] d'un montant de [MONTANT]€.
VOUS DISPOSEZ DE 8 JOURS pour régulariser votre situation.
À défaut, nous engagerons contre vous une procédure de recouvrement contentieux qui entraînera des frais supplémentaires à votre charge.
BTP Express`
};
useEffect(() => {
loadFactures();
loadConfiguration();
loadMetriques();
}, []);
const loadFactures = async () => {
try {
setLoading(true);
// Simulation factures avec relances
const mockFactures = [
{
id: 'FAC-2025-001',
numero: 'FAC-2025-001',
client: 'Claire Rousseau',
montantTTC: 18000,
dateEmission: '2024-12-15',
dateEcheance: '2025-01-15',
joursRetard: 15,
statut: 'IMPAYEE',
nbRelances: 1,
derniereRelance: '2025-01-20',
prochainNiveau: 'AMIABLE_2',
risqueClient: 'FAIBLE',
historique: [
{ date: '2025-01-20', type: 'AMIABLE_1', canal: 'EMAIL', statut: 'ENVOYE' }
]
},
{
id: 'FAC-2025-002',
numero: 'FAC-2025-002',
client: 'Jean Dupont',
montantTTC: 32000,
dateEmission: '2024-11-30',
dateEcheance: '2024-12-30',
joursRetard: 31,
statut: 'IMPAYEE',
nbRelances: 2,
derniereRelance: '2025-01-25',
prochainNiveau: 'MISE_EN_DEMEURE',
risqueClient: 'MOYEN',
historique: [
{ date: '2025-01-10', type: 'AMIABLE_1', canal: 'EMAIL', statut: 'ENVOYE' },
{ date: '2025-01-25', type: 'AMIABLE_2', canal: 'EMAIL', statut: 'ENVOYE' }
]
},
{
id: 'FAC-2025-003',
numero: 'FAC-2025-003',
client: 'Sophie Martin',
montantTTC: 8500,
dateEmission: '2025-01-10',
dateEcheance: '2025-02-10',
joursRetard: -10, // Pas encore échue
statut: 'EN_ATTENTE',
nbRelances: 0,
derniereRelance: null,
prochainNiveau: 'AMIABLE_1',
risqueClient: 'FAIBLE',
historique: []
},
{
id: 'FAC-2024-089',
numero: 'FAC-2024-089',
client: 'Michel Bernard',
montantTTC: 45000,
dateEmission: '2024-10-15',
dateEcheance: '2024-11-15',
joursRetard: 77,
statut: 'CONTENTIEUX',
nbRelances: 3,
derniereRelance: '2025-01-15',
prochainNiveau: 'CONTENTIEUX',
risqueClient: 'FORT',
historique: [
{ date: '2024-12-01', type: 'AMIABLE_1', canal: 'EMAIL', statut: 'ENVOYE' },
{ date: '2024-12-20', type: 'AMIABLE_2', canal: 'EMAIL', statut: 'ENVOYE' },
{ date: '2025-01-15', type: 'MISE_EN_DEMEURE', canal: 'COURRIER', statut: 'ENVOYE' }
]
}
];
setFactures(mockFactures);
} catch (error) {
console.error('Erreur chargement factures:', error);
} finally {
setLoading(false);
}
};
const loadConfiguration = () => {
setConfigRelances({
delaiAmiable1: 30,
delaiAmiable2: 45,
delaiMiseEnDemeure: 60,
automatique: true,
canalPrioritaire: 'EMAIL',
avecCopie: true,
fraisRecouvrement: 40
});
};
const loadMetriques = () => {
setMetriques({
montantEnAttente: 103500,
nombreFacturesImpayees: 4,
tauxRecouvrement: 87.5,
delaiMoyenPaiement: 38,
relancesEnvoyees: 12,
montantRecouvre: 285000
});
};
const ouvrirRelance = (facture: any) => {
setSelectedFacture(facture);
setTypeRelance(facture.prochainNiveau);
setMessageRelance(modeleMessages[facture.prochainNiveau as keyof typeof modeleMessages] || '');
setDateRelance(new Date());
setRelanceDialog(true);
};
const envoyerRelance = async () => {
if (!selectedFacture || !typeRelance) return;
try {
// Simulation envoi relance
await new Promise(resolve => setTimeout(resolve, 1500));
const factureUpdated = {
...selectedFacture,
nbRelances: selectedFacture.nbRelances + 1,
derniereRelance: new Date().toISOString().split('T')[0],
historique: [
...selectedFacture.historique,
{
date: new Date().toISOString().split('T')[0],
type: typeRelance,
canal: 'EMAIL',
statut: 'ENVOYE'
}
]
};
// Déterminer prochain niveau
const indexActuel = typesRelance.findIndex(t => t.value === typeRelance);
if (indexActuel < typesRelance.length - 1) {
factureUpdated.prochainNiveau = typesRelance[indexActuel + 1].value;
}
// Mettre à jour la liste
const facturesUpdated = factures.map(f =>
f.id === selectedFacture.id ? factureUpdated : f
);
setFactures(facturesUpdated);
toast.current?.show({
severity: 'success',
summary: 'Relance envoyée',
detail: `Relance ${typeRelance} envoyée à ${selectedFacture.client}`,
life: 4000
});
setRelanceDialog(false);
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible d\'envoyer la relance',
life: 3000
});
}
};
const envoyerRelancesGroupees = async () => {
if (selectedFactures.length === 0) return;
try {
toast.current?.show({
severity: 'info',
summary: 'Traitement en cours',
detail: `Envoi de ${selectedFactures.length} relances...`,
life: 3000
});
// Simulation envoi groupé
await new Promise(resolve => setTimeout(resolve, 2000));
const facturesUpdated = factures.map(f => {
if (selectedFactures.find(sf => sf.id === f.id)) {
return {
...f,
nbRelances: f.nbRelances + 1,
derniereRelance: new Date().toISOString().split('T')[0]
};
}
return f;
});
setFactures(facturesUpdated);
setSelectedFactures([]);
toast.current?.show({
severity: 'success',
summary: 'Relances envoyées',
detail: `${selectedFactures.length} relances envoyées avec succès`,
life: 4000
});
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de l\'envoi groupé',
life: 3000
});
}
};
const retardBodyTemplate = (rowData: any) => {
if (rowData.joursRetard <= 0) {
return <Tag value="À échoir" severity="info" />;
}
let severity = 'warning';
if (rowData.joursRetard > 60) severity = 'danger';
else if (rowData.joursRetard > 30) severity = 'warning';
return (
<div className="flex align-items-center gap-2">
<Tag value={`+${rowData.joursRetard}j`} severity={severity} />
{rowData.joursRetard > 60 && <i className="pi pi-exclamation-triangle text-red-500" />}
</div>
);
};
const relancesBodyTemplate = (rowData: any) => {
return (
<div className="flex align-items-center gap-2">
<Badge value={rowData.nbRelances} severity={rowData.nbRelances > 2 ? 'danger' : 'warning'} />
{rowData.derniereRelance && (
<span className="text-sm text-color-secondary">
{new Date(rowData.derniereRelance).toLocaleDateString('fr-FR')}
</span>
)}
</div>
);
};
const risqueBodyTemplate = (rowData: any) => {
const config = {
'FAIBLE': { color: 'success', icon: 'pi-check-circle' },
'MOYEN': { color: 'warning', icon: 'pi-exclamation-triangle' },
'FORT': { color: 'danger', icon: 'pi-times-circle' }
};
const riskConfig = config[rowData.risqueClient as keyof typeof config];
return (
<div className="flex align-items-center gap-2">
<i className={`pi ${riskConfig.icon} text-${riskConfig.color}`} />
<Tag value={rowData.risqueClient} severity={riskConfig.color} />
</div>
);
};
const actionsBodyTemplate = (rowData: any) => {
return (
<ActionButtonGroup>
<ActionButton
icon="pi pi-send"
color="warning"
tooltip="Envoyer relance"
onClick={() => ouvrirRelance(rowData)}
disabled={rowData.joursRetard <= 0}
/>
<ActionButton
icon="pi pi-phone"
color="info"
tooltip="Appeler client"
/>
<ViewButton
tooltip="Voir historique"
/>
</ActionButtonGroup>
);
};
const header = (
<div className="flex justify-content-between align-items-center">
<h5 className="m-0">Gestion des Relances</h5>
<div className="flex gap-2">
<Button
label="Relances groupées"
icon="pi pi-send"
severity="warning"
onClick={envoyerRelancesGroupees}
disabled={selectedFactures.length === 0}
/>
<Button
label="Configuration"
icon="pi pi-cog"
outlined
onClick={() => setConfigDialog(true)}
/>
</div>
</div>
);
return (
<div className="grid">
<Toast ref={toast} />
{/* Métriques Relances */}
<div className="col-12">
<div className="grid">
<div className="col-12 md:col-2">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-red-500">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact'
}).format(metriques.montantEnAttente)}
</div>
<div className="text-color-secondary">En attente</div>
</div>
</Card>
</div>
<div className="col-12 md:col-2">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">{metriques.nombreFacturesImpayees}</div>
<div className="text-color-secondary">Factures impayées</div>
</div>
</Card>
</div>
<div className="col-12 md:col-2">
<Card>
<div className="text-center">
<Knob
value={metriques.tauxRecouvrement}
size={60}
strokeWidth={8}
valueColor="#10B981"
/>
<div className="text-color-secondary mt-2">Taux recouvrement</div>
</div>
</Card>
</div>
<div className="col-12 md:col-2">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">{metriques.delaiMoyenPaiement}j</div>
<div className="text-color-secondary">Délai moyen</div>
</div>
</Card>
</div>
<div className="col-12 md:col-2">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-cyan-500">{metriques.relancesEnvoyees}</div>
<div className="text-color-secondary">Relances ce mois</div>
</div>
</Card>
</div>
<div className="col-12 md:col-2">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact'
}).format(metriques.montantRecouvre)}
</div>
<div className="text-color-secondary">Recouvré ce mois</div>
</div>
</Card>
</div>
</div>
</div>
<div className="col-12">
<Divider />
</div>
{/* Tableau factures */}
<div className="col-12">
<Card>
<DataTable
value={factures}
loading={loading}
selection={selectedFactures}
onSelectionChange={(e) => setSelectedFactures(e.value)}
paginator
rows={15}
dataKey="id"
header={header}
emptyMessage="Aucune facture trouvée"
responsiveLayout="scroll"
>
<Column selectionMode="multiple" headerStyle={{ width: '3rem' }} />
<Column field="numero" header="Facture" sortable style={{ minWidth: '120px' }} />
<Column field="client" header="Client" sortable style={{ minWidth: '150px' }} />
<Column
field="montantTTC"
header="Montant"
body={(rowData) => new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(rowData.montantTTC)}
sortable
style={{ minWidth: '120px' }}
/>
<Column
field="dateEcheance"
header="Échéance"
body={(rowData) => new Date(rowData.dateEcheance).toLocaleDateString('fr-FR')}
sortable
style={{ minWidth: '100px' }}
/>
<Column header="Retard" body={retardBodyTemplate} sortable style={{ minWidth: '100px' }} />
<Column header="Relances" body={relancesBodyTemplate} style={{ minWidth: '120px' }} />
<Column header="Risque" body={risqueBodyTemplate} style={{ minWidth: '120px' }} />
<Column body={actionsBodyTemplate} style={{ minWidth: '150px' }} />
</DataTable>
</Card>
</div>
{/* Dialog Relance */}
<Dialog
visible={relanceDialog}
style={{ width: '800px' }}
header={`Relance - ${selectedFacture?.numero}`}
modal
onHide={() => setRelanceDialog(false)}
>
{selectedFacture && (
<div className="grid">
<div className="col-12 md:col-6">
<Card title="Informations Facture">
<div className="flex flex-column gap-3">
<div><strong>Client:</strong> {selectedFacture.client}</div>
<div><strong>Montant:</strong> {new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(selectedFacture.montantTTC)}</div>
<div><strong>Échéance:</strong> {new Date(selectedFacture.dateEcheance).toLocaleDateString('fr-FR')}</div>
<div><strong>Retard:</strong> <Tag value={`${selectedFacture.joursRetard} jours`} severity="danger" /></div>
<div><strong>Relances déjà envoyées:</strong> {selectedFacture.nbRelances}</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6">
<Card title="Type de Relance">
<div className="flex flex-column gap-3">
<Dropdown
value={typeRelance}
options={typesRelance}
onChange={(e) => {
setTypeRelance(e.value);
setMessageRelance(modeleMessages[e.value as keyof typeof modeleMessages] || '');
}}
placeholder="Sélectionnez le type"
className="w-full"
/>
<div>
<label className="block text-sm font-medium mb-2">Date d'envoi</label>
<Calendar
value={dateRelance}
onChange={(e) => setDateRelance(e.value || null)}
dateFormat="dd/mm/yy"
showIcon
className="w-full"
/>
</div>
</div>
</Card>
</div>
<div className="col-12">
<Card title="Message de Relance">
<InputTextarea
value={messageRelance}
onChange={(e) => setMessageRelance(e.target.value)}
rows={12}
className="w-full"
placeholder="Rédigez votre message de relance..."
/>
<div className="flex justify-content-end gap-2 mt-4">
<Button
label="Annuler"
icon="pi pi-times"
outlined
onClick={() => setRelanceDialog(false)}
/>
<Button
label="Envoyer Relance"
icon="pi pi-send"
severity="warning"
onClick={envoyerRelance}
/>
</div>
</Card>
</div>
</div>
)}
</Dialog>
{/* Dialog Configuration */}
<Dialog
visible={configDialog}
style={{ width: '600px' }}
header="Configuration des Relances"
modal
onHide={() => setConfigDialog(false)}
>
<div className="flex flex-column gap-4">
<div>
<label className="block text-sm font-medium mb-2">Délais automatiques (jours)</label>
<div className="grid">
<div className="col-6">
<label className="text-sm">1ère relance amiable</label>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-calendar"></i>
</span>
<input className="p-inputtext" value={configRelances.delaiAmiable1} readOnly />
<span className="p-inputgroup-addon">jours</span>
</div>
</div>
<div className="col-6">
<label className="text-sm">2ème relance amiable</label>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-calendar"></i>
</span>
<input className="p-inputtext" value={configRelances.delaiAmiable2} readOnly />
<span className="p-inputgroup-addon">jours</span>
</div>
</div>
</div>
</div>
<div className="flex align-items-center gap-2">
<Checkbox
checked={configRelances.automatique}
onChange={(e) => setConfigRelances({...configRelances, automatique: e.checked})}
/>
<label>Envoi automatique des relances</label>
</div>
<div className="flex justify-content-end gap-2">
<Button
label="Annuler"
icon="pi pi-times"
outlined
onClick={() => setConfigDialog(false)}
/>
<Button
label="Sauvegarder"
icon="pi pi-save"
severity="success"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Configuration sauvegardée',
detail: 'Paramètres de relances mis à jour',
life: 3000
});
setConfigDialog(false);
}}
/>
</div>
</div>
</Dialog>
</div>
);
};
export default RelancesFactures;