Initial commit

This commit is contained in:
dahoud
2025-10-01 01:39:07 +00:00
commit b430bf3b96
826 changed files with 255287 additions and 0 deletions

View File

@@ -0,0 +1,603 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Toast } from 'primereact/toast';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Toolbar } from 'primereact/toolbar';
import { Timeline } from 'primereact/timeline';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Dialog } from 'primereact/dialog';
import { Checkbox } from 'primereact/checkbox';
import { factureService } from '../../../../../services/api';
import { formatDate, formatCurrency } from '../../../../../utils/formatters';
import type { Facture } from '../../../../../types/btp';
interface Relance {
id: string;
type: 'EMAIL' | 'COURRIER' | 'TELEPHONE' | 'SMS';
niveau: number;
dateEnvoi: Date;
destinataire: string;
objet: string;
message: string;
statut: 'ENVOYEE' | 'LUE' | 'REPONDUE' | 'ECHEC';
reponse?: string;
dateReponse?: Date;
}
interface RelanceTemplate {
id: string;
nom: string;
type: 'EMAIL' | 'COURRIER' | 'TELEPHONE' | 'SMS';
niveau: number;
objet: string;
message: string;
delaiJours: number;
}
const FactureRelancePage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [facture, setFacture] = useState<Facture | null>(null);
const [relances, setRelances] = useState<Relance[]>([]);
const [templates, setTemplates] = useState<RelanceTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [showRelanceDialog, setShowRelanceDialog] = useState(false);
const [nouvelleRelance, setNouvelleRelance] = useState({
type: 'EMAIL' as 'EMAIL' | 'COURRIER' | 'TELEPHONE' | 'SMS',
destinataire: '',
objet: '',
message: '',
dateEnvoi: new Date(),
utiliserTemplate: false,
templateId: ''
});
const factureId = params.id as string;
const typeOptions = [
{ label: 'Email', value: 'EMAIL', icon: 'pi pi-envelope' },
{ label: 'Courrier', value: 'COURRIER', icon: 'pi pi-send' },
{ label: 'Téléphone', value: 'TELEPHONE', icon: 'pi pi-phone' },
{ label: 'SMS', value: 'SMS', icon: 'pi pi-mobile' }
];
useEffect(() => {
loadData();
}, [factureId]);
const loadData = async () => {
try {
setLoading(true);
// Charger la facture
const factureResponse = await factureService.getById(factureId);
setFacture(factureResponse.data);
// TODO: Charger les relances et templates depuis l'API
// const relancesResponse = await factureService.getRelances(factureId);
// const templatesResponse = await factureService.getRelanceTemplates();
// Données simulées pour la démonstration
const mockRelances: Relance[] = [
{
id: '1',
type: 'EMAIL',
niveau: 1,
dateEnvoi: new Date('2024-03-01'),
destinataire: 'client@example.com',
objet: 'Rappel - Facture en attente de paiement',
message: 'Nous vous rappelons que votre facture est en attente de paiement...',
statut: 'LUE'
},
{
id: '2',
type: 'TELEPHONE',
niveau: 2,
dateEnvoi: new Date('2024-03-15'),
destinataire: '01 23 45 67 89',
objet: 'Appel de relance',
message: 'Appel téléphonique pour relance de paiement',
statut: 'REPONDUE',
reponse: 'Client confirme le paiement sous 48h',
dateReponse: new Date('2024-03-15')
}
];
const mockTemplates: RelanceTemplate[] = [
{
id: '1',
nom: 'Première relance aimable',
type: 'EMAIL',
niveau: 1,
objet: 'Rappel - Facture #{numero} en attente de paiement',
message: 'Madame, Monsieur,\n\nNous vous rappelons que votre facture #{numero} d\'un montant de {montant} est en attente de paiement depuis le {dateEcheance}.\n\nMerci de bien vouloir régulariser cette situation dans les meilleurs délais.\n\nCordialement,',
delaiJours: 7
},
{
id: '2',
nom: 'Relance ferme',
type: 'COURRIER',
niveau: 2,
objet: 'Mise en demeure - Facture #{numero}',
message: 'Madame, Monsieur,\n\nMalgré notre précédent rappel, votre facture #{numero} d\'un montant de {montant} demeure impayée.\n\nNous vous mettons en demeure de procéder au règlement sous 8 jours, faute de quoi nous nous verrons contraints d\'engager des poursuites.\n\nCordialement,',
delaiJours: 15
}
];
setRelances(mockRelances);
setTemplates(mockTemplates);
// Pré-remplir le destinataire
if (factureResponse.data.client) {
const client = factureResponse.data.client;
setNouvelleRelance(prev => ({
...prev,
destinataire: typeof client === 'string' ? client : client.email || client.nom
}));
}
} catch (error) {
console.error('Erreur lors du chargement:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les données'
});
} finally {
setLoading(false);
}
};
const handleTemplateChange = (templateId: string) => {
const template = templates.find(t => t.id === templateId);
if (template && facture) {
setNouvelleRelance(prev => ({
...prev,
templateId,
type: template.type,
objet: template.objet
.replace('{numero}', facture.numero)
.replace('{montant}', formatCurrency(facture.montantTTC)),
message: template.message
.replace('{numero}', facture.numero)
.replace('{montant}', formatCurrency(facture.montantTTC))
.replace('{dateEcheance}', formatDate(facture.dateEcheance))
}));
}
};
const handleSendRelance = async () => {
try {
setSending(true);
if (!nouvelleRelance.destinataire || !nouvelleRelance.objet || !nouvelleRelance.message) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez remplir tous les champs obligatoires'
});
return;
}
// TODO: Appel API pour envoyer la relance
// await factureService.sendRelance(factureId, nouvelleRelance);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Relance envoyée avec succès'
});
setShowRelanceDialog(false);
loadData();
} catch (error) {
console.error('Erreur lors de l\'envoi:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de l\'envoi de la relance'
});
} finally {
setSending(false);
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'ENVOYEE': return 'info';
case 'LUE': return 'warning';
case 'REPONDUE': return 'success';
case 'ECHEC': return 'danger';
default: return 'info';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'EMAIL': return 'pi pi-envelope';
case 'COURRIER': return 'pi pi-send';
case 'TELEPHONE': return 'pi pi-phone';
case 'SMS': return 'pi pi-mobile';
default: return 'pi pi-circle';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'EMAIL': return '#3B82F6';
case 'COURRIER': return '#8B5CF6';
case 'TELEPHONE': return '#10B981';
case 'SMS': return '#F59E0B';
default: return '#6B7280';
}
};
const toolbarStartTemplate = () => (
<div className="flex align-items-center gap-2">
<Button
icon="pi pi-arrow-left"
label="Retour"
className="p-button-outlined"
onClick={() => router.push(`/factures/${factureId}`)}
/>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
<Button
label="Nouvelle relance"
icon="pi pi-plus"
onClick={() => setShowRelanceDialog(true)}
disabled={!facture || facture.statut === 'PAYEE'}
/>
</div>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
if (!facture) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<div className="text-center">
<i className="pi pi-exclamation-triangle text-6xl text-orange-500 mb-3"></i>
<h3>Facture introuvable</h3>
<p className="text-600 mb-4">La facture demandée n'existe pas</p>
<Button
label="Retour à la liste"
icon="pi pi-arrow-left"
onClick={() => router.push('/factures')}
/>
</div>
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* Informations de la facture */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-start mb-4">
<div>
<h2 className="text-2xl font-bold mb-2">Relances - Facture #{facture.numero}</h2>
<p className="text-600 mb-3">{facture.objet}</p>
<Tag
value={facture.statut}
severity={facture.statut === 'EN_RETARD' ? 'danger' : 'warning'}
/>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-red-500 mb-2">
{formatCurrency(facture.montantTTC - (facture.montantPaye || 0))}
</div>
<div className="text-sm text-600">
Montant en retard
</div>
<div className="text-sm text-600">
Échéance: {formatDate(facture.dateEcheance)}
</div>
<div className="text-sm font-semibold text-red-600">
Retard: {Math.ceil((new Date().getTime() - new Date(facture.dateEcheance).getTime()) / (1000 * 60 * 60 * 24))} jours
</div>
</div>
</div>
</Card>
</div>
{/* Historique des relances */}
<div className="col-12 lg:col-8">
<Card title="Historique des relances">
{relances.length > 0 ? (
<Timeline
value={relances}
opposite={(item) => (
<div className="text-right">
<div className="text-sm font-semibold">{formatDate(item.dateEnvoi)}</div>
<div className="text-xs text-600">{item.destinataire}</div>
</div>
)}
content={(item) => (
<div className="flex align-items-start">
<div className="flex-1">
<div className="flex align-items-center mb-2">
<Badge
value={`Niveau ${item.niveau}`}
severity="info"
className="mr-2"
/>
<Tag
value={item.type}
style={{ backgroundColor: getTypeColor(item.type) }}
className="mr-2"
/>
<Tag
value={item.statut}
severity={getStatutSeverity(item.statut)}
/>
</div>
<div className="font-semibold mb-1">{item.objet}</div>
<div className="text-sm text-600 mb-2 line-height-3">
{item.message.length > 100
? `${item.message.substring(0, 100)}...`
: item.message
}
</div>
{item.reponse && (
<div className="p-2 border-round bg-green-50 border-left-3 border-green-500">
<div className="text-sm font-semibold text-green-900 mb-1">
Réponse ({formatDate(item.dateReponse!)})
</div>
<div className="text-sm text-green-800">{item.reponse}</div>
</div>
)}
</div>
</div>
)}
marker={(item) => (
<span
className={`flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1 shadow-1`}
style={{ backgroundColor: getTypeColor(item.type) }}
>
<i className={getTypeIcon(item.type)}></i>
</span>
)}
/>
) : (
<div className="text-center p-4">
<i className="pi pi-inbox text-4xl text-400 mb-3"></i>
<p className="text-600">Aucune relance envoyée pour cette facture</p>
<Button
label="Envoyer la première relance"
icon="pi pi-plus"
onClick={() => setShowRelanceDialog(true)}
/>
</div>
)}
</Card>
</div>
{/* Statistiques et actions */}
<div className="col-12 lg:col-4">
<Card title="Statistiques">
<div className="grid">
<div className="col-12">
<div className="text-center p-3 border-round bg-blue-50">
<div className="text-blue-600 font-bold text-xl mb-2">
{relances.length}
</div>
<div className="text-blue-900 font-semibold">Relances envoyées</div>
</div>
</div>
<div className="col-12">
<div className="text-center p-3 border-round bg-orange-50">
<div className="text-orange-600 font-bold text-xl mb-2">
{relances.filter(r => r.statut === 'REPONDUE').length}
</div>
<div className="text-orange-900 font-semibold">Réponses reçues</div>
</div>
</div>
<div className="col-12">
<div className="text-center p-3 border-round bg-red-50">
<div className="text-red-600 font-bold text-xl mb-2">
{Math.ceil((new Date().getTime() - new Date(facture.dateEcheance).getTime()) / (1000 * 60 * 60 * 24))}
</div>
<div className="text-red-900 font-semibold">Jours de retard</div>
</div>
</div>
</div>
<div className="mt-4">
<h6>Actions recommandées</h6>
<div className="flex flex-column gap-2">
<Button
label="Appel téléphonique"
icon="pi pi-phone"
className="p-button-outlined p-button-sm"
onClick={() => {
setNouvelleRelance(prev => ({ ...prev, type: 'TELEPHONE' }));
setShowRelanceDialog(true);
}}
/>
<Button
label="Mise en demeure"
icon="pi pi-exclamation-triangle"
className="p-button-outlined p-button-sm p-button-warning"
onClick={() => {
setNouvelleRelance(prev => ({
...prev,
type: 'COURRIER',
utiliserTemplate: true,
templateId: '2'
}));
handleTemplateChange('2');
setShowRelanceDialog(true);
}}
/>
</div>
</div>
</Card>
</div>
{/* Dialog de nouvelle relance */}
<Dialog
header="Nouvelle relance"
visible={showRelanceDialog}
onHide={() => setShowRelanceDialog(false)}
style={{ width: '800px' }}
footer={
<div className="flex justify-content-end gap-2">
<Button
label="Annuler"
icon="pi pi-times"
className="p-button-outlined"
onClick={() => setShowRelanceDialog(false)}
/>
<Button
label="Envoyer"
icon="pi pi-send"
onClick={handleSendRelance}
loading={sending}
/>
</div>
}
>
<div className="grid">
<div className="col-12">
<div className="flex align-items-center mb-3">
<Checkbox
inputId="utiliserTemplate"
checked={nouvelleRelance.utiliserTemplate}
onChange={(e) => setNouvelleRelance(prev => ({
...prev,
utiliserTemplate: e.checked || false,
templateId: e.checked ? templates[0]?.id || '' : ''
}))}
/>
<label htmlFor="utiliserTemplate" className="ml-2">Utiliser un template</label>
</div>
</div>
{nouvelleRelance.utiliserTemplate && (
<div className="col-12">
<div className="field">
<label htmlFor="template" className="font-semibold">Template</label>
<Dropdown
id="template"
value={nouvelleRelance.templateId}
options={templates.map(t => ({ label: t.nom, value: t.id }))}
onChange={(e) => handleTemplateChange(e.value)}
className="w-full"
placeholder="Sélectionner un template"
/>
</div>
</div>
)}
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="type" className="font-semibold">Type de relance *</label>
<Dropdown
id="type"
value={nouvelleRelance.type}
options={typeOptions}
onChange={(e) => setNouvelleRelance(prev => ({ ...prev, type: e.value }))}
className="w-full"
itemTemplate={(option) => (
<div className="flex align-items-center">
<i className={`${option.icon} mr-2`}></i>
{option.label}
</div>
)}
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="dateEnvoi" className="font-semibold">Date d'envoi</label>
<Calendar
id="dateEnvoi"
value={nouvelleRelance.dateEnvoi}
onChange={(e) => setNouvelleRelance(prev => ({ ...prev, dateEnvoi: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
showTime
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="destinataire" className="font-semibold">Destinataire *</label>
<InputTextarea
id="destinataire"
value={nouvelleRelance.destinataire}
onChange={(e) => setNouvelleRelance(prev => ({ ...prev, destinataire: e.target.value }))}
className="w-full"
rows={1}
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="objet" className="font-semibold">Objet *</label>
<InputTextarea
id="objet"
value={nouvelleRelance.objet}
onChange={(e) => setNouvelleRelance(prev => ({ ...prev, objet: e.target.value }))}
className="w-full"
rows={2}
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="message" className="font-semibold">Message *</label>
<InputTextarea
id="message"
value={nouvelleRelance.message}
onChange={(e) => setNouvelleRelance(prev => ({ ...prev, message: e.target.value }))}
className="w-full"
rows={8}
/>
</div>
</div>
</div>
</Dialog>
</div>
);
};
export default FactureRelancePage;

View File

@@ -0,0 +1,684 @@
'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;