604 lines
26 KiB
TypeScript
604 lines
26 KiB
TypeScript
'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;
|