Initial commit
This commit is contained in:
502
app/(main)/devis/[id]/convert/page.tsx
Normal file
502
app/(main)/devis/[id]/convert/page.tsx
Normal 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;
|
||||
407
app/(main)/devis/[id]/duplicate/page.tsx
Normal file
407
app/(main)/devis/[id]/duplicate/page.tsx
Normal 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 été 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;
|
||||
490
app/(main)/devis/[id]/edit/page.tsx
Normal file
490
app/(main)/devis/[id]/edit/page.tsx
Normal 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;
|
||||
383
app/(main)/devis/[id]/page.tsx
Normal file
383
app/(main)/devis/[id]/page.tsx
Normal 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;
|
||||
587
app/(main)/devis/acceptes/page.tsx
Normal file
587
app/(main)/devis/acceptes/page.tsx
Normal 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;
|
||||
552
app/(main)/devis/attente/page.tsx
Normal file
552
app/(main)/devis/attente/page.tsx
Normal 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;
|
||||
606
app/(main)/devis/expires/page.tsx
Normal file
606
app/(main)/devis/expires/page.tsx
Normal 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;
|
||||
750
app/(main)/devis/nouveau/page.tsx
Normal file
750
app/(main)/devis/nouveau/page.tsx
Normal 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
619
app/(main)/devis/page.tsx
Normal 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;
|
||||
477
app/(main)/devis/stats/page.tsx
Normal file
477
app/(main)/devis/stats/page.tsx
Normal 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;
|
||||
589
app/(main)/devis/templates/page.tsx
Normal file
589
app/(main)/devis/templates/page.tsx
Normal 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;
|
||||
527
app/(main)/devis/workflow/[id]/page.tsx
Normal file
527
app/(main)/devis/workflow/[id]/page.tsx
Normal 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;
|
||||
620
app/(main)/devis/workflow/page.tsx
Normal file
620
app/(main)/devis/workflow/page.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user