Files
btpxpress-frontend/app/(main)/devis/workflow/page.tsx
dahoud a8825a058b Fix: Corriger toutes les erreurs de build du frontend
- Correction des erreurs TypeScript dans userService.ts et workflowTester.ts
- Ajout des propriétés manquantes aux objets User mockés
- Conversion des dates de string vers objets Date
- Correction des appels asynchrones et des types incompatibles
- Ajout de dynamic rendering pour résoudre les erreurs useSearchParams
- Enveloppement de useSearchParams dans Suspense boundary
- Configuration de force-dynamic au niveau du layout principal

Build réussi: 126 pages générées avec succès

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 13:23:08 +00:00

625 lines
27 KiB
TypeScript

'use client';
export const dynamic = 'force-dynamic';
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 as any} />
</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 as any} />
{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" as any}
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 as any}
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 as any}
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 as any}
/>
</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;