Initial commit

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

View File

@@ -0,0 +1,502 @@
'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 { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
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 { Message } from 'primereact/message';
import { Checkbox } from 'primereact/checkbox';
import { InputNumber } from 'primereact/inputnumber';
import { devisService, factureService } from '../../../../../services/api';
import { formatCurrency, formatDate } from '../../../../../utils/formatters';
import type { Devis, Facture } from '../../../../../types/btp';
const ConvertDevisPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [devis, setDevis] = useState<Devis | null>(null);
const [facture, setFacture] = useState<Partial<Facture>>({
numero: '',
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
type: 'FACTURE',
actif: true,
lignes: []
});
const [loading, setLoading] = useState(true);
const [converting, setConverting] = useState(false);
const [selectedLignes, setSelectedLignes] = useState<boolean[]>([]);
const [acomptePercentage, setAcomptePercentage] = useState<number>(0);
const [createAcompte, setCreateAcompte] = useState(false);
const devisId = params.id as string;
const typeFactureOptions = [
{ label: 'Facture', value: 'FACTURE' },
{ label: 'Facture d\'acompte', value: 'ACOMPTE' },
{ label: 'Facture de situation', value: 'SITUATION' },
{ label: 'Facture de solde', value: 'SOLDE' }
];
useEffect(() => {
loadDevis();
}, [devisId]);
const loadDevis = async () => {
try {
setLoading(true);
const response = await devisService.getById(devisId);
const devisData = response.data;
if (devisData.statut !== 'ACCEPTE') {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Seuls les devis acceptés peuvent être convertis en facture'
});
}
setDevis(devisData);
// Générer un nouveau numéro de facture
const newNumero = await generateNewNumero();
// Initialiser la facture avec les données du devis
setFacture({
numero: newNumero,
dateEmission: new Date(),
dateEcheance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: devisData.objet,
description: devisData.description,
montantHT: devisData.montantHT,
montantTTC: devisData.montantTTC,
tauxTVA: devisData.tauxTVA,
statut: 'BROUILLON',
type: 'FACTURE',
actif: true,
clientId: devisData.clientId,
client: devisData.client,
devisId: devisData.id,
lignes: devisData.lignes ? [...devisData.lignes] : []
});
// Initialiser toutes les lignes comme sélectionnées
setSelectedLignes(new Array(devisData.lignes?.length || 0).fill(true));
} catch (error) {
console.error('Erreur lors du chargement:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger le devis'
});
} finally {
setLoading(false);
}
};
const generateNewNumero = async (): Promise<string> => {
try {
// TODO: Implémenter la génération automatique de numéro
const year = new Date().getFullYear();
const timestamp = Date.now().toString().slice(-6);
return `FAC-${year}-${timestamp}`;
} catch (error) {
console.error('Erreur génération numéro:', error);
return `FAC-${Date.now()}`;
}
};
const calculateMontants = () => {
if (!facture.lignes) return;
const lignesSelectionnees = facture.lignes.filter((_, index) => selectedLignes[index]);
let montantHT = lignesSelectionnees.reduce((sum, ligne) => sum + (ligne.montantHT || 0), 0);
// Appliquer le pourcentage d'acompte si nécessaire
if (createAcompte && acomptePercentage > 0) {
montantHT = montantHT * (acomptePercentage / 100);
}
const montantTTC = montantHT * (1 + (facture.tauxTVA || 0) / 100);
setFacture(prev => ({
...prev,
montantHT,
montantTTC
}));
};
useEffect(() => {
calculateMontants();
}, [selectedLignes, facture.lignes, facture.tauxTVA, createAcompte, acomptePercentage]);
const handleConvert = async () => {
try {
setConverting(true);
// Validation
if (!facture.numero || !facture.objet) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez remplir tous les champs obligatoires'
});
return;
}
if (!selectedLignes.some(selected => selected)) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez sélectionner au moins une ligne à facturer'
});
return;
}
// Préparer les lignes sélectionnées
const lignesFacture = facture.lignes?.filter((_, index) => selectedLignes[index]) || [];
// Ajuster les montants si c'est un acompte
if (createAcompte && acomptePercentage > 0) {
lignesFacture.forEach(ligne => {
ligne.montantHT = (ligne.montantHT || 0) * (acomptePercentage / 100);
ligne.prixUnitaire = ligne.montantHT / (ligne.quantite || 1);
});
}
const factureData = {
...facture,
lignes: lignesFacture,
type: createAcompte ? 'ACOMPTE' : facture.type
};
const response = await factureService.create(factureData);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Facture créée avec succès'
});
router.push(`/factures/${response.data.id}`);
} catch (error) {
console.error('Erreur lors de la conversion:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la conversion en facture'
});
} finally {
setConverting(false);
}
};
const handleLigneSelection = (index: number, checked: boolean) => {
const newSelection = [...selectedLignes];
newSelection[index] = checked;
setSelectedLignes(newSelection);
};
const handleSelectAll = (checked: boolean) => {
setSelectedLignes(new Array(facture.lignes?.length || 0).fill(checked));
};
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(`/devis/${devisId}`)}
/>
</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(`/devis/${devisId}`)}
/>
<Button
label="Créer la facture"
icon="pi pi-arrow-right"
loading={converting}
onClick={handleConvert}
disabled={!devis || devis.statut !== 'ACCEPTE'}
/>
</div>
);
const selectionBodyTemplate = (rowData: any, options: any) => (
<Checkbox
checked={selectedLignes[options.rowIndex] || false}
onChange={(e) => handleLigneSelection(options.rowIndex, e.checked || false)}
/>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
if (!devis) {
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>Devis introuvable</h3>
<p className="text-600 mb-4">Le devis à convertir n'existe pas</p>
<Button
label="Retour à la liste"
icon="pi pi-arrow-left"
onClick={() => router.push('/devis')}
/>
</div>
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* Message d'information */}
<div className="col-12">
{devis.statut !== 'ACCEPTE' ? (
<Message
severity="warn"
text="Ce devis n'est pas accepté. Seuls les devis acceptés peuvent être convertis en facture."
className="w-full"
/>
) : (
<Message
severity="info"
text={`Conversion du devis #${devis.numero} en facture. Sélectionnez les prestations à facturer.`}
className="w-full"
/>
)}
</div>
{/* Informations du devis source */}
<div className="col-12 md:col-6">
<Card title="Devis source" className="h-full">
<div className="field">
<label className="font-semibold">Numéro:</label>
<p>{devis.numero}</p>
</div>
<div className="field">
<label className="font-semibold">Objet:</label>
<p>{devis.objet}</p>
</div>
<div className="field">
<label className="font-semibold">Client:</label>
<p>{typeof devis.client === 'string' ? devis.client : devis.client?.nom}</p>
</div>
<div className="field">
<label className="font-semibold">Date d'émission:</label>
<p>{formatDate(devis.dateEmission)}</p>
</div>
<div className="field">
<label className="font-semibold">Montant TTC:</label>
<p className="text-xl font-bold text-primary">{formatCurrency(devis.montantTTC)}</p>
</div>
</Card>
</div>
{/* Informations de la facture */}
<div className="col-12 md:col-6">
<Card title="Nouvelle facture" className="h-full">
<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"
required
/>
</div>
<div className="field">
<label htmlFor="dateEmission" className="font-semibold">Date d'émission *</label>
<Calendar
id="dateEmission"
value={facture.dateEmission ? new Date(facture.dateEmission) : new Date()}
onChange={(e) => setFacture(prev => ({ ...prev, dateEmission: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="field">
<label htmlFor="dateEcheance" className="font-semibold">Date d'échéance *</label>
<Calendar
id="dateEcheance"
value={facture.dateEcheance ? new Date(facture.dateEcheance) : new Date()}
onChange={(e) => setFacture(prev => ({ ...prev, dateEcheance: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="field">
<label htmlFor="type" className="font-semibold">Type de facture</label>
<Dropdown
id="type"
value={facture.type}
options={typeFactureOptions}
onChange={(e) => setFacture(prev => ({ ...prev, type: e.value }))}
className="w-full"
/>
</div>
<div className="field">
<label className="font-semibold">Montant TTC:</label>
<p className="text-xl font-bold text-green-600">{formatCurrency(facture.montantTTC || 0)}</p>
</div>
</Card>
</div>
{/* Options d'acompte */}
<div className="col-12">
<Card title="Options de facturation">
<div className="field-checkbox mb-3">
<Checkbox
id="createAcompte"
checked={createAcompte}
onChange={(e) => setCreateAcompte(e.checked || false)}
/>
<label htmlFor="createAcompte" className="ml-2 font-semibold">
Créer une facture d'acompte
</label>
</div>
{createAcompte && (
<div className="field">
<label htmlFor="acomptePercentage" className="font-semibold">
Pourcentage d'acompte (%)
</label>
<InputNumber
id="acomptePercentage"
value={acomptePercentage}
onValueChange={(e) => setAcomptePercentage(e.value || 0)}
className="w-full md:w-3"
suffix="%"
min={0}
max={100}
/>
<small className="text-600 block mt-1">
Le montant de la facture sera calculé en fonction de ce pourcentage
</small>
</div>
)}
</Card>
</div>
{/* Sélection des prestations */}
<div className="col-12">
<Card
title="Prestations à facturer"
subTitle={`${selectedLignes.filter(s => s).length}/${facture.lignes?.length || 0} sélectionnée(s) | Total: ${formatCurrency(facture.montantTTC || 0)}`}
>
<div className="mb-3">
<Button
label="Tout sélectionner"
icon="pi pi-check"
className="p-button-outlined p-button-sm mr-2"
onClick={() => handleSelectAll(true)}
/>
<Button
label="Tout désélectionner"
icon="pi pi-times"
className="p-button-outlined p-button-sm"
onClick={() => handleSelectAll(false)}
/>
</div>
<DataTable
value={facture.lignes || []}
responsiveLayout="scroll"
emptyMessage="Aucune prestation disponible"
>
<Column
header="Sélection"
style={{ width: '80px' }}
body={selectionBodyTemplate}
/>
<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, options) => {
let montant = rowData.montantHT;
if (createAcompte && acomptePercentage > 0 && selectedLignes[options.rowIndex]) {
montant = montant * (acomptePercentage / 100);
}
return formatCurrency(montant);
}}
/>
</DataTable>
<div className="mt-3 p-3 bg-green-50 border-round">
<div className="flex justify-content-between align-items-center">
<span className="font-semibold">Total HT: {formatCurrency(facture.montantHT || 0)}</span>
<span className="font-semibold text-primary">Total TTC: {formatCurrency(facture.montantTTC || 0)}</span>
</div>
{createAcompte && acomptePercentage > 0 && (
<p className="text-sm text-600 mt-2 mb-0">
<i className="pi pi-info-circle mr-2"></i>
Acompte de {acomptePercentage}% appliqué sur les prestations sélectionnées
</p>
)}
</div>
</Card>
</div>
</div>
);
};
export default ConvertDevisPage;

View File

@@ -0,0 +1,407 @@
'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 { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
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 { Message } from 'primereact/message';
import { devisService, clientService } from '../../../../../services/api';
import { formatCurrency } from '../../../../../utils/formatters';
import type { Devis } from '../../../../../types/btp';
const DuplicateDevisPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [originalDevis, setOriginalDevis] = useState<Devis | null>(null);
const [newDevis, setNewDevis] = useState<Devis>({
id: '',
numero: '',
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
actif: true,
lignes: []
});
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const originalDevisId = params.id as string;
useEffect(() => {
loadData();
}, [originalDevisId]);
const loadData = async () => {
try {
setLoading(true);
const [devisResponse, clientsResponse] = await Promise.all([
devisService.getById(originalDevisId),
clientService.getAll()
]);
const original = devisResponse.data;
setOriginalDevis(original);
setClients(clientsResponse.data);
// Générer un nouveau numéro de devis
const newNumero = await generateNewNumero();
// Préparer le nouveau devis avec les données de l'original
setNewDevis({
id: '',
numero: newNumero,
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: `${original.objet} (Copie)`,
description: original.description,
montantHT: original.montantHT,
montantTTC: original.montantTTC,
tauxTVA: original.tauxTVA,
statut: 'BROUILLON',
actif: true,
clientId: original.clientId,
client: original.client,
lignes: original.lignes ? [...original.lignes] : []
});
} catch (error) {
console.error('Erreur lors du chargement:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger le devis original'
});
} finally {
setLoading(false);
}
};
const generateNewNumero = async (): Promise<string> => {
try {
// TODO: Implémenter la génération automatique de numéro
const year = new Date().getFullYear();
const timestamp = Date.now().toString().slice(-6);
return `DEV-${year}-${timestamp}`;
} catch (error) {
console.error('Erreur génération numéro:', error);
return `DEV-${Date.now()}`;
}
};
const calculateMontants = () => {
const montantHT = (newDevis.lignes || []).reduce((sum, ligne) => sum + (ligne.montantHT || 0), 0);
const montantTTC = montantHT * (1 + newDevis.tauxTVA / 100);
setNewDevis(prev => ({
...prev,
montantHT,
montantTTC
}));
};
useEffect(() => {
calculateMontants();
}, [newDevis.lignes, newDevis.tauxTVA]);
const handleSave = async () => {
try {
setSaving(true);
// Validation
if (!newDevis.numero || !newDevis.objet || !newDevis.clientId) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez remplir tous les champs obligatoires'
});
return;
}
const response = await devisService.create(newDevis);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis dupliqué avec succès'
});
router.push(`/devis/${response.data.id}`);
} catch (error) {
console.error('Erreur lors de la duplication:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la duplication du devis'
});
} 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(`/devis/${originalDevisId}`)}
/>
</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(`/devis/${originalDevisId}`)}
/>
<Button
label="Créer la copie"
icon="pi pi-copy"
loading={saving}
onClick={handleSave}
/>
</div>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
if (!originalDevis) {
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>Devis introuvable</h3>
<p className="text-600 mb-4">Le devis à dupliquer n'existe pas</p>
<Button
label="Retour à la liste"
icon="pi pi-arrow-left"
onClick={() => router.push('/devis')}
/>
</div>
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* Message d'information */}
<div className="col-12">
<Message
severity="info"
text={`Duplication du devis #${originalDevis.numero}. Vous pouvez modifier les informations avant de créer la copie.`}
className="w-full"
/>
</div>
{/* Comparaison Original vs Nouveau */}
<div className="col-12">
<Card title="Comparaison">
<div className="grid">
<div className="col-12 md:col-6">
<h5 className="text-primary">Devis original</h5>
<div className="field">
<label className="font-semibold">Numéro:</label>
<p>{originalDevis.numero}</p>
</div>
<div className="field">
<label className="font-semibold">Objet:</label>
<p>{originalDevis.objet}</p>
</div>
<div className="field">
<label className="font-semibold">Montant TTC:</label>
<p className="text-xl font-bold">{formatCurrency(originalDevis.montantTTC)}</p>
</div>
</div>
<div className="col-12 md:col-6">
<h5 className="text-green-500">Nouveau devis</h5>
<div className="field">
<label className="font-semibold">Numéro:</label>
<p className="text-green-600 font-semibold">{newDevis.numero}</p>
</div>
<div className="field">
<label className="font-semibold">Objet:</label>
<p className="text-green-600">{newDevis.objet}</p>
</div>
<div className="field">
<label className="font-semibold">Montant TTC:</label>
<p className="text-xl font-bold text-green-600">{formatCurrency(newDevis.montantTTC)}</p>
</div>
</div>
</div>
</Card>
</div>
{/* Informations du nouveau devis */}
<div className="col-12">
<Card title="Informations du nouveau devis">
<div className="grid">
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="numero" className="font-semibold">Numéro *</label>
<InputText
id="numero"
value={newDevis.numero}
onChange={(e) => setNewDevis(prev => ({ ...prev, numero: e.target.value }))}
className="w-full"
required
/>
</div>
<div className="field">
<label htmlFor="objet" className="font-semibold">Objet *</label>
<InputText
id="objet"
value={newDevis.objet}
onChange={(e) => setNewDevis(prev => ({ ...prev, objet: e.target.value }))}
className="w-full"
required
/>
</div>
<div className="field">
<label htmlFor="client" className="font-semibold">Client *</label>
<Dropdown
id="client"
value={newDevis.clientId}
options={clients}
onChange={(e) => setNewDevis(prev => ({ ...prev, clientId: e.value }))}
optionLabel="nom"
optionValue="id"
placeholder="Sélectionner un client"
className="w-full"
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={new Date(newDevis.dateEmission)}
onChange={(e) => setNewDevis(prev => ({ ...prev, dateEmission: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="field">
<label htmlFor="dateValidite" className="font-semibold">Date de validité *</label>
<Calendar
id="dateValidite"
value={new Date(newDevis.dateValidite)}
onChange={(e) => setNewDevis(prev => ({ ...prev, dateValidite: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="field">
<label className="font-semibold">Statut</label>
<p className="text-600">Brouillon (par défaut pour les nouveaux devis)</p>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="description" className="font-semibold">Description</label>
<InputTextarea
id="description"
value={newDevis.description}
onChange={(e) => setNewDevis(prev => ({ ...prev, description: e.target.value }))}
className="w-full"
rows={4}
/>
</div>
</div>
</div>
</Card>
</div>
{/* Prestations copiées */}
<div className="col-12">
<Card
title="Prestations copiées"
subTitle={`${newDevis.lignes?.length || 0} ligne(s) | Total TTC: ${formatCurrency(newDevis.montantTTC)}`}
>
<DataTable
value={newDevis.lignes || []}
responsiveLayout="scroll"
emptyMessage="Aucune prestation copiée"
>
<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>
<div className="mt-3 p-3 bg-blue-50 border-round">
<p className="text-sm text-600 mb-2">
<i className="pi pi-info-circle mr-2"></i>
Les prestations ont é copiées depuis le devis original.
Vous pourrez les modifier après la création du nouveau devis.
</p>
<div className="flex justify-content-between align-items-center">
<span className="font-semibold">Total HT: {formatCurrency(newDevis.montantHT)}</span>
<span className="font-semibold text-primary">Total TTC: {formatCurrency(newDevis.montantTTC)}</span>
</div>
</div>
</Card>
</div>
</div>
);
};
export default DuplicateDevisPage;

View File

@@ -0,0 +1,490 @@
'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 { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
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 { devisService, clientService } from '../../../../../services/api';
import { formatCurrency } from '../../../../../utils/formatters';
import type { Devis, LigneDevis } from '../../../../../types/btp';
const EditDevisPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [devis, setDevis] = useState<Devis>({
id: '',
numero: '',
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
actif: true,
lignes: []
});
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showLigneDialog, setShowLigneDialog] = useState(false);
const [editingLigne, setEditingLigne] = useState<LigneDevis | null>(null);
const [nouvelleLigne, setNouvelleLigne] = useState<LigneDevis>({
designation: '',
quantite: 1,
unite: 'unité',
prixUnitaire: 0,
montantHT: 0
});
const devisId = params.id as string;
const statutOptions = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'En attente', value: 'EN_ATTENTE' },
{ label: 'Accepté', value: 'ACCEPTE' },
{ label: 'Refusé', value: 'REFUSE' },
{ label: 'Expiré', value: 'EXPIRE' }
];
const uniteOptions = [
{ label: 'Unité', value: 'unité' },
{ label: 'Heure', value: 'heure' },
{ label: 'Jour', value: 'jour' },
{ label: 'M²', value: 'm²' },
{ label: 'M³', value: 'm³' },
{ label: 'ML', value: 'ml' },
{ label: 'Kg', value: 'kg' },
{ label: 'Tonne', value: 'tonne' },
{ label: 'Forfait', value: 'forfait' }
];
useEffect(() => {
loadData();
}, [devisId]);
const loadData = async () => {
try {
setLoading(true);
const [devisResponse, clientsResponse] = await Promise.all([
devisService.getById(devisId),
clientService.getAll()
]);
setDevis(devisResponse.data);
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 calculateMontants = () => {
const montantHT = (devis.lignes || []).reduce((sum, ligne) => sum + (ligne.montantHT || 0), 0);
const montantTTC = montantHT * (1 + devis.tauxTVA / 100);
setDevis(prev => ({
...prev,
montantHT,
montantTTC
}));
};
useEffect(() => {
calculateMontants();
}, [devis.lignes, devis.tauxTVA]);
const handleSave = async () => {
try {
setSaving(true);
await devisService.update(devisId, devis);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis modifié avec succès'
});
router.push(`/devis/${devisId}`);
} 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);
setNouvelleLigne({
designation: '',
quantite: 1,
unite: 'unité',
prixUnitaire: 0,
montantHT: 0
});
setShowLigneDialog(true);
};
const handleEditLigne = (ligne: LigneDevis, index: number) => {
setEditingLigne({ ...ligne, index });
setNouvelleLigne({ ...ligne });
setShowLigneDialog(true);
};
const handleSaveLigne = () => {
const montantHT = nouvelleLigne.quantite * nouvelleLigne.prixUnitaire;
const ligneComplete = { ...nouvelleLigne, montantHT };
const nouvelleLignes = [...(devis.lignes || [])];
if (editingLigne && editingLigne.index !== undefined) {
nouvelleLignes[editingLigne.index] = ligneComplete;
} else {
nouvelleLignes.push(ligneComplete);
}
setDevis(prev => ({ ...prev, lignes: nouvelleLignes }));
setShowLigneDialog(false);
};
const handleDeleteLigne = (index: number) => {
const nouvelleLignes = [...(devis.lignes || [])];
nouvelleLignes.splice(index, 1);
setDevis(prev => ({ ...prev, lignes: nouvelleLignes }));
};
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(`/devis/${devisId}`)}
/>
</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(`/devis/${devisId}`)}
/>
<Button
label="Enregistrer"
icon="pi pi-save"
loading={saving}
onClick={handleSave}
/>
</div>
);
const actionBodyTemplate = (rowData: LigneDevis, options: any) => (
<div className="flex gap-2">
<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>
);
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>
{/* Informations générales */}
<div className="col-12">
<Card title="Informations générales">
<div className="grid">
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="numero" className="font-semibold">Numéro *</label>
<InputText
id="numero"
value={devis.numero}
onChange={(e) => setDevis(prev => ({ ...prev, numero: e.target.value }))}
className="w-full"
required
/>
</div>
<div className="field">
<label htmlFor="objet" className="font-semibold">Objet *</label>
<InputText
id="objet"
value={devis.objet}
onChange={(e) => setDevis(prev => ({ ...prev, objet: e.target.value }))}
className="w-full"
required
/>
</div>
<div className="field">
<label htmlFor="client" className="font-semibold">Client *</label>
<Dropdown
id="client"
value={devis.clientId}
options={clients}
onChange={(e) => setDevis(prev => ({ ...prev, clientId: e.value }))}
optionLabel="nom"
optionValue="id"
placeholder="Sélectionner un client"
className="w-full"
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={new Date(devis.dateEmission)}
onChange={(e) => setDevis(prev => ({ ...prev, dateEmission: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="field">
<label htmlFor="dateValidite" className="font-semibold">Date de validité *</label>
<Calendar
id="dateValidite"
value={new Date(devis.dateValidite)}
onChange={(e) => setDevis(prev => ({ ...prev, dateValidite: e.value || new Date() }))}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="field">
<label htmlFor="statut" className="font-semibold">Statut</label>
<Dropdown
id="statut"
value={devis.statut}
options={statutOptions}
onChange={(e) => setDevis(prev => ({ ...prev, statut: e.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={devis.description}
onChange={(e) => setDevis(prev => ({ ...prev, description: e.target.value }))}
className="w-full"
rows={4}
/>
</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={devis.tauxTVA}
onValueChange={(e) => setDevis(prev => ({ ...prev, tauxTVA: e.value || 0 }))}
className="w-full"
suffix="%"
min={0}
max={100}
/>
</div>
</div>
</div>
</Card>
</div>
{/* Lignes du devis */}
<div className="col-12">
<Card
title="Prestations"
subTitle={`Total HT: ${formatCurrency(devis.montantHT)} | Total TTC: ${formatCurrency(devis.montantTTC)}`}
>
<div className="mb-3">
<Button
label="Ajouter une ligne"
icon="pi pi-plus"
onClick={handleAddLigne}
/>
</div>
<DataTable
value={devis.lignes || []}
responsiveLayout="scroll"
emptyMessage="Aucune prestation ajoutée"
>
<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={actionBodyTemplate}
/>
</DataTable>
</Card>
</div>
{/* Dialog pour ajouter/modifier une ligne */}
<Dialog
header={editingLigne ? "Modifier la prestation" : "Ajouter une prestation"}
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={nouvelleLigne.designation}
onChange={(e) => setNouvelleLigne(prev => ({ ...prev, designation: e.target.value }))}
className="w-full"
required
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="quantite" className="font-semibold">Quantité *</label>
<InputNumber
id="quantite"
value={nouvelleLigne.quantite}
onValueChange={(e) => setNouvelleLigne(prev => ({ ...prev, quantite: e.value || 0 }))}
className="w-full"
min={0}
required
/>
</div>
</div>
<div className="col-12 md:col-4">
<div className="field">
<label htmlFor="unite" className="font-semibold">Unité</label>
<Dropdown
id="unite"
value={nouvelleLigne.unite}
options={uniteOptions}
onChange={(e) => setNouvelleLigne(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={nouvelleLigne.prixUnitaire}
onValueChange={(e) => setNouvelleLigne(prev => ({ ...prev, prixUnitaire: e.value || 0 }))}
className="w-full"
mode="currency"
currency="EUR"
locale="fr-FR"
min={0}
required
/>
</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(nouvelleLigne.quantite * nouvelleLigne.prixUnitaire)}
</div>
</div>
</div>
</div>
</Dialog>
</div>
);
};
export default EditDevisPage;

View File

@@ -0,0 +1,383 @@
'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 { devisService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Devis } from '../../../../types/btp';
const DevisDetailPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const menuRef = useRef<Menu>(null);
const [devis, setDevis] = useState<Devis | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const devisId = params.id as string;
useEffect(() => {
loadDevis();
}, [devisId]);
const loadDevis = async () => {
try {
setLoading(true);
const response = await devisService.getById(devisId);
setDevis(response.data);
} catch (error) {
console.error('Erreur lors du chargement du devis:', error);
setError('Impossible de charger le devis');
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger le devis'
});
} finally {
setLoading(false);
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'ACCEPTE': return 'success';
case 'REFUSE': return 'danger';
case 'EXPIRE': return 'warning';
case 'EN_ATTENTE': return 'info';
case 'BROUILLON': return 'secondary';
default: return 'info';
}
};
const menuItems = [
{
label: 'Modifier',
icon: 'pi pi-pencil',
command: () => router.push(`/devis/${devisId}/edit`)
},
{
label: 'Dupliquer',
icon: 'pi pi-copy',
command: () => router.push(`/devis/${devisId}/duplicate`)
},
{
label: 'Convertir en facture',
icon: 'pi pi-arrow-right',
command: () => router.push(`/devis/${devisId}/convert`)
},
{
separator: true
},
{
label: 'Imprimer',
icon: 'pi pi-print',
command: () => window.print()
},
{
label: 'Télécharger PDF',
icon: 'pi pi-download',
command: () => handleDownloadPDF()
},
{
separator: true
},
{
label: 'Supprimer',
icon: 'pi pi-trash',
className: 'text-red-500',
command: () => handleDelete()
}
];
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 handleDelete = async () => {
try {
await devisService.delete(devisId);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis supprimé avec succès'
});
router.push('/devis');
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de la suppression'
});
}
};
const handleAccept = async () => {
try {
await devisService.updateStatut(devisId, 'ACCEPTE');
loadDevis();
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis accepté'
});
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de l\'acceptation'
});
}
};
const handleReject = async () => {
try {
await devisService.updateStatut(devisId, 'REFUSE');
loadDevis();
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis refusé'
});
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors du refus'
});
}
};
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('/devis')}
/>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
{devis?.statut === 'EN_ATTENTE' && (
<>
<Button
label="Accepter"
icon="pi pi-check"
className="p-button-success"
onClick={handleAccept}
/>
<Button
label="Refuser"
icon="pi pi-times"
className="p-button-danger p-button-outlined"
onClick={handleReject}
/>
</>
)}
<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 || !devis) {
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>Devis introuvable</h3>
<p className="text-600 mb-4">{error || 'Le devis demandé n\'existe pas'}</p>
<Button
label="Retour à la liste"
icon="pi pi-arrow-left"
onClick={() => router.push('/devis')}
/>
</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 du devis */}
<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">Devis #{devis.numero}</h2>
<p className="text-600 mb-3">{devis.objet}</p>
<Tag
value={devis.statut}
severity={getStatutSeverity(devis.statut)}
className="mb-2"
/>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary mb-2">
{formatCurrency(devis.montantTTC)}
</div>
<div className="text-sm text-600">
HT: {formatCurrency(devis.montantHT)}
</div>
</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(devis.dateEmission)}</p>
</div>
<div className="field">
<label className="font-semibold">Date de validité:</label>
<p>{formatDate(devis.dateValidite)}</p>
</div>
<div className="field">
<label className="font-semibold">Client:</label>
<p>{typeof devis.client === 'string' ? devis.client : devis.client?.nom}</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(devis.montantHT)}</p>
</div>
<div className="field">
<label className="font-semibold">TVA ({devis.tauxTVA}%):</label>
<p>{formatCurrency(devis.montantTTC - devis.montantHT)}</p>
</div>
<div className="field">
<label className="font-semibold">Montant TTC:</label>
<p className="text-xl font-bold text-primary">{formatCurrency(devis.montantTTC)}</p>
</div>
</div>
</div>
{devis.description && (
<>
<Divider />
<div>
<h5>Description</h5>
<p className="line-height-3">{devis.description}</p>
</div>
</>
)}
</Card>
</div>
{/* Lignes du devis */}
{devis.lignes && devis.lignes.length > 0 && (
<div className="col-12">
<Card title="Détail des prestations">
<DataTable value={devis.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 */}
<div className="col-12">
<Card title="Historique">
<Timeline
value={[
{
status: 'Créé',
date: devis.dateEmission,
icon: 'pi pi-plus',
color: '#9C27B0'
},
...(devis.statut === 'ACCEPTE' ? [{
status: 'Accepté',
date: new Date(),
icon: 'pi pi-check',
color: '#4CAF50'
}] : []),
...(devis.statut === 'REFUSE' ? [{
status: 'Refusé',
date: new Date(),
icon: 'pi pi-times',
color: '#F44336'
}] : [])
]}
opposite={(item) => formatDate(item.date)}
content={(item) => (
<div className="flex align-items-center">
<Badge value={item.status} style={{ backgroundColor: item.color }} />
</div>
)}
/>
</Card>
</div>
</div>
);
};
export default DevisDetailPage;

