Initial commit
This commit is contained in:
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