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,474 @@
'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 { InputText } from 'primereact/inputtext';
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 { Divider } from 'primereact/divider';
import { Checkbox } from 'primereact/checkbox';
import { factureService, clientService } from '../../../../../services/api';
import { formatCurrency } from '../../../../../utils/formatters';
import type { Facture, Client } from '../../../../../types/btp';
const FactureDuplicatePage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [originalFacture, setOriginalFacture] = useState<Facture | null>(null);
const [newFacture, setNewFacture] = useState<Partial<Facture>>({
numero: '',
objet: '',
description: '',
type: 'FACTURE',
statut: 'BROUILLON',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 jours
tauxTVA: 20,
lignes: []
});
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [copyLignes, setCopyLignes] = useState(true);
const [copyClient, setCopyClient] = useState(true);
const factureId = params.id as string;
const typeOptions = [
{ label: 'Facture', value: 'FACTURE' },
{ label: 'Acompte', value: 'ACOMPTE' },
{ label: 'Facture de situation', value: 'SITUATION' },
{ label: 'Facture de solde', value: 'SOLDE' }
];
useEffect(() => {
loadData();
}, [factureId]);
useEffect(() => {
if (originalFacture) {
updateNewFacture();
}
}, [originalFacture, copyLignes, copyClient]);
const loadData = async () => {
try {
setLoading(true);
// Charger la facture originale
const factureResponse = await factureService.getById(factureId);
setOriginalFacture(factureResponse.data);
// Charger les clients
const clientsResponse = await clientService.getAll();
setClients(clientsResponse.data);
// Générer un nouveau numéro
const numeroResponse = await factureService.generateNumero();
setNewFacture(prev => ({ ...prev, numero: numeroResponse.data.numero }));
} catch (error) {
console.error('Erreur lors du chargement:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger la facture'
});
} finally {
setLoading(false);
}
};
const updateNewFacture = () => {
if (!originalFacture) return;
setNewFacture(prev => ({
...prev,
objet: `${originalFacture.objet} (Copie)`,
description: originalFacture.description,
type: originalFacture.type,
tauxTVA: originalFacture.tauxTVA,
client: copyClient ? originalFacture.client : undefined,
lignes: copyLignes ? [...(originalFacture.lignes || [])] : [],
montantHT: copyLignes ? originalFacture.montantHT : 0,
montantTTC: copyLignes ? originalFacture.montantTTC : 0
}));
};
const handleSave = async () => {
try {
setSaving(true);
if (!newFacture.numero || !newFacture.objet || !newFacture.client) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez remplir tous les champs obligatoires'
});
return;
}
await factureService.create(newFacture);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture dupliquée avec succès'
});
router.push('/factures');
} catch (error) {
console.error('Erreur lors de la duplication:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la duplication'
});
} finally {
setSaving(false);
}
};
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="Annuler"
icon="pi pi-times"
className="p-button-outlined"
onClick={() => router.push(`/factures/${factureId}`)}
/>
<Button
label="Créer la copie"
icon="pi pi-copy"
onClick={handleSave}
loading={saving}
/>
</div>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
if (!originalFacture) {
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 à dupliquer 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>
<div className="col-12">
<Card title="Dupliquer la facture">
<div className="grid">
{/* Options de duplication */}
<div className="col-12">
<h5>Options de duplication</h5>
<div className="flex gap-4 mb-4">
<div className="flex align-items-center">
<Checkbox
inputId="copyClient"
checked={copyClient}
onChange={(e) => setCopyClient(e.checked || false)}
/>
<label htmlFor="copyClient" className="ml-2">Copier le client</label>
</div>
<div className="flex align-items-center">
<Checkbox
inputId="copyLignes"
checked={copyLignes}
onChange={(e) => setCopyLignes(e.checked || false)}
/>
<label htmlFor="copyLignes" className="ml-2">Copier les lignes de facturation</label>
</div>
</div>
</div>
</div>
</Card>
</div>
{/* Comparaison côte à côte */}
<div className="col-12 lg:col-6">
<Card title="Facture originale" className="h-full">
<div className="mb-3">
<strong>Numéro:</strong> {originalFacture.numero}
</div>
<div className="mb-3">
<strong>Objet:</strong> {originalFacture.objet}
</div>
<div className="mb-3">
<strong>Type:</strong> {originalFacture.type}
</div>
<div className="mb-3">
<strong>Client:</strong> {typeof originalFacture.client === 'string' ? originalFacture.client : originalFacture.client?.nom}
</div>
<div className="mb-3">
<strong>Montant TTC:</strong> {formatCurrency(originalFacture.montantTTC)}
</div>
<div className="mb-3">
<strong>Nombre de lignes:</strong> {originalFacture.lignes?.length || 0}
</div>
{originalFacture.description && (
<div className="mb-3">
<strong>Description:</strong>
<p className="mt-1 text-600">{originalFacture.description}</p>
</div>
)}
</Card>
</div>
<div className="col-12 lg:col-6">
<Card title="Nouvelle facture" className="h-full">
<div className="grid">
<div className="col-12">
<div className="field">
<label htmlFor="numero" className="font-semibold">Numéro *</label>
<InputText
id="numero"
value={newFacture.numero}
onChange={(e) => setNewFacture(prev => ({ ...prev, numero: e.target.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="objet" className="font-semibold">Objet *</label>
<InputText
id="objet"
value={newFacture.objet}
onChange={(e) => setNewFacture(prev => ({ ...prev, objet: e.target.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="type" className="font-semibold">Type *</label>
<Dropdown
id="type"
value={newFacture.type}
options={typeOptions}
onChange={(e) => setNewFacture(prev => ({ ...prev, type: e.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="client" className="font-semibold">Client *</label>
<Dropdown
id="client"
value={newFacture.client}
options={clients.map(client => ({ label: client.nom, value: client }))}
onChange={(e) => setNewFacture(prev => ({ ...prev, client: e.value }))}
className="w-full"
placeholder="Sélectionner un client"
filter
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="dateEmission" className="font-semibold">Date d'émission *</label>
<Calendar
id="dateEmission"
value={newFacture.dateEmission}
onChange={(e) => setNewFacture(prev => ({ ...prev, dateEmission: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="dateEcheance" className="font-semibold">Date d'échéance *</label>
<Calendar
id="dateEcheance"
value={newFacture.dateEcheance}
onChange={(e) => setNewFacture(prev => ({ ...prev, dateEcheance: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="description" className="font-semibold">Description</label>
<InputTextarea
id="description"
value={newFacture.description}
onChange={(e) => setNewFacture(prev => ({ ...prev, description: e.target.value }))}
className="w-full"
rows={3}
/>
</div>
</div>
</div>
</Card>
</div>
{/* Aperçu des lignes copiées */}
{copyLignes && newFacture.lignes && newFacture.lignes.length > 0 && (
<div className="col-12">
<Card title="Lignes de facturation copiées">
<DataTable value={newFacture.lignes} responsiveLayout="scroll">
<Column field="designation" header="Désignation" />
<Column
field="quantite"
header="Quantité"
style={{ width: '100px' }}
body={(rowData) => rowData.quantite?.toLocaleString('fr-FR')}
/>
<Column field="unite" header="Unité" style={{ width: '80px' }} />
<Column
field="prixUnitaire"
header="Prix unitaire"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.prixUnitaire)}
/>
<Column
field="montantHT"
header="Montant HT"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montantHT)}
/>
</DataTable>
<Divider />
<div className="flex justify-content-end">
<div className="w-20rem">
<div className="flex justify-content-between mb-2">
<span>Montant HT:</span>
<span className="font-semibold">{formatCurrency(newFacture.montantHT || 0)}</span>
</div>
<div className="flex justify-content-between mb-2">
<span>TVA ({newFacture.tauxTVA}%):</span>
<span className="font-semibold">
{formatCurrency(((newFacture.montantHT || 0) * (newFacture.tauxTVA || 0)) / 100)}
</span>
</div>
<Divider />
<div className="flex justify-content-between">
<span className="font-bold">Montant TTC:</span>
<span className="font-bold text-primary text-xl">
{formatCurrency(newFacture.montantTTC || 0)}
</span>
</div>
</div>
</div>
</Card>
</div>
)}
{/* Résumé de la duplication */}
<div className="col-12">
<Card>
<div className="grid">
<div className="col-12 md:col-4">
<div className="p-3 border-round bg-blue-50">
<h6 className="text-blue-900 mb-2">
<i className="pi pi-info-circle mr-2"></i>
Informations copiées
</h6>
<ul className="text-sm text-blue-800 list-none p-0 m-0">
<li className="mb-1">• Objet (modifié)</li>
<li className="mb-1">• Type de facture</li>
<li className="mb-1">• Taux de TVA</li>
<li className="mb-1">• Description</li>
{copyClient && <li className="mb-1">• Client</li>}
{copyLignes && <li className="mb-1">• Lignes de facturation</li>}
</ul>
</div>
</div>
<div className="col-12 md:col-4">
<div className="p-3 border-round bg-green-50">
<h6 className="text-green-900 mb-2">
<i className="pi pi-check mr-2"></i>
Nouvelles valeurs
</h6>
<ul className="text-sm text-green-800 list-none p-0 m-0">
<li className="mb-1">• Nouveau numéro généré</li>
<li className="mb-1">• Statut: Brouillon</li>
<li className="mb-1">• Date d'émission: Aujourd'hui</li>
<li className="mb-1">• Date d'échéance: +30 jours</li>
<li className="mb-1"> Montants payés: Remis à zéro</li>
</ul>
</div>
</div>
<div className="col-12 md:col-4">
<div className="p-3 border-round bg-orange-50">
<h6 className="text-orange-900 mb-2">
<i className="pi pi-exclamation-triangle mr-2"></i>
À vérifier
</h6>
<ul className="text-sm text-orange-800 list-none p-0 m-0">
<li className="mb-1"> Objet de la facture</li>
<li className="mb-1"> Client sélectionné</li>
<li className="mb-1"> Dates d'émission et d'échéance</li>
<li className="mb-1"> Lignes de facturation</li>
<li className="mb-1"> Montants calculés</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
</div>
);
};
export default FactureDuplicatePage;

View File

@@ -0,0 +1,588 @@
'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 { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber';
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 { Dialog } from 'primereact/dialog';
import { Divider } from 'primereact/divider';
import { factureService, clientService } from '../../../../../services/api';
import { formatCurrency } from '../../../../../utils/formatters';
import type { Facture, LigneFacture, Client } from '../../../../../types/btp';
const FactureEditPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [facture, setFacture] = useState<Partial<Facture>>({
numero: '',
objet: '',
description: '',
type: 'FACTURE',
statut: 'BROUILLON',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 jours
tauxTVA: 20,
lignes: []
});
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showLigneDialog, setShowLigneDialog] = useState(false);
const [editingLigne, setEditingLigne] = useState<LigneFacture | null>(null);
const [editingIndex, setEditingIndex] = useState<number>(-1);
const [ligneForm, setLigneForm] = useState<Partial<LigneFacture>>({
designation: '',
quantite: 1,
unite: 'unité',
prixUnitaire: 0,
montantHT: 0
});
const factureId = params.id as string;
const isNew = factureId === 'nouveau';
const typeOptions = [
{ label: 'Facture', value: 'FACTURE' },
{ label: 'Acompte', value: 'ACOMPTE' },
{ label: 'Facture de situation', value: 'SITUATION' },
{ label: 'Facture de solde', value: 'SOLDE' }
];
const statutOptions = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'Envoyée', value: 'ENVOYEE' },
{ label: 'Payée', value: 'PAYEE' },
{ label: 'Partiellement payée', value: 'PARTIELLEMENT_PAYEE' },
{ label: 'En retard', value: 'EN_RETARD' }
];
const uniteOptions = [
{ label: 'Unité', value: 'unité' },
{ label: 'Mètre', value: 'm' },
{ label: 'Mètre carré', value: 'm²' },
{ label: 'Mètre cube', value: 'm³' },
{ label: 'Heure', value: 'h' },
{ label: 'Jour', value: 'jour' },
{ label: 'Forfait', value: 'forfait' },
{ label: 'Kilogramme', value: 'kg' },
{ label: 'Tonne', value: 't' }
];
useEffect(() => {
loadData();
}, [factureId]);
useEffect(() => {
calculateMontants();
}, [facture.lignes, facture.tauxTVA]);
const loadData = async () => {
try {
setLoading(true);
// Charger les clients
const clientsResponse = await clientService.getAll();
setClients(clientsResponse.data);
if (!isNew) {
// Charger la facture existante
const factureResponse = await factureService.getById(factureId);
setFacture(factureResponse.data);
} else {
// Générer un nouveau numéro
const numeroResponse = await factureService.generateNumero();
setFacture(prev => ({ ...prev, numero: numeroResponse.data.numero }));
}
} 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 calculateMontants = () => {
if (!facture.lignes) return;
const montantHT = facture.lignes.reduce((total, ligne) => total + (ligne.montantHT || 0), 0);
const montantTVA = montantHT * (facture.tauxTVA || 0) / 100;
const montantTTC = montantHT + montantTVA;
setFacture(prev => ({
...prev,
montantHT,
montantTTC
}));
};
const handleSave = async () => {
try {
setSaving(true);
if (!facture.numero || !facture.objet || !facture.client) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez remplir tous les champs obligatoires'
});
return;
}
if (isNew) {
await factureService.create(facture);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture créée avec succès'
});
} else {
await factureService.update(factureId, facture);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture modifiée avec succès'
});
}
router.push('/factures');
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la sauvegarde'
});
} finally {
setSaving(false);
}
};
const handleAddLigne = () => {
setEditingLigne(null);
setEditingIndex(-1);
setLigneForm({
designation: '',
quantite: 1,
unite: 'unité',
prixUnitaire: 0,
montantHT: 0
});
setShowLigneDialog(true);
};
const handleEditLigne = (ligne: LigneFacture, index: number) => {
setEditingLigne(ligne);
setEditingIndex(index);
setLigneForm({ ...ligne });
setShowLigneDialog(true);
};
const handleSaveLigne = () => {
if (!ligneForm.designation || !ligneForm.quantite || !ligneForm.prixUnitaire) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez remplir tous les champs de la ligne'
});
return;
}
const montantHT = (ligneForm.quantite || 0) * (ligneForm.prixUnitaire || 0);
const nouvelleLigne: LigneFacture = {
...ligneForm,
montantHT
} as LigneFacture;
const nouvellesLignes = [...(facture.lignes || [])];
if (editingIndex >= 0) {
nouvellesLignes[editingIndex] = nouvelleLigne;
} else {
nouvellesLignes.push(nouvelleLigne);
}
setFacture(prev => ({ ...prev, lignes: nouvellesLignes }));
setShowLigneDialog(false);
};
const handleDeleteLigne = (index: number) => {
const nouvellesLignes = facture.lignes?.filter((_, i) => i !== index) || [];
setFacture(prev => ({ ...prev, lignes: nouvellesLignes }));
};
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')}
/>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
<Button
label="Annuler"
icon="pi pi-times"
className="p-button-outlined"
onClick={() => router.push('/factures')}
/>
<Button
label="Enregistrer"
icon="pi pi-save"
onClick={handleSave}
loading={saving}
/>
</div>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
<div className="col-12">
<Card title={isNew ? "Nouvelle facture" : "Modifier la facture"}>
<div className="grid">
{/* Informations générales */}
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="numero" className="font-semibold">Numéro *</label>
<InputText
id="numero"
value={facture.numero}
onChange={(e) => setFacture(prev => ({ ...prev, numero: e.target.value }))}
className="w-full"
disabled={!isNew}
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="type" className="font-semibold">Type *</label>
<Dropdown
id="type"
value={facture.type}
options={typeOptions}
onChange={(e) => setFacture(prev => ({ ...prev, type: e.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="objet" className="font-semibold">Objet *</label>
<InputText
id="objet"
value={facture.objet}
onChange={(e) => setFacture(prev => ({ ...prev, objet: e.target.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="description" className="font-semibold">Description</label>
<InputTextarea
id="description"
value={facture.description}
onChange={(e) => setFacture(prev => ({ ...prev, description: e.target.value }))}
className="w-full"
rows={3}
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="client" className="font-semibold">Client *</label>
<Dropdown
id="client"
value={facture.client}
options={clients.map(client => ({ label: client.nom, value: client }))}
onChange={(e) => setFacture(prev => ({ ...prev, client: e.value }))}
className="w-full"
placeholder="Sélectionner un client"
filter
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="statut" className="font-semibold">Statut</label>
<Dropdown
id="statut"
value={facture.statut}
options={statutOptions}
onChange={(e) => setFacture(prev => ({ ...prev, statut: e.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="dateEmission" className="font-semibold">Date d'émission *</label>
<Calendar
id="dateEmission"
value={facture.dateEmission}
onChange={(e) => setFacture(prev => ({ ...prev, dateEmission: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="dateEcheance" className="font-semibold">Date d'échéance *</label>
<Calendar
id="dateEcheance"
value={facture.dateEcheance}
onChange={(e) => setFacture(prev => ({ ...prev, dateEcheance: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="tauxTVA" className="font-semibold">Taux TVA (%)</label>
<InputNumber
id="tauxTVA"
value={facture.tauxTVA}
onValueChange={(e) => setFacture(prev => ({ ...prev, tauxTVA: e.value || 0 }))}
className="w-full"
suffix="%"
min={0}
max={100}
/>
</div>
</div>
</div>
</Card>
</div>
{/* Lignes de la facture */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h5 className="m-0">Lignes de facturation</h5>
<Button
label="Ajouter une ligne"
icon="pi pi-plus"
onClick={handleAddLigne}
/>
</div>
<DataTable
value={facture.lignes || []}
responsiveLayout="scroll"
emptyMessage="Aucune ligne de facturation"
>
<Column field="designation" header="Désignation" />
<Column
field="quantite"
header="Quantité"
style={{ width: '100px' }}
body={(rowData) => rowData.quantite?.toLocaleString('fr-FR')}
/>
<Column field="unite" header="Unité" style={{ width: '80px' }} />
<Column
field="prixUnitaire"
header="Prix unitaire"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.prixUnitaire)}
/>
<Column
field="montantHT"
header="Montant HT"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montantHT)}
/>
<Column
header="Actions"
style={{ width: '100px' }}
body={(rowData, options) => (
<div className="flex gap-1">
<Button
icon="pi pi-pencil"
className="p-button-text p-button-sm"
onClick={() => handleEditLigne(rowData, options.rowIndex)}
/>
<Button
icon="pi pi-trash"
className="p-button-text p-button-sm p-button-danger"
onClick={() => handleDeleteLigne(options.rowIndex)}
/>
</div>
)}
/>
</DataTable>
<Divider />
{/* Totaux */}
<div className="flex justify-content-end">
<div className="w-20rem">
<div className="flex justify-content-between mb-2">
<span>Montant HT:</span>
<span className="font-semibold">{formatCurrency(facture.montantHT || 0)}</span>
</div>
<div className="flex justify-content-between mb-2">
<span>TVA ({facture.tauxTVA}%):</span>
<span className="font-semibold">
{formatCurrency(((facture.montantHT || 0) * (facture.tauxTVA || 0)) / 100)}
</span>
</div>
<Divider />
<div className="flex justify-content-between">
<span className="font-bold">Montant TTC:</span>
<span className="font-bold text-primary text-xl">
{formatCurrency(facture.montantTTC || 0)}
</span>
</div>
</div>
</div>
</Card>
</div>
{/* Dialog d'ajout/modification de ligne */}
<Dialog
header={editingLigne ? "Modifier la ligne" : "Ajouter une ligne"}
visible={showLigneDialog}
onHide={() => setShowLigneDialog(false)}
style={{ width: '600px' }}
footer={
<div className="flex justify-content-end gap-2">
<Button
label="Annuler"
icon="pi pi-times"
className="p-button-outlined"
onClick={() => setShowLigneDialog(false)}
/>
<Button
label="Enregistrer"
icon="pi pi-save"
onClick={handleSaveLigne}
/>
</div>
}
>
<div className="grid">
<div className="col-12">
<div className="field">
<label htmlFor="designation" className="font-semibold">Désignation *</label>
<InputText
id="designation"
value={ligneForm.designation}
onChange={(e) => setLigneForm(prev => ({ ...prev, designation: e.target.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="quantite" className="font-semibold">Quantité *</label>
<InputNumber
id="quantite"
value={ligneForm.quantite}
onValueChange={(e) => {
const quantite = e.value || 0;
const montantHT = quantite * (ligneForm.prixUnitaire || 0);
setLigneForm(prev => ({ ...prev, quantite, montantHT }));
}}
className="w-full"
min={0}
maxFractionDigits={2}
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="unite" className="font-semibold">Unité</label>
<Dropdown
id="unite"
value={ligneForm.unite}
options={uniteOptions}
onChange={(e) => setLigneForm(prev => ({ ...prev, unite: e.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="prixUnitaire" className="font-semibold">Prix unitaire *</label>
<InputNumber
id="prixUnitaire"
value={ligneForm.prixUnitaire}
onValueChange={(e) => {
const prixUnitaire = e.value || 0;
const montantHT = (ligneForm.quantite || 0) * prixUnitaire;
setLigneForm(prev => ({ ...prev, prixUnitaire, montantHT }));
}}
className="w-full"
mode="currency"
currency="EUR"
locale="fr-FR"
min={0}
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label className="font-semibold">Montant HT</label>
<div className="text-xl font-bold text-primary">
{formatCurrency(ligneForm.montantHT || 0)}
</div>
</div>
</div>
</div>
</Dialog>
</div>
);
};
export default FactureEditPage;

View File

@@ -0,0 +1,464 @@
'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 { Tag } from 'primereact/tag';
import { Divider } from 'primereact/divider';
import { Toast } from 'primereact/toast';
import { ProgressSpinner } from 'primereact/progressspinner';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Timeline } from 'primereact/timeline';
import { Badge } from 'primereact/badge';
import { Toolbar } from 'primereact/toolbar';
import { Menu } from 'primereact/menu';
import { ProgressBar } from 'primereact/progressbar';
import { factureService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Facture } from '../../../../types/btp';
const FactureDetailPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const menuRef = useRef<Menu>(null);
const [facture, setFacture] = useState<Facture | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const factureId = params.id as string;
useEffect(() => {
loadFacture();
}, [factureId]);
const loadFacture = async () => {
try {
setLoading(true);
const response = await factureService.getById(factureId);
setFacture(response.data);
} catch (error) {
console.error('Erreur lors du chargement de la facture:', error);
setError('Impossible de charger la facture');
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger la facture'
});
} finally {
setLoading(false);
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'PAYEE': return 'success';
case 'EN_RETARD': return 'danger';
case 'PARTIELLEMENT_PAYEE': return 'warning';
case 'ENVOYEE': return 'info';
case 'BROUILLON': return 'secondary';
default: return 'info';
}
};
const getTypeSeverity = (type: string) => {
switch (type) {
case 'FACTURE': return 'primary';
case 'ACOMPTE': return 'info';
case 'SITUATION': return 'warning';
case 'SOLDE': return 'success';
default: return 'secondary';
}
};
const menuItems = [
{
label: 'Modifier',
icon: 'pi pi-pencil',
command: () => router.push(`/factures/${factureId}/edit`)
},
{
label: 'Dupliquer',
icon: 'pi pi-copy',
command: () => router.push(`/factures/${factureId}/duplicate`)
},
{
separator: true
},
{
label: 'Marquer comme payée',
icon: 'pi pi-check',
command: () => handleMarkAsPaid(),
disabled: facture?.statut === 'PAYEE'
},
{
label: 'Enregistrer paiement partiel',
icon: 'pi pi-money-bill',
command: () => handlePartialPayment()
},
{
separator: true
},
{
label: 'Imprimer',
icon: 'pi pi-print',
command: () => window.print()
},
{
label: 'Télécharger PDF',
icon: 'pi pi-download',
command: () => handleDownloadPDF()
},
{
label: 'Envoyer par email',
icon: 'pi pi-send',
command: () => handleSendEmail()
},
{
separator: true
},
{
label: 'Supprimer',
icon: 'pi pi-trash',
className: 'text-red-500',
command: () => handleDelete()
}
];
const handleMarkAsPaid = async () => {
try {
await factureService.updateStatut(factureId, 'PAYEE');
loadFacture();
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture marquée comme payée'
});
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la mise à jour'
});
}
};
const handlePartialPayment = () => {
// TODO: Ouvrir dialog pour paiement partiel
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Fonctionnalité de paiement partiel en cours de développement'
});
};
const handleDownloadPDF = async () => {
try {
// TODO: Implémenter le téléchargement PDF
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Téléchargement PDF en cours de développement'
});
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors du téléchargement'
});
}
};
const handleSendEmail = () => {
// TODO: Ouvrir dialog d'envoi email
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Envoi par email en cours de développement'
});
};
const handleDelete = async () => {
try {
await factureService.delete(factureId);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture supprimée avec succès'
});
router.push('/factures');
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la suppression'
});
}
};
const calculateProgress = () => {
if (!facture) return 0;
return (facture.montantPaye || 0) / facture.montantTTC * 100;
};
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')}
/>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
{facture?.statut !== 'PAYEE' && (
<Button
label="Marquer payée"
icon="pi pi-check"
className="p-button-success"
onClick={handleMarkAsPaid}
/>
)}
<Button
icon="pi pi-ellipsis-v"
className="p-button-text"
onClick={(e) => menuRef.current?.toggle(e)}
/>
</div>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
if (error || !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">{error || '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} />
<Menu ref={menuRef} model={menuItems} popup />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* En-tête 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">Facture #{facture.numero}</h2>
<p className="text-600 mb-3">{facture.objet}</p>
<div className="flex gap-2 mb-2">
<Tag
value={facture.statut}
severity={getStatutSeverity(facture.statut)}
/>
<Tag
value={facture.type}
severity={getTypeSeverity(facture.type)}
/>
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary mb-2">
{formatCurrency(facture.montantTTC)}
</div>
<div className="text-sm text-600">
HT: {formatCurrency(facture.montantHT)}
</div>
{facture.montantPaye && facture.montantPaye > 0 && (
<div className="text-sm text-green-600 font-semibold">
Payé: {formatCurrency(facture.montantPaye)}
</div>
)}
</div>
</div>
{/* Barre de progression du paiement */}
{facture.montantPaye && facture.montantPaye > 0 && (
<div className="mb-4">
<div className="flex justify-content-between align-items-center mb-2">
<span className="font-semibold">Progression du paiement</span>
<span className="text-sm">{Math.round(calculateProgress())}%</span>
</div>
<ProgressBar
value={calculateProgress()}
className="mb-2"
style={{ height: '8px' }}
/>
<div className="flex justify-content-between text-sm text-600">
<span>Payé: {formatCurrency(facture.montantPaye)}</span>
<span>Restant: {formatCurrency(facture.montantTTC - facture.montantPaye)}</span>
</div>
</div>
)}
<div className="grid">
<div className="col-12 md:col-6">
<h5>Informations générales</h5>
<div className="field">
<label className="font-semibold">Date d'émission:</label>
<p>{formatDate(facture.dateEmission)}</p>
</div>
<div className="field">
<label className="font-semibold">Date d'échéance:</label>
<p className={new Date(facture.dateEcheance) < new Date() && facture.statut !== 'PAYEE' ? 'text-red-500 font-semibold' : ''}>
{formatDate(facture.dateEcheance)}
</p>
</div>
<div className="field">
<label className="font-semibold">Client:</label>
<p>{typeof facture.client === 'string' ? facture.client : facture.client?.nom}</p>
</div>
{facture.devisId && (
<div className="field">
<label className="font-semibold">Devis source:</label>
<p>
<Button
label={`Devis #${facture.devisId}`}
className="p-button-link p-0"
onClick={() => router.push(`/devis/${facture.devisId}`)}
/>
</p>
</div>
)}
</div>
<div className="col-12 md:col-6">
<h5>Détails financiers</h5>
<div className="field">
<label className="font-semibold">Montant HT:</label>
<p>{formatCurrency(facture.montantHT)}</p>
</div>
<div className="field">
<label className="font-semibold">TVA ({facture.tauxTVA}%):</label>
<p>{formatCurrency(facture.montantTTC - facture.montantHT)}</p>
</div>
<div className="field">
<label className="font-semibold">Montant TTC:</label>
<p className="text-xl font-bold text-primary">{formatCurrency(facture.montantTTC)}</p>
</div>
{facture.montantPaye && (
<div className="field">
<label className="font-semibold">Montant payé:</label>
<p className="text-green-600 font-semibold">{formatCurrency(facture.montantPaye)}</p>
</div>
)}
</div>
</div>
{facture.description && (
<>
<Divider />
<div>
<h5>Description</h5>
<p className="line-height-3">{facture.description}</p>
</div>
</>
)}
</Card>
</div>
{/* Lignes de la facture */}
{facture.lignes && facture.lignes.length > 0 && (
<div className="col-12">
<Card title="Détail des prestations">
<DataTable value={facture.lignes} responsiveLayout="scroll">
<Column field="designation" header="Désignation" />
<Column
field="quantite"
header="Quantité"
style={{ width: '100px' }}
body={(rowData) => rowData.quantite?.toLocaleString('fr-FR')}
/>
<Column field="unite" header="Unité" style={{ width: '80px' }} />
<Column
field="prixUnitaire"
header="Prix unitaire"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.prixUnitaire)}
/>
<Column
field="montantHT"
header="Montant HT"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montantHT)}
/>
</DataTable>
</Card>
</div>
)}
{/* Historique des paiements */}
<div className="col-12">
<Card title="Historique">
<Timeline
value={[
{
status: 'Créée',
date: facture.dateEmission,
icon: 'pi pi-plus',
color: '#9C27B0'
},
...(facture.statut === 'ENVOYEE' || facture.statut === 'PAYEE' ? [{
status: 'Envoyée',
date: new Date(),
icon: 'pi pi-send',
color: '#2196F3'
}] : []),
...(facture.montantPaye && facture.montantPaye > 0 ? [{
status: facture.statut === 'PAYEE' ? 'Payée intégralement' : 'Paiement partiel',
date: new Date(),
icon: 'pi pi-money-bill',
color: facture.statut === 'PAYEE' ? '#4CAF50' : '#FF9800',
details: `Montant: ${formatCurrency(facture.montantPaye)}`
}] : []),
...(new Date(facture.dateEcheance) < new Date() && facture.statut !== 'PAYEE' ? [{
status: 'Échue',
date: facture.dateEcheance,
icon: 'pi pi-exclamation-triangle',
color: '#F44336'
}] : [])
]}
opposite={(item) => formatDate(item.date)}
content={(item) => (
<div className="flex align-items-center">
<Badge value={item.status} style={{ backgroundColor: item.color }} />
{item.details && (
<div className="ml-2 text-sm text-600">{item.details}</div>
)}
</div>
)}
/>
</Card>
</div>
</div>
);
};
export default FactureDetailPage;