View File

@@ -0,0 +1,587 @@
'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 { devisService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Devis } from '../../../../types/btp';
import devisActionsService from '../../../../services/devisActionsService';
const DevisAcceptesPage = () => {
const [devis, setDevis] = useState<Devis[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedDevis, setSelectedDevis] = useState<Devis[]>([]);
const [actionDialog, setActionDialog] = useState(false);
const [selectedDevisItem, setSelectedDevisItem] = useState<Devis | null>(null);
const [actionType, setActionType] = useState<'create_chantier' | 'create_facture' | 'contract'>('create_chantier');
const [chantierData, setChantierData] = useState({
nom: '',
dateDebut: new Date(),
dateFinPrevue: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
description: ''
});
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Devis[]>>(null);
useEffect(() => {
loadDevis();
}, []);
const loadDevis = async () => {
try {
setLoading(true);
const data = await devisService.getAll();
// Filtrer les devis acceptés
const devisAcceptes = data.filter(devis => devis.statut === 'ACCEPTE');
setDevis(devisAcceptes);
} catch (error) {
console.error('Erreur lors du chargement des devis:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les devis acceptés',
life: 3000
});
} finally {
setLoading(false);
}
};
const getDaysSinceAcceptance = (dateEmission: string | Date) => {
const today = new Date();
const acceptanceDate = new Date(dateEmission); // Approximation
const diffTime = today.getTime() - acceptanceDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
const needsAction = (dateEmission: string | Date) => {
const days = getDaysSinceAcceptance(dateEmission);
return days > 7; // Plus de 7 jours sans action
};
const createChantier = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('create_chantier');
setChantierData({
nom: `Chantier - ${devisItem.objet}`,
dateDebut: new Date(),
dateFinPrevue: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
description: devisItem.description || ''
});
setActionDialog(true);
};
const createFacture = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('create_facture');
setActionDialog(true);
};
const generateContract = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('contract');
setActionDialog(true);
};
const handleAction = async () => {
if (!selectedDevisItem) return;
try {
let message = '';
switch (actionType) {
case 'create_chantier':
await devisActionsService.createChantierFromDevis({
devisId: selectedDevisItem.id,
dateDebutSouhaitee: chantierData.dateDebut.toISOString(),
notes: chantierData.notes
});
message = 'Chantier créé avec succès';
break;
case 'create_facture':
await devisActionsService.createFactureFromDevis({
devisId: selectedDevisItem.id,
type: 'FACTURE'
});
message = 'Facture créée avec succès';
break;
case 'contract':
// Générer un contrat PDF (simulation)
generateContractPDF(selectedDevisItem);
message = 'Contrat généré avec succè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 generateContractPDF = (devisItem: Devis) => {
// Simulation de génération de contrat
const contractContent = `
=== CONTRAT DE TRAVAUX ===
DEVIS DE RÉFÉRENCE: ${devisItem.numero}
DATE: ${new Date().toLocaleDateString('fr-FR')}
CLIENT:
${devisItem.client ? `${devisItem.client.prenom} ${devisItem.client.nom}` : 'N/A'}
${devisItem.client?.entreprise || ''}
${devisItem.client?.adresse || ''}
${devisItem.client?.codePostal || ''} ${devisItem.client?.ville || ''}
PRESTATAIRE:
[Votre entreprise]
OBJET DES TRAVAUX:
${devisItem.objet}
DESCRIPTION:
${devisItem.description || 'Voir devis en annexe'}
MONTANT:
Montant HT: ${formatCurrency(devisItem.montantHT || 0)}
TVA (${devisItem.tauxTVA}%): ${formatCurrency((devisItem.montantTVA || 0))}
Montant TTC: ${formatCurrency(devisItem.montantTTC || 0)}
CONDITIONS:
- Délai d'exécution: ${devisItem.delaiExecution || 30} jours
- Conditions de paiement: ${devisItem.conditionsPaiement || 'À définir'}
- Validité du contrat: À partir de la signature
Date et signature du client:
Date et signature du prestataire:
`;
const blob = new Blob([contractContent], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `contrat_${devisItem.numero}_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const generateAcceptanceReport = () => {
const totalValue = devis.reduce((sum, d) => sum + (d.montantTTC || 0), 0);
const avgValue = totalValue / (devis.length || 1);
const needingAction = devis.filter(d => needsAction(d.dateEmission));
const report = `
=== RAPPORT DEVIS ACCEPTÉS ===
Période: ${new Date().toLocaleDateString('fr-FR')}
STATISTIQUES GÉNÉRALES:
- Nombre total de devis acceptés: ${devis.length}
- Valeur totale des devis: ${formatCurrency(totalValue)}
- Valeur moyenne par devis: ${formatCurrency(avgValue)}
- Devis nécessitant une action: ${needingAction.length}
DEVIS NÉCESSITANT UNE ACTION (${needingAction.length}):
${needingAction.map(d => `
- ${d.numero} - ${d.objet}
Client: ${d.client ? `${d.client.prenom} ${d.client.nom}` : 'N/A'}
Montant: ${formatCurrency(d.montantTTC || 0)}
Accepté depuis: ${getDaysSinceAcceptance(d.dateEmission)} jours
`).join('')}
RÉPARTITION PAR MOIS:
${getMonthlyBreakdown()}
RECOMMANDATIONS:
- Créer les chantiers pour les devis acceptés depuis plus de 7 jours
- Établir les factures d'acompte si prévues
- Générer les contrats pour formaliser les accords
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `rapport_devis_acceptes_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
toast.current?.show({
severity: 'success',
summary: 'Rapport généré',
detail: 'Le rapport a été téléchargé',
life: 3000
});
};
const getMonthlyBreakdown = () => {
const months = {};
devis.forEach(d => {
const month = new Date(d.dateEmission).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long' });
if (!months[month]) {
months[month] = { count: 0, value: 0 };
}
months[month].count++;
months[month].value += d.montantTTC || 0;
});
return Object.entries(months)
.map(([month, data]: [string, any]) => `- ${month}: ${data.count} devis, ${formatCurrency(data.value)}`)
.join('\n');
};
const leftToolbarTemplate = () => {
return (
<div className="my-2 flex gap-2">
<h5 className="m-0 flex align-items-center text-green-600">
<i className="pi pi-check-circle mr-2"></i>
Devis acceptés ({devis.length})
</h5>
<Button
label="Rapport d'activité"
icon="pi pi-chart-bar"
severity="success"
size="small"
onClick={generateAcceptanceReport}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Devis) => {
return (
<div className="flex gap-1">
<Button
icon="pi pi-building"
rounded
severity="success"
size="small"
tooltip="Créer un chantier"
onClick={() => createChantier(rowData)}
/>
<Button
icon="pi pi-file-excel"
rounded
severity="info"
size="small"
tooltip="Créer une facture"
onClick={() => createFacture(rowData)}
/>
<Button
icon="pi pi-file-pdf"
rounded
severity="help"
size="small"
tooltip="Générer un contrat"
onClick={() => generateContract(rowData)}
/>
<Button
icon="pi pi-eye"
rounded
severity="secondary"
size="small"
tooltip="Voir détails"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: `Détails du devis ${rowData.numero}`,
life: 3000
});
}}
/>
</div>
);
};
const statusBodyTemplate = (rowData: Devis) => {
const needAction = needsAction(rowData.dateEmission);
return (
<div className="flex align-items-center gap-2">
<Tag value="Accepté" severity="success" />
{needAction && <Tag value="Action requise" severity="warning" />}
</div>
);
};
const acceptanceDateBodyTemplate = (rowData: Devis) => {
const days = getDaysSinceAcceptance(rowData.dateEmission);
const needAction = needsAction(rowData.dateEmission);
return (
<div className={needAction ? 'text-orange-600' : ''}>
<div>{formatDate(rowData.dateEmission)}</div>
<small className="text-600">
{days === 0 ? 'Aujourd\'hui' :
days === 1 ? 'Il y a 1 jour' :
`Il y a ${days} jours`}
</small>
{needAction && <i className="pi pi-exclamation-triangle ml-1 text-orange-500"></i>}
</div>
);
};
const clientBodyTemplate = (rowData: Devis) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const progressBodyTemplate = (rowData: Devis) => {
// Simuler le statut de conversion (chantier créé, facture émise, etc.)
const hasChantier = Math.random() > 0.7; // 30% ont un chantier
const hasFacture = Math.random() > 0.8; // 20% ont une facture
const hasContract = Math.random() > 0.6; // 40% ont un contrat
return (
<div className="flex gap-1">
{hasContract ? (
<Tag value="Contrat" severity="success" className="text-xs" />
) : (
<Tag value="Pas de contrat" severity="secondary" className="text-xs" />
)}
{hasChantier ? (
<Tag value="Chantier" severity="success" className="text-xs" />
) : (
<Tag value="Pas de chantier" severity="warning" className="text-xs" />
)}
{hasFacture && (
<Tag value="Facturé" severity="info" className="text-xs" />
)}
</div>
);
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Devis acceptés - Prêts pour exécution</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 === 'create_chantier' ? 'Créer le chantier' :
actionType === 'create_facture' ? 'Créer la facture' :
'Générer le contrat'
}
icon={
actionType === 'create_chantier' ? 'pi pi-building' :
actionType === 'create_facture' ? 'pi pi-file-excel' :
'pi pi-file-pdf'
}
text
onClick={handleAction}
/>
</>
);
const getActionTitle = () => {
switch (actionType) {
case 'create_chantier': return 'Créer un chantier';
case 'create_facture': return 'Créer une facture';
case 'contract': return 'Générer un contrat';
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={devis}
selection={selectedDevis}
onSelectionChange={(e) => setSelectedDevis(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} devis"
globalFilter={globalFilter}
emptyMessage="Aucun devis accepté 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 d'acceptation" body={acceptanceDateBodyTemplate} sortable 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: '12rem' }} />
<Column field="progress" header="Suivi" body={progressBodyTemplate} headerStyle={{ minWidth: '15rem' }} />
<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>
Devis: <strong>{selectedDevisItem?.numero} - {selectedDevisItem?.objet}</strong>
</p>
<p>
Client: <strong>{selectedDevisItem?.client ? `${selectedDevisItem.client.prenom} ${selectedDevisItem.client.nom}` : 'N/A'}</strong>
</p>
<p>
Montant: <strong>{selectedDevisItem ? formatCurrency(selectedDevisItem.montantTTC || 0) : ''}</strong>
</p>
</div>
{actionType === 'create_chantier' && (
<>
<div className="field col-12">
<label htmlFor="chantierNom">Nom du chantier</label>
<InputText
id="chantierNom"
value={chantierData.nom}
onChange={(e) => setChantierData(prev => ({ ...prev, nom: e.target.value }))}
placeholder="Nom du chantier"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateDebut">Date de début</label>
<Calendar
id="dateDebut"
value={chantierData.dateDebut}
onChange={(e) => setChantierData(prev => ({ ...prev, dateDebut: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateFinPrevue">Date de fin prévue</label>
<Calendar
id="dateFinPrevue"
value={chantierData.dateFinPrevue}
onChange={(e) => setChantierData(prev => ({ ...prev, dateFinPrevue: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
minDate={chantierData.dateDebut}
/>
</div>
<div className="field col-12">
<label htmlFor="chantierDescription">Description</label>
<InputTextarea
id="chantierDescription"
value={chantierData.description}
onChange={(e) => setChantierData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
placeholder="Description du chantier"
/>
</div>
</>
)}
{actionType === 'create_facture' && (
<div className="field col-12">
<p>
Une facture sera créée automatiquement à partir de ce devis avec les montants suivants :
</p>
<ul>
<li>Montant HT : {selectedDevisItem ? formatCurrency(selectedDevisItem.montantHT || 0) : ''}</li>
<li>TVA ({selectedDevisItem?.tauxTVA}%) : {selectedDevisItem ? formatCurrency((selectedDevisItem.montantTVA || 0)) : ''}</li>
<li>Montant TTC : {selectedDevisItem ? formatCurrency(selectedDevisItem.montantTTC || 0) : ''}</li>
</ul>
</div>
)}
{actionType === 'contract' && (
<div className="field col-12">
<p>
Un contrat de travaux sera généré automatiquement à partir de ce devis.
Le document contiendra :
</p>
<ul>
<li>Les informations du client</li>
<li>Le détail des prestations</li>
<li>Les montants et conditions</li>
<li>Les clauses standards</li>
</ul>
<p className="text-600">
Vous pourrez modifier le contrat avant signature.
</p>
</div>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default DevisAcceptesPage;

View File

@@ -0,0 +1,552 @@
'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 { Dropdown } from 'primereact/dropdown';
import { InputTextarea } from 'primereact/inputtextarea';
import { ProgressBar } from 'primereact/progressbar';
import { devisService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Devis } from '../../../../types/btp';
const DevisAttentePage = () => {
const [devis, setDevis] = useState<Devis[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedDevis, setSelectedDevis] = useState<Devis[]>([]);
const [actionDialog, setActionDialog] = useState(false);
const [selectedDevisItem, setSelectedDevisItem] = useState<Devis | null>(null);
const [actionType, setActionType] = useState<'accept' | 'refuse' | 'send' | 'extend'>('send');
const [actionNotes, setActionNotes] = useState('');
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Devis[]>>(null);
useEffect(() => {
loadDevis();
}, []);
const loadDevis = async () => {
try {
setLoading(true);
const data = await devisService.getAll();
// Filtrer les devis en attente (brouillon ou envoyé)
const devisEnAttente = data.filter(devis =>
devis.statut === 'BROUILLON' || devis.statut === 'ENVOYE'
);
setDevis(devisEnAttente);
} catch (error) {
console.error('Erreur lors du chargement des devis:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les devis en attente',
life: 3000
});
} finally {
setLoading(false);
}
};
const getDaysUntilExpiry = (dateValidite: string | Date) => {
const today = new Date();
const validityDate = new Date(dateValidite);
const diffTime = validityDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
const isExpiringSoon = (dateValidite: string | Date) => {
const days = getDaysUntilExpiry(dateValidite);
return days >= 0 && days <= 7; // Expire dans les 7 prochains jours
};
const isExpired = (dateValidite: string | Date) => {
const days = getDaysUntilExpiry(dateValidite);
return days < 0;
};
const getUrgencyLevel = (dateValidite: string | Date) => {
const days = getDaysUntilExpiry(dateValidite);
if (days < 0) return { level: 'EXPIRÉ', color: 'red', severity: 'danger' as const };
if (days <= 3) return { level: 'URGENT', color: 'red', severity: 'danger' as const };
if (days <= 7) return { level: 'BIENTÔT', color: 'orange', severity: 'warning' as const };
return { level: 'NORMAL', color: 'green', severity: 'success' as const };
};
const sendDevis = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('send');
setActionNotes('');
setActionDialog(true);
};
const acceptDevis = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('accept');
setActionNotes('');
setActionDialog(true);
};
const refuseDevis = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('refuse');
setActionNotes('');
setActionDialog(true);
};
const extendValidité = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('extend');
setActionNotes('');
setActionDialog(true);
};
const handleAction = async () => {
if (!selectedDevisItem) return;
try {
let newStatut = selectedDevisItem.statut;
let message = '';
switch (actionType) {
case 'send':
newStatut = 'ENVOYE';
message = 'Devis envoyé au client';
break;
case 'accept':
newStatut = 'ACCEPTE';
message = 'Devis accepté par le client';
break;
case 'refuse':
newStatut = 'REFUSE';
message = 'Devis refusé par le client';
break;
case 'extend':
// Prolonger la validité de 30 jours
const newValidityDate = new Date(selectedDevisItem.dateValidite);
newValidityDate.setDate(newValidityDate.getDate() + 30);
// Pour cette démo, on simule juste le message
message = 'Validité du devis prolongée de 30 jours';
break;
}
// TODO: Implémenter quand l'API backend sera disponible
console.log(`Action ${actionType} sur devis ${selectedDevisItem.numero}:`, {
nouveauStatut: newStatut,
notes: actionNotes
});
// Simulation de succès
if (actionType === 'accept' || actionType === 'refuse') {
// Retirer le devis de la liste car il n'est plus en attente
setDevis(prev => prev.filter(d => d.id !== selectedDevisItem.id));
}
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 bulkSend = async () => {
if (selectedDevis.length === 0) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez sélectionner au moins un devis',
life: 3000
});
return;
}
// Simulation d'envoi en lot
console.log('Envoi en lot de', selectedDevis.length, 'devis');
setSelectedDevis([]);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `${selectedDevis.length} devis envoyé(s) (simulation)`,
life: 3000
});
};
const generateFollowUpReport = () => {
const urgent = devis.filter(d => getUrgencyLevel(d.dateValidite).level === 'URGENT');
const expiringSoon = devis.filter(d => getUrgencyLevel(d.dateValidite).level === 'BIENTÔT');
const expired = devis.filter(d => getUrgencyLevel(d.dateValidite).level === 'EXPIRÉ');
const report = `
=== RAPPORT DE SUIVI DEVIS EN ATTENTE ===
Date du rapport: ${new Date().toLocaleDateString('fr-FR')}
DEVIS URGENTS (${urgent.length}):
${urgent.map(d => `
- ${d.numero} - ${d.objet}
Client: ${d.client ? `${d.client.prenom} ${d.client.nom}` : 'N/A'}
Montant: ${formatCurrency(d.montantTTC || 0)}
Expire: ${formatDate(d.dateValidite)}
`).join('')}
DEVIS EXPIRANT BIENTÔT (${expiringSoon.length}):
${expiringSoon.map(d => `
- ${d.numero} - ${d.objet}
Client: ${d.client ? `${d.client.prenom} ${d.client.nom}` : 'N/A'}
Montant: ${formatCurrency(d.montantTTC || 0)}
Expire: ${formatDate(d.dateValidite)}
`).join('')}
DEVIS EXPIRÉS (${expired.length}):
${expired.map(d => `
- ${d.numero} - ${d.objet}
Client: ${d.client ? `${d.client.prenom} ${d.client.nom}` : 'N/A'}
Montant: ${formatCurrency(d.montantTTC || 0)}
Expiré: ${formatDate(d.dateValidite)}
`).join('')}
STATISTIQUES:
- Total devis en attente: ${devis.length}
- Montant total en attente: ${formatCurrency(devis.reduce((sum, d) => sum + (d.montantTTC || 0), 0))}
- Taux d'urgence: ${Math.round(((urgent.length + expiringSoon.length) / devis.length) * 100)}%
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `suivi_devis_attente_${new Date().toISOString().split('T')[0]}.txt`;
link.click();
toast.current?.show({
severity: 'success',
summary: 'Rapport généré',
detail: 'Le rapport de suivi a été téléchargé',
life: 3000
});
};
const leftToolbarTemplate = () => {
return (
<div className="my-2 flex gap-2">
<h5 className="m-0 flex align-items-center">
<i className="pi pi-clock mr-2 text-orange-500"></i>
Devis en attente ({devis.length})
</h5>
<Button
label="Envoyer sélection"
icon="pi pi-send"
severity="info"
size="small"
onClick={bulkSend}
disabled={selectedDevis.length === 0}
/>
<Button
label="Rapport de suivi"
icon="pi pi-file-export"
severity="help"
size="small"
onClick={generateFollowUpReport}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Devis) => {
const expired = isExpired(rowData.dateValidite);
return (
<div className="flex gap-1">
{rowData.statut === 'BROUILLON' && (
<Button
icon="pi pi-send"
rounded
severity="info"
size="small"
tooltip="Envoyer au client"
onClick={() => sendDevis(rowData)}
/>
)}
{rowData.statut === 'ENVOYE' && !expired && (
<>
<Button
icon="pi pi-check"
rounded
severity="success"
size="small"
tooltip="Marquer comme accepté"
onClick={() => acceptDevis(rowData)}
/>
<Button
icon="pi pi-times"
rounded
severity="danger"
size="small"
tooltip="Marquer comme refusé"
onClick={() => refuseDevis(rowData)}
/>
</>
)}
<Button
icon="pi pi-calendar-plus"
rounded
severity="warning"
size="small"
tooltip="Prolonger la validité"
onClick={() => extendValidité(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 du devis ${rowData.numero}`,
life: 3000
});
}}
/>
</div>
);
};
const statusBodyTemplate = (rowData: Devis) => {
const urgency = getUrgencyLevel(rowData.dateValidite);
return (
<div className="flex align-items-center gap-2">
<Tag
value={rowData.statut === 'BROUILLON' ? 'Brouillon' : 'Envoyé'}
severity={rowData.statut === 'BROUILLON' ? 'secondary' : 'info'}
/>
<Tag
value={urgency.level}
severity={urgency.severity}
/>
</div>
);
};
const validityBodyTemplate = (rowData: Devis) => {
const days = getDaysUntilExpiry(rowData.dateValidite);
const urgency = getUrgencyLevel(rowData.dateValidite);
return (
<div className={urgency.level === 'EXPIRÉ' ? 'text-red-500 font-bold' : ''}>
<div>{formatDate(rowData.dateValidite)}</div>
<small className={`text-${urgency.color}-500`}>
{days < 0 ? `Expiré il y a ${Math.abs(days)} jour${Math.abs(days) > 1 ? 's' : ''}` :
days === 0 ? 'Expire aujourd\'hui' :
`Expire dans ${days} jour${days > 1 ? 's' : ''}`}
</small>
</div>
);
};
const progressBodyTemplate = (rowData: Devis) => {
const totalDays = Math.ceil((new Date(rowData.dateValidite).getTime() - new Date(rowData.dateEmission).getTime()) / (1000 * 60 * 60 * 24));
const remainingDays = getDaysUntilExpiry(rowData.dateValidite);
const elapsed = Math.max(0, totalDays - remainingDays);
const progress = Math.min((elapsed / totalDays) * 100, 100);
const urgency = getUrgencyLevel(rowData.dateValidite);
let color = '#22c55e'; // green
if (urgency.level === 'BIENTÔT') color = '#f59e0b'; // orange
if (urgency.level === 'URGENT' || urgency.level === 'EXPIRÉ') color = '#ef4444'; // red
return (
<div className="flex align-items-center gap-2">
<ProgressBar
value={progress}
style={{ height: '6px', width: '100px' }}
color={color}
/>
<span className="text-sm">
{Math.round(progress)}%
</span>
</div>
);
};
const clientBodyTemplate = (rowData: Devis) => {
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">Devis en attente de réponse client</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 === 'send' ? 'Envoyer' :
actionType === 'accept' ? 'Accepter' :
actionType === 'refuse' ? 'Refuser' :
'Prolonger'
}
icon={
actionType === 'send' ? 'pi pi-send' :
actionType === 'accept' ? 'pi pi-check' :
actionType === 'refuse' ? 'pi pi-times' :
'pi pi-calendar-plus'
}
text
onClick={handleAction}
/>
</>
);
const getActionTitle = () => {
switch (actionType) {
case 'send': return 'Envoyer le devis';
case 'accept': return 'Accepter le devis';
case 'refuse': return 'Refuser le devis';
case 'extend': return 'Prolonger la validité';
default: return 'Action';
}
};
const getActionDescription = () => {
switch (actionType) {
case 'send': return 'Confirmer l\'envoi de ce devis au client ?';
case 'accept': return 'Marquer ce devis comme accepté par le client ?';
case 'refuse': return 'Marquer ce devis comme refusé par le client ?';
case 'extend': return 'Prolonger la validité de ce devis de 30 jours ?';
default: return '';
}
};
return (
<div className="grid">
<div className="col-12">
<Card>
<Toast ref={toast} />
<Toolbar className="mb-4" left={leftToolbarTemplate} right={rightToolbarTemplate} />
<DataTable
ref={dt}
value={devis}
selection={selectedDevis}
onSelectionChange={(e) => setSelectedDevis(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} devis"
globalFilter={globalFilter}
emptyMessage="Aucun devis en attente trouvé."
header={header}
responsiveLayout="scroll"
loading={loading}
sortField="dateValidite"
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="dateValidite" header="Date validité" body={validityBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="progress" header="Progression" body={progressBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column field="montantTTC" header="Montant TTC" body={(rowData) => formatCurrency(rowData.montantTTC)} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="statut" header="Statut/Urgence" body={statusBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '15rem' }} />
</DataTable>
<Dialog
visible={actionDialog}
style={{ width: '500px' }}
header={getActionTitle()}
modal
className="p-fluid"
footer={actionDialogFooter}
onHide={() => setActionDialog(false)}
>
<div className="formgrid grid">
<div className="field col-12">
<p>{getActionDescription()}</p>
<p>
Devis: <strong>{selectedDevisItem?.numero} - {selectedDevisItem?.objet}</strong>
</p>
<p>
Montant: <strong>{selectedDevisItem ? formatCurrency(selectedDevisItem.montantTTC || 0) : ''}</strong>
</p>
</div>
<div className="field col-12">
<label htmlFor="actionNotes">Notes (optionnel)</label>
<InputTextarea
id="actionNotes"
value={actionNotes}
onChange={(e) => setActionNotes(e.target.value)}
rows={3}
placeholder="Ajoutez des notes sur cette action..."
/>
</div>
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default DevisAttentePage;

View File

@@ -0,0 +1,606 @@
'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 { Chip } from 'primereact/chip';
import { devisService } from '../../../../services/api';
import { formatDate, formatCurrency } from '../../../../utils/formatters';
import type { Devis } from '../../../../types/btp';
import devisActionsService from '../../../../services/devisActionsService';
const DevisExpiresPage = () => {
const [devis, setDevis] = useState<Devis[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedDevis, setSelectedDevis] = useState<Devis[]>([]);
const [actionDialog, setActionDialog] = useState(false);
const [selectedDevisItem, setSelectedDevisItem] = useState<Devis | null>(null);
const [actionType, setActionType] = useState<'renew' | 'archive' | 'follow_up'>('renew');
const [renewData, setRenewData] = useState({
newValidityDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
adjustments: '',
newPrice: 0
});
const [followUpNotes, setFollowUpNotes] = useState('');
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Devis[]>>(null);
useEffect(() => {
loadDevis();
}, []);
const loadDevis = async () => {
try {
setLoading(true);
const data = await devisService.getAll();
// Filtrer les devis expirés ou marqués comme expirés
const devisExpires = data.filter(devis => {
const today = new Date();
const validityDate = new Date(devis.dateValidite);
return devis.statut === 'EXPIRE' || (devis.statut === 'ENVOYE' && validityDate < today);
});
setDevis(devisExpires);
} catch (error) {
console.error('Erreur lors du chargement des devis:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les devis expirés',
life: 3000
});
} finally {
setLoading(false);
}
};
const getDaysExpired = (dateValidite: string | Date) => {
const today = new Date();
const validityDate = new Date(dateValidite);
const diffTime = today.getTime() - validityDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
};
const getExpiryCategory = (dateValidite: string | Date) => {
const days = getDaysExpired(dateValidite);
if (days <= 7) return { category: 'RÉCEMMENT', color: 'orange', severity: 'warning' as const };
if (days <= 30) return { category: 'ANCIEN', color: 'red', severity: 'danger' as const };
return { category: 'TRÈS ANCIEN', color: 'darkred', severity: 'danger' as const };
};
const calculateLostValue = () => {
return devis.reduce((sum, d) => sum + (d.montantTTC || 0), 0);
};
const renewDevis = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('renew');
setRenewData({
newValidityDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
adjustments: '',
newPrice: devisItem.montantTTC || 0
});
setActionDialog(true);
};
const archiveDevis = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('archive');
setActionDialog(true);
};
const followUpDevis = (devisItem: Devis) => {
setSelectedDevisItem(devisItem);
setActionType('follow_up');
setFollowUpNotes('');
setActionDialog(true);
};
const handleAction = async () => {
if (!selectedDevisItem) return;
try {
let message = '';
switch (actionType) {
case 'renew':
await devisActionsService.renewDevis({
devisId: selectedDevisItem.id,
nouveaueDateValidite: renewData.newValidityDate.toISOString(),
modifications: renewData.modifications
});
message = 'Devis renouvelé avec succès';
break;
case 'archive':
await devisActionsService.archiveDevis({
devisId: selectedDevisItem.id,
motif: followUpNotes
});
setDevis(prev => prev.filter(d => d.id !== selectedDevisItem.id));
message = 'Devis archivé avec succès';
break;
case 'follow_up':
await devisActionsService.followUpClient({
devisId: selectedDevisItem.id,
clientId: selectedDevisItem.client.id,
type: 'email',
message: followUpNotes
});
message = 'Suivi client effectué avec succè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 bulkArchive = async () => {
if (selectedDevis.length === 0) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez sélectionner au moins un devis',
life: 3000
});
return;
}
// Simulation d'archivage en lot
console.log('Archivage en lot de', selectedDevis.length, 'devis');
setDevis(prev => prev.filter(d => !selectedDevis.includes(d)));
setSelectedDevis([]);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `${selectedDevis.length} devis archivé(s) (simulation)`,
life: 3000
});
};
const generateLossAnalysis = () => {
const totalLoss = calculateLostValue();
const recentlyExpired = devis.filter(d => getDaysExpired(d.dateValidite) <= 7);
const oldExpired = devis.filter(d => getDaysExpired(d.dateValidite) > 30);
const report = `
=== ANALYSE DES DEVIS EXPIRÉS ===
Date du rapport: ${new Date().toLocaleDateString('fr-FR')}
STATISTIQUES GÉNÉRALES:
- Nombre total de devis expirés: ${devis.length}
- Valeur totale perdue: ${formatCurrency(totalLoss)}
- Valeur moyenne par devis: ${formatCurrency(totalLoss / (devis.length || 1))}
RÉPARTITION PAR ANCIENNETÉ:
- Récemment expirés (≤7 jours): ${recentlyExpired.length} devis, ${formatCurrency(recentlyExpired.reduce((sum, d) => sum + (d.montantTTC || 0), 0))}
- Anciens (>30 jours): ${oldExpired.length} devis, ${formatCurrency(oldExpired.reduce((sum, d) => sum + (d.montantTTC || 0), 0))}
DEVIS À FORT POTENTIEL (>5000€):
${devis.filter(d => (d.montantTTC || 0) > 5000).map(d => `
- ${d.numero} - ${d.objet}
Client: ${d.client ? `${d.client.prenom} ${d.client.nom}` : 'N/A'}
Montant: ${formatCurrency(d.montantTTC || 0)}
Expiré depuis: ${getDaysExpired(d.dateValidite)} jours
`).join('')}
ANALYSE PAR CLIENT:
${getClientAnalysis()}
RECOMMANDATIONS:
- Relancer les clients pour les devis récemment expirés
- Proposer des devis renouvelés avec ajustements
- Analyser les causes d'expiration pour améliorer le taux de conversion
- Archiver les devis très anciens après relance finale
`;
const blob = new Blob([report], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `analyse_devis_expires_${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 getClientAnalysis = () => {
const clientStats = {};
devis.forEach(d => {
if (d.client) {
const clientKey = `${d.client.prenom} ${d.client.nom}`;
if (!clientStats[clientKey]) {
clientStats[clientKey] = { count: 0, value: 0 };
}
clientStats[clientKey].count++;
clientStats[clientKey].value += d.montantTTC || 0;
}
});
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} devis, ${formatCurrency(data.value)}`)
.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-times-circle mr-2"></i>
Devis expirés ({devis.length})
</h5>
<Chip
label={`Valeur perdue: ${formatCurrency(calculateLostValue())}`}
className="bg-red-100 text-red-800"
/>
<Button
label="Archiver sélection"
icon="pi pi-archive"
severity="secondary"
size="small"
onClick={bulkArchive}
disabled={selectedDevis.length === 0}
/>
<Button
label="Analyse des pertes"
icon="pi pi-chart-line"
severity="danger"
size="small"
onClick={generateLossAnalysis}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Devis) => {
const category = getExpiryCategory(rowData.dateValidite);
const isRecent = category.category === 'RÉCEMMENT';
return (
<div className="flex gap-1">
{isRecent && (
<Button
icon="pi pi-refresh"
rounded
severity="warning"
size="small"
tooltip="Renouveler le devis"
onClick={() => renewDevis(rowData)}
/>
)}
<Button
icon="pi pi-phone"
rounded
severity="info"
size="small"
tooltip="Suivi client"
onClick={() => followUpDevis(rowData)}
/>
<Button
icon="pi pi-archive"
rounded
severity="secondary"
size="small"
tooltip="Archiver"
onClick={() => archiveDevis(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 du devis ${rowData.numero}`,
life: 3000
});
}}
/>
</div>
);
};
const statusBodyTemplate = (rowData: Devis) => {
const category = getExpiryCategory(rowData.dateValidite);
return (
<div className="flex align-items-center gap-2">
<Tag value="Expiré" severity="danger" />
<Tag
value={category.category}
severity={category.severity}
/>
</div>
);
};
const expiryBodyTemplate = (rowData: Devis) => {
const days = getDaysExpired(rowData.dateValidite);
const category = getExpiryCategory(rowData.dateValidite);
return (
<div className="text-red-600">
<div className="font-bold">{formatDate(rowData.dateValidite)}</div>
<small>
{days === 0 ? 'Expire aujourd\'hui' :
days === 1 ? 'Expiré hier' :
`Expiré il y a ${days} jours`}
</small>
<div className="mt-1">
<Chip
label={category.category}
style={{ backgroundColor: category.color, color: 'white', fontSize: '0.7rem' }}
/>
</div>
</div>
);
};
const potentialBodyTemplate = (rowData: Devis) => {
const amount = rowData.montantTTC || 0;
let severity = 'secondary';
let label = 'Faible';
if (amount > 10000) {
severity = 'danger';
label = 'Très élevé';
} else if (amount > 5000) {
severity = 'warning';
label = 'Élevé';
} else if (amount > 1000) {
severity = 'info';
label = 'Moyen';
}
return (
<div>
<div>{formatCurrency(amount)}</div>
<Tag value={label} severity={severity as any} className="text-xs mt-1" />
</div>
);
};
const clientBodyTemplate = (rowData: Devis) => {
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">Devis expirés - Opportunités perdues</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 === 'renew' ? 'Renouveler' :
actionType === 'archive' ? 'Archiver' :
'Enregistrer le suivi'
}
icon={
actionType === 'renew' ? 'pi pi-refresh' :
actionType === 'archive' ? 'pi pi-archive' :
'pi pi-check'
}
text
onClick={handleAction}
/>
</>
);
const getActionTitle = () => {
switch (actionType) {
case 'renew': return 'Renouveler le devis';
case 'archive': return 'Archiver le devis';
case 'follow_up': return 'Suivi 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={devis}
selection={selectedDevis}
onSelectionChange={(e) => setSelectedDevis(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} devis"
globalFilter={globalFilter}
emptyMessage="Aucun devis expiré trouvé."
header={header}
responsiveLayout="scroll"
loading={loading}
sortField="dateValidite"
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="dateValidite" header="Date d'expiration" body={expiryBodyTemplate} sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="montantTTC" header="Potentiel perdu" body={potentialBodyTemplate} 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>
Devis: <strong>{selectedDevisItem?.numero} - {selectedDevisItem?.objet}</strong>
</p>
<p>
Client: <strong>{selectedDevisItem?.client ? `${selectedDevisItem.client.prenom} ${selectedDevisItem.client.nom}` : 'N/A'}</strong>
</p>
<p>
Montant: <strong>{selectedDevisItem ? formatCurrency(selectedDevisItem.montantTTC || 0) : ''}</strong>
</p>
<p>
Expiré depuis: <strong>{selectedDevisItem ? getDaysExpired(selectedDevisItem.dateValidite) : 0} jour(s)</strong>
</p>
</div>
{actionType === 'renew' && (
<>
<div className="field col-12 md:col-6">
<label htmlFor="newValidityDate">Nouvelle date de validité</label>
<Calendar
id="newValidityDate"
value={renewData.newValidityDate}
onChange={(e) => setRenewData(prev => ({ ...prev, newValidityDate: e.value || new Date() }))}
dateFormat="dd/mm/yy"
showIcon
minDate={new Date()}
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="newPrice">Nouveau montant ()</label>
<InputNumber
id="newPrice"
value={renewData.newPrice}
onValueChange={(e) => setRenewData(prev => ({ ...prev, newPrice: e.value || 0 }))}
mode="currency"
currency="EUR"
locale="fr-FR"
/>
</div>
<div className="field col-12">
<label htmlFor="adjustments">Ajustements apportés</label>
<InputTextarea
id="adjustments"
value={renewData.adjustments}
onChange={(e) => setRenewData(prev => ({ ...prev, adjustments: e.target.value }))}
rows={3}
placeholder="Décrivez les modifications apportées au devis..."
/>
</div>
</>
)}
{actionType === 'archive' && (
<div className="field col-12">
<p className="text-600">
<i className="pi pi-info-circle mr-2"></i>
Ce devis sera définitivement archivé et ne sera plus visible dans les listes actives.
Cette action est irréversible.
</p>
<p>
<strong>Confirmer l'archivage de ce devis ?</strong>
</p>
</div>
)}
{actionType === 'follow_up' && (
<div className="field col-12">
<label htmlFor="followUpNotes">Notes de suivi client</label>
<InputTextarea
id="followUpNotes"
value={followUpNotes}
onChange={(e) => setFollowUpNotes(e.target.value)}
rows={4}
placeholder="Notez vos actions de suivi : appel téléphonique, email, visite, résultats obtenus..."
/>
<small className="text-600">
Ces notes seront ajoutées à l'historique du client pour le suivi commercial.
</small>
</div>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default DevisExpiresPage;

View File

@@ -0,0 +1,750 @@
'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 { Steps } from 'primereact/steps';
import { devisService, clientService } from '../../../../services/api';
import type { Devis, Client } from '../../../../types/btp';
interface DevisLigne {
id: string;
designation: string;
quantite: number;
unite: string;
prixUnitaire: number;
montantHT: number;
}
const NouveauDevisPage = () => {
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 [lignes, setLignes] = useState<DevisLigne[]>([]);
const [devis, setDevis] = useState<Devis>({
id: '',
numero: '',
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
actif: true,
client: null,
chantier: null
});
const [nouvelleLigne, setNouvelleLigne] = useState<DevisLigne>({
id: '',
designation: '',
quantite: 1,
unite: 'u',
prixUnitaire: 0,
montantHT: 0
});
const [errors, setErrors] = useState<Record<string, string>>({});
const statuts = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'Envoyé', value: 'ENVOYE' },
{ label: 'Accepté', value: 'ACCEPTE' },
{ label: 'Refusé', value: 'REFUSE' },
{ label: 'Expiré', value: 'EXPIRE' }
];
const unites = [
{ label: 'Unité', value: 'u' },
{ label: 'Mètre', value: 'm' },
{ label: 'Mètre carré', value: 'm²' },
{ label: 'Mètre cube', value: 'm³' },
{ label: 'Kilogramme', value: 'kg' },
{ label: 'Heure', value: 'h' },
{ label: 'Jour', value: 'j' },
{ label: 'Forfait', value: 'forfait' }
];
const steps = [
{ label: 'Informations générales' },
{ label: 'Lignes de devis' },
{ label: 'Montants et TVA' },
{ label: 'Validation' }
];
useEffect(() => {
loadClients();
}, []);
useEffect(() => {
// Recalcul automatique des montants
const totalHT = lignes.reduce((sum, ligne) => sum + ligne.montantHT, 0);
const totalTTC = totalHT * (1 + devis.tauxTVA / 100);
setDevis(prev => ({
...prev,
montantHT: totalHT,
montantTTC: totalTTC
}));
}, [lignes, devis.tauxTVA]);
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 validateStep = (step: number) => {
const newErrors: Record<string, string> = {};
switch (step) {
case 0: // Informations générales
if (!devis.objet.trim()) {
newErrors.objet = 'L\'objet du devis est obligatoire';
}
if (!devis.client) {
newErrors.client = 'Le client est obligatoire';
}
if (!devis.dateEmission) {
newErrors.dateEmission = 'La date d\'émission est obligatoire';
}
if (!devis.dateValidite) {
newErrors.dateValidite = 'La date de validité est obligatoire';
}
if (devis.dateEmission && devis.dateValidite && devis.dateEmission > devis.dateValidite) {
newErrors.dateValidite = 'La date de validité doit être postérieure à la date d\'émission';
}
break;
case 1: // Lignes de devis
if (lignes.length === 0) {
newErrors.lignes = 'Au moins une ligne de devis est requise';
}
break;
case 2: // Montants et TVA
if (!devis.tauxTVA || devis.tauxTVA < 0 || devis.tauxTVA > 100) {
newErrors.tauxTVA = 'Le taux de TVA doit être compris entre 0 et 100%';
}
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 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 {
const devisToSave = {
...devis,
client: { id: devis.client }, // Envoyer seulement l'ID du client
lignes: lignes
};
// TODO: Implement when backend supports it
toast.current?.show({
severity: 'warn',
summary: 'Non implémenté',
detail: 'La création de devis n\'est pas encore disponible',
life: 3000
});
// Simulate success for demo
setTimeout(() => {
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis créé avec succès (simulation)',
life: 3000
});
setTimeout(() => {
router.push('/devis');
}, 1000);
}, 1000);
} catch (error) {
console.error('Erreur lors de la création:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de créer le devis',
life: 3000
});
} finally {
setLoading(false);
}
};
const handleCancel = () => {
router.push('/devis');
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _devis = { ...devis };
(_devis as any)[name] = val;
setDevis(_devis);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onDateChange = (e: any, name: string) => {
let _devis = { ...devis };
(_devis as any)[name] = e.value;
setDevis(_devis);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onNumberChange = (e: any, name: string) => {
let _devis = { ...devis };
(_devis as any)[name] = e.value;
setDevis(_devis);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onDropdownChange = (e: any, name: string) => {
let _devis = { ...devis };
(_devis as any)[name] = e.value;
setDevis(_devis);
if (errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
setErrors(newErrors);
}
};
const onLigneInputChange = (e: React.ChangeEvent<HTMLInputElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _ligne = { ...nouvelleLigne };
(_ligne as any)[name] = val;
setNouvelleLigne(_ligne);
};
const onLigneNumberChange = (e: any, name: string) => {
let _ligne = { ...nouvelleLigne };
(_ligne as any)[name] = e.value;
// Recalcul automatique du montant HT
if (name === 'quantite' || name === 'prixUnitaire') {
const quantite = name === 'quantite' ? e.value : _ligne.quantite;
const prixUnitaire = name === 'prixUnitaire' ? e.value : _ligne.prixUnitaire;
_ligne.montantHT = (quantite || 0) * (prixUnitaire || 0);
}
setNouvelleLigne(_ligne);
};
const onLigneDropdownChange = (e: any, name: string) => {
let _ligne = { ...nouvelleLigne };
(_ligne as any)[name] = e.value;
setNouvelleLigne(_ligne);
};
const ajouterLigne = () => {
if (!nouvelleLigne.designation.trim()) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'La désignation est obligatoire',
life: 3000
});
return;
}
const ligne: DevisLigne = {
...nouvelleLigne,
id: Date.now().toString() // Simple ID pour la démo
};
setLignes(prev => [...prev, ligne]);
setNouvelleLigne({
id: '',
designation: '',
quantite: 1,
unite: 'u',
prixUnitaire: 0,
montantHT: 0
});
if (errors.lignes) {
const newErrors = { ...errors };
delete newErrors.lignes;
setErrors(newErrors);
}
};
const supprimerLigne = (id: string) => {
setLignes(prev => prev.filter(ligne => ligne.id !== id));
};
const renderStepContent = () => {
switch (activeIndex) {
case 0:
return (
<div className="formgrid grid">
<div className="field col-12">
<label htmlFor="objet" className="font-bold">
Objet du devis <span className="text-red-500">*</span>
</label>
<InputText
id="objet"
value={devis.objet}
onChange={(e) => onInputChange(e, 'objet')}
className={errors.objet ? 'p-invalid' : ''}
placeholder="Objet du devis"
/>
{errors.objet && <small className="p-error">{errors.objet}</small>}
</div>
<div className="field col-12">
<label htmlFor="client" className="font-bold">
Client <span className="text-red-500">*</span>
</label>
<Dropdown
id="client"
value={devis.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="description" className="font-bold">Description</label>
<InputTextarea
id="description"
value={devis.description}
onChange={(e) => onInputChange(e, 'description')}
rows={4}
placeholder="Description détaillée des travaux"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateEmission" className="font-bold">
Date d'émission <span className="text-red-500">*</span>
</label>
<Calendar
id="dateEmission"
value={devis.dateEmission}
onChange={(e) => onDateChange(e, 'dateEmission')}
dateFormat="dd/mm/yy"
showIcon
className={errors.dateEmission ? 'p-invalid' : ''}
/>
{errors.dateEmission && <small className="p-error">{errors.dateEmission}</small>}
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateValidite" className="font-bold">
Date de validité <span className="text-red-500">*</span>
</label>
<Calendar
id="dateValidite"
value={devis.dateValidite}
onChange={(e) => onDateChange(e, 'dateValidite')}
dateFormat="dd/mm/yy"
showIcon
className={errors.dateValidite ? 'p-invalid' : ''}
minDate={devis.dateEmission}
/>
{errors.dateValidite && <small className="p-error">{errors.dateValidite}</small>}
</div>
<div className="field col-12">
<label htmlFor="statut" className="font-bold">Statut</label>
<Dropdown
id="statut"
value={devis.statut}
options={statuts}
onChange={(e) => onDropdownChange(e, 'statut')}
placeholder="Sélectionnez un statut"
/>
</div>
</div>
);
case 1:
return (
<div>
<h4 className="text-primary mb-4">Lignes de devis</h4>
{/* Formulaire d'ajout de ligne */}
<Card className="mb-4">
<h5>Ajouter une ligne</h5>
<div className="formgrid grid">
<div className="field col-12 md:col-4">
<label htmlFor="designation" className="font-bold">Désignation</label>
<InputText
id="designation"
value={nouvelleLigne.designation}
onChange={(e) => onLigneInputChange(e, 'designation')}
placeholder="Description du poste"
/>
</div>
<div className="field col-12 md:col-2">
<label htmlFor="quantite" className="font-bold">Quantité</label>
<InputNumber
id="quantite"
value={nouvelleLigne.quantite}
onValueChange={(e) => onLigneNumberChange(e, 'quantite')}
min={0}
maxFractionDigits={2}
/>
</div>
<div className="field col-12 md:col-2">
<label htmlFor="unite" className="font-bold">Unité</label>
<Dropdown
id="unite"
value={nouvelleLigne.unite}
options={unites}
onChange={(e) => onLigneDropdownChange(e, 'unite')}
/>
</div>
<div className="field col-12 md:col-2">
<label htmlFor="prixUnitaire" className="font-bold">Prix unitaire</label>
<InputNumber
id="prixUnitaire"
value={nouvelleLigne.prixUnitaire}
onValueChange={(e) => onLigneNumberChange(e, 'prixUnitaire')}
mode="currency"
currency="EUR"
locale="fr-FR"
min={0}
/>
</div>
<div className="field col-12 md:col-2">
<label htmlFor="montantHT" className="font-bold">Montant HT</label>
<InputNumber
id="montantHT"
value={nouvelleLigne.montantHT}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
</div>
<div className="flex justify-content-end">
<Button
label="Ajouter"
icon="pi pi-plus"
onClick={ajouterLigne}
size="small"
/>
</div>
</Card>
{/* Tableau des lignes */}
<DataTable value={lignes} emptyMessage="Aucune ligne ajoutée">
<Column field="designation" header="Désignation" />
<Column
field="quantite"
header="Quantité"
body={(rowData) => `${rowData.quantite} ${rowData.unite}`}
/>
<Column
field="prixUnitaire"
header="Prix unitaire"
body={(rowData) => rowData.prixUnitaire.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
/>
<Column
field="montantHT"
header="Montant HT"
body={(rowData) => rowData.montantHT.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
/>
<Column
body={(rowData) => (
<Button
icon="pi pi-trash"
rounded
severity="danger"
size="small"
onClick={() => supprimerLigne(rowData.id)}
/>
)}
/>
</DataTable>
{errors.lignes && <small className="p-error">{errors.lignes}</small>}
</div>
);
case 2:
return (
<div className="formgrid grid">
<div className="field col-12">
<h4 className="text-primary mb-4">Récapitulatif des montants</h4>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="montantHT" className="font-bold">Montant HT</label>
<InputNumber
id="montantHT"
value={devis.montantHT}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="tauxTVA" className="font-bold">
Taux TVA (%) <span className="text-red-500">*</span>
</label>
<InputNumber
id="tauxTVA"
value={devis.tauxTVA}
onValueChange={(e) => onNumberChange(e, 'tauxTVA')}
suffix="%"
min={0}
max={100}
className={errors.tauxTVA ? 'p-invalid' : ''}
/>
{errors.tauxTVA && <small className="p-error">{errors.tauxTVA}</small>}
</div>
<div className="field col-12 md:col-6">
<label htmlFor="montantTVA" className="font-bold">Montant TVA</label>
<InputNumber
id="montantTVA"
value={devis.montantTTC - devis.montantHT}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="montantTTC" className="font-bold">Montant TTC</label>
<InputNumber
id="montantTTC"
value={devis.montantTTC}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
</div>
);
case 3:
return (
<div className="formgrid grid">
<div className="field col-12">
<h3 className="text-primary">Récapitulatif du devis</h3>
<Divider />
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Objet :</label>
<p>{devis.objet}</p>
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Client :</label>
<p>{clients.find(c => c.value === devis.client)?.label}</p>
</div>
<div className="field col-12">
<label className="font-bold">Description :</label>
<p>{devis.description || 'Aucune description'}</p>
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Date d'émission :</label>
<p>{devis.dateEmission?.toLocaleDateString('fr-FR')}</p>
</div>
<div className="field col-12 md:col-6">
<label className="font-bold">Date de validité :</label>
<p>{devis.dateValidite?.toLocaleDateString('fr-FR')}</p>
</div>
<div className="field col-12">
<label className="font-bold">Lignes du devis :</label>
<DataTable value={lignes} size="small">
<Column field="designation" header="Désignation" />
<Column
field="quantite"
header="Quantité"
body={(rowData) => `${rowData.quantite} ${rowData.unite}`}
/>
<Column
field="prixUnitaire"
header="Prix unitaire"
body={(rowData) => rowData.prixUnitaire.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
/>
<Column
field="montantHT"
header="Montant HT"
body={(rowData) => rowData.montantHT.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
/>
</DataTable>
</div>
<div className="field col-12 md:col-4">
<label className="font-bold">Montant HT :</label>
<p>{devis.montantHT?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}</p>
</div>
<div className="field col-12 md:col-4">
<label className="font-bold">TVA ({devis.tauxTVA}%) :</label>
<p>{(devis.montantTTC - devis.montantHT)?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}</p>
</div>
<div className="field col-12 md:col-4">
<label className="font-bold">Montant TTC :</label>
<p className="text-primary font-bold text-xl">{devis.montantTTC?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}</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>Nouveau Devis</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 le devis"
icon="pi pi-check"
loading={loading}
disabled={loading}
/>
)}
</div>
</div>
</form>
</Card>
</div>
</div>
);
};
export default NouveauDevisPage;

619
app/(main)/devis/page.tsx Normal file
View File

@@ -0,0 +1,619 @@
'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 { devisService, clientService } from '../../../services/api';
import { formatDate, formatCurrency } from '../../../utils/formatters';
import { ErrorHandler } from '../../../services/errorHandler';
import type { Devis } from '../../../types/btp';
import {
ActionButtonGroup,
ViewButton,
EditButton,
DeleteButton,
PrintButton,
ActionButton
} from '../../../components/ui/ActionButton';
const DevisPage = () => {
const [devis, setDevis] = useState<Devis[]>([]);
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedDevis, setSelectedDevis] = useState<Devis[]>([]);
const [devisDialog, setDevisDialog] = useState(false);
const [deleteDevisDialog, setDeleteDevisDialog] = useState(false);
const [deleteDevisssDialog, setDeleteDevisssDialog] = useState(false);
const [devisItem, setDevisItem] = useState<Devis>({
id: '',
numero: '',
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
actif: true,
client: null,
chantier: null
});
const [submitted, setSubmitted] = useState(false);
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<Devis[]>>(null);
const statuts = [
{ label: 'Brouillon', value: 'BROUILLON' },
{ label: 'Envoyé', value: 'ENVOYE' },
{ label: 'Accepté', value: 'ACCEPTE' },
{ label: 'Refusé', value: 'REFUSE' },
{ label: 'Expiré', value: 'EXPIRE' }
];
useEffect(() => {
// Initialiser le gestionnaire d'erreurs
ErrorHandler.setToast(toast);
loadDevis();
loadClients();
}, []);
const loadDevis = async () => {
try {
setLoading(true);
const data = await devisService.getAll();
setDevis(data);
} catch (error) {
ErrorHandler.handleApiError(error, 'chargement des devis');
} 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) {
ErrorHandler.handleApiError(error, 'chargement des clients');
}
};
const openNew = () => {
setDevisItem({
id: '',
numero: '',
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
actif: true,
client: null,
chantier: null
});
setSubmitted(false);
setDevisDialog(true);
};
const hideDialog = () => {
setSubmitted(false);
setDevisDialog(false);
};
const hideDeleteDevisDialog = () => {
setDeleteDevisDialog(false);
};
const hideDeleteDevisssDialog = () => {
setDeleteDevisssDialog(false);
};
const saveDevis = async () => {
setSubmitted(true);
// Validation complète côté client
const validationErrors = [];
if (!devisItem.objet.trim()) {
validationErrors.push("L'objet du devis est obligatoire");
}
if (!devisItem.client) {
validationErrors.push("Le client est obligatoire");
}
if (!devisItem.numero.trim()) {
validationErrors.push("Le numéro de devis est obligatoire");
}
if (devisItem.montantHT <= 0) {
validationErrors.push("Le montant HT doit être supérieur à 0");
}
if (devisItem.tauxTVA < 0 || devisItem.tauxTVA > 100) {
validationErrors.push("Le taux de TVA doit être entre 0 et 100%");
}
if (!devisItem.dateValidite || devisItem.dateValidite <= new Date()) {
validationErrors.push("La date de validité doit être dans le futur");
}
if (validationErrors.length > 0) {
toast.current?.show({
severity: 'error',
summary: 'Erreurs de validation',
detail: validationErrors.join(', '),
life: 5000
});
return;
}
if (devisItem.objet.trim() && devisItem.client) {
try {
let updatedDevis = [...devis];
const devisToSave = {
...devisItem,
client: devisItem.client ? { id: devisItem.client } : null, // Envoyer seulement l'ID du client
montantTTC: devisItem.montantHT * (1 + devisItem.tauxTVA / 100)
};
if (devisItem.id) {
// Mise à jour du devis existant
const updatedDevis = await devisService.update(devisItem.id, devisToSave);
setDevis(devis.map(d => d.id === devisItem.id ? updatedDevis : d));
ErrorHandler.showSuccess('Succès', 'Devis mis à jour avec succès');
} else {
// Création d'un nouveau devis
const newDevis = await devisService.create(devisToSave);
setDevis([...devis, newDevis]);
ErrorHandler.showSuccess('Succès', 'Devis créé avec succès');
}
setDevisDialog(false);
setDevisItem({
id: '',
numero: '',
dateEmission: new Date(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON',
actif: true,
client: null,
chantier: null
});
} catch (error) {
ErrorHandler.handleApiError(error, 'sauvegarde du devis');
}
}
};
const editDevis = (devis: Devis) => {
setDevisItem({
...devis,
client: devis.client?.id || null
});
setDevisDialog(true);
};
const confirmDeleteDevis = (devis: Devis) => {
setDevisItem(devis);
setDeleteDevisDialog(true);
};
const deleteDevis = async () => {
try {
if (devisItem.id) {
await devisService.delete(devisItem.id);
setDevis(devis.filter(d => d.id !== devisItem.id));
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Devis supprimé avec succès',
life: 3000
});
}
setDeleteDevisDialog(false);
} catch (error) {
console.error('Erreur lors de la suppression:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de supprimer le devis',
life: 3000
});
}
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _devis = { ...devisItem };
(_devis as any)[name] = val;
setDevisItem(_devis);
};
const onDateChange = (e: any, name: string) => {
let _devis = { ...devisItem };
(_devis as any)[name] = e.value;
setDevisItem(_devis);
};
const onNumberChange = (e: any, name: string) => {
let _devis = { ...devisItem };
(_devis as any)[name] = e.value;
setDevisItem(_devis);
// Recalcul automatique du TTC
if (name === 'montantHT' || name === 'tauxTVA') {
const montantHT = name === 'montantHT' ? e.value : _devis.montantHT;
const tauxTVA = name === 'tauxTVA' ? e.value : _devis.tauxTVA;
_devis.montantTTC = montantHT * (1 + tauxTVA / 100);
setDevisItem(_devis);
}
};
const onDropdownChange = (e: any, name: string) => {
let _devis = { ...devisItem };
(_devis as any)[name] = e.value;
setDevisItem(_devis);
};
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={() => setDeleteDevisssDialog(true)}
disabled={!selectedDevis || selectedDevis.length === 0}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: Devis) => {
return (
<ActionButtonGroup>
<ViewButton
tooltip="Aperçu PDF"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Aperçu',
detail: `Aperçu du devis ${rowData.numero}`,
life: 3000
});
}}
/>
<EditButton
tooltip="Modifier"
onClick={() => editDevis(rowData)}
/>
<DeleteButton
tooltip="Supprimer"
onClick={() => confirmDeleteDevis(rowData)}
/>
</ActionButtonGroup>
);
};
const statusBodyTemplate = (rowData: Devis) => {
const getSeverity = (status: string) => {
switch (status) {
case 'BROUILLON': return 'secondary';
case 'ENVOYE': return 'info';
case 'ACCEPTE': return 'success';
case 'REFUSE': return 'danger';
case 'EXPIRE': return 'warning';
default: return 'secondary';
}
};
const getLabel = (status: string) => {
switch (status) {
case 'BROUILLON': return 'Brouillon';
case 'ENVOYE': return 'Envoyé';
case 'ACCEPTE': return 'Accepté';
case 'REFUSE': return 'Refusé';
case 'EXPIRE': return 'Expiré';
default: return status;
}
};
return (
<Tag
value={getLabel(rowData.statut)}
severity={getSeverity(rowData.statut)}
/>
);
};
const clientBodyTemplate = (rowData: Devis) => {
if (!rowData.client) return '';
return `${rowData.client.prenom} ${rowData.client.nom}`;
};
const dateBodyTemplate = (rowData: Devis, field: string) => {
const date = (rowData as any)[field];
return date ? formatDate(date) : '';
};
const validityBodyTemplate = (rowData: Devis) => {
const today = new Date();
const validityDate = new Date(rowData.dateValidite);
const isExpired = validityDate < today;
return (
<div className={`flex align-items-center ${isExpired ? 'text-red-500' : ''}`}>
{isExpired && <i className="pi pi-exclamation-triangle mr-1"></i>}
{formatDate(rowData.dateValidite)}
</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 Devis</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 devisDialogFooter = (
<>
<Button label="Annuler" icon="pi pi-times" text onClick={hideDialog} />
<Button label="Sauvegarder" icon="pi pi-check" text onClick={saveDevis} />
</>
);
const deleteDevisDialogFooter = (
<>
<Button label="Non" icon="pi pi-times" text onClick={hideDeleteDevisDialog} />
<Button label="Oui" icon="pi pi-check" text onClick={deleteDevis} />
</>
);
return (
<div className="grid">
<div className="col-12">
<Card>
<Toast ref={toast} />
<Toolbar className="mb-4" left={leftToolbarTemplate} right={rightToolbarTemplate} />
<DataTable
ref={dt}
value={devis}
selection={selectedDevis}
onSelectionChange={(e) => setSelectedDevis(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} devis"
globalFilter={globalFilter}
emptyMessage="Aucun devis trouvé."
header={header}
responsiveLayout="scroll"
loading={loading}
>
<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) => dateBodyTemplate(rowData, 'dateEmission')} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="dateValidite" header="Date validité" body={validityBodyTemplate} 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: '12rem' }} />
</DataTable>
<Dialog
visible={devisDialog}
style={{ width: '700px' }}
header="Détails du Devis"
modal
className="p-fluid"
footer={devisDialogFooter}
onHide={hideDialog}
>
<div className="formgrid grid">
<div className="field col-12 md:col-6">
<label htmlFor="numero">Numéro</label>
<InputText
id="numero"
value={devisItem.numero}
onChange={(e) => onInputChange(e, 'numero')}
placeholder="Généré automatiquement"
disabled
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="client">Client</label>
<Dropdown
id="client"
value={devisItem.client}
options={clients}
onChange={(e) => onDropdownChange(e, 'client')}
placeholder="Sélectionnez un client"
required
className={submitted && !devisItem.client ? 'p-invalid' : ''}
/>
{submitted && !devisItem.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={devisItem.objet}
onChange={(e) => onInputChange(e, 'objet')}
required
className={submitted && !devisItem.objet ? 'p-invalid' : ''}
placeholder="Objet du devis"
/>
{submitted && !devisItem.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={devisItem.description}
onChange={(e) => onInputChange(e, 'description')}
rows={4}
placeholder="Description détaillée des travaux"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateEmission">Date d'émission</label>
<Calendar
id="dateEmission"
value={devisItem.dateEmission}
onChange={(e) => onDateChange(e, 'dateEmission')}
dateFormat="dd/mm/yy"
showIcon
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="dateValidite">Date de validité</label>
<Calendar
id="dateValidite"
value={devisItem.dateValidite}
onChange={(e) => onDateChange(e, 'dateValidite')}
dateFormat="dd/mm/yy"
showIcon
minDate={devisItem.dateEmission}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="montantHT">Montant HT ()</label>
<InputNumber
id="montantHT"
value={devisItem.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={devisItem.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={devisItem.montantTTC}
mode="currency"
currency="EUR"
locale="fr-FR"
disabled
/>
</div>
<div className="field col-12">
<label htmlFor="statut">Statut</label>
<Dropdown
id="statut"
value={devisItem.statut}
options={statuts}
onChange={(e) => onDropdownChange(e, 'statut')}
placeholder="Sélectionnez un statut"
/>
</div>
</div>
</Dialog>
<Dialog
visible={deleteDevisDialog}
style={{ width: '450px' }}
header="Confirmer"
modal
footer={deleteDevisDialogFooter}
onHide={hideDeleteDevisDialog}
>
<div className="flex align-items-center justify-content-center">
<i className="pi pi-exclamation-triangle mr-3" style={{ fontSize: '2rem' }} />
{devisItem && (
<span>
Êtes-vous sûr de vouloir supprimer le devis <b>{devisItem.numero}</b> ?
</span>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default DevisPage;

View File

@@ -0,0 +1,477 @@
'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 { devisService } from '../../../../services/api';
import { formatCurrency, formatDate } from '../../../../utils/formatters';
interface DevisStats {
totalDevis: number;
montantTotal: number;
tauxAcceptation: number;
tauxConversion: number;
delaiMoyenReponse: number;
repartitionStatuts: { [key: string]: number };
evolutionMensuelle: Array<{ mois: string; nombre: number; montant: number }>;
topClients: Array<{ client: string; nombre: number; montant: number }>;
performanceCommerciale: Array<{ commercial: string; devis: number; acceptes: number; montant: number }>;
}
const DevisStatsPage = () => {
const toast = useRef<Toast>(null);
const [stats, setStats] = useState<DevisStats>({
totalDevis: 0,
montantTotal: 0,
tauxAcceptation: 0,
tauxConversion: 0,
delaiMoyenReponse: 0,
repartitionStatuts: {},
evolutionMensuelle: [],
topClients: [],
performanceCommerciale: []
});
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 devisService.getStatistiques(dateDebut, dateFin);
// Données simulées pour la démonstration
const mockStats: DevisStats = {
totalDevis: 156,
montantTotal: 2450000,
tauxAcceptation: 68.5,
tauxConversion: 72.3,
delaiMoyenReponse: 5.2,
repartitionStatuts: {
'ACCEPTE': 107,
'EN_ATTENTE': 23,
'REFUSE': 18,
'EXPIRE': 8
},
evolutionMensuelle: [
{ mois: 'Jan', nombre: 12, montant: 180000 },
{ mois: 'Fév', nombre: 15, montant: 220000 },
{ mois: 'Mar', nombre: 18, montant: 280000 },
{ mois: 'Avr', nombre: 14, montant: 195000 },
{ mois: 'Mai', nombre: 20, montant: 310000 },
{ mois: 'Jun', nombre: 16, montant: 240000 },
{ mois: 'Jul', nombre: 22, montant: 350000 },
{ mois: 'Aoû', nombre: 19, montant: 290000 },
{ mois: 'Sep', nombre: 20, montant: 395000 }
],
topClients: [
{ client: 'Bouygues Construction', nombre: 8, montant: 450000 },
{ client: 'Vinci Construction', nombre: 6, montant: 380000 },
{ client: 'Eiffage', nombre: 5, montant: 320000 },
{ client: 'Spie Batignolles', nombre: 4, montant: 280000 },
{ client: 'GTM Bâtiment', nombre: 3, montant: 210000 }
],
performanceCommerciale: [
{ commercial: 'Jean Dupont', devis: 45, acceptes: 32, montant: 680000 },
{ commercial: 'Marie Martin', devis: 38, acceptes: 26, montant: 520000 },
{ commercial: 'Pierre Durand', devis: 42, acceptes: 28, montant: 590000 },
{ commercial: 'Sophie Bernard', devis: 31, acceptes: 21, montant: 410000 }
]
};
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: 'Nombre de devis',
data: stats.evolutionMensuelle.map(item => item.nombre),
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
yAxisID: 'y'
},
{
label: 'Montant (k€)',
data: stats.evolutionMensuelle.map(item => item.montant / 1000),
borderColor: '#10B981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
yAxisID: 'y1'
}
]
};
const repartitionData = {
labels: Object.keys(stats.repartitionStatuts),
datasets: [{
data: Object.values(stats.repartitionStatuts),
backgroundColor: [
'#10B981', // ACCEPTE - vert
'#F59E0B', // EN_ATTENTE - orange
'#EF4444', // REFUSE - rouge
'#6B7280' // EXPIRE - gris
]
}]
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'ACCEPTE': return 'success';
case 'REFUSE': return 'danger';
case 'EXPIRE': return 'warning';
case 'EN_ATTENTE': return 'info';
default: return 'info';
}
};
const toolbarStartTemplate = () => (
<div className="flex align-items-center gap-2">
<h2 className="text-xl font-bold m-0">Statistiques des Devis</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 Devis</span>
<div className="text-900 font-medium text-xl">{stats.totalDevis}</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-edit text-blue-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">Montant Total</span>
<div className="text-900 font-medium text-xl">{formatCurrency(stats.montantTotal)}</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">+8% </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 d'Acceptation</span>
<div className="text-900 font-medium text-xl">{stats.tauxAcceptation}%</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.tauxAcceptation} 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</span>
<div className="text-900 font-medium text-xl">{stats.delaiMoyenReponse} 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">+0.3j </span>
<span className="text-500">vs période précédente</span>
</Card>
</div>
{/* Graphiques */}
<div className="col-12 lg:col-8">
<Card title="Évolution mensuelle">
<Chart
type="line"
data={evolutionData}
options={{
...chartOptions,
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Nombre de devis'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Montant (k)'
},
grid: {
drawOnChartArea: false,
},
}
}
}}
height="300px"
/>
</Card>
</div>
<div className="col-12 lg:col-4">
<Card title="Répartition par statut">
<Chart
type="doughnut"
data={repartitionData}
options={chartOptions}
height="300px"
/>
</Card>
</div>
{/* Top clients */}
<div className="col-12 lg:col-6">
<Card title="Top 5 Clients">
<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 Devis"
style={{ width: '100px' }}
/>
<Column
field="montant"
header="Montant"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montant)}
/>
</DataTable>
</Card>
</div>
{/* Performance commerciale */}
<div className="col-12 lg:col-6">
<Card title="Performance Commerciale">
<DataTable value={stats.performanceCommerciale} responsiveLayout="scroll">
<Column field="commercial" header="Commercial" />
<Column
field="devis"
header="Devis"
style={{ width: '80px' }}
/>
<Column
field="acceptes"
header="Acceptés"
style={{ width: '80px' }}
/>
<Column
header="Taux"
style={{ width: '80px' }}
body={(rowData) => (
<Tag
value={`${Math.round((rowData.acceptes / rowData.devis) * 100)}%`}
severity={rowData.acceptes / rowData.devis > 0.7 ? 'success' : 'warning'}
/>
)}
/>
<Column
field="montant"
header="CA"
style={{ width: '120px' }}
body={(rowData) => formatCurrency(rowData.montant)}
/>
</DataTable>
</Card>
</div>
{/* Analyse détaillée */}
<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>
Tendances
</h6>
<ul className="text-sm text-blue-800 list-none p-0 m-0">
<li className="mb-1">• Croissance de 12% du nombre de devis</li>
<li className="mb-1">• Augmentation de 8% du CA</li>
<li className="mb-1">• Taux d'acceptation stable à 68.5%</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"> Délai de réponse en hausse (+0.3j)</li>
<li className="mb-1"> 23 devis en attente de réponse</li>
<li className="mb-1"> 8 devis expirés ce mois</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>
Recommandations
</h6>
<ul className="text-sm text-green-800 list-none p-0 m-0">
<li className="mb-1"> Relancer les devis en attente</li>
<li className="mb-1"> Optimiser les délais de réponse</li>
<li className="mb-1"> Cibler les gros clients</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
</div>
);
};
export default DevisStatsPage;

