Initial commit
This commit is contained in:
474
app/(main)/factures/[id]/duplicate/page.tsx
Normal file
474
app/(main)/factures/[id]/duplicate/page.tsx
Normal 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;
|
||||
588
app/(main)/factures/[id]/edit/page.tsx
Normal file
588
app/(main)/factures/[id]/edit/page.tsx
Normal 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;
|
||||
464
app/(main)/factures/[id]/page.tsx
Normal file
464
app/(main)/factures/[id]/page.tsx
Normal 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;
|
||||
705
app/(main)/factures/avoirs/page.tsx
Normal file
705
app/(main)/factures/avoirs/page.tsx
Normal 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;
|
||||
582
app/(main)/factures/export/page.tsx
Normal file
582
app/(main)/factures/export/page.tsx
Normal 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;
|
||||
643
app/(main)/factures/impayees/page.tsx
Normal file
643
app/(main)/factures/impayees/page.tsx
Normal 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 dû: <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;
|
||||
998
app/(main)/factures/nouvelle/page.tsx
Normal file
998
app/(main)/factures/nouvelle/page.tsx
Normal 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;
|
||||
726
app/(main)/factures/page.tsx
Normal file
726
app/(main)/factures/page.tsx
Normal 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;
|
||||
610
app/(main)/factures/payees/page.tsx
Normal file
610
app/(main)/factures/payees/page.tsx
Normal 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;
|
||||
603
app/(main)/factures/relances/[id]/page.tsx
Normal file
603
app/(main)/factures/relances/[id]/page.tsx
Normal 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;
|
||||
684
app/(main)/factures/relances/page.tsx
Normal file
684
app/(main)/factures/relances/page.tsx
Normal 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;
|
||||
715
app/(main)/factures/retard/page.tsx
Normal file
715
app/(main)/factures/retard/page.tsx
Normal 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 dû: <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;
|
||||
527
app/(main)/factures/stats/page.tsx
Normal file
527
app/(main)/factures/stats/page.tsx
Normal 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;
|
||||
668
app/(main)/factures/templates/page.tsx
Normal file
668
app/(main)/factures/templates/page.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user