View File

@@ -0,0 +1,705 @@
'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 { InputNumber } from 'primereact/inputnumber';
import { Dropdown } from 'primereact/dropdown';
import { Chip } from 'primereact/chip';
import { Divider } from 'primereact/divider';
import { factureService, clientService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Facture } from '../../../../types/btp';
const FacturesAvoirsPage = () => {
const [avoirs, setAvoirs] = useState<Facture[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedAvoirs, setSelectedAvoirs] = useState<Facture[]>([]);
const [avoirDialog, setAvoirDialog] = useState(false);
const [selectedAvoir, setSelectedAvoir] = useState<Facture | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [avoir, setAvoir] = useState<Facture>({
id: '',
numero: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
datePaiement: null,
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'EMISE',
type: 'AVOIR',
actif: true,
client: null,
chantier: null,
devis: null
});
const [submitted, setSubmitted] = useState(false);
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Facture[]>>(null);
const avoirReasons = [
{ label: 'Erreur de facturation', value: 'ERREUR_FACTURATION' },
{ label: 'Retour de marchandise', value: 'RETOUR_MARCHANDISE' },
{ label: 'Annulation partielle', value: 'ANNULATION_PARTIELLE' },
{ label: 'Remise commerciale', value: 'REMISE_COMMERCIALE' },
{ label: 'Défaut de conformité', value: 'DEFAUT_CONFORMITE' },
{ label: 'Geste commercial', value: 'GESTE_COMMERCIAL' }
];
useEffect(() => {
loadAvoirs();
loadClients();
}, []);
const loadAvoirs = async () => {
try {
setLoading(true);
const data = await factureService.getAll();
// Filtrer les avoirs
const avoirsList = data.filter(facture => facture.type === 'AVOIR');
setAvoirs(avoirsList);
} catch (error) {
console.error('Erreur lors du chargement des avoirs:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les avoirs',
life: 3000
});
} finally {
setLoading(false);
}
};
const loadClients = async () => {
try {
const data = await clientService.getAll();
setClients(data.map(client => ({
label: `${client.prenom} ${client.nom}${client.entreprise ? ' - ' + client.entreprise : ''}`,
value: client.id,
client: client
})));
} catch (error) {
console.error('Erreur lors du chargement des clients:', error);
}
};
const getAvoirReason = (description: string) => {
// Simuler la détection de la raison à partir de la description
if (description.toLowerCase().includes('erreur')) return 'ERREUR_FACTURATION';
if (description.toLowerCase().includes('retour')) return 'RETOUR_MARCHANDISE';
if (description.toLowerCase().includes('annulation')) return 'ANNULATION_PARTIELLE';
if (description.toLowerCase().includes('remise')) return 'REMISE_COMMERCIALE';
if (description.toLowerCase().includes('défaut')) return 'DEFAUT_CONFORMITE';
return 'GESTE_COMMERCIAL';
};
const getReasonLabel = (reason: string) => {
const reasonObj = avoirReasons.find(r => r.value === reason);
return reasonObj ? reasonObj.label : 'Autre';
};
const getAvoirSeverity = (reason: string) => {
switch (reason) {
case 'ERREUR_FACTURATION':
case 'DEFAUT_CONFORMITE':
return 'danger';
case 'RETOUR_MARCHANDISE':
case 'ANNULATION_PARTIELLE':
return 'warning';
case 'REMISE_COMMERCIALE':
case 'GESTE_COMMERCIAL':
return 'info';
default:
return 'secondary';
}
};
const openNew = () => {
setAvoir({
id: '',
numero: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
datePaiement: null,
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'EMISE',
type: 'AVOIR',
actif: true,
client: null,
chantier: null,
devis: null
});
setSubmitted(false);
setIsCreating(true);
setAvoirDialog(true);
};
const viewDetails = (avoirItem: Facture) => {
setSelectedAvoir(avoirItem);
setIsCreating(false);
setAvoirDialog(true);
};
const hideDialog = () => {
setSubmitted(false);
setAvoirDialog(false);
setSelectedAvoir(null);
setIsCreating(false);
};
const saveAvoir = async () => {
setSubmitted(true);
if (avoir.objet.trim() && avoir.client && avoir.montantHT > 0) {
try {
const avoirToSave = {
...avoir,
client: avoir.client ? { id: avoir.client } : null,
montantTTC: avoir.montantHT * (1 + avoir.tauxTVA / 100),
numero: `AV-${new Date().getFullYear()}-${String(avoirs.length + 1).padStart(4, '0')}`
};
// TODO: Implémenter la création d'avoir quand l'API sera disponible
console.log('Création d\'avoir:', avoirToSave);
// Simulation
setAvoirs(prev => [...prev, { ...avoirToSave, id: Date.now().toString() }]);
setAvoirDialog(false);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Avoir créé avec succès (simulation)',
life: 3000
});
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de sauvegarder l\'avoir',
life: 3000
});
}
}
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const generateAvoirReport = () => {
const totalAvoirs = avoirs.reduce((sum, a) => sum + (a.montantTTC || 0), 0);
const reasonStats = {};
avoirs.forEach(a => {
const reason = getAvoirReason(a.description || '');
const label = getReasonLabel(reason);
if (!reasonStats[label]) {
reasonStats[label] = { count: 0, value: 0 };
}
reasonStats[label].count++;
reasonStats[label].value += a.montantTTC || 0;
});
const report = `
=== RAPPORT AVOIRS ===
Date du rapport: ${new Date().toLocaleDateString('fr-FR')}
STATISTIQUES GÉNÉRALES:
- Nombre total d'avoirs: ${avoirs.length}
- Montant total des avoirs: ${formatCurrency(totalAvoirs)}
- Montant moyen par avoir: ${formatCurrency(totalAvoirs / (avoirs.length || 1))}
RÉPARTITION PAR MOTIF:
${Object.entries(reasonStats)
.sort((a: [string, any], b: [string, any]) => b[1].value - a[1].value)
.map(([reason, data]: [string, any]) => `- ${reason}: ${data.count} avoirs, ${formatCurrency(data.value)} (${Math.round((data.value / totalAvoirs) * 100)}%)`)
.join('\n')}
ANALYSE PAR MOIS:
${getMonthlyAvoirBreakdown()}
CLIENTS AVEC LE PLUS D'AVOIRS:
${getClientAvoirAnalysis()}
IMPACT SUR LE CHIFFRE D'AFFAIRES:
- Pourcentage du CA en avoirs: ${getCAImpact()}%
- Tendance mensuelle: ${getTrend()}
RECOMMANDATIONS:
- Analyser les causes récurrentes d'avoirs
- Mettre en place des mesures préventives
- Former les équipes pour réduire les erreurs
- Améliorer les processus de contrôle qualité
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `rapport_avoirs_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
toast.current?.show({
severity: 'success',
summary: 'Rapport généré',
detail: 'Le rapport d\'avoirs a été téléchargé',
life: 3000
});
};
const getMonthlyAvoirBreakdown = () => {
const months = {};
avoirs.forEach(a => {
const month = new Date(a.dateEmission).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long' });
if (!months[month]) {
months[month] = { count: 0, value: 0 };
}
months[month].count++;
months[month].value += a.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} avoirs, ${formatCurrency(data.value)}`)
.join('\n');
};
const getClientAvoirAnalysis = () => {
const clientStats = {};
avoirs.forEach(a => {
if (a.client) {
const clientKey = `${a.client.prenom} ${a.client.nom}`;
if (!clientStats[clientKey]) {
clientStats[clientKey] = { count: 0, value: 0 };
}
clientStats[clientKey].count++;
clientStats[clientKey].value += a.montantTTC || 0;
}
});
return Object.entries(clientStats)
.sort((a: [string, any], b: [string, any]) => b[1].count - a[1].count)
.slice(0, 5)
.map(([client, data]: [string, any]) => `- ${client}: ${data.count} avoirs, ${formatCurrency(data.value)}`)
.join('\n');
};
const getCAImpact = () => {
// Simulation - calcul de l'impact sur le CA
const totalCA = 500000; // CA simulé
const totalAvoirs = avoirs.reduce((sum, a) => sum + (a.montantTTC || 0), 0);
return ((totalAvoirs / totalCA) * 100).toFixed(2);
};
const getTrend = () => {
// Simulation de tendance
const thisMonth = avoirs.filter(a => {
const avoirDate = new Date(a.dateEmission);
const now = new Date();
return avoirDate.getMonth() === now.getMonth() && avoirDate.getFullYear() === now.getFullYear();
}).length;
const lastMonth = avoirs.filter(a => {
const avoirDate = new Date(a.dateEmission);
const lastMonthDate = new Date();
lastMonthDate.setMonth(lastMonthDate.getMonth() - 1);
return avoirDate.getMonth() === lastMonthDate.getMonth() && avoirDate.getFullYear() === lastMonthDate.getFullYear();
}).length;
if (thisMonth > lastMonth) return 'En hausse';
if (thisMonth < lastMonth) return 'En baisse';
return 'Stable';
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _avoir = { ...avoir };
(_avoir as any)[name] = val;
setAvoir(_avoir);
};
const onNumberChange = (e: any, name: string) => {
let _avoir = { ...avoir };
(_avoir as any)[name] = e.value;
setAvoir(_avoir);
// Recalcul automatique du TTC
if (name === 'montantHT' || name === 'tauxTVA') {
const montantHT = name === 'montantHT' ? e.value : _avoir.montantHT;
const tauxTVA = name === 'tauxTVA' ? e.value : _avoir.tauxTVA;
_avoir.montantTTC = montantHT * (1 + tauxTVA / 100);
setAvoir(_avoir);
}
};
const onDropdownChange = (e: any, name: string) => {
let _avoir = { ...avoir };
(_avoir as any)[name] = e.value;
setAvoir(_avoir);
};
const leftToolbarTemplate = () => {
return (
<div className="my-2 flex gap-2">
<h5 className="m-0 flex align-items-center text-purple-600">
<i className="pi pi-minus-circle mr-2"></i>
Avoirs ({avoirs.length})
</h5>
<Chip
label={`Montant total: ${formatCurrency(avoirs.reduce((sum, a) => sum + (a.montantTTC || 0), 0))}`}
className="bg-purple-100 text-purple-800"
/>
<Button
label="Nouveau"
icon="pi pi-plus"
severity="success"
size="small"
onClick={openNew}
/>
<Button
label="Rapport d'analyse"
icon="pi pi-chart-pie"
severity="help"
size="small"
onClick={generateAvoirReport}
/>
</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"
onClick={() => viewDetails(rowData)}
/>
<Button
icon="pi pi-print"
rounded
severity="help"
size="small"
tooltip="Imprimer l'avoir"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Impression',
detail: `Impression de l'avoir ${rowData.numero}`,
life: 3000
});
}}
/>
<Button
icon="pi pi-send"
rounded
severity="secondary"
size="small"
tooltip="Envoyer au client"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Envoi',
detail: `Avoir ${rowData.numero} envoyé au client`,
life: 3000
});
}}
/>
</div>
);
};
const statusBodyTemplate = (rowData: Facture) => {
return (
<div className="flex align-items-center gap-2">
<Tag value="Avoir" severity="info" />
<Tag
value={rowData.statut === 'EMISE' ? 'Émis' : rowData.statut}
severity="success"
/>
</div>
);
};
const reasonBodyTemplate = (rowData: Facture) => {
const reason = getAvoirReason(rowData.description || '');
const label = getReasonLabel(reason);
const severity = getAvoirSeverity(reason);
return (
<Tag
value={label}
severity={severity as any}
className="text-xs"
/>
);
};
const clientBodyTemplate = (rowData: Facture) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Avoirs - Gestion des remboursements</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 avoirDialogFooter = (
<>
<Button label="Annuler" icon="pi pi-times" text onClick={hideDialog} />
{isCreating && (
<Button label="Créer l'avoir" icon="pi pi-check" text onClick={saveAvoir} />
)}
</>
);
return (
<div className="grid">
<div className="col-12">
<Card>
<Toast ref={toast} />
<Toolbar className="mb-4" left={leftToolbarTemplate} right={rightToolbarTemplate} />
<DataTable
ref={dt}
value={avoirs}
selection={selectedAvoirs}
onSelectionChange={(e) => setSelectedAvoirs(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} avoirs"
globalFilter={globalFilter}
emptyMessage="Aucun avoir trouvé."
header={header}
responsiveLayout="scroll"
loading={loading}
sortField="dateEmission"
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="reason" header="Motif" body={reasonBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column field="montantTTC" header="Montant TTC" body={(rowData) => formatCurrency(rowData.montantTTC)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="statut" header="Statut" body={statusBodyTemplate} headerStyle={{ minWidth: '10rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
</DataTable>
<Dialog
visible={avoirDialog}
style={{ width: '700px' }}
header={isCreating ? "Créer un avoir" : "Détails de l'avoir"}
modal
className="p-fluid"
footer={avoirDialogFooter}
onHide={hideDialog}
>
{isCreating ? (
<div className="formgrid grid">
<div className="field col-12">
<label htmlFor="client">Client *</label>
<Dropdown
id="client"
value={avoir.client}
options={clients}
onChange={(e) => onDropdownChange(e, 'client')}
placeholder="Sélectionnez un client"
required
className={submitted && !avoir.client ? 'p-invalid' : ''}
/>
{submitted && !avoir.client && <small className="p-invalid">Le client est requis.</small>}
</div>
<div className="field col-12">
<label htmlFor="objet">Objet *</label>
<InputText
id="objet"
value={avoir.objet}
onChange={(e) => onInputChange(e, 'objet')}
required
className={submitted && !avoir.objet ? 'p-invalid' : ''}
placeholder="Objet de l'avoir"
/>
{submitted && !avoir.objet && <small className="p-invalid">L'objet est requis.</small>}
</div>
<div className="field col-12">
<label htmlFor="description">Description / Motif</label>
<InputTextarea
id="description"
value={avoir.description}
onChange={(e) => onInputChange(e, 'description')}
rows={4}
placeholder="Décrivez la raison de l'avoir..."
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="montantHT">Montant HT (€) *</label>
<InputNumber
id="montantHT"
value={avoir.montantHT}
onValueChange={(e) => onNumberChange(e, 'montantHT')}
mode="currency"
currency="EUR"
locale="fr-FR"
className={submitted && avoir.montantHT <= 0 ? 'p-invalid' : ''}
/>
{submitted && avoir.montantHT <= 0 && <small className="p-invalid">Le montant est requis.</small>}
</div>
<div className="field col-12 md:col-4">
<label htmlFor="tauxTVA">Taux TVA (%)</label>
<InputNumber
id="tauxTVA"
value={avoir.tauxTVA}
onValueChange={(e) => onNumberChange(e, 'tauxTVA')}
suffix="%"
min={0}
max={100}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="montantTTC">Montant TTC (€)</label>
<InputNumber
id="montantTTC"
value={avoir.montantTTC}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
<div className="field col-12">
<label htmlFor="dateEmission">Date d'émission</label>
<Calendar
id="dateEmission"
value={avoir.dateEmission}
onChange={(e) => setAvoir(prev => ({ ...prev, dateEmission: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
</div>
) : selectedAvoir && (
<div className="formgrid grid">
<div className="field col-12">
<h6>Informations de l'avoir</h6>
<p><strong>Numéro:</strong> {selectedAvoir.numero}</p>
<p><strong>Objet:</strong> {selectedAvoir.objet}</p>
<p><strong>Client:</strong> {selectedAvoir.client ? `${selectedAvoir.client.prenom} ${selectedAvoir.client.nom}` : 'N/A'}</p>
<p><strong>Date d'émission:</strong> {formatDate(selectedAvoir.dateEmission)}</p>
<p><strong>Montant TTC:</strong> {formatCurrency(selectedAvoir.montantTTC || 0)}</p>
</div>
<Divider />
<div className="field col-12">
<h6>Motif de l'avoir</h6>
<div className="mb-2">
<Tag
value={getReasonLabel(getAvoirReason(selectedAvoir.description || ''))}
severity={getAvoirSeverity(getAvoirReason(selectedAvoir.description || '')) as any}
/>
</div>
<p><strong>Description:</strong></p>
<p className="text-700">{selectedAvoir.description || 'Aucune description'}</p>
</div>
<Divider />
<div className="field col-12">
<h6>Actions disponibles</h6>
<div className="flex gap-2">
<Button
label="Imprimer"
icon="pi pi-print"
size="small"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Impression',
detail: `Impression de l'avoir ${selectedAvoir.numero}`,
life: 3000
});
}}
/>
<Button
label="Envoyer"
icon="pi pi-send"
severity="secondary"
size="small"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Envoi',
detail: `Avoir ${selectedAvoir.numero} envoyé au client`,
life: 3000
});
}}
/>
</div>
</div>
</div>
)}
</Dialog>
</Card>
</div>
</div>
);
};
export default FacturesAvoirsPage;

View File

@@ -0,0 +1,582 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
import { MultiSelect } from 'primereact/multiselect';
import { Checkbox } from 'primereact/checkbox';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Toast } from 'primereact/toast';
import { ProgressBar } from 'primereact/progressbar';
import { Toolbar } from 'primereact/toolbar';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Divider } from 'primereact/divider';
import { factureService, clientService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Facture, Client } from '../../../../types/btp';
interface ExportConfig {
format: string;
dateDebut: Date;
dateFin: Date;
statuts: string[];
clients: Client[];
types: string[];
includeDetails: boolean;
includeStatistiques: boolean;
grouperParClient: boolean;
grouperParMois: boolean;
}
const FactureExportPage = () => {
const toast = useRef<Toast>(null);
const [factures, setFactures] = useState<Facture[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [config, setConfig] = useState<ExportConfig>({
format: 'EXCEL',
dateDebut: new Date(new Date().getFullYear(), 0, 1),
dateFin: new Date(),
statuts: [],
clients: [],
types: [],
includeDetails: true,
includeStatistiques: false,
grouperParClient: false,
grouperParMois: false
});
const formatOptions = [
{ label: 'Excel (.xlsx)', value: 'EXCEL', icon: 'pi pi-file-excel' },
{ label: 'PDF', value: 'PDF', icon: 'pi pi-file-pdf' },
{ label: 'CSV', value: 'CSV', icon: 'pi pi-file' },
{ label: 'JSON', value: 'JSON', icon: 'pi pi-code' }
];
const statutOptions = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'Envoyée', value: 'ENVOYEE' },
{ label: 'Payée', value: 'PAYEE' },
{ label: 'Partiellement payée', value: 'PARTIELLEMENT_PAYEE' },
{ label: 'En retard', value: 'EN_RETARD' }
];
const typeOptions = [
{ label: 'Facture', value: 'FACTURE' },
{ label: 'Acompte', value: 'ACOMPTE' },
{ label: 'Facture de situation', value: 'SITUATION' },
{ label: 'Facture de solde', value: 'SOLDE' }
];
useEffect(() => {
loadData();
}, []);
useEffect(() => {
loadFactures();
}, [config.dateDebut, config.dateFin, config.statuts, config.clients, config.types]);
const loadData = async () => {
try {
setLoading(true);
// Charger les clients
const clientsResponse = await clientService.getAll();
setClients(clientsResponse.data);
} 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 loadFactures = async () => {
try {
setLoading(true);
// TODO: Appel API avec filtres
// const response = await factureService.getFiltered({
// dateDebut: config.dateDebut,
// dateFin: config.dateFin,
// statuts: config.statuts,
// clients: config.clients.map(c => c.id),
// types: config.types
// });
// Données simulées pour la démonstration
const mockFactures: Facture[] = [
{
id: '1',
numero: 'FAC-2024-001',
objet: 'Rénovation salle de bain',
type: 'FACTURE',
statut: 'PAYEE',
dateEmission: new Date('2024-01-15'),
dateEcheance: new Date('2024-02-15'),
client: { id: '1', nom: 'Dupont Construction' } as Client,
montantHT: 2500,
montantTTC: 3000,
tauxTVA: 20,
montantPaye: 3000
},
{
id: '2',
numero: 'FAC-2024-002',
objet: 'Extension maison',
type: 'ACOMPTE',
statut: 'ENVOYEE',
dateEmission: new Date('2024-02-01'),
dateEcheance: new Date('2024-03-01'),
client: { id: '2', nom: 'Martin SARL' } as Client,
montantHT: 5000,
montantTTC: 6000,
tauxTVA: 20,
montantPaye: 0
},
{
id: '3',
numero: 'FAC-2024-003',
objet: 'Travaux électricité',
type: 'FACTURE',
statut: 'EN_RETARD',
dateEmission: new Date('2024-01-20'),
dateEcheance: new Date('2024-02-20'),
client: { id: '3', nom: 'Bâti Plus' } as Client,
montantHT: 1800,
montantTTC: 2160,
tauxTVA: 20,
montantPaye: 0
}
];
// Appliquer les filtres
let facturesFiltrees = mockFactures;
if (config.statuts.length > 0) {
facturesFiltrees = facturesFiltrees.filter(f => config.statuts.includes(f.statut));
}
if (config.types.length > 0) {
facturesFiltrees = facturesFiltrees.filter(f => config.types.includes(f.type));
}
if (config.clients.length > 0) {
const clientIds = config.clients.map(c => c.id);
facturesFiltrees = facturesFiltrees.filter(f =>
typeof f.client === 'object' && clientIds.includes(f.client.id)
);
}
setFactures(facturesFiltrees);
} catch (error) {
console.error('Erreur lors du chargement des factures:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les factures'
});
} finally {
setLoading(false);
}
};
const handleExport = async () => {
try {
setExporting(true);
setExportProgress(0);
// Simulation du processus d'export
const steps = [
'Préparation des données...',
'Application des filtres...',
'Génération du fichier...',
'Finalisation...'
];
for (let i = 0; i < steps.length; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
setExportProgress((i + 1) * 25);
toast.current?.show({
severity: 'info',
summary: 'Export en cours',
detail: steps[i],
life: 1000
});
}
// TODO: Appel API réel pour l'export
// const response = await factureService.export(config);
// Simulation du téléchargement
const filename = `factures_${formatDate(config.dateDebut)}_${formatDate(config.dateFin)}.${config.format.toLowerCase()}`;
toast.current?.show({
severity: 'success',
summary: 'Export terminé',
detail: `Fichier ${filename} généré avec succès`
});
// Simuler le téléchargement
const link = document.createElement('a');
link.href = '#';
link.download = filename;
link.click();
} catch (error) {
console.error('Erreur lors de l\'export:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de l\'export'
});
} finally {
setExporting(false);
setExportProgress(0);
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'PAYEE': return 'success';
case 'EN_RETARD': return 'danger';
case 'PARTIELLEMENT_PAYEE': return 'warning';
case 'ENVOYEE': return 'info';
case 'BROUILLON': return 'secondary';
default: return 'info';
}
};
const calculateTotals = () => {
const montantTotal = factures.reduce((sum, f) => sum + f.montantTTC, 0);
const montantPaye = factures.reduce((sum, f) => sum + (f.montantPaye || 0), 0);
const montantEnAttente = montantTotal - montantPaye;
return { montantTotal, montantPaye, montantEnAttente };
};
const totals = calculateTotals();
const toolbarStartTemplate = () => (
<div className="flex align-items-center gap-2">
<h2 className="text-xl font-bold m-0">Export des Factures</h2>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
<Button
label="Réinitialiser"
icon="pi pi-refresh"
className="p-button-outlined"
onClick={() => {
setConfig({
format: 'EXCEL',
dateDebut: new Date(new Date().getFullYear(), 0, 1),
dateFin: new Date(),
statuts: [],
clients: [],
types: [],
includeDetails: true,
includeStatistiques: false,
grouperParClient: false,
grouperParMois: false
});
}}
/>
<Button
label="Exporter"
icon="pi pi-download"
onClick={handleExport}
loading={exporting}
disabled={factures.length === 0}
/>
</div>
);
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* Configuration de l'export */}
<div className="col-12 lg:col-4">
<Card title="Configuration de l'export">
<div className="grid">
<div className="col-12">
<div className="field">
<label htmlFor="format" className="font-semibold">Format d'export *</label>
<Dropdown
id="format"
value={config.format}
options={formatOptions}
onChange={(e) => setConfig(prev => ({ ...prev, format: 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="dateDebut" className="font-semibold">Date début</label>
<Calendar
id="dateDebut"
value={config.dateDebut}
onChange={(e) => setConfig(prev => ({ ...prev, dateDebut: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="dateFin" className="font-semibold">Date fin</label>
<Calendar
id="dateFin"
value={config.dateFin}
onChange={(e) => setConfig(prev => ({ ...prev, dateFin: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="statuts" className="font-semibold">Statuts</label>
<MultiSelect
id="statuts"
value={config.statuts}
options={statutOptions}
onChange={(e) => setConfig(prev => ({ ...prev, statuts: e.value }))}
className="w-full"
placeholder="Tous les statuts"
maxSelectedLabels={2}
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="types" className="font-semibold">Types</label>
<MultiSelect
id="types"
value={config.types}
options={typeOptions}
onChange={(e) => setConfig(prev => ({ ...prev, types: e.value }))}
className="w-full"
placeholder="Tous les types"
maxSelectedLabels={2}
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="clients" className="font-semibold">Clients</label>
<MultiSelect
id="clients"
value={config.clients}
options={clients.map(client => ({ label: client.nom, value: client }))}
onChange={(e) => setConfig(prev => ({ ...prev, clients: e.value }))}
className="w-full"
placeholder="Tous les clients"
maxSelectedLabels={2}
filter
/>
</div>
</div>
</div>
<Divider />
<h6>Options d'export</h6>
<div className="grid">
<div className="col-12">
<div className="flex align-items-center mb-2">
<Checkbox
inputId="includeDetails"
checked={config.includeDetails}
onChange={(e) => setConfig(prev => ({ ...prev, includeDetails: e.checked || false }))}
/>
<label htmlFor="includeDetails" className="ml-2">Inclure les détails des lignes</label>
</div>
</div>
<div className="col-12">
<div className="flex align-items-center mb-2">
<Checkbox
inputId="includeStatistiques"
checked={config.includeStatistiques}
onChange={(e) => setConfig(prev => ({ ...prev, includeStatistiques: e.checked || false }))}
/>
<label htmlFor="includeStatistiques" className="ml-2">Inclure les statistiques</label>
</div>
</div>
<div className="col-12">
<div className="flex align-items-center mb-2">
<Checkbox
inputId="grouperParClient"
checked={config.grouperParClient}
onChange={(e) => setConfig(prev => ({ ...prev, grouperParClient: e.checked || false }))}
/>
<label htmlFor="grouperParClient" className="ml-2">Grouper par client</label>
</div>
</div>
<div className="col-12">
<div className="flex align-items-center mb-2">
<Checkbox
inputId="grouperParMois"
checked={config.grouperParMois}
onChange={(e) => setConfig(prev => ({ ...prev, grouperParMois: e.checked || false }))}
/>
<label htmlFor="grouperParMois" className="ml-2">Grouper par mois</label>
</div>
</div>
</div>
</Card>
</div>
{/* Aperçu des données */}
<div className="col-12 lg:col-8">
<Card title="Aperçu des données à exporter">
{exporting && (
<div className="mb-4">
<div className="flex justify-content-between align-items-center mb-2">
<span className="font-semibold">Export en cours...</span>
<span className="text-sm">{exportProgress}%</span>
</div>
<ProgressBar value={exportProgress} />
</div>
)}
<DataTable
value={factures}
loading={loading}
responsiveLayout="scroll"
paginator
rows={10}
emptyMessage="Aucune facture trouvée avec ces critères"
header={
<div className="flex justify-content-between align-items-center">
<span className="text-xl font-bold">Factures sélectionnées</span>
<Badge value={factures.length} />
</div>
}
>
<Column field="numero" header="Numéro" sortable />
<Column field="objet" header="Objet" />
<Column
field="type"
header="Type"
body={(rowData) => (
<Tag value={rowData.type} severity="info" />
)}
/>
<Column
field="statut"
header="Statut"
body={(rowData) => (
<Tag
value={rowData.statut}
severity={getStatutSeverity(rowData.statut)}
/>
)}
/>
<Column
field="client"
header="Client"
body={(rowData) =>
typeof rowData.client === 'string' ? rowData.client : rowData.client?.nom
}
/>
<Column
field="dateEmission"
header="Date émission"
body={(rowData) => formatDate(rowData.dateEmission)}
sortable
/>
<Column
field="montantTTC"
header="Montant TTC"
body={(rowData) => formatCurrency(rowData.montantTTC)}
sortable
/>
</DataTable>
</Card>
</div>
{/* Résumé financier */}
<div className="col-12">
<Card title="Résumé financier">
<div className="grid">
<div className="col-12 md:col-3">
<div className="text-center p-3 border-round bg-blue-50">
<div className="text-blue-600 font-bold text-xl mb-2">
{factures.length}
</div>
<div className="text-blue-900 font-semibold">Factures</div>
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 border-round bg-green-50">
<div className="text-green-600 font-bold text-xl mb-2">
{formatCurrency(totals.montantTotal)}
</div>
<div className="text-green-900 font-semibold">Montant Total</div>
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 border-round bg-teal-50">
<div className="text-teal-600 font-bold text-xl mb-2">
{formatCurrency(totals.montantPaye)}
</div>
<div className="text-teal-900 font-semibold">Montant Payé</div>
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 border-round bg-orange-50">
<div className="text-orange-600 font-bold text-xl mb-2">
{formatCurrency(totals.montantEnAttente)}
</div>
<div className="text-orange-900 font-semibold">En Attente</div>
</div>
</div>
</div>
</Card>
</div>
</div>
);
};
export default FactureExportPage;

View File

@@ -0,0 +1,643 @@
'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 { factureService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Facture } from '../../../../types/btp';
import {
ActionButtonGroup,
ViewButton,
EditButton,
DeleteButton,
ActionButton
} from '../../../../components/ui/ActionButton';
const FacturesImpayeesPage = () => {
const [factures, setFactures] = useState<Facture[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedFactures, setSelectedFactures] = useState<Facture[]>([]);
const [actionDialog, setActionDialog] = useState(false);
const [selectedFacture, setSelectedFacture] = useState<Facture | null>(null);
const [actionType, setActionType] = useState<'payment' | 'reminder' | 'schedule'>('payment');
const [paymentData, setPaymentData] = useState({
datePaiement: new Date(),
montantPaye: 0,
modePaiement: '',
notes: ''
});
const [reminderData, setReminderData] = useState({
type: 'EMAIL',
message: ''
});
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Facture[]>>(null);
const paymentMethods = [
{ label: 'Virement bancaire', value: 'VIREMENT' },
{ label: 'Chèque', value: 'CHEQUE' },
{ label: 'Espèces', value: 'ESPECES' },
{ label: 'Carte bancaire', value: 'CB' },
{ label: 'Prélèvement', value: 'PRELEVEMENT' }
];
const reminderTypes = [
{ label: 'Email', value: 'EMAIL' },
{ label: 'Courrier', value: 'COURRIER' },
{ label: 'Téléphone', value: 'TELEPHONE' },
{ label: 'SMS', value: 'SMS' }
];
useEffect(() => {
loadFactures();
}, []);
const loadFactures = async () => {
try {
setLoading(true);
const data = await factureService.getAll();
// Filtrer les factures impayées (émises ou envoyées mais pas payées)
const facturesImpayees = data.filter(facture =>
(facture.statut === 'EMISE' || facture.statut === 'ENVOYEE') &&
!facture.datePaiement
);
setFactures(facturesImpayees);
} catch (error) {
console.error('Erreur lors du chargement des factures:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les factures impayées',
life: 3000
});
} finally {
setLoading(false);
}
};
const getDaysOverdue = (dateEcheance: string | Date) => {
const today = new Date();
const dueDate = new Date(dateEcheance);
const diffTime = today.getTime() - dueDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
};
const isOverdue = (dateEcheance: string | Date) => {
return getDaysOverdue(dateEcheance) > 0;
};
const getUrgencyLevel = (dateEcheance: string | Date) => {
const days = getDaysOverdue(dateEcheance);
if (days === 0) return { level: 'À ÉCHÉANCE', color: 'orange', severity: 'warning' as const };
if (days <= 7) return { level: 'LÉGER RETARD', color: 'orange', severity: 'warning' as const };
if (days <= 30) return { level: 'RETARD', color: 'red', severity: 'danger' as const };
return { level: 'RETARD IMPORTANT', color: 'darkred', severity: 'danger' as const };
};
const recordPayment = (facture: Facture) => {
setSelectedFacture(facture);
setActionType('payment');
setPaymentData({
datePaiement: new Date(),
montantPaye: facture.montantTTC || 0,
modePaiement: '',
notes: ''
});
setActionDialog(true);
};
const sendReminder = (facture: Facture) => {
setSelectedFacture(facture);
setActionType('reminder');
setReminderData({
type: 'EMAIL',
message: `Madame, Monsieur,\n\nNous vous rappelons que la facture ${facture.numero} d'un montant de ${formatCurrency(facture.montantTTC || 0)} arrive à échéance le ${formatDate(facture.dateEcheance)}.\n\nMerci de bien vouloir procéder au règlement dans les plus brefs délais.\n\nCordialement`
});
setActionDialog(true);
};
const schedulePayment = (facture: Facture) => {
setSelectedFacture(facture);
setActionType('schedule');
setActionDialog(true);
};
const handleAction = async () => {
if (!selectedFacture) return;
try {
let message = '';
switch (actionType) {
case 'payment':
// TODO: Implémenter l'enregistrement de paiement
console.log('Enregistrement de paiement:', {
facture: selectedFacture,
paymentData
});
// Retirer de la liste des impayées
setFactures(prev => prev.filter(f => f.id !== selectedFacture.id));
message = 'Paiement enregistré avec succès';
break;
case 'reminder':
// TODO: Implémenter l'envoi de relance
console.log('Envoi de relance:', {
facture: selectedFacture,
reminderData
});
message = 'Relance envoyée au client';
break;
case 'schedule':
// TODO: Implémenter la planification de paiement
console.log('Planification de paiement:', selectedFacture);
message = 'Échéancier de paiement programmé';
break;
}
setActionDialog(false);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `${message} (simulation)`,
life: 3000
});
} catch (error) {
console.error('Erreur lors de l\'action:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible d\'effectuer l\'action',
life: 3000
});
}
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const bulkReminder = async () => {
if (selectedFactures.length === 0) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez sélectionner au moins une facture',
life: 3000
});
return;
}
// Simulation d'envoi de relances en lot
console.log('Envoi de relances en lot à', selectedFactures.length, 'factures');
setSelectedFactures([]);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `${selectedFactures.length} relance(s) envoyée(s) (simulation)`,
life: 3000
});
};
const generateOverdueReport = () => {
const totalOverdue = factures.reduce((sum, f) => sum + (f.montantTTC || 0), 0);
const severelyOverdue = factures.filter(f => getDaysOverdue(f.dateEcheance) > 30);
const recentOverdue = factures.filter(f => getDaysOverdue(f.dateEcheance) <= 7);
const report = `
=== RAPPORT FACTURES IMPAYÉES ===
Date du rapport: ${new Date().toLocaleDateString('fr-FR')}
STATISTIQUES GÉNÉRALES:
- Nombre total de factures impayées: ${factures.length}
- Montant total impayé: ${formatCurrency(totalOverdue)}
- Montant moyen par facture: ${formatCurrency(totalOverdue / (factures.length || 1))}
RÉPARTITION PAR GRAVITÉ:
- Retard léger (≤7 jours): ${recentOverdue.length} factures, ${formatCurrency(recentOverdue.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}
- Retard important (>30 jours): ${severelyOverdue.length} factures, ${formatCurrency(severelyOverdue.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}
FACTURES EN RETARD CRITIQUE (>30 jours):
${severelyOverdue.map(f => `
- ${f.numero} - ${f.objet}
Client: ${f.client ? `${f.client.prenom} ${f.client.nom}` : 'N/A'}
Montant: ${formatCurrency(f.montantTTC || 0)}
Retard: ${getDaysOverdue(f.dateEcheance)} jours
Échéance: ${formatDate(f.dateEcheance)}
`).join('')}
ANALYSE PAR CLIENT:
${getClientOverdueAnalysis()}
RECOMMANDATIONS:
- Relancer immédiatement les factures en retard critique
- Mettre en place des échéanciers pour les gros montants
- Examiner les conditions de paiement accordées
- Considérer un contentieux pour les retards >60 jours
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `rapport_factures_impayees_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
toast.current?.show({
severity: 'success',
summary: 'Rapport généré',
detail: 'Le rapport d\'impayés a été téléchargé',
life: 3000
});
};
const getClientOverdueAnalysis = () => {
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, maxDays: 0 };
}
clientStats[clientKey].count++;
clientStats[clientKey].value += f.montantTTC || 0;
clientStats[clientKey].maxDays = Math.max(clientStats[clientKey].maxDays, getDaysOverdue(f.dateEcheance));
}
});
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)}, retard max: ${data.maxDays} jours`)
.join('\n');
};
const leftToolbarTemplate = () => {
return (
<div className="my-2 flex gap-2">
<h5 className="m-0 flex align-items-center text-red-600">
<i className="pi pi-exclamation-triangle mr-2"></i>
Factures impayées ({factures.length})
</h5>
<Chip
label={`Total impayé: ${formatCurrency(factures.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}`}
className="bg-red-100 text-red-800"
/>
<Button
label="Relancer sélection"
icon="pi pi-send"
severity="warning"
size="small"
onClick={bulkReminder}
disabled={selectedFactures.length === 0}
/>
<Button
label="Rapport d'impayés"
icon="pi pi-chart-line"
severity="danger"
size="small"
onClick={generateOverdueReport}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Facture) => {
return (
<ActionButtonGroup>
<ActionButton
icon="pi pi-check-circle"
color="success"
tooltip="Enregistrer le paiement"
onClick={() => recordPayment(rowData)}
/>
<ActionButton
icon="pi pi-send"
color="warning"
tooltip="Envoyer une relance"
onClick={() => sendReminder(rowData)}
/>
<ActionButton
icon="pi pi-calendar"
color="info"
tooltip="Planifier un échéancier"
onClick={() => schedulePayment(rowData)}
/>
<ViewButton
tooltip="Voir détails"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: `Détails de la facture ${rowData.numero}`,
life: 3000
});
}}
/>
</ActionButtonGroup>
);
};
const statusBodyTemplate = (rowData: Facture) => {
const urgency = getUrgencyLevel(rowData.dateEcheance);
const overdue = isOverdue(rowData.dateEcheance);
return (
<div className="flex align-items-center gap-2">
<Tag value="Impayée" severity="danger" />
{overdue && (
<Tag
value={urgency.level}
severity={urgency.severity}
/>
)}
</div>
);
};
const dueDateBodyTemplate = (rowData: Facture) => {
const days = getDaysOverdue(rowData.dateEcheance);
const overdue = isOverdue(rowData.dateEcheance);
const urgency = getUrgencyLevel(rowData.dateEcheance);
return (
<div className={overdue ? 'text-red-600 font-bold' : 'text-orange-600'}>
<div>{formatDate(rowData.dateEcheance)}</div>
<small>
{days === 0 ? 'Échéance aujourd\'hui' :
overdue ? `Retard de ${days} jour${days > 1 ? 's' : ''}` :
`Échéance dans ${Math.abs(days)} jour${Math.abs(days) > 1 ? 's' : ''}`}
</small>
{overdue && <i className="pi pi-exclamation-triangle ml-1 text-red-500"></i>}
</div>
);
};
const clientBodyTemplate = (rowData: Facture) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const amountBodyTemplate = (rowData: Facture) => {
const amount = rowData.montantTTC || 0;
let severity = 'secondary';
if (amount > 10000) {
severity = 'danger';
} else if (amount > 5000) {
severity = 'warning';
} else if (amount > 1000) {
severity = 'info';
}
return (
<div>
<div className="font-bold">{formatCurrency(amount)}</div>
<Tag
value={amount > 10000 ? 'Prioritaire' : amount > 5000 ? 'Important' : 'Standard'}
severity={severity as any}
className="text-xs mt-1"
/>
</div>
);
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Factures impayé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 actionDialogFooter = (
<>
<Button
label="Annuler"
icon="pi pi-times"
text
onClick={() => setActionDialog(false)}
/>
<Button
label={
actionType === 'payment' ? 'Enregistrer le paiement' :
actionType === 'reminder' ? 'Envoyer la relance' :
'Programmer l\'échéancier'
}
icon={
actionType === 'payment' ? 'pi pi-check-circle' :
actionType === 'reminder' ? 'pi pi-send' :
'pi pi-calendar'
}
text
onClick={handleAction}
/>
</>
);
const getActionTitle = () => {
switch (actionType) {
case 'payment': return 'Enregistrer un paiement';
case 'reminder': return 'Envoyer une relance';
case 'schedule': return 'Planifier un échéancier';
default: return 'Action';
}
};
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 impayée trouvée."
header={header}
responsiveLayout="scroll"
loading={loading}
sortField="dateEcheance"
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="Date échéance" body={dueDateBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="montantTTC" header="Montant dû" body={amountBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="statut" header="Statut" body={statusBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '15rem' }} />
</DataTable>
<Dialog
visible={actionDialog}
style={{ width: '600px' }}
header={getActionTitle()}
modal
className="p-fluid"
footer={actionDialogFooter}
onHide={() => setActionDialog(false)}
>
<div className="formgrid grid">
<div className="field col-12">
<p>
Facture: <strong>{selectedFacture?.numero} - {selectedFacture?.objet}</strong>
</p>
<p>
Client: <strong>{selectedFacture?.client ? `${selectedFacture.client.prenom} ${selectedFacture.client.nom}` : 'N/A'}</strong>
</p>
<p>
Montant : <strong>{selectedFacture ? formatCurrency(selectedFacture.montantTTC || 0) : ''}</strong>
</p>
{selectedFacture && (
<p>
Retard: <strong>{getDaysOverdue(selectedFacture.dateEcheance)} jour(s)</strong>
</p>
)}
</div>
{actionType === 'payment' && (
<>
<div className="field col-12 md:col-6">
<label htmlFor="datePaiement">Date de paiement</label>
<Calendar
id="datePaiement"
value={paymentData.datePaiement}
onChange={(e) => setPaymentData(prev => ({ ...prev, datePaiement: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="montantPaye">Montant payé ()</label>
<InputText
id="montantPaye"
type="number"
value={paymentData.montantPaye.toString()}
onChange={(e) => setPaymentData(prev => ({ ...prev, montantPaye: parseFloat(e.target.value) || 0 }))}
/>
</div>
<div className="field col-12">
<label htmlFor="modePaiement">Mode de paiement</label>
<Dropdown
id="modePaiement"
value={paymentData.modePaiement}
options={paymentMethods}
onChange={(e) => setPaymentData(prev => ({ ...prev, modePaiement: e.value }))}
placeholder="Sélectionnez un mode de paiement"
/>
</div>
<div className="field col-12">
<label htmlFor="paymentNotes">Notes de paiement</label>
<InputTextarea
id="paymentNotes"
value={paymentData.notes}
onChange={(e) => setPaymentData(prev => ({ ...prev, notes: e.target.value }))}
rows={3}
placeholder="Notes sur le paiement..."
/>
</div>
</>
)}
{actionType === 'reminder' && (
<>
<div className="field col-12">
<label htmlFor="reminderType">Type de relance</label>
<Dropdown
id="reminderType"
value={reminderData.type}
options={reminderTypes}
onChange={(e) => setReminderData(prev => ({ ...prev, type: e.value }))}
placeholder="Sélectionnez le type de relance"
/>
</div>
<div className="field col-12">
<label htmlFor="reminderMessage">Message de relance</label>
<InputTextarea
id="reminderMessage"
value={reminderData.message}
onChange={(e) => setReminderData(prev => ({ ...prev, message: e.target.value }))}
rows={5}
placeholder="Message à envoyer au client..."
/>
</div>
</>
)}
{actionType === 'schedule' && (
<div className="field col-12">
<p>
<i className="pi pi-info-circle mr-2"></i>
Un échéancier de paiement sera proposé au client pour faciliter le règlement de cette facture.
</p>
<p>
Fonctionnalités prévues :
</p>
<ul>
<li>Division du montant en plusieurs échéances</li>
<li>Définition des dates de paiement</li>
<li>Envoi automatique de rappels</li>
<li>Suivi des paiements partiels</li>
</ul>
</div>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default FacturesImpayeesPage;

View File

@@ -0,0 +1,998 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber';
import { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Dialog } from 'primereact/dialog';
import { Steps } from 'primereact/steps';
import { RadioButton } from 'primereact/radiobutton';
import { clientService, chantierService, devisService } from '../../../../services/api';
import { formatCurrency } from '../../../../utils/formatters';
import type { Facture, LigneFacture, Client, Chantier, Devis } from '../../../../types/btp';
interface LigneFactureFormData {
designation: string;
description: string;
quantite: number;
unite: string;
prixUnitaire: number;
montantLigne: number;
ordre: number;
}
const NouvelleFacturePage = () => {
const router = useRouter();
const toast = useRef<Toast>(null);
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [clients, setClients] = useState<any[]>([]);
const [chantiers, setChantiers] = useState<any[]>([]);
const [devisList, setDevisList] = useState<any[]>([]);
const [ligneDialog, setLigneDialog] = useState(false);
const [creationMode, setCreationMode] = useState<'manual' | 'from_devis'>('manual');
const [facture, setFacture] = useState<Facture>({
id: '',
numero: '',
objet: '',
description: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 jours
datePaiement: null,
statut: 'BROUILLON',
montantHT: 0,
tauxTVA: 20,
montantTVA: 0,
montantTTC: 0,
montantPaye: 0,
conditionsPaiement: 'Paiement à 30 jours fin de mois',
typeFacture: 'FACTURE',
actif: true,
client: null,
chantier: null,
devis: null,
lignes: []
});
const [selectedDevis, setSelectedDevis] = useState<Devis | null>(null);
const [currentLigne, setCurrentLigne] = useState<LigneFactureFormData>({
designation: '',
description: '',
quantite: 1,
unite: 'h',
prixUnitaire: 0,
montantLigne: 0,
ordre: 1
});
const [editingLigne, setEditingLigne] = useState<number | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const unites = [
{ label: 'Heures', value: 'h' },
{ label: 'Jours', value: 'j' },
{ label: 'Mètres', value: 'm' },
{ label: 'Mètres carrés', value: 'm²' },
{ label: 'Mètres cubes', value: 'm³' },
{ label: 'Unités', value: 'u' },
{ label: 'Forfait', value: 'forfait' },
{ label: 'Lots', value: 'lot' }
];
const statuts = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'Émise', value: 'ENVOYEE' },
{ label: 'Payée', value: 'PAYEE' }
];
const typesFacture = [
{ label: 'Facture complète', value: 'FACTURE' },
{ label: 'Facture d\'acompte', value: 'ACOMPTE' },
{ label: 'Avoir', value: 'AVOIR' }
];
const steps = [
{ label: 'Mode de création' },
{ label: 'Informations générales' },
{ label: 'Client et références' },
{ label: 'Prestations' },
{ label: 'Conditions' },
{ label: 'Validation' }
];
useEffect(() => {
loadClients();
generateNumero();
}, []);
useEffect(() => {
if (facture.client) {
loadChantiersByClient(facture.client as string);
loadDevisByClient(facture.client as string);
} else {
setChantiers([]);
setDevisList([]);
}
}, [facture.client]);
useEffect(() => {
calculateTotals();
}, [facture.lignes, facture.tauxTVA]);
useEffect(() => {
if (selectedDevis && creationMode === 'from_devis') {
importFromDevis();
}
}, [selectedDevis]);
const generateNumero = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const time = String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0');
const numero = `FACT-${year}${month}${day}-${time}`;
setFacture(prev => ({ ...prev, numero }));
};
const loadClients = async () => {
try {
const data = await clientService.getAll();
setClients(data.map(client => ({
label: `${client.prenom} ${client.nom}${client.entreprise ? ' - ' + client.entreprise : ''}`,
value: client.id,
client: client
})));
} catch (error) {
console.error('Erreur lors du chargement des clients:', error);
}
};
const loadChantiersByClient = async (clientId: string) => {
try {
const data = await chantierService.getByClient(clientId);
setChantiers(data.map(chantier => ({
label: chantier.nom,
value: chantier.id,
chantier: chantier
})));
} catch (error) {
console.error('Erreur lors du chargement des chantiers:', error);
setChantiers([]);
}
};
const loadDevisByClient = async (clientId: string) => {
try {
const data = await devisService.getAll();
// Filtrer les devis acceptés du client
const clientDevis = data.filter(devis =>
devis.client?.id === clientId && devis.statut === 'ACCEPTE'
);
setDevisList(clientDevis.map(devis => ({
label: `${devis.numero} - ${devis.objet} (${formatCurrency(devis.montantTTC || 0)})`,
value: devis.id,
devis: devis
})));
} catch (error) {
console.error('Erreur lors du chargement des devis:', error);
setDevisList([]);
}
};
const importFromDevis = () => {
if (!selectedDevis) return;
setFacture(prev => ({
...prev,
objet: `Facture - ${selectedDevis.objet}`,
description: selectedDevis.description || '',
montantHT: selectedDevis.montantHT || 0,
tauxTVA: selectedDevis.tauxTVA || 20,
montantTVA: selectedDevis.montantTVA || 0,
montantTTC: selectedDevis.montantTTC || 0,
client: selectedDevis.client?.id || null,
devis: selectedDevis.id,
lignes: selectedDevis.lignes?.map(ligneDevis => ({
id: '',
designation: ligneDevis.designation,
description: ligneDevis.description || '',
quantite: ligneDevis.quantite,
unite: ligneDevis.unite,
prixUnitaire: ligneDevis.prixUnitaire,
montantLigne: ligneDevis.montantLigne || 0,
ordre: ligneDevis.ordre,
dateCreation: new Date().toISOString(),
dateModification: new Date().toISOString(),
facture: {} as Facture
})) || []
}));
};
const calculateTotals = () => {
const montantHT = facture.lignes?.reduce((sum, ligne) => sum + (ligne.montantLigne || 0), 0) || 0;
const montantTVA = montantHT * (facture.tauxTVA / 100);
const montantTTC = montantHT + montantTVA;
setFacture(prev => ({
...prev,
montantHT,
montantTVA,
montantTTC
}));
};
const validateStep = (step: number) => {
const newErrors: Record<string, string> = {};
switch (step) {
case 1: // Informations générales
if (!facture.objet.trim()) {
newErrors.objet = 'L\'objet de la facture est obligatoire';
}
break;
case 2: // Client et références
if (!facture.client) {
newErrors.client = 'Le client est obligatoire';
}
break;
case 3: // Prestations
if (creationMode === 'manual' && (!facture.lignes || facture.lignes.length === 0)) {
newErrors.lignes = 'Au moins une prestation est obligatoire';
}
if (creationMode === 'from_devis' && !selectedDevis) {
newErrors.devis = 'Veuillez sélectionner un devis';
}
break;
case 4: // Conditions
if (!facture.conditionsPaiement?.trim()) {
newErrors.conditionsPaiement = 'Les conditions de paiement sont obligatoires';
}
break;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const nextStep = () => {
if (validateStep(activeIndex)) {
setActiveIndex(prev => Math.min(prev + 1, steps.length - 1));
}
};
const prevStep = () => {
setActiveIndex(prev => Math.max(prev - 1, 0));
};
const openLigneDialog = () => {
setCurrentLigne({
designation: '',
description: '',
quantite: 1,
unite: 'h',
prixUnitaire: 0,
montantLigne: 0,
ordre: (facture.lignes?.length || 0) + 1
});
setEditingLigne(null);
setLigneDialog(true);
};
const editLigne = (index: number) => {
const ligne = facture.lignes?.[index];
if (ligne) {
setCurrentLigne({
designation: ligne.designation,
description: ligne.description || '',
quantite: ligne.quantite,
unite: ligne.unite,
prixUnitaire: ligne.prixUnitaire,
montantLigne: ligne.montantLigne || 0,
ordre: ligne.ordre
});
setEditingLigne(index);
setLigneDialog(true);
}
};
const saveLigne = () => {
const montantLigne = currentLigne.quantite * currentLigne.prixUnitaire;
const newLigne = {
...currentLigne,
montantLigne,
id: '',
dateCreation: new Date().toISOString(),
dateModification: new Date().toISOString(),
facture: {} as Facture
};
if (editingLigne !== null) {
// Modification
const updatedLignes = [...(facture.lignes || [])];
updatedLignes[editingLigne] = newLigne as LigneFacture;
setFacture(prev => ({ ...prev, lignes: updatedLignes }));
} else {
// Ajout
setFacture(prev => ({
...prev,
lignes: [...(prev.lignes || []), newLigne as LigneFacture]
}));
}
setLigneDialog(false);
};
const deleteLigne = (index: number) => {
const updatedLignes = facture.lignes?.filter((_, i) => i !== index) || [];
setFacture(prev => ({ ...prev, lignes: updatedLignes }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
if (!validateStep(activeIndex)) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Veuillez corriger les erreurs du formulaire',
life: 3000
});
return;
}
setLoading(true);
try {
// Simulation de création (l'API n'est pas encore implémentée)
console.log('Données de la facture:', facture);
toast.current?.show({
severity: 'info',
summary: 'Information',
detail: 'Fonctionnalité en cours d\'implémentation côté serveur',
life: 3000
});
setTimeout(() => {
router.push('/factures');
}, 1000);
} catch (error: any) {
console.error('Erreur lors de la création:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de créer la facture',
life: 5000
});
} finally {
setLoading(false);
}
};
const handleCancel = () => {
router.push('/factures');
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _facture = { ...facture };
(_facture as any)[name] = val;
setFacture(_facture);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onDateChange = (e: any, name: string) => {
let _facture = { ...facture };
(_facture as any)[name] = e.value;
setFacture(_facture);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onNumberChange = (e: any, name: string) => {
let _facture = { ...facture };
(_facture as any)[name] = e.value;
setFacture(_facture);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onDropdownChange = (e: any, name: string) => {
let _facture = { ...facture };
(_facture as any)[name] = e.value;
setFacture(_facture);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onLigneInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _ligne = { ...currentLigne };
(_ligne as any)[name] = val;
setCurrentLigne(_ligne);
};
const onLigneNumberChange = (e: any, name: string) => {
let _ligne = { ...currentLigne };
(_ligne as any)[name] = e.value || 0;
setCurrentLigne(_ligne);
};
const onLigneDropdownChange = (e: any, name: string) => {
let _ligne = { ...currentLigne };
(_ligne as any)[name] = e.value;
setCurrentLigne(_ligne);
};
const ligneActionBodyTemplate = (rowData: LigneFacture, options: any) => {
return (
<div className="flex gap-2">
<Button
icon="pi pi-pencil"
rounded
severity="success"
size="small"
onClick={() => editLigne(options.rowIndex)}
/>
<Button
icon="pi pi-trash"
rounded
severity="danger"
size="small"
onClick={() => deleteLigne(options.rowIndex)}
/>
</div>
);
};
const ligneDialogFooter = (
<>
<Button
label="Annuler"
icon="pi pi-times"
text
onClick={() => setLigneDialog(false)}
/>
<Button
label="Enregistrer"
icon="pi pi-check"
text
onClick={saveLigne}
disabled={!currentLigne.designation || currentLigne.quantite <= 0 || currentLigne.prixUnitaire <= 0}
/>
</>
);
const renderStepContent = () => {
switch (activeIndex) {
case 0:
return (
<div className="formgrid grid">
<div className="field col-12">
<h4 className="text-primary">Mode de création de la facture</h4>
<p className="text-600">Choisissez comment vous souhaitez créer cette facture :</p>
</div>
<div className="field col-12">
<div className="flex flex-column gap-3">
<div className="flex align-items-center">
<RadioButton
inputId="manual"
name="creationMode"
value="manual"
onChange={(e) => setCreationMode(e.value)}
checked={creationMode === 'manual'}
/>
<label htmlFor="manual" className="ml-2">
<strong>Création manuelle</strong>
<div className="text-600 text-sm">Saisir les informations et prestations manuellement</div>
</label>
</div>
<div className="flex align-items-center">
<RadioButton
inputId="from_devis"
name="creationMode"
value="from_devis"
onChange={(e) => setCreationMode(e.value)}
checked={creationMode === 'from_devis'}
/>
<label htmlFor="from_devis" className="ml-2">
<strong>À partir d'un devis accepté</strong>
<div className="text-600 text-sm">Importer les informations d'un devis existant</div>
</label>
</div>
</div>
</div>
</div>
);
case 1:
return (
<div className="formgrid grid">
<div className="field col-12 md:col-6">
<label htmlFor="numero" className="font-bold">Numéro de facture</label>
<InputText
id="numero"
value={facture.numero}
disabled
placeholder="Généré automatiquement"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="typeFacture" className="font-bold">Type de facture</label>
<Dropdown
id="typeFacture"
value={facture.typeFacture}
options={typesFacture}
onChange={(e) => onDropdownChange(e, 'typeFacture')}
placeholder="Sélectionnez un type"
/>
</div>
<div className="field col-12">
<label htmlFor="objet" className="font-bold">
Objet de la facture <span className="text-red-500">*</span>
</label>
<InputText
id="objet"
value={facture.objet}
onChange={(e) => onInputChange(e, 'objet')}
className={errors.objet ? 'p-invalid' : ''}
placeholder="Objet de la facture"
/>
{errors.objet && <small className="p-error">{errors.objet}</small>}
</div>
<div className="field col-12">
<label htmlFor="description" className="font-bold">Description</label>
<InputTextarea
id="description"
value={facture.description}
onChange={(e) => onInputChange(e, 'description')}
rows={4}
placeholder="Description détaillée des travaux facturés"
/>
</div>
</div>
);
case 2:
return (
<div className="formgrid grid">
<div className="field col-12">
<label htmlFor="client" className="font-bold">
Client <span className="text-red-500">*</span>
</label>
<Dropdown
id="client"
value={facture.client}
options={clients}
onChange={(e) => onDropdownChange(e, 'client')}
placeholder="Sélectionnez un client"
className={errors.client ? 'p-invalid' : ''}
filter
/>
{errors.client && <small className="p-error">{errors.client}</small>}
</div>
<div className="field col-12">
<label htmlFor="chantier" className="font-bold">Chantier (optionnel)</label>
<Dropdown
id="chantier"
value={facture.chantier}
options={chantiers}
onChange={(e) => onDropdownChange(e, 'chantier')}
placeholder="Sélectionnez un chantier"
disabled={!facture.client}
/>
</div>
{creationMode === 'from_devis' && (
<div className="field col-12">
<label htmlFor="devis" className="font-bold">
Devis de référence <span className="text-red-500">*</span>
</label>
<Dropdown
id="devis"
value={selectedDevis?.id}
options={devisList}
onChange={(e) => {
const devis = devisList.find(d => d.value === e.value)?.devis;
setSelectedDevis(devis || null);
}}
placeholder="Sélectionnez un devis accepté"
className={errors.devis ? 'p-invalid' : ''}
disabled={!facture.client}
/>
{errors.devis && <small className="p-error">{errors.devis}</small>}
</div>
)}
<div className="field col-12 md:col-6">
<label htmlFor="dateEmission" className="font-bold">Date d'émission</label>
<Calendar
id="dateEmission"
value={facture.dateEmission}
onChange={(e) => onDateChange(e, 'dateEmission')}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateEcheance" className="font-bold">Date d'échéance</label>
<Calendar
id="dateEcheance"
value={facture.dateEcheance}
onChange={(e) => onDateChange(e, 'dateEcheance')}
dateFormat="dd/mm/yy"
showIcon
minDate={facture.dateEmission}
/>
</div>
</div>
);
case 3:
return (
<div>
{creationMode === 'manual' ? (
<>
<div className="flex justify-content-between align-items-center mb-3">
<h3>Prestations facturées</h3>
<Button
label="Ajouter une prestation"
icon="pi pi-plus"
onClick={openLigneDialog}
/>
</div>
{errors.lignes && <small className="p-error block mb-3">{errors.lignes}</small>}
<DataTable
value={facture.lignes || []}
responsiveLayout="scroll"
emptyMessage="Aucune prestation ajoutée."
>
<Column field="designation" header="Désignation" />
<Column field="quantite" header="Quantité" />
<Column field="unite" header="Unité" />
<Column
field="prixUnitaire"
header="Prix unitaire"
body={(rowData) => formatCurrency(rowData.prixUnitaire)}
/>
<Column
field="montantLigne"
header="Montant"
body={(rowData) => formatCurrency(rowData.montantLigne || 0)}
/>
<Column body={ligneActionBodyTemplate} headerStyle={{ width: '8rem' }} />
</DataTable>
</>
) : (
<div>
<h3>Prestations importées du devis</h3>
{selectedDevis ? (
<>
<p className="text-600 mb-3">
Devis: <strong>{selectedDevis.numero} - {selectedDevis.objet}</strong>
</p>
<DataTable
value={facture.lignes || []}
responsiveLayout="scroll"
emptyMessage="Aucune prestation dans le devis."
>
<Column field="designation" header="Désignation" />
<Column field="quantite" header="Quantité" />
<Column field="unite" header="Unité" />
<Column
field="prixUnitaire"
header="Prix unitaire"
body={(rowData) => formatCurrency(rowData.prixUnitaire)}
/>
<Column
field="montantLigne"
header="Montant"
body={(rowData) => formatCurrency(rowData.montantLigne || 0)}
/>
</DataTable>
</>
) : (
<p className="text-600">Veuillez d'abord sélectionner un devis à l'étape précédente.</p>
)}
</div>
)}
<div className="mt-3 text-right">
<p><strong>Montant HT: {formatCurrency(facture.montantHT || 0)}</strong></p>
<p>TVA ({facture.tauxTVA}%): {formatCurrency(facture.montantTVA || 0)}</p>
<p className="text-xl"><strong>Montant TTC: {formatCurrency(facture.montantTTC || 0)}</strong></p>
</div>
</div>
);
case 4:
return (
<div className="formgrid grid">
<div className="field col-12 md:col-6">
<label htmlFor="tauxTVA" className="font-bold">Taux TVA (%)</label>
<InputNumber
id="tauxTVA"
value={facture.tauxTVA}
onValueChange={(e) => onNumberChange(e, 'tauxTVA')}
suffix="%"
min={0}
max={100}
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="statut" className="font-bold">Statut initial</label>
<Dropdown
id="statut"
value={facture.statut}
options={statuts}
onChange={(e) => onDropdownChange(e, 'statut')}
placeholder="Sélectionnez un statut"
/>
</div>
<div className="field col-12">
<label htmlFor="conditionsPaiement" className="font-bold">
Conditions de paiement <span className="text-red-500">*</span>
</label>
<InputTextarea
id="conditionsPaiement"
value={facture.conditionsPaiement}
onChange={(e) => onInputChange(e, 'conditionsPaiement')}
rows={3}
className={errors.conditionsPaiement ? 'p-invalid' : ''}
placeholder="Conditions de paiement"
/>
{errors.conditionsPaiement && <small className="p-error">{errors.conditionsPaiement}</small>}
</div>
</div>
);
case 5:
return (
<div className="formgrid grid">
<div className="field col-12">
<h3 className="text-primary">Récapitulatif de la facture</h3>
<Divider />
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Numéro:</label>
<p>{facture.numero}</p>
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Type:</label>
<p>{typesFacture.find(t => t.value === facture.typeFacture)?.label}</p>
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Client:</label>
<p>{clients.find(c => c.value === facture.client)?.label}</p>
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Mode de création:</label>
<p>{creationMode === 'manual' ? 'Création manuelle' : 'À partir du devis'}</p>
</div>
<div className="field col-12">
<label className="font-bold">Objet:</label>
<p>{facture.objet}</p>
</div>
<div className="field col-12">
<label className="font-bold">Prestations ({facture.lignes?.length || 0}):</label>
<ul>
{facture.lignes?.map((ligne, index) => (
<li key={index}>
{ligne.designation} - {ligne.quantite} {ligne.unite} × {formatCurrency(ligne.prixUnitaire)} = {formatCurrency(ligne.montantLigne || 0)}
</li>
))}
</ul>
</div>
<div className="field col-12 text-right">
<p>Montant HT: {formatCurrency(facture.montantHT || 0)}</p>
<p>TVA ({facture.tauxTVA}%): {formatCurrency(facture.montantTVA || 0)}</p>
<p className="text-xl"><strong>Total TTC: {formatCurrency(facture.montantTTC || 0)}</strong></p>
</div>
</div>
);
default:
return null;
}
};
return (
<div className="grid">
<div className="col-12">
<Card>
<Toast ref={toast} />
<div className="flex justify-content-between align-items-center mb-4">
<h2>Nouvelle Facture</h2>
<Button
icon="pi pi-arrow-left"
label="Retour"
className="p-button-text"
onClick={handleCancel}
/>
</div>
<Steps model={steps} activeIndex={activeIndex} className="mb-4" />
<form onSubmit={handleSubmit} className="p-fluid">
{renderStepContent()}
<Divider />
<div className="flex justify-content-between">
<Button
type="button"
label="Précédent"
icon="pi pi-chevron-left"
className="p-button-text"
onClick={prevStep}
disabled={activeIndex === 0}
/>
<div className="flex gap-2">
<Button
type="button"
label="Annuler"
icon="pi pi-times"
className="p-button-text"
onClick={handleCancel}
disabled={loading}
/>
{activeIndex < steps.length - 1 ? (
<Button
type="button"
label="Suivant"
icon="pi pi-chevron-right"
iconPos="right"
onClick={nextStep}
/>
) : (
<Button
type="submit"
label="Créer la facture"
icon="pi pi-check"
loading={loading}
disabled={loading}
/>
)}
</div>
</div>
</form>
<Dialog
visible={ligneDialog}
style={{ width: '600px' }}
header={editingLigne !== null ? 'Modifier la prestation' : 'Ajouter une prestation'}
modal
className="p-fluid"
footer={ligneDialogFooter}
onHide={() => setLigneDialog(false)}
>
<div className="formgrid grid">
<div className="field col-12">
<label htmlFor="designation" className="font-bold">Désignation</label>
<InputText
id="designation"
value={currentLigne.designation}
onChange={(e) => onLigneInputChange(e, 'designation')}
placeholder="Désignation de la prestation"
/>
</div>
<div className="field col-12">
<label htmlFor="ligneDescription" className="font-bold">Description</label>
<InputTextarea
id="ligneDescription"
value={currentLigne.description}
onChange={(e) => onLigneInputChange(e, 'description')}
rows={2}
placeholder="Description détaillée"
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="quantite" className="font-bold">Quantité</label>
<InputNumber
id="quantite"
value={currentLigne.quantite}
onValueChange={(e) => onLigneNumberChange(e, 'quantite')}
min={0.01}
minFractionDigits={0}
maxFractionDigits={2}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="unite" className="font-bold">Unité</label>
<Dropdown
id="unite"
value={currentLigne.unite}
options={unites}
onChange={(e) => onLigneDropdownChange(e, 'unite')}
placeholder="Sélectionnez une unité"
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="prixUnitaire" className="font-bold">Prix unitaire ()</label>
<InputNumber
id="prixUnitaire"
value={currentLigne.prixUnitaire}
onValueChange={(e) => onLigneNumberChange(e, 'prixUnitaire')}
mode="currency"
currency="EUR"
locale="fr-FR"
min={0}
/>
</div>
<div className="field col-12">
<label className="font-bold">Montant de la ligne:</label>
<p className="text-primary text-xl">
{formatCurrency(currentLigne.quantite * currentLigne.prixUnitaire)}
</p>
</div>
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default NouvelleFacturePage;

View File

@@ -0,0 +1,726 @@
'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 { Dialog } from 'primereact/dialog';
import { Toast } from 'primereact/toast';
import { Toolbar } from 'primereact/toolbar';
import { Tag } from 'primereact/tag';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber';
import { factureService, clientService } from '../../../services/api';
import { formatDate, formatCurrency } from '../../../utils/formatters';
import { ErrorHandler } from '../../../services/errorHandler';
import type { Facture } from '../../../types/btp';
import {
ActionButtonGroup,
ViewButton,
EditButton,
DeleteButton,
ActionButton
} from '../../../components/ui/ActionButton';
const FacturesPage = () => {
const [factures, setFactures] = useState<Facture[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedFactures, setSelectedFactures] = useState<Facture[]>([]);
const [factureDialog, setFactureDialog] = useState(false);
const [deleteFactureDialog, setDeleteFactureDialog] = useState(false);
const [deleteFacturessDialog, setDeleteFacturessDialog] = useState(false);
const [facture, setFacture] = useState<Facture>({
id: '',
numero: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
datePaiement: null,
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
type: 'FACTURE',
actif: true,
client: null,
chantier: null,
devis: null
});
const [submitted, setSubmitted] = useState(false);
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Facture[]>>(null);
const statuts = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'Émise', value: 'EMISE' },
{ label: 'Envoyée', value: 'ENVOYEE' },
{ label: 'Payée', value: 'PAYEE' },
{ label: 'En retard', value: 'EN_RETARD' },
{ label: 'Annulée', value: 'ANNULEE' }
];
const types = [
{ label: 'Facture', value: 'FACTURE' },
{ label: 'Facture d\'acompte', value: 'ACOMPTE' },
{ label: 'Facture de solde', value: 'SOLDE' },
{ label: 'Avoir', value: 'AVOIR' }
];
useEffect(() => {
loadFactures();
loadClients();
}, []);
const loadFactures = async () => {
try {
setLoading(true);
const data = await factureService.getAll();
setFactures(data);
} catch (error) {
console.error('Erreur lors du chargement des factures:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les factures',
life: 3000
});
} finally {
setLoading(false);
}
};
const loadClients = async () => {
try {
const data = await clientService.getAll();
setClients(data.map(client => ({
label: `${client.prenom} ${client.nom}${client.entreprise ? ' - ' + client.entreprise : ''}`,
value: client.id,
client: client
})));
} catch (error) {
console.error('Erreur lors du chargement des clients:', error);
}
};
const openNew = () => {
setFacture({
id: '',
numero: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
datePaiement: null,
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
type: 'FACTURE',
actif: true,
client: null,
chantier: null,
devis: null
});
setSubmitted(false);
setFactureDialog(true);
};
const hideDialog = () => {
setSubmitted(false);
setFactureDialog(false);
};
const hideDeleteFactureDialog = () => {
setDeleteFactureDialog(false);
};
const hideDeleteFacturessDialog = () => {
setDeleteFacturessDialog(false);
};
const saveFacture = async () => {
setSubmitted(true);
// Validation complète côté client
const validationErrors = [];
if (!facture.objet.trim()) {
validationErrors.push("L'objet de la facture est obligatoire");
}
if (!facture.client) {
validationErrors.push("Le client est obligatoire");
}
if (!facture.numero.trim()) {
validationErrors.push("Le numéro de facture est obligatoire");
}
if (facture.montantHT <= 0) {
validationErrors.push("Le montant HT doit être supérieur à 0");
}
if (facture.tauxTVA < 0 || facture.tauxTVA > 100) {
validationErrors.push("Le taux de TVA doit être entre 0 et 100%");
}
if (!facture.dateEcheance || facture.dateEcheance <= facture.dateEmission) {
validationErrors.push("La date d'échéance doit être postérieure à la date d'émission");
}
if (validationErrors.length > 0) {
toast.current?.show({
severity: 'error',
summary: 'Erreurs de validation',
detail: validationErrors.join(', '),
life: 5000
});
return;
}
if (facture.objet.trim() && facture.client) {
try {
let updatedFactures = [...factures];
const factureToSave = {
...facture,
client: facture.client ? { id: facture.client } : null, // Envoyer seulement l'ID du client
montantTTC: facture.montantHT * (1 + facture.tauxTVA / 100)
};
if (facture.id) {
// Mise à jour de la facture existante
const updatedFacture = await factureService.update(facture.id, factureToSave);
setFactures(factures.map(f => f.id === facture.id ? updatedFacture : f));
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture mise à jour avec succès',
life: 3000
});
} else {
// Création d'une nouvelle facture
const newFacture = await factureService.create(factureToSave);
setFactures([...factures, newFacture]);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture créée avec succès',
life: 3000
});
}
setFactureDialog(false);
setFacture({
id: '',
numero: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
datePaiement: null,
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
type: 'FACTURE',
actif: true,
client: null,
chantier: null,
devis: null
});
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de sauvegarder la facture',
life: 3000
});
}
}
};
const editFacture = (facture: Facture) => {
setFacture({
...facture,
client: facture.client?.id || null
});
setFactureDialog(true);
};
const confirmDeleteFacture = (facture: Facture) => {
setFacture(facture);
setDeleteFactureDialog(true);
};
const deleteFacture = async () => {
try {
if (facture.id) {
await factureService.delete(facture.id);
setFactures(factures.filter(f => f.id !== facture.id));
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture supprimée avec succès',
life: 3000
});
}
setDeleteFactureDialog(false);
} catch (error) {
console.error('Erreur lors de la suppression:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de supprimer la facture',
life: 3000
});
}
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _facture = { ...facture };
(_facture as any)[name] = val;
setFacture(_facture);
};
const onDateChange = (e: any, name: string) => {
let _facture = { ...facture };
(_facture as any)[name] = e.value;
setFacture(_facture);
};
const onNumberChange = (e: any, name: string) => {
let _facture = { ...facture };
(_facture as any)[name] = e.value;
setFacture(_facture);
// Recalcul automatique du TTC
if (name === 'montantHT' || name === 'tauxTVA') {
const montantHT = name === 'montantHT' ? e.value : _facture.montantHT;
const tauxTVA = name === 'tauxTVA' ? e.value : _facture.tauxTVA;
_facture.montantTTC = montantHT * (1 + tauxTVA / 100);
setFacture(_facture);
}
};
const onDropdownChange = (e: any, name: string) => {
let _facture = { ...facture };
(_facture as any)[name] = e.value;
setFacture(_facture);
};
const leftToolbarTemplate = () => {
return (
<div className="my-2">
<Button
label="Nouveau"
icon="pi pi-plus"
severity="success"
className="mr-2"
onClick={openNew}
/>
<Button
label="Supprimer"
icon="pi pi-trash"
severity="danger"
onClick={() => setDeleteFacturessDialog(true)}
disabled={!selectedFactures || selectedFactures.length === 0}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<div className="flex gap-2">
<Button
label="Relances"
icon="pi pi-send"
severity="warning"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Relances',
detail: 'Fonctionnalité de relance non implémentée',
life: 3000
});
}}
/>
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
</div>
);
};
const actionBodyTemplate = (rowData: Facture) => {
return (
<ActionButtonGroup>
<ViewButton
tooltip="Aperçu PDF"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Aperçu',
detail: `Aperçu de la facture ${rowData.numero}`,
life: 3000
});
}}
/>
<ActionButton
icon="pi pi-send"
color="secondary"
tooltip="Envoyer par email"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Envoi',
detail: `Envoi de la facture ${rowData.numero}`,
life: 3000
});
}}
/>
<EditButton
onClick={() => editFacture(rowData)}
/>
<DeleteButton
onClick={() => confirmDeleteFacture(rowData)}
/>
</ActionButtonGroup>
);
};
const statusBodyTemplate = (rowData: Facture) => {
const getSeverity = (status: string) => {
switch (status) {
case 'BROUILLON': return 'secondary';
case 'EMISE': return 'info';
case 'ENVOYEE': return 'info';
case 'PAYEE': return 'success';
case 'EN_RETARD': return 'danger';
case 'ANNULEE': return 'danger';
default: return 'secondary';
}
};
const getLabel = (status: string) => {
switch (status) {
case 'BROUILLON': return 'Brouillon';
case 'EMISE': return 'Émise';
case 'ENVOYEE': return 'Envoyée';
case 'PAYEE': return 'Payée';
case 'EN_RETARD': return 'En retard';
case 'ANNULEE': return 'Annulée';
default: return status;
}
};
return (
<Tag
value={getLabel(rowData.statut)}
severity={getSeverity(rowData.statut)}
/>
);
};
const typeBodyTemplate = (rowData: Facture) => {
const getTypeLabel = (type: string) => {
switch (type) {
case 'FACTURE': return 'Facture';
case 'ACOMPTE': return 'Acompte';
case 'SOLDE': return 'Solde';
case 'AVOIR': return 'Avoir';
default: return type;
}
};
return (
<Tag
value={getTypeLabel(rowData.type)}
severity={rowData.type === 'AVOIR' ? 'warning' : 'info'}
/>
);
};
const clientBodyTemplate = (rowData: Facture) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const dateBodyTemplate = (rowData: Facture, field: string) => {
const date = (rowData as any)[field];
return date ? formatDate(date) : '';
};
const echeanceBodyTemplate = (rowData: Facture) => {
const today = new Date();
const echeanceDate = new Date(rowData.dateEcheance);
const isOverdue = echeanceDate < today && rowData.statut !== 'PAYEE';
return (
<div className={`flex align-items-center ${isOverdue ? 'text-red-500' : ''}`}>
{isOverdue && <i className="pi pi-exclamation-triangle mr-1"></i>}
{formatDate(rowData.dateEcheance)}
</div>
);
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Gestion des Factures</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 factureDialogFooter = (
<>
<Button label="Annuler" icon="pi pi-times" text onClick={hideDialog} />
<Button label="Sauvegarder" icon="pi pi-check" text onClick={saveFacture} />
</>
);
const deleteFactureDialogFooter = (
<>
<Button label="Non" icon="pi pi-times" text onClick={hideDeleteFactureDialog} />
<Button label="Oui" icon="pi pi-check" text onClick={deleteFacture} />
</>
);
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 trouvée."
header={header}
responsiveLayout="scroll"
loading={loading}
>
<Column selectionMode="multiple" headerStyle={{ width: '4rem' }} />
<Column field="numero" header="Numéro" sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="type" header="Type" body={typeBodyTemplate} sortable headerStyle={{ minWidth: '8rem' }} />
<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) => dateBodyTemplate(rowData, 'dateEmission')} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="dateEcheance" header="Date échéance" body={echeanceBodyTemplate} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="datePaiement" header="Date paiement" body={(rowData) => dateBodyTemplate(rowData, 'datePaiement')} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="montantHT" header="Montant HT" body={(rowData) => formatCurrency(rowData.montantHT)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="montantTTC" header="Montant TTC" body={(rowData) => formatCurrency(rowData.montantTTC)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="statut" header="Statut" body={statusBodyTemplate} sortable headerStyle={{ minWidth: '8rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '14rem' }} />
</DataTable>
<Dialog
visible={factureDialog}
style={{ width: '700px' }}
header="Détails de la Facture"
modal
className="p-fluid"
footer={factureDialogFooter}
onHide={hideDialog}
>
<div className="formgrid grid">
<div className="field col-12 md:col-6">
<label htmlFor="numero">Numéro</label>
<InputText
id="numero"
value={facture.numero}
onChange={(e) => onInputChange(e, 'numero')}
placeholder="Généré automatiquement"
disabled
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="type">Type</label>
<Dropdown
id="type"
value={facture.type}
options={types}
onChange={(e) => onDropdownChange(e, 'type')}
placeholder="Sélectionnez un type"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="client">Client</label>
<Dropdown
id="client"
value={facture.client}
options={clients}
onChange={(e) => onDropdownChange(e, 'client')}
placeholder="Sélectionnez un client"
required
className={submitted && !facture.client ? 'p-invalid' : ''}
/>
{submitted && !facture.client && <small className="p-invalid">Le client est requis.</small>}
</div>
<div className="field col-12 md:col-6">
<label htmlFor="statut">Statut</label>
<Dropdown
id="statut"
value={facture.statut}
options={statuts}
onChange={(e) => onDropdownChange(e, 'statut')}
placeholder="Sélectionnez un statut"
/>
</div>
<div className="field col-12">
<label htmlFor="objet">Objet</label>
<InputText
id="objet"
value={facture.objet}
onChange={(e) => onInputChange(e, 'objet')}
required
className={submitted && !facture.objet ? 'p-invalid' : ''}
placeholder="Objet de la facture"
/>
{submitted && !facture.objet && <small className="p-invalid">L'objet est requis.</small>}
</div>
<div className="field col-12">
<label htmlFor="description">Description</label>
<InputTextarea
id="description"
value={facture.description}
onChange={(e) => onInputChange(e, 'description')}
rows={4}
placeholder="Description détaillée des travaux facturés"
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="dateEmission">Date d'émission</label>
<Calendar
id="dateEmission"
value={facture.dateEmission}
onChange={(e) => onDateChange(e, 'dateEmission')}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="dateEcheance">Date d'échéance</label>
<Calendar
id="dateEcheance"
value={facture.dateEcheance}
onChange={(e) => onDateChange(e, 'dateEcheance')}
dateFormat="dd/mm/yy"
showIcon
minDate={facture.dateEmission}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="datePaiement">Date de paiement</label>
<Calendar
id="datePaiement"
value={facture.datePaiement}
onChange={(e) => onDateChange(e, 'datePaiement')}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="montantHT">Montant HT (€)</label>
<InputNumber
id="montantHT"
value={facture.montantHT}
onValueChange={(e) => onNumberChange(e, 'montantHT')}
mode="currency"
currency="EUR"
locale="fr-FR"
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="tauxTVA">Taux TVA (%)</label>
<InputNumber
id="tauxTVA"
value={facture.tauxTVA}
onValueChange={(e) => onNumberChange(e, 'tauxTVA')}
suffix="%"
min={0}
max={100}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="montantTTC">Montant TTC (€)</label>
<InputNumber
id="montantTTC"
value={facture.montantTTC}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
</div>
</Dialog>
<Dialog
visible={deleteFactureDialog}
style={{ width: '450px' }}
header="Confirmer"
modal
footer={deleteFactureDialogFooter}
onHide={hideDeleteFactureDialog}
>
<div className="flex align-items-center justify-content-center">
<i className="pi pi-exclamation-triangle mr-3" style={{ fontSize: '2rem' }} />
{facture && (
<span>
Êtes-vous sûr de vouloir supprimer la facture <b>{facture.numero}</b> ?
</span>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default FacturesPage;

View File

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

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;

View File

@@ -0,0 +1,715 @@
'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 { ProgressBar } from 'primereact/progressbar';
import { factureService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Facture } from '../../../../types/btp';
import factureActionsService from '../../../../services/factureActionsService';
const FacturesRetardPage = () => {
const [factures, setFactures] = useState<Facture[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedFactures, setSelectedFactures] = useState<Facture[]>([]);
const [actionDialog, setActionDialog] = useState(false);
const [selectedFacture, setSelectedFacture] = useState<Facture | null>(null);
const [actionType, setActionType] = useState<'urgent_reminder' | 'legal_notice' | 'suspend_client'>('urgent_reminder');
const [urgentReminderData, setUrgentReminderData] = useState({
method: 'RECOMMANDE',
deadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
message: '',
penaltyRate: 0
});
const [legalNoticeData, setLegalNoticeData] = useState({
type: 'MISE_EN_DEMEURE',
deadline: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000),
lawyer: '',
content: ''
});
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Facture[]>>(null);
const reminderMethods = [
{ label: 'Courrier recommandé', value: 'RECOMMANDE' },
{ label: 'Huissier', value: 'HUISSIER' },
{ label: 'Avocat', value: 'AVOCAT' },
{ label: 'Email urgent', value: 'EMAIL_URGENT' },
{ label: 'Téléphone + Courrier', value: 'TELEPHONE_COURRIER' }
];
const legalNoticeTypes = [
{ label: 'Mise en demeure', value: 'MISE_EN_DEMEURE' },
{ label: 'Commandement de payer', value: 'COMMANDEMENT' },
{ label: 'Assignation en référé', value: 'REFERE' },
{ label: 'Procédure simplifiée', value: 'PROCEDURE_SIMPLIFIEE' }
];
useEffect(() => {
loadFactures();
}, []);
const loadFactures = async () => {
try {
setLoading(true);
const data = await factureService.getAll();
// Filtrer les factures en retard (échéance dépassée + statut EN_RETARD)
const facturesEnRetard = data.filter(facture => {
const today = new Date();
const echeanceDate = new Date(facture.dateEcheance);
return (facture.statut === 'EN_RETARD' ||
(echeanceDate < today && facture.statut !== 'PAYEE' && !facture.datePaiement));
});
setFactures(facturesEnRetard);
} catch (error) {
console.error('Erreur lors du chargement des factures:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les factures en retard',
life: 3000
});
} finally {
setLoading(false);
}
};
const getDaysOverdue = (dateEcheance: string | Date) => {
const today = new Date();
const dueDate = new Date(dateEcheance);
const diffTime = today.getTime() - dueDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
};
const getOverdueCategory = (dateEcheance: string | Date) => {
const days = getDaysOverdue(dateEcheance);
if (days <= 15) return { category: 'RETARD RÉCENT', color: 'orange', severity: 'warning' as const, priority: 'MOYENNE' };
if (days <= 45) return { category: 'RETARD IMPORTANT', color: 'red', severity: 'danger' as const, priority: 'ÉLEVÉE' };
return { category: 'RETARD CRITIQUE', color: 'darkred', severity: 'danger' as const, priority: 'URGENTE' };
};
const calculateInterestPenalty = (amount: number, days: number, rate: number = 10) => {
// Calcul des pénalités de retard (taux annuel)
const dailyRate = rate / 365 / 100;
return amount * dailyRate * days;
};
const sendUrgentReminder = (facture: Facture) => {
setSelectedFacture(facture);
setActionType('urgent_reminder');
const days = getDaysOverdue(facture.dateEcheance);
setUrgentReminderData({
method: 'RECOMMANDE',
deadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
message: `RELANCE URGENTE\n\nMadame, Monsieur,\n\nMalgré nos précédents rappels, nous constatons que la facture ${facture.numero} d'un montant de ${formatCurrency(facture.montantTTC || 0)} n'a toujours pas été réglée.\n\nCette facture est en retard de ${days} jours depuis son échéance du ${formatDate(facture.dateEcheance)}.\n\nEn l'absence de règlement sous 8 jours, nous nous verrons contraints d'engager une procédure de recouvrement contentieux.\n\nPénalités de retard applicables: ${formatCurrency(calculateInterestPenalty(facture.montantTTC || 0, days))}\n\nNous vous demandons de bien vouloir régulariser cette situation dans les plus brefs délais.`,
penaltyRate: 10
});
setActionDialog(true);
};
const issueLegalNotice = (facture: Facture) => {
setSelectedFacture(facture);
setActionType('legal_notice');
setLegalNoticeData({
type: 'MISE_EN_DEMEURE',
deadline: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000),
lawyer: '',
content: `MISE EN DEMEURE DE PAYER\n\nPar la présente, nous vous mettons en demeure de procéder au règlement de la facture ${facture.numero} d'un montant de ${formatCurrency(facture.montantTTC || 0)}, échue depuis le ${formatDate(facture.dateEcheance)}.\n\nVous disposez d'un délai de 15 jours à compter de la réception de cette mise en demeure pour procéder au règlement.\n\nÀ défaut, nous nous réservons le droit d'engager contre vous une procédure judiciaire en recouvrement de créances, sans autre préavis.`
});
setActionDialog(true);
};
const suspendClient = (facture: Facture) => {
setSelectedFacture(facture);
setActionType('suspend_client');
setActionDialog(true);
};
const handleAction = async () => {
if (!selectedFacture) return;
try {
let message = '';
switch (actionType) {
case 'urgent_reminder':
await factureActionsService.sendUrgentRelance(
selectedFacture.id,
urgentReminderData.message
);
message = 'Relance urgente envoyée avec succès';
break;
case 'legal_notice':
await factureActionsService.sendMiseEnDemeure({
factureId: selectedFacture.id,
delaiPaiement: legalNoticeData.delai,
mentionsLegales: legalNoticeData.mentions,
fraisDossier: legalNoticeData.frais
});
message = 'Mise en demeure émise avec succès';
break;
case 'suspend_client':
await factureActionsService.suspendClient({
clientId: selectedFacture.client.id,
motif: 'Factures impayées en retard',
temporaire: false
});
message = 'Client suspendu pour impayés';
break;
}
setActionDialog(false);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `${message} (simulation)`,
life: 3000
});
} catch (error) {
console.error('Erreur lors de l\'action:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible d\'effectuer l\'action',
life: 3000
});
}
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const bulkLegalAction = async () => {
if (selectedFactures.length === 0) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez sélectionner au moins une facture',
life: 3000
});
return;
}
// Simulation de procédure contentieuse en lot
console.log('Procédure contentieuse en lot pour', selectedFactures.length, 'factures');
setSelectedFactures([]);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `Procédure contentieuse engagée pour ${selectedFactures.length} facture(s) (simulation)`,
life: 3000
});
};
const generateOverdueAnalysis = () => {
const totalOverdue = factures.reduce((sum, f) => sum + (f.montantTTC || 0), 0);
const totalPenalties = factures.reduce((sum, f) => sum + calculateInterestPenalty(f.montantTTC || 0, getDaysOverdue(f.dateEcheance)), 0);
const criticalOverdue = factures.filter(f => getDaysOverdue(f.dateEcheance) > 45);
const recentOverdue = factures.filter(f => getDaysOverdue(f.dateEcheance) <= 15);
const report = `
=== ANALYSE FACTURES EN RETARD ===
Date du rapport: ${new Date().toLocaleDateString('fr-FR')}
SITUATION CRITIQUE:
- Nombre total de factures en retard: ${factures.length}
- Montant total en retard: ${formatCurrency(totalOverdue)}
- Pénalités de retard calculées: ${formatCurrency(totalPenalties)}
- Impact sur la trésorerie: ${formatCurrency(totalOverdue + totalPenalties)}
RÉPARTITION PAR GRAVITÉ:
- Retard récent (≤15 jours): ${recentOverdue.length} factures, ${formatCurrency(recentOverdue.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}
- Retard critique (>45 jours): ${criticalOverdue.length} factures, ${formatCurrency(criticalOverdue.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}
FACTURES PRIORITAIRES (>45 jours):
${criticalOverdue.map(f => {
const days = getDaysOverdue(f.dateEcheance);
const penalty = calculateInterestPenalty(f.montantTTC || 0, days);
return `
- ${f.numero} - ${f.objet}
Client: ${f.client ? `${f.client.prenom} ${f.client.nom}` : 'N/A'}
Montant: ${formatCurrency(f.montantTTC || 0)}
Retard: ${days} jours
Pénalités: ${formatCurrency(penalty)}
Total dû: ${formatCurrency((f.montantTTC || 0) + penalty)}
`;
}).join('')}
CLIENTS À RISQUE:
${getHighRiskClients()}
ACTIONS RECOMMANDÉES:
- Mise en demeure immédiate pour les retards >45 jours
- Suspension des prestations pour les clients récidivistes
- Engagement de procédures contentieuses si nécessaire
- Révision des conditions de paiement accordées
- Application systématique des pénalités de retard
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `analyse_retards_critiques_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
toast.current?.show({
severity: 'success',
summary: 'Analyse générée',
detail: 'Le rapport d\'analyse a été téléchargé',
life: 3000
});
};
const getHighRiskClients = () => {
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, maxDays: 0, totalPenalties: 0 };
}
const days = getDaysOverdue(f.dateEcheance);
clientStats[clientKey].count++;
clientStats[clientKey].value += f.montantTTC || 0;
clientStats[clientKey].maxDays = Math.max(clientStats[clientKey].maxDays, days);
clientStats[clientKey].totalPenalties += calculateInterestPenalty(f.montantTTC || 0, days);
}
});
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)}, retard max: ${data.maxDays} jours, pénalités: ${formatCurrency(data.totalPenalties)}`)
.join('\n');
};
const leftToolbarTemplate = () => {
return (
<div className="my-2 flex gap-2">
<h5 className="m-0 flex align-items-center text-red-700">
<i className="pi pi-exclamation-triangle mr-2"></i>
Factures en retard ({factures.length})
</h5>
<Chip
label={`Montant en retard: ${formatCurrency(factures.reduce((sum, f) => sum + (f.montantTTC || 0), 0))}`}
className="bg-red-100 text-red-800"
/>
<Chip
label={`Pénalités: ${formatCurrency(factures.reduce((sum, f) => sum + calculateInterestPenalty(f.montantTTC || 0, getDaysOverdue(f.dateEcheance)), 0))}`}
className="bg-red-200 text-red-900"
/>
<Button
label="Contentieux groupé"
icon="pi pi-exclamation-triangle"
severity="danger"
size="small"
onClick={bulkLegalAction}
disabled={selectedFactures.length === 0}
/>
<Button
label="Analyse des retards"
icon="pi pi-chart-line"
severity="danger"
size="small"
onClick={generateOverdueAnalysis}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Facture) => {
const category = getOverdueCategory(rowData.dateEcheance);
const isCritical = category.category === 'RETARD CRITIQUE';
return (
<div className="flex gap-1">
<Button
icon="pi pi-send"
rounded
severity="warning"
size="small"
tooltip="Relance urgente"
onClick={() => sendUrgentReminder(rowData)}
/>
{isCritical && (
<Button
icon="pi pi-ban"
rounded
severity="danger"
size="small"
tooltip="Mise en demeure"
onClick={() => issueLegalNotice(rowData)}
/>
)}
<Button
icon="pi pi-user-minus"
rounded
severity="secondary"
size="small"
tooltip="Suspendre le client"
onClick={() => suspendClient(rowData)}
/>
<Button
icon="pi pi-eye"
rounded
severity="help"
size="small"
tooltip="Voir détails"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: `Détails de la facture ${rowData.numero}`,
life: 3000
});
}}
/>
</div>
);
};
const statusBodyTemplate = (rowData: Facture) => {
const category = getOverdueCategory(rowData.dateEcheance);
return (
<div className="flex align-items-center gap-2">
<Tag value="En retard" severity="danger" />
<Tag
value={category.category}
severity={category.severity}
/>
</div>
);
};
const overdueBodyTemplate = (rowData: Facture) => {
const days = getDaysOverdue(rowData.dateEcheance);
const category = getOverdueCategory(rowData.dateEcheance);
const penalty = calculateInterestPenalty(rowData.montantTTC || 0, days);
return (
<div className="text-red-700">
<div className="font-bold">{formatDate(rowData.dateEcheance)}</div>
<small className="text-red-600">
Retard de {days} jour{days > 1 ? 's' : ''}
</small>
<div className="mt-1">
<Chip
label={`Pénalités: ${formatCurrency(penalty)}`}
style={{ backgroundColor: category.color, color: 'white', fontSize: '0.7rem' }}
/>
</div>
</div>
);
};
const priorityBodyTemplate = (rowData: Facture) => {
const category = getOverdueCategory(rowData.dateEcheance);
const days = getDaysOverdue(rowData.dateEcheance);
const urgencyProgress = Math.min((days / 60) * 100, 100); // 60 jours = 100% urgent
return (
<div>
<div className="flex align-items-center gap-2 mb-1">
<Tag
value={category.priority}
severity={category.severity}
className="text-xs"
/>
</div>
<ProgressBar
value={urgencyProgress}
style={{ height: '6px' }}
color={category.color}
/>
<small className="text-600">{Math.round(urgencyProgress)}% critique</small>
</div>
);
};
const clientBodyTemplate = (rowData: Facture) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const amountBodyTemplate = (rowData: Facture) => {
const penalty = calculateInterestPenalty(rowData.montantTTC || 0, getDaysOverdue(rowData.dateEcheance));
const total = (rowData.montantTTC || 0) + penalty;
return (
<div>
<div className="font-bold">{formatCurrency(rowData.montantTTC || 0)}</div>
<small className="text-red-600">+ {formatCurrency(penalty)} pénalités</small>
<div className="font-bold text-red-700">{formatCurrency(total)}</div>
</div>
);
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Factures en retard - Recouvrement contentieux</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 actionDialogFooter = (
<>
<Button
label="Annuler"
icon="pi pi-times"
text
onClick={() => setActionDialog(false)}
/>
<Button
label={
actionType === 'urgent_reminder' ? 'Envoyer la relance' :
actionType === 'legal_notice' ? 'Émettre la mise en demeure' :
'Suspendre le client'
}
icon={
actionType === 'urgent_reminder' ? 'pi pi-send' :
actionType === 'legal_notice' ? 'pi pi-ban' :
'pi pi-user-minus'
}
text
onClick={handleAction}
/>
</>
);
const getActionTitle = () => {
switch (actionType) {
case 'urgent_reminder': return 'Relance urgente';
case 'legal_notice': return 'Mise en demeure';
case 'suspend_client': return 'Suspension du client';
default: return 'Action';
}
};
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 en retard trouvée."
header={header}
responsiveLayout="scroll"
loading={loading}
sortField="dateEcheance"
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="dateEcheance" header="Échéance / Retard" body={overdueBodyTemplate} sortable headerStyle={{ minWidth: '14rem' }} />
<Column field="montantTTC" header="Montant + Pénalités" body={amountBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="priority" header="Priorité" body={priorityBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column field="statut" header="Statut" body={statusBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '15rem' }} />
</DataTable>
<Dialog
visible={actionDialog}
style={{ width: '700px' }}
header={getActionTitle()}
modal
className="p-fluid"
footer={actionDialogFooter}
onHide={() => setActionDialog(false)}
>
<div className="formgrid grid">
<div className="field col-12">
<p>
Facture: <strong>{selectedFacture?.numero} - {selectedFacture?.objet}</strong>
</p>
<p>
Client: <strong>{selectedFacture?.client ? `${selectedFacture.client.prenom} ${selectedFacture.client.nom}` : 'N/A'}</strong>
</p>
<p>
Montant initial: <strong>{selectedFacture ? formatCurrency(selectedFacture.montantTTC || 0) : ''}</strong>
</p>
{selectedFacture && (
<>
<p>
Retard: <strong>{getDaysOverdue(selectedFacture.dateEcheance)} jour(s)</strong>
</p>
<p>
Pénalités: <strong>{formatCurrency(calculateInterestPenalty(selectedFacture.montantTTC || 0, getDaysOverdue(selectedFacture.dateEcheance)))}</strong>
</p>
<p className="font-bold text-red-600">
Total : <strong>{formatCurrency((selectedFacture.montantTTC || 0) + calculateInterestPenalty(selectedFacture.montantTTC || 0, getDaysOverdue(selectedFacture.dateEcheance)))}</strong>
</p>
</>
)}
</div>
{actionType === 'urgent_reminder' && (
<>
<div className="field col-12 md:col-6">
<label htmlFor="reminderMethod">Méthode de relance</label>
<Dropdown
id="reminderMethod"
value={urgentReminderData.method}
options={reminderMethods}
onChange={(e) => setUrgentReminderData(prev => ({ ...prev, method: e.value }))}
placeholder="Sélectionnez la méthode"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="deadline">Délai de règlement</label>
<Calendar
id="deadline"
value={urgentReminderData.deadline}
onChange={(e) => setUrgentReminderData(prev => ({ ...prev, deadline: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
minDate={new Date()}
/>
</div>
<div className="field col-12">
<label htmlFor="reminderMessage">Message de relance</label>
<InputTextarea
id="reminderMessage"
value={urgentReminderData.message}
onChange={(e) => setUrgentReminderData(prev => ({ ...prev, message: e.target.value }))}
rows={6}
placeholder="Message de relance urgente..."
/>
</div>
</>
)}
{actionType === 'legal_notice' && (
<>
<div className="field col-12 md:col-6">
<label htmlFor="legalType">Type de procédure</label>
<Dropdown
id="legalType"
value={legalNoticeData.type}
options={legalNoticeTypes}
onChange={(e) => setLegalNoticeData(prev => ({ ...prev, type: e.value }))}
placeholder="Sélectionnez le type"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="legalDeadline">Délai de mise en demeure</label>
<Calendar
id="legalDeadline"
value={legalNoticeData.deadline}
onChange={(e) => setLegalNoticeData(prev => ({ ...prev, deadline: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
minDate={new Date()}
/>
</div>
<div className="field col-12">
<label htmlFor="lawyer">Avocat ou huissier</label>
<InputText
id="lawyer"
value={legalNoticeData.lawyer}
onChange={(e) => setLegalNoticeData(prev => ({ ...prev, lawyer: e.target.value }))}
placeholder="Nom du professionnel en charge"
/>
</div>
<div className="field col-12">
<label htmlFor="legalContent">Contenu de la mise en demeure</label>
<InputTextarea
id="legalContent"
value={legalNoticeData.content}
onChange={(e) => setLegalNoticeData(prev => ({ ...prev, content: e.target.value }))}
rows={6}
placeholder="Contenu de la mise en demeure..."
/>
</div>
</>
)}
{actionType === 'suspend_client' && (
<div className="field col-12">
<div className="bg-red-50 p-3 border-round">
<p className="text-red-700 font-bold">
<i className="pi pi-exclamation-triangle mr-2"></i>
Attention : Suspension du client
</p>
<p>
Cette action va suspendre ce client pour cause d'impayés. Les conséquences seront :
</p>
<ul className="text-red-600">
<li>Arrêt immédiat de tous les travaux en cours</li>
<li>Blocage de toute nouvelle commande</li>
<li>Notification automatique des équipes</li>
<li>Gel des livraisons de matériel</li>
</ul>
<p className="font-bold">
Confirmer la suspension de ce client ?
</p>
</div>
</div>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default FacturesRetardPage;

View File

@@ -0,0 +1,527 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Chart } from 'primereact/chart';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Toast } from 'primereact/toast';
import { Toolbar } from 'primereact/toolbar';
import { Badge } from 'primereact/badge';
import { factureService } from '../../../../services/api';
import { formatCurrency, formatDate } from '../../../../utils/formatters';
interface FactureStats {
totalFactures: number;
chiffreAffaires: number;
montantEnAttente: number;
montantEnRetard: number;
tauxRecouvrement: number;
delaiMoyenPaiement: number;
repartitionStatuts: { [key: string]: number };
evolutionMensuelle: Array<{ mois: string; emises: number; payees: number; montantEmis: number; montantPaye: number }>;
topClients: Array<{ client: string; nombre: number; montant: number; montantPaye: number }>;
retardsParClient: Array<{ client: string; factures: number; montant: number; retardMoyen: number }>;
}
const FactureStatsPage = () => {
const toast = useRef<Toast>(null);
const [stats, setStats] = useState<FactureStats>({
totalFactures: 0,
chiffreAffaires: 0,
montantEnAttente: 0,
montantEnRetard: 0,
tauxRecouvrement: 0,
delaiMoyenPaiement: 0,
repartitionStatuts: {},
evolutionMensuelle: [],
topClients: [],
retardsParClient: []
});
const [loading, setLoading] = useState(true);
const [dateDebut, setDateDebut] = useState<Date>(new Date(new Date().getFullYear(), 0, 1));
const [dateFin, setDateFin] = useState<Date>(new Date());
const [periodeSelectionnee, setPeriodeSelectionnee] = useState('annee');
const periodeOptions = [
{ label: 'Cette année', value: 'annee' },
{ label: 'Ce trimestre', value: 'trimestre' },
{ label: 'Ce mois', value: 'mois' },
{ label: 'Personnalisée', value: 'custom' }
];
useEffect(() => {
loadStats();
}, [dateDebut, dateFin]);
const loadStats = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await factureService.getStatistiques(dateDebut, dateFin);
// Données simulées pour la démonstration
const mockStats: FactureStats = {
totalFactures: 234,
chiffreAffaires: 3250000,
montantEnAttente: 485000,
montantEnRetard: 125000,
tauxRecouvrement: 87.5,
delaiMoyenPaiement: 28.5,
repartitionStatuts: {
'PAYEE': 156,
'ENVOYEE': 45,
'EN_RETARD': 18,
'PARTIELLEMENT_PAYEE': 12,
'BROUILLON': 3
},
evolutionMensuelle: [
{ mois: 'Jan', emises: 18, payees: 15, montantEmis: 285000, montantPaye: 240000 },
{ mois: 'Fév', emises: 22, payees: 19, montantEmis: 340000, montantPaye: 295000 },
{ mois: 'Mar', emises: 25, payees: 22, montantEmis: 395000, montantPaye: 350000 },
{ mois: 'Avr', emises: 20, payees: 18, montantEmis: 315000, montantPaye: 285000 },
{ mois: 'Mai', emises: 28, payees: 24, montantEmis: 445000, montantPaye: 380000 },
{ mois: 'Jun', emises: 24, payees: 21, montantEmis: 375000, montantPaye: 320000 },
{ mois: 'Jul', emises: 30, payees: 26, montantEmis: 485000, montantPaye: 420000 },
{ mois: 'Aoû', emises: 26, payees: 23, montantEmis: 410000, montantPaye: 365000 },
{ mois: 'Sep', emises: 29, payees: 25, montantEmis: 465000, montantPaye: 395000 }
],
topClients: [
{ client: 'Bouygues Construction', nombre: 12, montant: 680000, montantPaye: 620000 },
{ client: 'Vinci Construction', nombre: 9, montant: 520000, montantPaye: 485000 },
{ client: 'Eiffage', nombre: 8, montant: 445000, montantPaye: 445000 },
{ client: 'Spie Batignolles', nombre: 6, montant: 385000, montantPaye: 320000 },
{ client: 'GTM Bâtiment', nombre: 5, montant: 295000, montantPaye: 295000 }
],
retardsParClient: [
{ client: 'Constructa SARL', factures: 3, montant: 85000, retardMoyen: 45 },
{ client: 'Bâti Plus', factures: 2, montant: 65000, retardMoyen: 38 },
{ client: 'Rénov Express', factures: 4, montant: 125000, retardMoyen: 32 },
{ client: 'Maisons du Sud', factures: 1, montant: 35000, retardMoyen: 28 }
]
};
setStats(mockStats);
} catch (error) {
console.error('Erreur lors du chargement des statistiques:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les statistiques'
});
} finally {
setLoading(false);
}
};
const handlePeriodeChange = (periode: string) => {
setPeriodeSelectionnee(periode);
const now = new Date();
switch (periode) {
case 'annee':
setDateDebut(new Date(now.getFullYear(), 0, 1));
setDateFin(new Date());
break;
case 'trimestre':
const trimestre = Math.floor(now.getMonth() / 3);
setDateDebut(new Date(now.getFullYear(), trimestre * 3, 1));
setDateFin(new Date());
break;
case 'mois':
setDateDebut(new Date(now.getFullYear(), now.getMonth(), 1));
setDateFin(new Date());
break;
}
};
// Configuration des graphiques
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const
}
},
scales: {
y: {
beginAtZero: true
}
}
};
const evolutionData = {
labels: stats.evolutionMensuelle.map(item => item.mois),
datasets: [
{
label: 'Factures émises',
data: stats.evolutionMensuelle.map(item => item.emises),
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
yAxisID: 'y'
},
{
label: 'Factures payées',
data: stats.evolutionMensuelle.map(item => item.payees),
borderColor: '#10B981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
yAxisID: 'y'
}
]
};
const chiffreAffairesData = {
labels: stats.evolutionMensuelle.map(item => item.mois),
datasets: [
{
label: 'CA émis (k€)',
data: stats.evolutionMensuelle.map(item => item.montantEmis / 1000),
backgroundColor: 'rgba(59, 130, 246, 0.8)'
},
{
label: 'CA encaissé (k€)',
data: stats.evolutionMensuelle.map(item => item.montantPaye / 1000),
backgroundColor: 'rgba(16, 185, 129, 0.8)'
}
]
};
const repartitionData = {
labels: Object.keys(stats.repartitionStatuts),
datasets: [{
data: Object.values(stats.repartitionStatuts),
backgroundColor: [
'#10B981', // PAYEE - vert
'#3B82F6', // ENVOYEE - bleu
'#EF4444', // EN_RETARD - rouge
'#F59E0B', // PARTIELLEMENT_PAYEE - orange
'#6B7280' // BROUILLON - gris
]
}]
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'PAYEE': return 'success';
case 'EN_RETARD': return 'danger';
case 'PARTIELLEMENT_PAYEE': return 'warning';
case 'ENVOYEE': return 'info';
case 'BROUILLON': return 'secondary';
default: return 'info';
}
};
const toolbarStartTemplate = () => (
<div className="flex align-items-center gap-2">
<h2 className="text-xl font-bold m-0">Statistiques des Factures</h2>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
<Dropdown
value={periodeSelectionnee}
options={periodeOptions}
onChange={(e) => handlePeriodeChange(e.value)}
className="w-10rem"
/>
{periodeSelectionnee === 'custom' && (
<>
<Calendar
value={dateDebut}
onChange={(e) => setDateDebut(e.value || new Date())}
placeholder="Date début"
dateFormat="dd/mm/yy"
/>
<Calendar
value={dateFin}
onChange={(e) => setDateFin(e.value || new Date())}
placeholder="Date fin"
dateFormat="dd/mm/yy"
/>
</>
)}
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadStats}
loading={loading}
/>
</div>
);
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* KPIs principaux */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Total Factures</span>
<div className="text-900 font-medium text-xl">{stats.totalFactures}</div>
</div>
<div className="flex align-items-center justify-content-center bg-blue-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-file text-blue-500 text-xl"></i>
</div>
</div>
<span className="text-green-500 font-medium">+15% </span>
<span className="text-500">vs période précédente</span>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Chiffre d'Affaires</span>
<div className="text-900 font-medium text-xl">{formatCurrency(stats.chiffreAffaires)}</div>
</div>
<div className="flex align-items-center justify-content-center bg-green-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-euro text-green-500 text-xl"></i>
</div>
</div>
<span className="text-green-500 font-medium">+12% </span>
<span className="text-500">vs période précédente</span>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Taux de Recouvrement</span>
<div className="text-900 font-medium text-xl">{stats.tauxRecouvrement}%</div>
</div>
<div className="flex align-items-center justify-content-center bg-orange-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-chart-line text-orange-500 text-xl"></i>
</div>
</div>
<ProgressBar value={stats.tauxRecouvrement} className="mt-2" style={{ height: '6px' }} />
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Délai Moyen Paiement</span>
<div className="text-900 font-medium text-xl">{stats.delaiMoyenPaiement} jours</div>
</div>
<div className="flex align-items-center justify-content-center bg-cyan-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-clock text-cyan-500 text-xl"></i>
</div>
</div>
<span className="text-red-500 font-medium">+2.5j </span>
<span className="text-500">vs période précédente</span>
</Card>
</div>
{/* Alertes financières */}
<div className="col-12 lg:col-6">
<Card className="h-full">
<div className="flex justify-content-between align-items-center mb-4">
<h5 className="m-0">Alertes Financières</h5>
<Badge value="2" severity="danger" />
</div>
<div className="grid">
<div className="col-12">
<div className="p-3 border-round bg-red-50 border-left-3 border-red-500">
<div className="flex justify-content-between align-items-center">
<div>
<h6 className="text-red-900 mb-1">Factures en retard</h6>
<p className="text-red-800 text-sm m-0">{formatCurrency(stats.montantEnRetard)} à recouvrer</p>
</div>
<Badge value={stats.repartitionStatuts['EN_RETARD'] || 0} severity="danger" />
</div>
</div>
</div>
<div className="col-12">
<div className="p-3 border-round bg-orange-50 border-left-3 border-orange-500">
<div className="flex justify-content-between align-items-center">
<div>
<h6 className="text-orange-900 mb-1">En attente de paiement</h6>
<p className="text-orange-800 text-sm m-0">{formatCurrency(stats.montantEnAttente)} en cours</p>
</div>
<Badge value={stats.repartitionStatuts['ENVOYEE'] || 0} severity="warning" />
</div>
</div>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-6">
<Card title="Répartition par statut" className="h-full">
<Chart
type="doughnut"
data={repartitionData}
options={chartOptions}
height="250px"
/>
</Card>
</div>
{/* Graphiques d'évolution */}
<div className="col-12 lg:col-6">
<Card title="Évolution du nombre de factures">
<Chart
type="line"
data={evolutionData}
options={chartOptions}
height="300px"
/>
</Card>
</div>
<div className="col-12 lg:col-6">
<Card title="Évolution du chiffre d'affaires">
<Chart
type="bar"
data={chiffreAffairesData}
options={chartOptions}
height="300px"
/>
</Card>
</div>
{/* Top clients */}
<div className="col-12 lg:col-6">
<Card title="Top 5 Clients par CA">
<DataTable value={stats.topClients} responsiveLayout="scroll">
<Column
field="client"
header="Client"
body={(rowData, options) => (
<div className="flex align-items-center">
<Badge value={options.rowIndex + 1} className="mr-2" />
{rowData.client}
</div>
)}
/>
<Column
field="nombre"
header="Nb Factures"
style={{ width: '100px' }}
/>
<Column
field="montant"
header="CA Total"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montant)}
/>
<Column
header="Taux Paiement"
style={{ width: '120px' }}
body={(rowData) => (
<Tag
value={`${Math.round((rowData.montantPaye / rowData.montant) * 100)}%`}
severity={rowData.montantPaye / rowData.montant > 0.9 ? 'success' : 'warning'}
/>
)}
/>
</DataTable>
</Card>
</div>
{/* Clients en retard */}
<div className="col-12 lg:col-6">
<Card title="Clients avec retards de paiement">
<DataTable value={stats.retardsParClient} responsiveLayout="scroll">
<Column field="client" header="Client" />
<Column
field="factures"
header="Nb Factures"
style={{ width: '100px' }}
/>
<Column
field="montant"
header="Montant"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montant)}
/>
<Column
field="retardMoyen"
header="Retard Moyen"
style={{ width: '120px' }}
body={(rowData) => (
<Tag
value={`${rowData.retardMoyen}j`}
severity={rowData.retardMoyen > 30 ? 'danger' : 'warning'}
/>
)}
/>
</DataTable>
</Card>
</div>
{/* Analyse et recommandations */}
<div className="col-12">
<Card title="Analyse et Recommandations">
<div className="grid">
<div className="col-12 md:col-4">
<div className="p-3 border-round bg-blue-50">
<h6 className="text-blue-900 mb-2">
<i className="pi pi-chart-line mr-2"></i>
Performance
</h6>
<ul className="text-sm text-blue-800 list-none p-0 m-0">
<li className="mb-1"> CA en croissance de 12%</li>
<li className="mb-1"> Taux de recouvrement à 87.5%</li>
<li className="mb-1"> 234 factures émises</li>
</ul>
</div>
</div>
<div className="col-12 md:col-4">
<div className="p-3 border-round bg-orange-50">
<h6 className="text-orange-900 mb-2">
<i className="pi pi-exclamation-triangle mr-2"></i>
Points d'attention
</h6>
<ul className="text-sm text-orange-800 list-none p-0 m-0">
<li className="mb-1"> {formatCurrency(stats.montantEnRetard)} en retard</li>
<li className="mb-1"> Délai moyen en hausse (+2.5j)</li>
<li className="mb-1"> 18 factures en retard</li>
</ul>
</div>
</div>
<div className="col-12 md:col-4">
<div className="p-3 border-round bg-green-50">
<h6 className="text-green-900 mb-2">
<i className="pi pi-lightbulb mr-2"></i>
Actions recommandées
</h6>
<ul className="text-sm text-green-800 list-none p-0 m-0">
<li className="mb-1"> Relancer les impayés</li>
<li className="mb-1"> Revoir les conditions de paiement</li>
<li className="mb-1"> Mettre en place des acomptes</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
</div>
);
};
export default FactureStatsPage;

View File

@@ -0,0 +1,668 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { Toolbar } from 'primereact/toolbar';
import { Dialog } from 'primereact/dialog';
import { ConfirmDialog } from 'primereact/confirmdialog';
import { Menu } from 'primereact/menu';
import { Badge } from 'primereact/badge';
import { Checkbox } from 'primereact/checkbox';
import { factureService } from '../../../../services/api';
import { formatDate } from '../../../../utils/formatters';
interface FactureTemplate {
id: string;
nom: string;
description: string;
type: string;
categorie: string;
lignes: Array<{
designation: string;
quantite: number;
unite: string;
prixUnitaire: number;
}>;
tauxTVA: number;
conditionsPaiement: string;
actif: boolean;
dateCreation: Date;
utilisations: number;
}
const FactureTemplatesPage = () => {
const toast = useRef<Toast>(null);
const menuRef = useRef<Menu>(null);
const [templates, setTemplates] = useState<FactureTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<FactureTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<FactureTemplate | null>(null);
const [globalFilter, setGlobalFilter] = useState('');
const [formData, setFormData] = useState<Partial<FactureTemplate>>({
nom: '',
description: '',
type: 'FACTURE',
categorie: '',
lignes: [],
tauxTVA: 20,
conditionsPaiement: 'Paiement à 30 jours',
actif: true
});
const typeOptions = [
{ label: 'Facture', value: 'FACTURE' },
{ label: 'Acompte', value: 'ACOMPTE' },
{ label: 'Facture de situation', value: 'SITUATION' },
{ label: 'Facture de solde', value: 'SOLDE' }
];
const categorieOptions = [
{ label: 'Gros œuvre', value: 'GROS_OEUVRE' },
{ label: 'Second œuvre', value: 'SECOND_OEUVRE' },
{ label: 'Finitions', value: 'FINITIONS' },
{ label: 'Plomberie', value: 'PLOMBERIE' },
{ label: 'Électricité', value: 'ELECTRICITE' },
{ label: 'Chauffage', value: 'CHAUFFAGE' },
{ label: 'Rénovation', value: 'RENOVATION' },
{ label: 'Maintenance', value: 'MAINTENANCE' }
];
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await factureService.getTemplates();
// Données simulées pour la démonstration
const mockTemplates: FactureTemplate[] = [
{
id: '1',
nom: 'Facture Rénovation Standard',
description: 'Template pour factures de rénovation complète',
type: 'FACTURE',
categorie: 'RENOVATION',
lignes: [
{ designation: 'Main d\'œuvre', quantite: 1, unite: 'forfait', prixUnitaire: 2500 },
{ designation: 'Matériaux', quantite: 1, unite: 'forfait', prixUnitaire: 1800 },
{ designation: 'Évacuation déchets', quantite: 1, unite: 'forfait', prixUnitaire: 300 }
],
tauxTVA: 20,
conditionsPaiement: 'Paiement à 30 jours',
actif: true,
dateCreation: new Date('2024-01-15'),
utilisations: 45
},
{
id: '2',
nom: 'Acompte Gros Œuvre',
description: 'Template pour acomptes sur travaux de gros œuvre',
type: 'ACOMPTE',
categorie: 'GROS_OEUVRE',
lignes: [
{ designation: 'Acompte 30% - Fondations', quantite: 1, unite: 'forfait', prixUnitaire: 0 }
],
tauxTVA: 20,
conditionsPaiement: 'Paiement à réception',
actif: true,
dateCreation: new Date('2024-02-10'),
utilisations: 28
},
{
id: '3',
nom: 'Facture Maintenance Préventive',
description: 'Template pour factures de maintenance préventive',
type: 'FACTURE',
categorie: 'MAINTENANCE',
lignes: [
{ designation: 'Contrôle technique', quantite: 1, unite: 'forfait', prixUnitaire: 150 },
{ designation: 'Remplacement pièces', quantite: 1, unite: 'forfait', prixUnitaire: 200 },
{ designation: 'Rapport de maintenance', quantite: 1, unite: 'forfait', prixUnitaire: 50 }
],
tauxTVA: 20,
conditionsPaiement: 'Paiement à 15 jours',
actif: true,
dateCreation: new Date('2024-03-05'),
utilisations: 67
}
];
setTemplates(mockTemplates);
} catch (error) {
console.error('Erreur lors du chargement des templates:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les templates'
});
} finally {
setLoading(false);
}
};
const handleNew = () => {
setEditingTemplate(null);
setFormData({
nom: '',
description: '',
type: 'FACTURE',
categorie: '',
lignes: [],
tauxTVA: 20,
conditionsPaiement: 'Paiement à 30 jours',
actif: true
});
setShowDialog(true);
};
const handleEdit = (template: FactureTemplate) => {
setEditingTemplate(template);
setFormData({ ...template });
setShowDialog(true);
};
const handleSave = async () => {
try {
if (editingTemplate) {
// TODO: Appel API pour mise à jour
// await factureService.updateTemplate(editingTemplate.id, formData);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Template modifié avec succès'
});
} else {
// TODO: Appel API pour création
// await factureService.createTemplate(formData);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Template créé avec succès'
});
}
setShowDialog(false);
loadTemplates();
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la sauvegarde'
});
}
};
const handleDelete = async (template: FactureTemplate) => {
try {
// TODO: Appel API pour suppression
// await factureService.deleteTemplate(template.id);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Template supprimé avec succès'
});
loadTemplates();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la suppression'
});
}
};
const handleUseTemplate = async (template: FactureTemplate) => {
try {
// TODO: Créer une nouvelle facture basée sur le template
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Redirection vers la création de facture...'
});
// Simuler la redirection
setTimeout(() => {
window.location.href = `/factures/nouveau?template=${template.id}`;
}, 1000);
} catch (error) {
console.error('Erreur lors de l\'utilisation du template:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de l\'utilisation du template'
});
}
};
const getMenuItems = (template: FactureTemplate) => [
{
label: 'Utiliser',
icon: 'pi pi-plus',
command: () => handleUseTemplate(template)
},
{
label: 'Modifier',
icon: 'pi pi-pencil',
command: () => handleEdit(template)
},
{
label: 'Dupliquer',
icon: 'pi pi-copy',
command: () => {
setEditingTemplate(null);
setFormData({
...template,
nom: `${template.nom} (Copie)`,
id: undefined
});
setShowDialog(true);
}
},
{
separator: true
},
{
label: 'Supprimer',
icon: 'pi pi-trash',
className: 'text-red-500',
command: () => {
setSelectedTemplate(template);
// TODO: Afficher dialog de confirmation
}
}
];
const actionBodyTemplate = (rowData: FactureTemplate) => (
<div className="flex gap-2">
<Button
icon="pi pi-plus"
className="p-button-text p-button-sm p-button-success"
tooltip="Utiliser ce template"
onClick={() => handleUseTemplate(rowData)}
/>
<Button
icon="pi pi-ellipsis-v"
className="p-button-text p-button-sm"
onClick={(e) => {
setSelectedTemplate(rowData);
menuRef.current?.toggle(e);
}}
/>
</div>
);
const typeBodyTemplate = (rowData: FactureTemplate) => (
<Tag
value={rowData.type}
severity={rowData.type === 'FACTURE' ? 'primary' : 'info'}
/>
);
const categorieBodyTemplate = (rowData: FactureTemplate) => {
const categorie = categorieOptions.find(opt => opt.value === rowData.categorie);
return (
<Tag
value={categorie?.label || rowData.categorie}
severity="info"
/>
);
};
const statutBodyTemplate = (rowData: FactureTemplate) => (
<Tag
value={rowData.actif ? 'Actif' : 'Inactif'}
severity={rowData.actif ? 'success' : 'danger'}
/>
);
const utilisationsBodyTemplate = (rowData: FactureTemplate) => (
<Badge value={rowData.utilisations} severity="info" />
);
const toolbarStartTemplate = () => (
<div className="flex align-items-center gap-2">
<h2 className="text-xl font-bold m-0">Templates de Factures</h2>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
<span className="p-input-icon-left">
<i className="pi pi-search" />
<InputText
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Rechercher..."
/>
</span>
<Button
label="Nouveau Template"
icon="pi pi-plus"
onClick={handleNew}
/>
</div>
);
return (
<div className="grid">
<Toast ref={toast} />
<ConfirmDialog />
<Menu ref={menuRef} model={selectedTemplate ? getMenuItems(selectedTemplate) : []} popup />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* Statistiques rapides */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Total Templates</span>
<div className="text-900 font-medium text-xl">{templates.length}</div>
</div>
<div className="flex align-items-center justify-content-center bg-blue-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-file text-blue-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Templates Actifs</span>
<div className="text-900 font-medium text-xl">
{templates.filter(t => t.actif).length}
</div>
</div>
<div className="flex align-items-center justify-content-center bg-green-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-check-circle text-green-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Plus Utilisé</span>
<div className="text-900 font-medium text-xl">
{Math.max(...templates.map(t => t.utilisations), 0)} fois
</div>
</div>
<div className="flex align-items-center justify-content-center bg-orange-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-star text-orange-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Types</span>
<div className="text-900 font-medium text-xl">
{new Set(templates.map(t => t.type)).size}
</div>
</div>
<div className="flex align-items-center justify-content-center bg-purple-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-tags text-purple-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Table des templates */}
<div className="col-12">
<Card>
<DataTable
value={templates}
loading={loading}
globalFilter={globalFilter}
responsiveLayout="scroll"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
emptyMessage="Aucun template trouvé"
header={
<div className="flex justify-content-between align-items-center">
<span className="text-xl font-bold">Liste des Templates</span>
<span className="text-sm text-600">{templates.length} template(s)</span>
</div>
}
>
<Column field="nom" header="Nom" sortable />
<Column field="description" header="Description" />
<Column
field="type"
header="Type"
body={typeBodyTemplate}
sortable
/>
<Column
field="categorie"
header="Catégorie"
body={categorieBodyTemplate}
sortable
/>
<Column
field="lignes"
header="Nb Lignes"
body={(rowData) => rowData.lignes?.length || 0}
style={{ width: '100px' }}
/>
<Column
field="utilisations"
header="Utilisations"
body={utilisationsBodyTemplate}
sortable
style={{ width: '120px' }}
/>
<Column
field="actif"
header="Statut"
body={statutBodyTemplate}
style={{ width: '100px' }}
/>
<Column
field="dateCreation"
header="Créé le"
body={(rowData) => formatDate(rowData.dateCreation)}
sortable
style={{ width: '120px' }}
/>
<Column
header="Actions"
body={actionBodyTemplate}
style={{ width: '120px' }}
/>
</DataTable>
</Card>
</div>
{/* Dialog de création/modification */}
<Dialog
header={editingTemplate ? "Modifier le template" : "Nouveau template"}
visible={showDialog}
onHide={() => setShowDialog(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={() => setShowDialog(false)}
/>
<Button
label="Enregistrer"
icon="pi pi-save"
onClick={handleSave}
/>
</div>
}
>
<div className="grid">
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="nom" className="font-semibold">Nom *</label>
<InputText
id="nom"
value={formData.nom}
onChange={(e) => setFormData(prev => ({ ...prev, nom: e.target.value }))}
className="w-full"
required
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="type" className="font-semibold">Type *</label>
<Dropdown
id="type"
value={formData.type}
options={typeOptions}
onChange={(e) => setFormData(prev => ({ ...prev, type: e.value }))}
className="w-full"
placeholder="Sélectionner un type"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="categorie" className="font-semibold">Catégorie *</label>
<Dropdown
id="categorie"
value={formData.categorie}
options={categorieOptions}
onChange={(e) => setFormData(prev => ({ ...prev, categorie: e.value }))}
className="w-full"
placeholder="Sélectionner une catégorie"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="description" className="font-semibold">Description</label>
<InputTextarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full"
rows={3}
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="tauxTVA" className="font-semibold">Taux TVA (%)</label>
<InputText
id="tauxTVA"
value={formData.tauxTVA?.toString()}
onChange={(e) => setFormData(prev => ({ ...prev, tauxTVA: parseFloat(e.target.value) || 0 }))}
className="w-full"
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="conditionsPaiement" className="font-semibold">Conditions de paiement</label>
<InputText
id="conditionsPaiement"
value={formData.conditionsPaiement}
onChange={(e) => setFormData(prev => ({ ...prev, conditionsPaiement: e.target.value }))}
className="w-full"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<div className="flex align-items-center">
<Checkbox
inputId="actif"
checked={formData.actif}
onChange={(e) => setFormData(prev => ({ ...prev, actif: e.checked || false }))}
/>
<label htmlFor="actif" className="ml-2">Template actif</label>
</div>
</div>
</div>
<div className="col-12">
<h6>Lignes du template</h6>
<p className="text-sm text-600 mb-3">
Les lignes seront automatiquement ajoutées lors de l'utilisation du template.
</p>
{formData.lignes && formData.lignes.length > 0 ? (
<DataTable value={formData.lignes} responsiveLayout="scroll">
<Column field="designation" header="Désignation" />
<Column field="quantite" header="Quantité" style={{ width: '100px' }} />
<Column field="unite" header="Unité" style={{ width: '80px' }} />
<Column
field="prixUnitaire"
header="Prix unitaire"
style={{ width: '120px' }}
body={(rowData) => `${rowData.prixUnitaire}€`}
/>
</DataTable>
) : (
<div className="text-center p-4 border-2 border-dashed border-300 border-round">
<i className="pi pi-inbox text-4xl text-400 mb-3"></i>
<p className="text-600">Aucune ligne définie</p>
<Button
label="Ajouter des lignes"
icon="pi pi-plus"
className="p-button-outlined p-button-sm"
onClick={() => {
// TODO: Ouvrir dialog pour ajouter des lignes
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Fonctionnalité en cours de développement'
});
}}
/>
</div>
)}
</div>
</div>
</Dialog>
</div>
);
};
export default FactureTemplatesPage;