View File

@@ -0,0 +1,589 @@
'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 { devisService } from '../../../../services/api';
import { formatDate } from '../../../../utils/formatters';
interface DevisTemplate {
id: string;
nom: string;
description: string;
categorie: string;
lignes: Array<{
designation: string;
quantite: number;
unite: string;
prixUnitaire: number;
}>;
tauxTVA: number;
actif: boolean;
dateCreation: Date;
utilisations: number;
}
const DevisTemplatesPage = () => {
const toast = useRef<Toast>(null);
const menuRef = useRef<Menu>(null);
const [templates, setTemplates] = useState<DevisTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<DevisTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<DevisTemplate | null>(null);
const [globalFilter, setGlobalFilter] = useState('');
const [formData, setFormData] = useState<Partial<DevisTemplate>>({
nom: '',
description: '',
categorie: '',
lignes: [],
tauxTVA: 20,
actif: true
});
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 devisService.getTemplates();
// Données simulées pour la démonstration
const mockTemplates: DevisTemplate[] = [
{
id: '1',
nom: 'Rénovation Salle de Bain Standard',
description: 'Template pour rénovation complète salle de bain 6m²',
categorie: 'RENOVATION',
lignes: [
{ designation: 'Démolition existant', quantite: 1, unite: 'forfait', prixUnitaire: 800 },
{ designation: 'Carrelage sol', quantite: 6, unite: 'm²', prixUnitaire: 45 },
{ designation: 'Carrelage mural', quantite: 20, unite: 'm²', prixUnitaire: 35 },
{ designation: 'Sanitaires standard', quantite: 1, unite: 'forfait', prixUnitaire: 1200 }
],
tauxTVA: 20,
actif: true,
dateCreation: new Date('2024-01-15'),
utilisations: 23
},
{
id: '2',
nom: 'Extension Maison 20m²',
description: 'Template pour extension plain-pied 20m²',
categorie: 'GROS_OEUVRE',
lignes: [
{ designation: 'Fondations', quantite: 20, unite: 'm²', prixUnitaire: 120 },
{ designation: 'Murs parpaings', quantite: 40, unite: 'm²', prixUnitaire: 85 },
{ designation: 'Charpente', quantite: 20, unite: 'm²', prixUnitaire: 95 },
{ designation: 'Couverture', quantite: 22, unite: 'm²', prixUnitaire: 65 }
],
tauxTVA: 20,
actif: true,
dateCreation: new Date('2024-02-10'),
utilisations: 15
},
{
id: '3',
nom: 'Installation Électrique Complète',
description: 'Template pour installation électrique maison 100m²',
categorie: 'ELECTRICITE',
lignes: [
{ designation: 'Tableau électrique', quantite: 1, unite: 'unité', prixUnitaire: 450 },
{ designation: 'Prises de courant', quantite: 25, unite: 'unité', prixUnitaire: 35 },
{ designation: 'Points lumineux', quantite: 15, unite: 'unité', prixUnitaire: 45 },
{ designation: 'Câblage', quantite: 1, unite: 'forfait', prixUnitaire: 1200 }
],
tauxTVA: 20,
actif: true,
dateCreation: new Date('2024-03-05'),
utilisations: 31
}
];
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: '',
categorie: '',
lignes: [],
tauxTVA: 20,
actif: true
});
setShowDialog(true);
};
const handleEdit = (template: DevisTemplate) => {
setEditingTemplate(template);
setFormData({ ...template });
setShowDialog(true);
};
const handleSave = async () => {
try {
if (editingTemplate) {
// TODO: Appel API pour mise à jour
// await devisService.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 devisService.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: DevisTemplate) => {
try {
// TODO: Appel API pour suppression
// await devisService.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: DevisTemplate) => {
try {
// TODO: Créer un nouveau devis basé sur le template
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Redirection vers la création de devis...'
});
// Simuler la redirection
setTimeout(() => {
window.location.href = `/devis/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: DevisTemplate) => [
{
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: DevisTemplate) => (
<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 categorieBodyTemplate = (rowData: DevisTemplate) => {
const categorie = categorieOptions.find(opt => opt.value === rowData.categorie);
return (
<Tag
value={categorie?.label || rowData.categorie}
severity="info"
/>
);
};
const statutBodyTemplate = (rowData: DevisTemplate) => (
<Tag
value={rowData.actif ? 'Actif' : 'Inactif'}
severity={rowData.actif ? 'success' : 'danger'}
/>
);
const utilisationsBodyTemplate = (rowData: DevisTemplate) => (
<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 Devis</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-edit 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">Catégories</span>
<div className="text-900 font-medium text-xl">
{new Set(templates.map(t => t.categorie)).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="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="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">
<h6>Prestations du template</h6>
<p className="text-sm text-600 mb-3">
Les prestations 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 prestation définie</p>
<Button
label="Ajouter des prestations"
icon="pi pi-plus"
className="p-button-outlined p-button-sm"
onClick={() => {
// TODO: Ouvrir dialog pour ajouter des prestations
toast.current?.show({
severity: 'info',
summary: 'Info',
detail: 'Fonctionnalité en cours de développement'
});
}}
/>
</div>
)}
</div>
</div>
</Dialog>
</div>
);
};
export default DevisTemplatesPage;

View File

@@ -0,0 +1,527 @@
'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 { Steps } from 'primereact/steps';
import { Timeline } from 'primereact/timeline';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Toast } from 'primereact/toast';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Toolbar } from 'primereact/toolbar';
import { Dialog } from 'primereact/dialog';
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 { devisService } from '../../../../../services/api';
import { formatDate, formatCurrency } from '../../../../../utils/formatters';
import type { Devis } from '../../../../../types/btp';
interface WorkflowStep {
id: string;
nom: string;
description: string;
statut: 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'BLOQUE';
dateDebut?: Date;
dateFin?: Date;
responsable?: string;
commentaires?: string;
documents?: string[];
}
interface WorkflowEvent {
date: Date;
type: 'CREATION' | 'MODIFICATION' | 'VALIDATION' | 'REFUS' | 'COMMENTAIRE' | 'DOCUMENT';
description: string;
utilisateur: string;
details?: any;
}
const DevisWorkflowPage = () => {
const params = useParams();
const router = useRouter();
const toast = useRef<Toast>(null);
const [devis, setDevis] = useState<Devis | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [workflowEvents, setWorkflowEvents] = useState<WorkflowEvent[]>([]);
const [loading, setLoading] = useState(true);
const [activeStep, setActiveStep] = useState(0);
const [showActionDialog, setShowActionDialog] = useState(false);
const [actionType, setActionType] = useState<string>('');
const [actionComment, setActionComment] = useState('');
const [actionDate, setActionDate] = useState<Date>(new Date());
const devisId = params.id as string;
const actionOptions = [
{ label: 'Valider l\'étape', value: 'VALIDER' },
{ label: 'Rejeter', value: 'REJETER' },
{ label: 'Demander modification', value: 'MODIFIER' },
{ label: 'Mettre en attente', value: 'ATTENDRE' },
{ label: 'Ajouter commentaire', value: 'COMMENTER' }
];
useEffect(() => {
loadWorkflowData();
}, [devisId]);
const loadWorkflowData = async () => {
try {
setLoading(true);
// Charger le devis
const devisResponse = await devisService.getById(devisId);
setDevis(devisResponse.data);
// TODO: Charger les données de workflow depuis l'API
// const workflowResponse = await devisService.getWorkflow(devisId);
// Données simulées pour la démonstration
const mockSteps: WorkflowStep[] = [
{
id: '1',
nom: 'Création du devis',
description: 'Rédaction initiale du devis avec toutes les prestations',
statut: 'TERMINE',
dateDebut: new Date('2024-01-15'),
dateFin: new Date('2024-01-16'),
responsable: 'Jean Dupont',
commentaires: 'Devis créé selon les spécifications client'
},
{
id: '2',
nom: 'Validation technique',
description: 'Vérification de la faisabilité technique et des quantités',
statut: 'TERMINE',
dateDebut: new Date('2024-01-16'),
dateFin: new Date('2024-01-17'),
responsable: 'Marie Martin',
commentaires: 'Validation technique OK, ajustement des quantités'
},
{
id: '3',
nom: 'Validation commerciale',
description: 'Vérification des prix et des conditions commerciales',
statut: 'EN_COURS',
dateDebut: new Date('2024-01-17'),
responsable: 'Pierre Durand'
},
{
id: '4',
nom: 'Envoi au client',
description: 'Transmission du devis au client pour validation',
statut: 'EN_ATTENTE',
responsable: 'Sophie Bernard'
},
{
id: '5',
nom: 'Suivi client',
description: 'Relance et négociation avec le client',
statut: 'EN_ATTENTE'
}
];
const mockEvents: WorkflowEvent[] = [
{
date: new Date('2024-01-15T09:00:00'),
type: 'CREATION',
description: 'Création du devis #DEV-2024-001',
utilisateur: 'Jean Dupont'
},
{
date: new Date('2024-01-16T14:30:00'),
type: 'VALIDATION',
description: 'Validation de l\'étape "Création du devis"',
utilisateur: 'Jean Dupont'
},
{
date: new Date('2024-01-16T15:00:00'),
type: 'MODIFICATION',
description: 'Ajustement des quantités de carrelage',
utilisateur: 'Marie Martin'
},
{
date: new Date('2024-01-17T10:15:00'),
type: 'VALIDATION',
description: 'Validation technique approuvée',
utilisateur: 'Marie Martin'
},
{
date: new Date('2024-01-17T11:00:00'),
type: 'COMMENTAIRE',
description: 'Demande de révision des prix pour être plus compétitif',
utilisateur: 'Pierre Durand'
}
];
setWorkflowSteps(mockSteps);
setWorkflowEvents(mockEvents);
// Déterminer l'étape active
const currentStepIndex = mockSteps.findIndex(step => step.statut === 'EN_COURS');
setActiveStep(currentStepIndex >= 0 ? currentStepIndex : mockSteps.findIndex(step => step.statut === 'EN_ATTENTE'));
} catch (error) {
console.error('Erreur lors du chargement du workflow:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les données du workflow'
});
} finally {
setLoading(false);
}
};
const handleAction = async () => {
try {
if (!actionType || !actionComment.trim()) {
toast.current?.show({
severity: 'warn',
summary: 'Attention',
detail: 'Veuillez sélectionner une action et ajouter un commentaire'
});
return;
}
// TODO: Appel API pour exécuter l'action
// await devisService.executeWorkflowAction(devisId, {
// type: actionType,
// commentaire: actionComment,
// date: actionDate
// });
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Action exécutée avec succès'
});
setShowActionDialog(false);
setActionComment('');
loadWorkflowData();
} catch (error) {
console.error('Erreur lors de l\'exécution de l\'action:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Erreur lors de l\'exécution de l\'action'
});
}
};
const getStepStatus = (step: WorkflowStep, index: number) => {
if (step.statut === 'TERMINE') return 'success';
if (step.statut === 'EN_COURS') return 'info';
if (step.statut === 'BLOQUE') return 'danger';
return 'secondary';
};
const getEventIcon = (type: string) => {
switch (type) {
case 'CREATION': return 'pi pi-plus';
case 'MODIFICATION': return 'pi pi-pencil';
case 'VALIDATION': return 'pi pi-check';
case 'REFUS': return 'pi pi-times';
case 'COMMENTAIRE': return 'pi pi-comment';
case 'DOCUMENT': return 'pi pi-file';
default: return 'pi pi-circle';
}
};
const getEventColor = (type: string) => {
switch (type) {
case 'CREATION': return '#3B82F6';
case 'MODIFICATION': return '#F59E0B';
case 'VALIDATION': return '#10B981';
case 'REFUS': return '#EF4444';
case 'COMMENTAIRE': return '#8B5CF6';
case 'DOCUMENT': return '#6B7280';
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(`/devis/${devisId}`)}
/>
</div>
);
const toolbarEndTemplate = () => (
<div className="flex align-items-center gap-2">
<Button
label="Nouvelle action"
icon="pi pi-cog"
onClick={() => setShowActionDialog(true)}
disabled={!workflowSteps[activeStep] || workflowSteps[activeStep].statut === 'TERMINE'}
/>
</div>
);
if (loading) {
return (
<div className="flex justify-content-center align-items-center min-h-screen">
<ProgressSpinner />
</div>
);
}
if (!devis) {
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>Devis introuvable</h3>
<p className="text-600 mb-4">Le devis demandé n'existe pas</p>
<Button
label="Retour à la liste"
icon="pi pi-arrow-left"
onClick={() => router.push('/devis')}
/>
</div>
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
</div>
{/* En-tête du devis */}
<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">Workflow - Devis #{devis.numero}</h2>
<p className="text-600 mb-3">{devis.objet}</p>
<Tag
value={devis.statut}
severity={devis.statut === 'ACCEPTE' ? 'success' : 'info'}
className="mb-2"
/>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary mb-2">
{formatCurrency(devis.montantTTC)}
</div>
<div className="text-sm text-600">
Client: {typeof devis.client === 'string' ? devis.client : devis.client?.nom}
</div>
</div>
</div>
</Card>
</div>
{/* Étapes du workflow */}
<div className="col-12">
<Card title="Progression du workflow">
<Steps
model={workflowSteps.map((step, index) => ({
label: step.nom,
command: () => setActiveStep(index)
}))}
activeIndex={activeStep}
className="mb-4"
/>
{workflowSteps[activeStep] && (
<div className="mt-4 p-4 border-round bg-blue-50">
<div className="flex justify-content-between align-items-start mb-3">
<div>
<h5 className="text-blue-900 mb-2">{workflowSteps[activeStep].nom}</h5>
<p className="text-blue-800 mb-2">{workflowSteps[activeStep].description}</p>
</div>
<Tag
value={workflowSteps[activeStep].statut}
severity={getStepStatus(workflowSteps[activeStep], activeStep)}
/>
</div>
<div className="grid">
{workflowSteps[activeStep].responsable && (
<div className="col-12 md:col-4">
<label className="font-semibold text-blue-900">Responsable:</label>
<p className="text-blue-800">{workflowSteps[activeStep].responsable}</p>
</div>
)}
{workflowSteps[activeStep].dateDebut && (
<div className="col-12 md:col-4">
<label className="font-semibold text-blue-900">Date de début:</label>
<p className="text-blue-800">{formatDate(workflowSteps[activeStep].dateDebut!)}</p>
</div>
)}
{workflowSteps[activeStep].dateFin && (
<div className="col-12 md:col-4">
<label className="font-semibold text-blue-900">Date de fin:</label>
<p className="text-blue-800">{formatDate(workflowSteps[activeStep].dateFin!)}</p>
</div>
)}
{workflowSteps[activeStep].commentaires && (
<div className="col-12">
<label className="font-semibold text-blue-900">Commentaires:</label>
<p className="text-blue-800">{workflowSteps[activeStep].commentaires}</p>
</div>
)}
</div>
</div>
)}
</Card>
</div>
{/* Historique des événements */}
<div className="col-12 lg:col-8">
<Card title="Historique des événements">
<Timeline
value={workflowEvents}
opposite={(item) => (
<div className="text-right">
<div className="text-sm font-semibold">{formatDate(item.date)}</div>
<div className="text-xs text-600">{item.utilisateur}</div>
</div>
)}
content={(item) => (
<div className="flex align-items-center">
<Badge
value={item.type}
style={{ backgroundColor: getEventColor(item.type) }}
className="mr-2"
/>
<div>
<div className="font-semibold">{item.description}</div>
{item.details && (
<div className="text-sm text-600 mt-1">{item.details}</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: getEventColor(item.type) }}
>
<i className={getEventIcon(item.type)}></i>
</span>
)}
/>
</Card>
</div>
{/* Résumé des étapes */}
<div className="col-12 lg:col-4">
<Card title="Résumé des étapes">
<DataTable value={workflowSteps} responsiveLayout="scroll">
<Column
field="nom"
header="Étape"
body={(rowData, options) => (
<div className="flex align-items-center">
<Badge value={options.rowIndex + 1} className="mr-2" />
<span className={options.rowIndex === activeStep ? 'font-bold' : ''}>
{rowData.nom}
</span>
</div>
)}
/>
<Column
field="statut"
header="Statut"
body={(rowData, options) => (
<Tag
value={rowData.statut}
severity={getStepStatus(rowData, options.rowIndex)}
/>
)}
/>
</DataTable>
</Card>
</div>
{/* Dialog d'action */}
<Dialog
header="Nouvelle action sur le workflow"
visible={showActionDialog}
onHide={() => setShowActionDialog(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={() => setShowActionDialog(false)}
/>
<Button
label="Exécuter"
icon="pi pi-check"
onClick={handleAction}
/>
</div>
}
>
<div className="grid">
<div className="col-12">
<div className="field">
<label htmlFor="actionType" className="font-semibold">Type d'action *</label>
<Dropdown
id="actionType"
value={actionType}
options={actionOptions}
onChange={(e) => setActionType(e.value)}
className="w-full"
placeholder="Sélectionner une action"
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="actionDate" className="font-semibold">Date d'exécution</label>
<Calendar
id="actionDate"
value={actionDate}
onChange={(e) => setActionDate(e.value || new Date())}
className="w-full"
dateFormat="dd/mm/yy"
showTime
/>
</div>
</div>
<div className="col-12">
<div className="field">
<label htmlFor="actionComment" className="font-semibold">Commentaire *</label>
<InputTextarea
id="actionComment"
value={actionComment}
onChange={(e) => setActionComment(e.target.value)}
className="w-full"
rows={4}
placeholder="Décrivez l'action effectuée ou la raison de cette action..."
/>
</div>
</div>
</div>
</Dialog>
</div>
);
};
export default DevisWorkflowPage;

View File

@@ -0,0 +1,620 @@
'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 { ProgressBar } from 'primereact/progressbar';
import { FileUpload } from 'primereact/fileupload';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
/**
* Page Workflow Devis BTP Express
* Gestion cycle de vie complet des devis avec génération PDF et signature électronique
*/
const WorkflowDevis = () => {
const [devis, setDevis] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [workflowDialog, setWorkflowDialog] = useState(false);
const [pdfDialog, setPdfDialog] = useState(false);
const [signatureDialog, setSignatureDialog] = useState(false);
const [selectedDevis, setSelectedDevis] = useState<any>(null);
const [nouveauStatut, setNouveauStatut] = useState('');
const [commentaire, setCommentaire] = useState('');
const [historique, setHistorique] = useState<any[]>([]);
const [modeleDevis, setModeleDevis] = useState('');
const [dateValidite, setDateValidite] = useState<Date | null>(null);
const toast = useRef<Toast>(null);
// Workflow états devis BTP
const workflowTransitions = {
'BROUILLON': [
{ label: 'Envoyer au client', value: 'ENVOYE', icon: 'pi-send', color: 'info', action: 'SEND_EMAIL' },
{ label: 'Générer PDF', value: 'BROUILLON', icon: 'pi-file-pdf', color: 'help', action: 'GENERATE_PDF' }
],
'ENVOYE': [
{ label: 'Marquer accepté', value: 'ACCEPTE', icon: 'pi-check', color: 'success', action: 'ACCEPT' },
{ label: 'Marquer refusé', value: 'REFUSE', icon: 'pi-times', color: 'danger', action: 'REJECT' },
{ label: 'Relancer client', value: 'ENVOYE', icon: 'pi-refresh', color: 'warning', action: 'REMIND' }
],
'ACCEPTE': [
{ label: 'Créer chantier', value: 'ACCEPTE', icon: 'pi-map', color: 'success', action: 'CREATE_CHANTIER' },
{ label: 'Générer contrat', value: 'ACCEPTE', icon: 'pi-file-edit', color: 'info', action: 'GENERATE_CONTRACT' }
],
'REFUSE': [
{ label: 'Créer nouveau devis', value: 'BROUILLON', icon: 'pi-plus', color: 'info', action: 'CREATE_NEW' }
],
'EXPIRE': [
{ label: 'Renouveler', value: 'BROUILLON', icon: 'pi-refresh', color: 'warning', action: 'RENEW' }
]
};
const statutsConfig = {
'BROUILLON': { color: 'secondary', icon: 'pi-file-edit', label: 'Brouillon' },
'ENVOYE': { color: 'info', icon: 'pi-send', label: 'Envoyé' },
'ACCEPTE': { color: 'success', icon: 'pi-check-circle', label: 'Accepté' },
'REFUSE': { color: 'danger', icon: 'pi-times-circle', label: 'Refusé' },
'EXPIRE': { color: 'warning', icon: 'pi-clock', label: 'Expiré' }
};
const modelesDevis = [
{ label: 'Modèle Standard BTP', value: 'standard_btp' },
{ label: 'Modèle Rénovation', value: 'renovation' },
{ label: 'Modèle Gros Œuvre', value: 'gros_oeuvre' },
{ label: 'Modèle Maintenance', value: 'maintenance' }
];
useEffect(() => {
loadDevis();
}, []);
const loadDevis = async () => {
try {
setLoading(true);
// Simulation données devis avec workflow
const mockDevis = [
{
id: 'DEV-2025-001',
numero: 'DEV-2025-001',
client: 'Claire Rousseau',
objet: 'Rénovation cuisine complète',
statut: 'ENVOYE',
montantHT: 25000,
montantTTC: 30000,
dateEmission: '2025-01-25',
dateValidite: '2025-02-25',
dateEnvoi: '2025-01-26',
nbRelances: 1,
commercial: 'Mme Petit',
priorite: 'HAUTE',
tempsEcoule: 5, // jours depuis envoi
lignes: [
{ designation: 'Démolition existant', quantite: 1, prixUnitaire: 3000, total: 3000 },
{ designation: 'Mobilier cuisine haut de gamme', quantite: 1, prixUnitaire: 18000, total: 18000 },
{ designation: 'Installation plomberie', quantite: 1, prixUnitaire: 2500, total: 2500 },
{ designation: 'Installation électrique', quantite: 1, prixUnitaire: 1500, total: 1500 }
]
},
{
id: 'DEV-2025-002',
numero: 'DEV-2025-002',
client: 'Jean Dupont',
objet: 'Extension maison 40m²',
statut: 'BROUILLON',
montantHT: 48000,
montantTTC: 57600,
dateEmission: '2025-01-30',
dateValidite: '2025-03-01',
dateEnvoi: null,
nbRelances: 0,
commercial: 'M. Laurent',
priorite: 'NORMALE',
tempsEcoule: 0,
lignes: [
{ designation: 'Fondations extension', quantite: 40, prixUnitaire: 150, total: 6000 },
{ designation: 'Élévation murs', quantite: 40, prixUnitaire: 300, total: 12000 },
{ designation: 'Couverture', quantite: 45, prixUnitaire: 120, total: 5400 },
{ designation: 'Cloisons et isolation', quantite: 40, prixUnitaire: 180, total: 7200 },
{ designation: 'Électricité et plomberie', quantite: 1, prixUnitaire: 8500, total: 8500 },
{ designation: 'Revêtements sols et murs', quantite: 40, prixUnitaire: 220, total: 8800 }
]
},
{
id: 'DEV-2025-003',
numero: 'DEV-2025-003',
client: 'Sophie Martin',
objet: 'Réfection toiture 120m²',
statut: 'ACCEPTE',
montantHT: 15000,
montantTTC: 18000,
dateEmission: '2025-01-20',
dateValidite: '2025-02-20',
dateEnvoi: '2025-01-21',
dateAcceptation: '2025-01-28',
nbRelances: 0,
commercial: 'M. Thomas',
priorite: 'NORMALE',
tempsEcoule: 10,
lignes: [
{ designation: 'Dépose ancienne couverture', quantite: 120, prixUnitaire: 25, total: 3000 },
{ designation: 'Contrôle et réfection charpente', quantite: 1, prixUnitaire: 4000, total: 4000 },
{ designation: 'Nouvelle couverture tuiles', quantite: 120, prixUnitaire: 45, total: 5400 },
{ designation: 'Isolation sous-toiture', quantite: 120, prixUnitaire: 22, total: 2640 }
]
}
];
setDevis(mockDevis);
} catch (error) {
console.error('Erreur chargement devis:', error);
} finally {
setLoading(false);
}
};
const ouvrirWorkflow = (devisItem: any) => {
setSelectedDevis(devisItem);
setNouveauStatut('');
setCommentaire('');
// Charger historique du devis
const mockHistorique = [
{
date: new Date(devisItem.dateEmission),
statut: 'BROUILLON',
utilisateur: devisItem.commercial,
commentaire: 'Création du devis',
action: 'CREATE'
}
];
if (devisItem.dateEnvoi) {
mockHistorique.push({
date: new Date(devisItem.dateEnvoi),
statut: 'ENVOYE',
utilisateur: devisItem.commercial,
commentaire: 'Devis envoyé par email au client',
action: 'SEND_EMAIL'
});
}
if (devisItem.dateAcceptation) {
mockHistorique.push({
date: new Date(devisItem.dateAcceptation),
statut: 'ACCEPTE',
utilisateur: 'Client',
commentaire: 'Devis accepté par signature électronique',
action: 'ACCEPT'
});
}
setHistorique(mockHistorique.reverse());
setWorkflowDialog(true);
};
const executerAction = async (action: string) => {
if (!selectedDevis) return;
try {
let messageSucces = '';
let devisMisAJour = { ...selectedDevis };
switch (action) {
case 'SEND_EMAIL':
devisMisAJour.statut = 'ENVOYE';
devisMisAJour.dateEnvoi = new Date().toISOString().split('T')[0];
messageSucces = 'Devis envoyé par email au client';
break;
case 'GENERATE_PDF':
setPdfDialog(true);
return;
case 'ACCEPT':
devisMisAJour.statut = 'ACCEPTE';
devisMisAJour.dateAcceptation = new Date().toISOString().split('T')[0];
messageSucces = 'Devis marqué comme accepté';
break;
case 'REJECT':
devisMisAJour.statut = 'REFUSE';
devisMisAJour.dateRefus = new Date().toISOString().split('T')[0];
messageSucces = 'Devis marqué comme refusé';
break;
case 'REMIND':
devisMisAJour.nbRelances = (devisMisAJour.nbRelances || 0) + 1;
devisMisAJour.dateRelance = new Date().toISOString().split('T')[0];
messageSucces = 'Relance envoyée au client';
break;
case 'CREATE_CHANTIER':
messageSucces = 'Chantier créé à partir du devis';
break;
case 'GENERATE_CONTRACT':
messageSucces = 'Contrat généré et envoyé';
break;
default:
return;
}
// Mettre à jour la liste
const devisUpdated = devis.map(d =>
d.id === selectedDevis.id ? devisMisAJour : d
);
setDevis(devisUpdated);
toast.current?.show({
severity: 'success',
summary: 'Action réussie',
detail: messageSucces,
life: 4000
});
setWorkflowDialog(false);
} catch (error) {
console.error('Erreur action:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible d\'effectuer l\'action',
life: 3000
});
}
};
const genererPDF = async () => {
if (!selectedDevis || !modeleDevis) return;
try {
// Simulation génération PDF
await new Promise(resolve => setTimeout(resolve, 2000));
toast.current?.show({
severity: 'success',
summary: 'PDF généré',
detail: `Devis ${selectedDevis.numero} généré avec le modèle ${modeleDevis}`,
life: 4000
});
setPdfDialog(false);
setModeleDevis('');
} catch (error) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de générer le PDF',
life: 3000
});
}
};
const statutBodyTemplate = (rowData: any) => {
const config = statutsConfig[rowData.statut as keyof typeof statutsConfig];
return (
<div className="flex align-items-center gap-2">
<i className={`pi ${config.icon} text-${config.color}`} />
<Tag value={config.label} severity={config.color} />
</div>
);
};
const montantBodyTemplate = (rowData: any) => {
return (
<div className="flex flex-column">
<span className="font-medium">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(rowData.montantTTC)}
</span>
<span className="text-sm text-color-secondary">
HT: {new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(rowData.montantHT)}
</span>
</div>
);
};
const urgenceBodyTemplate = (rowData: any) => {
if (rowData.statut === 'ENVOYE') {
const joursPasses = rowData.tempsEcoule;
const joursRestants = Math.max(0, 30 - joursPasses); // 30 jours de validité standard
let severity = 'success';
if (joursRestants <= 5) severity = 'danger';
else if (joursRestants <= 10) severity = 'warning';
return (
<div className="flex align-items-center gap-2">
<Badge value={`${joursRestants}j`} severity={severity} />
{rowData.nbRelances > 0 && (
<Badge value={`${rowData.nbRelances}R`} severity="info" />
)}
</div>
);
}
return <span className="text-color-secondary">-</span>;
};
const actionsBodyTemplate = (rowData: any) => {
return (
<div className="flex gap-2">
<Button
icon="pi pi-cog"
size="small"
severity="info"
tooltip="Gérer workflow"
onClick={() => ouvrirWorkflow(rowData)}
/>
<Button
icon="pi pi-file-pdf"
size="small"
severity="help"
tooltip="Générer PDF"
onClick={() => {
setSelectedDevis(rowData);
setPdfDialog(true);
}}
/>
<Button
icon="pi pi-eye"
size="small"
severity="secondary"
tooltip="Voir détails"
/>
</div>
);
};
return (
<div className="grid">
<Toast ref={toast} />
{/* Métriques Devis */}
<div className="col-12">
<div className="grid">
<div className="col-12 md:col-3">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{devis.filter(d => d.statut === 'BROUILLON').length}
</div>
<div className="text-color-secondary">Brouillons</div>
</div>
</Card>
</div>
<div className="col-12 md:col-3">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-cyan-500">
{devis.filter(d => d.statut === 'ENVOYE').length}
</div>
<div className="text-color-secondary">En attente</div>
</div>
</Card>
</div>
<div className="col-12 md:col-3">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{devis.filter(d => d.statut === 'ACCEPTE').length}
</div>
<div className="text-color-secondary">Acceptés</div>
</div>
</Card>
</div>
<div className="col-12 md:col-3">
<Card>
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact'
}).format(devis.reduce((sum, d) => sum + (d.statut === 'ACCEPTE' ? d.montantTTC : 0), 0))}
</div>
<div className="text-color-secondary">CA potentiel</div>
</div>
</Card>
</div>
</div>
</div>
<div className="col-12">
<Divider />
</div>
{/* Tableau devis */}
<div className="col-12">
<Card title="Gestion Workflow Devis">
<DataTable
value={devis}
loading={loading}
paginator
rows={10}
dataKey="id"
emptyMessage="Aucun devis trouvé"
responsiveLayout="scroll"
>
<Column field="numero" header="Numéro" sortable style={{ minWidth: '120px' }} />
<Column field="client" header="Client" sortable style={{ minWidth: '150px' }} />
<Column field="objet" header="Objet" style={{ minWidth: '200px' }} />
<Column header="Statut" body={statutBodyTemplate} sortable style={{ minWidth: '120px' }} />
<Column header="Montant" body={montantBodyTemplate} style={{ minWidth: '130px' }} />
<Column
field="dateEmission"
header="Émission"
body={(rowData) => new Date(rowData.dateEmission).toLocaleDateString('fr-FR')}
sortable
style={{ minWidth: '100px' }}
/>
<Column header="Urgence" body={urgenceBodyTemplate} style={{ minWidth: '100px' }} />
<Column field="commercial" header="Commercial" style={{ minWidth: '120px' }} />
<Column body={actionsBodyTemplate} style={{ minWidth: '150px' }} />
</DataTable>
</Card>
</div>
{/* Dialog Workflow */}
<Dialog
visible={workflowDialog}
style={{ width: '90vw', height: '90vh' }}
header={`Workflow Devis - ${selectedDevis?.numero}`}
modal
onHide={() => setWorkflowDialog(false)}
maximizable
>
{selectedDevis && (
<div className="grid">
{/* Détails devis */}
<div className="col-12 md:col-4">
<Card title="Détails du Devis">
<div className="flex flex-column gap-3">
<div><strong>Client:</strong> {selectedDevis.client}</div>
<div><strong>Objet:</strong> {selectedDevis.objet}</div>
<div>
<strong>Statut:</strong>
<Tag
value={statutsConfig[selectedDevis.statut as keyof typeof statutsConfig]?.label}
severity={statutsConfig[selectedDevis.statut as keyof typeof statutsConfig]?.color}
className="ml-2"
/>
</div>
<div><strong>Montant TTC:</strong> {new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(selectedDevis.montantTTC)}</div>
<div><strong>Validité:</strong> {new Date(selectedDevis.dateValidite).toLocaleDateString('fr-FR')}</div>
<div><strong>Commercial:</strong> {selectedDevis.commercial}</div>
{selectedDevis.nbRelances > 0 && (
<div><strong>Relances:</strong> <Badge value={selectedDevis.nbRelances} severity="warning" /></div>
)}
</div>
</Card>
</div>
{/* Actions workflow */}
<div className="col-12 md:col-4">
<Card title="Actions Disponibles">
<div className="flex flex-column gap-3">
{workflowTransitions[selectedDevis.statut as keyof typeof workflowTransitions]?.map((transition) => (
<Button
key={transition.action}
label={transition.label}
icon={`pi ${transition.icon}`}
severity={transition.color}
className="justify-content-start"
onClick={() => executerAction(transition.action)}
/>
))}
</div>
</Card>
</div>
{/* Historique */}
<div className="col-12 md:col-4">
<Card title="Historique">
<Timeline
value={historique}
align="left"
content={(item) => (
<div className="p-2">
<div className="flex align-items-center gap-2 mb-1">
<Tag
value={statutsConfig[item.statut as keyof typeof statutsConfig]?.label}
severity={statutsConfig[item.statut as keyof typeof statutsConfig]?.color}
/>
</div>
<div className="text-sm text-color-secondary mb-1">
{item.date.toLocaleString('fr-FR')}
</div>
<div className="text-sm font-medium mb-1">
{item.utilisateur}
</div>
<div className="text-sm">
{item.commentaire}
</div>
</div>
)}
/>
</Card>
</div>
</div>
)}
</Dialog>
{/* Dialog Génération PDF */}
<Dialog
visible={pdfDialog}
style={{ width: '500px' }}
header="Générer PDF du Devis"
modal
onHide={() => setPdfDialog(false)}
>
<div className="flex flex-column gap-4">
<div>
<label className="block text-sm font-medium mb-2">Modèle de devis</label>
<Dropdown
value={modeleDevis}
options={modelesDevis}
onChange={(e) => setModeleDevis(e.value)}
placeholder="Sélectionnez un modèle"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Date de validité (optionnel)</label>
<Calendar
value={dateValidite}
onChange={(e) => setDateValidite(e.value || null)}
dateFormat="dd/mm/yy"
showIcon
className="w-full"
/>
</div>
<div className="flex justify-content-end gap-2">
<Button
label="Annuler"
icon="pi pi-times"
outlined
onClick={() => setPdfDialog(false)}
/>
<Button
label="Générer PDF"
icon="pi pi-file-pdf"
severity="success"
onClick={genererPDF}
disabled={!modeleDevis}
/>
</div>
</div>
</Dialog>
</div>
);
};
export default WorkflowDevis;