Files
btpxpress-frontend/app/(main)/devis/nouveau/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

754 lines
31 KiB
TypeScript

'use client';
export const dynamic = 'force-dynamic';
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<Partial<Devis>>({
id: '',
numero: '',
dateEmission: new Date().toISOString(),
dateValidite: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
objet: '',
description: '',
montantHT: 0,
montantTTC: 0,
tauxTVA: 20,
statut: 'BROUILLON' as any,
actif: true,
client: undefined,
chantier: undefined
});
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 ? new Date(devis.dateEmission) : null}
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 ? new Date(devis.dateValidite) : null}
onChange={(e) => onDateChange(e, 'dateValidite')}
dateFormat="dd/mm/yy"
showIcon
className={errors.dateValidite ? 'p-invalid' : ''}
minDate={devis.dateEmission ? new Date(devis.dateEmission) : undefined}
/>
{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 ? new Date(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 ? new Date(devis.dateValidite).toLocaleDateString('fr-FR') : ''}</p>
</div>
<div className="field col-12">
<label className="font-bold">Lignes du devis :</label>
<DataTable value={lignes} size="normal">
<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;