491 lines
No EOL
21 KiB
TypeScript
491 lines
No EOL
21 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import FormModal, { FormSection, FormField } from '@/components/ui/FormModal';
|
|
import { Calendar, FileText, Plus, Save, Trash2 } from "lucide-react";
|
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
|
import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Client } from "@/types/clientType";
|
|
import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
|
import { Article } from "@/types/articleType";
|
|
import { Alert, CircularProgress, Divider } from "@mui/material";
|
|
import Button from '@mui/material/Button';
|
|
import { useNavigate } from "react-router-dom";
|
|
import { toast } from "../ui/use-toast";
|
|
import { Facture, FactureRequest } from '@/types/factureType';
|
|
import { factureStatus } from '@/store/features/factures/selectors';
|
|
import { createFacture, getFacture, updateFacture } from '@/store/features/factures/thunk';
|
|
import { selectfacture } from '@/store/features/factures/slice';
|
|
import { ModalLoading } from "./ModalLoading";
|
|
import { formatForDateInput } from '@/lib/utils';
|
|
import { Input } from '../ui/ui';
|
|
|
|
// ✅ Interface corrigée avec les bons noms de champs
|
|
interface LigneForm {
|
|
article_code: string;
|
|
quantite: number;
|
|
prix_unitaire_ht: number;
|
|
articles: Article | null;
|
|
}
|
|
|
|
export function ModalFacture({
|
|
open,
|
|
onClose,
|
|
title,
|
|
editing,
|
|
client
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title?: string;
|
|
editing?: Facture | null;
|
|
client?: Client | null;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const dispatch = useAppDispatch();
|
|
const statusFacture = useAppSelector(factureStatus);
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
const [dateFacture, setDateFacture] = useState(() => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + 1);
|
|
return d.toISOString().split("T")[0];
|
|
});
|
|
const [dateEcheance, setDateEcheance] = useState('');
|
|
const [referenceClient, setReferenceClient] = useState('');
|
|
|
|
const [clientSelectionne, setClientSelectionne] = useState<Client | null>(client ?? null);
|
|
|
|
// du le
|
|
const [dateLivraison, setDateLivraison] = useState(() => {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() + 8);
|
|
return d.toISOString().split("T")[0];
|
|
});
|
|
|
|
|
|
// ✅ Ligne par défaut avec articles: null
|
|
const [lignes, setLignes] = useState<LigneForm[]>([
|
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
|
]);
|
|
|
|
const isEditing = !!editing;
|
|
|
|
// Calcul date d'échéance par défaut (30 jours)
|
|
useEffect(() => {
|
|
if (dateFacture && !dateEcheance) {
|
|
const date = new Date(dateFacture);
|
|
date.setDate(date.getDate() + 30);
|
|
setDateEcheance(date.toISOString().split('T')[0]);
|
|
}
|
|
}, [dateFacture]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
if (editing) {
|
|
setClientSelectionne({
|
|
numero: editing.client_code,
|
|
intitule: editing.client_intitule,
|
|
compte_collectif: "",
|
|
adresse: "",
|
|
code_postal: "",
|
|
ville: "",
|
|
email: "",
|
|
telephone: "",
|
|
} as Client);
|
|
|
|
const lignesInitiales: LigneForm[] = editing.lignes?.map(ligne => ({
|
|
article_code: ligne.article_code,
|
|
quantite: ligne.quantite,
|
|
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
|
|
articles: {
|
|
reference: ligne.article_code,
|
|
designation: ligne.designation ?? '',
|
|
prix_vente: ligne.prix_unitaire_ht ?? 0,
|
|
stock_reel: 0,
|
|
} as Article,
|
|
})) ?? [];
|
|
|
|
setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
|
]);
|
|
|
|
if (editing.date) setDateFacture(editing.date);
|
|
if(editing.date_livraison) setDateLivraison(editing.date_livraison);
|
|
setReferenceClient(editing.reference || '');
|
|
} else {
|
|
if (!client) {
|
|
setClientSelectionne(null);
|
|
} else {
|
|
setClientSelectionne(client);
|
|
}
|
|
setLignes([
|
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
|
]);
|
|
setDateFacture(new Date().toISOString().split('T')[0]);
|
|
setDateLivraison(() => {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() + 8);
|
|
return d.toISOString().split("T")[0];
|
|
});
|
|
setReferenceClient('');
|
|
setDateEcheance('');
|
|
setReferenceClient('');
|
|
}
|
|
|
|
setError(null);
|
|
setSuccess(false);
|
|
}, [open, editing, client]);
|
|
|
|
const appliquerBareme = async (index: number, articleRef: string) => {
|
|
if (!clientSelectionne) return;
|
|
try {
|
|
// Logique d'application du barème
|
|
} catch (err) {
|
|
console.error('Erreur application barème:', err);
|
|
}
|
|
};
|
|
|
|
const ajouterLigne = () => {
|
|
setLignes([
|
|
...lignes,
|
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null },
|
|
]);
|
|
};
|
|
|
|
const supprimerLigne = (index: number) => {
|
|
if (lignes.length > 1) {
|
|
setLignes(lignes.filter((_, i) => i !== index));
|
|
}
|
|
};
|
|
|
|
// ✅ Fonction updateLigne corrigée
|
|
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
|
|
const nouvelles = [...lignes];
|
|
|
|
if (field === 'articles' && value) {
|
|
const article = value as Article;
|
|
nouvelles[index] = {
|
|
...nouvelles[index],
|
|
articles: article,
|
|
article_code: article.reference,
|
|
prix_unitaire_ht: article.prix_vente,
|
|
};
|
|
|
|
if (clientSelectionne) {
|
|
appliquerBareme(index, article.reference);
|
|
}
|
|
} else if (field === 'articles' && !value) {
|
|
nouvelles[index] = {
|
|
...nouvelles[index],
|
|
articles: null,
|
|
article_code: '',
|
|
prix_unitaire_ht: 0,
|
|
};
|
|
} else {
|
|
(nouvelles[index] as any)[field] = value;
|
|
}
|
|
|
|
setLignes(nouvelles);
|
|
};
|
|
|
|
const calculerTotalLigne = (ligne: LigneForm) => {
|
|
if (!ligne.articles) return 0;
|
|
const prix = ligne.prix_unitaire_ht || ligne.articles.prix_vente;
|
|
return prix * ligne.quantite;
|
|
};
|
|
|
|
const calculerTotal = () => {
|
|
return lignes.reduce((acc, ligne) => acc + calculerTotalLigne(ligne), 0);
|
|
};
|
|
|
|
const canSave = useMemo(() => {
|
|
if (!clientSelectionne) return false;
|
|
const lignesValides = lignes.filter((l) => l.article_code);
|
|
if (lignesValides.length === 0) return false;
|
|
return true;
|
|
}, [clientSelectionne, lignes]);
|
|
|
|
const onSave = async () => {
|
|
if (!clientSelectionne) {
|
|
setError('Veuillez sélectionner un client');
|
|
return;
|
|
}
|
|
|
|
const lignesValides = lignes.filter((l) => l.article_code);
|
|
|
|
if (lignesValides.length === 0) {
|
|
setError('Veuillez ajouter au moins un article');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
if (isEditing) {
|
|
const payloadUpdate = {
|
|
client_id: clientSelectionne.numero,
|
|
date_facture: (() => {
|
|
const d = new Date(dateFacture);
|
|
d.setDate(d.getDate() + 1);
|
|
return d.toISOString().split("T")[0];
|
|
})(),
|
|
reference: referenceClient,
|
|
date_livraison: (() => {
|
|
const d = new Date(dateLivraison);
|
|
d.setDate(d.getDate() + 1);
|
|
return d.toISOString().split("T")[0];
|
|
})(),
|
|
lignes: lignesValides.map((l) => ({
|
|
article_code: l.article_code,
|
|
quantite: l.quantite
|
|
})),
|
|
};
|
|
|
|
const result = await dispatch(updateFacture({
|
|
numero: editing!.numero,
|
|
data: payloadUpdate
|
|
})).unwrap() as any;
|
|
|
|
const numero = result.facture.numero as any;
|
|
|
|
setSuccess(true);
|
|
toast({
|
|
title: "Facture mise à jour !",
|
|
description: `La facture a été mise à jour avec succès.`,
|
|
className: "bg-green-500 text-white border-green-600"
|
|
});
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
const itemCreated = await dispatch(getFacture(numero)).unwrap() as any;
|
|
const res = itemCreated as Facture;
|
|
dispatch(selectfacture(res));
|
|
|
|
setLoading(false);
|
|
onClose();
|
|
navigate(`/home/factures/${numero}`);
|
|
|
|
} else {
|
|
const payloadCreate: FactureRequest = {
|
|
client_id: clientSelectionne.numero,
|
|
date_facture: (() => {
|
|
const d = new Date(dateFacture);
|
|
d.setDate(d.getDate() + 1);
|
|
return d.toISOString().split("T")[0];
|
|
})(),
|
|
reference: referenceClient,
|
|
date_livraison: (() => {
|
|
const d = new Date(dateLivraison);
|
|
d.setDate(d.getDate() + 1);
|
|
return d.toISOString().split("T")[0];
|
|
})(),
|
|
lignes: lignesValides.map((l) => ({
|
|
article_code: l.article_code,
|
|
quantite: l.quantite
|
|
})),
|
|
};
|
|
|
|
const result = await dispatch(createFacture(payloadCreate)).unwrap();
|
|
|
|
const data = result.data;
|
|
setSuccess(true);
|
|
toast({
|
|
title: "Facture créée !",
|
|
description: `Une nouvelle facture ${data.numero_facture} a été créée avec succès.`,
|
|
className: "bg-green-500 text-white border-green-600"
|
|
});
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
const itemCreated = await dispatch(getFacture(data.numero_facture)).unwrap() as any;
|
|
const res = itemCreated as Facture;
|
|
dispatch(selectfacture(res));
|
|
|
|
setLoading(false);
|
|
onClose();
|
|
navigate(`/home/factures/${data.numero_facture}`);
|
|
}
|
|
|
|
} catch (err: any) {
|
|
setError("Cet article n'est pas disponible pour ce client.");
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const totalHT = calculerTotal();
|
|
const totalTVA = totalHT * 0.20;
|
|
const totalTTC = totalHT + totalTVA;
|
|
const lignesValides = lignes.filter((l) => l.article_code).length;
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<FormModal
|
|
isOpen={open}
|
|
onClose={onClose}
|
|
title={title || (isEditing ? "Modifier la facture" : "Créer une facture")}
|
|
size="xl"
|
|
onSubmit={onSave}
|
|
loading={statusFacture === "loading" ? true : false}
|
|
>
|
|
{/* Section Client */}
|
|
<FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
|
<FormField label="Client" required fullWidth>
|
|
<ClientAutocomplete
|
|
value={clientSelectionne}
|
|
onChange={setClientSelectionne}
|
|
required
|
|
/>
|
|
</FormField>
|
|
|
|
<div className="col-span-1 md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
|
<FormField label="Date d'émission" required>
|
|
<Input
|
|
type="date"
|
|
value={formatForDateInput(dateFacture)}
|
|
onChange={(e) => setDateFacture(e.target.value)}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Dû le" required>
|
|
<Input
|
|
type="date"
|
|
value={formatForDateInput(dateLivraison)}
|
|
onChange={(e) => setDateLivraison(e.target.value)}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Référence client">
|
|
<Input
|
|
placeholder="Ex: REF-FAC-123"
|
|
value={referenceClient}
|
|
onChange={(e) => setReferenceClient(e.target.value)}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</FormSection>
|
|
|
|
{/* Section Lignes */}
|
|
<FormSection title="Lignes de la facture" description="Ajoutez les produits et services facturés">
|
|
<div className="col-span-2">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full mb-4">
|
|
<thead className="text-left bg-gray-50 dark:bg-gray-900/50 text-xs uppercase text-gray-500">
|
|
<tr>
|
|
<th className="px-4 py-3 rounded-l-lg" style={{ width: '50%' }}>Article / Description</th>
|
|
<th className="px-2 py-3 text-center" style={{ width: '80px' }}>Qté</th>
|
|
<th className="px-2 py-3 text-right" style={{ width: '100px' }}>P.U. HT</th>
|
|
<th className="px-2 py-3 text-right rounded-r-lg" style={{ width: '100px' }}>Total HT</th>
|
|
<th style={{ width: '40px' }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{lignes.map((ligne, index) => (
|
|
<tr key={index} className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
|
<td className="px-4 py-3">
|
|
{/* ✅ Correction: passer articles et onChange sur 'articles' */}
|
|
<ArticleAutocomplete
|
|
value={ligne.articles}
|
|
onChange={(article) => updateLigne(index, 'articles', article)}
|
|
required
|
|
/>
|
|
</td>
|
|
|
|
<td className="px-2 py-3">
|
|
<input
|
|
type="number"
|
|
value={ligne.quantite}
|
|
onChange={(e) => updateLigne(index, 'quantite', parseFloat(e.target.value) || 0)}
|
|
min={0}
|
|
step={1}
|
|
className="w-full text-center px-2 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#941403] focus:border-transparent"
|
|
/>
|
|
</td>
|
|
|
|
<td className="px-2 py-3">
|
|
<input
|
|
type="number"
|
|
value={ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0}
|
|
onChange={(e) => updateLigne(index, 'prix_unitaire_ht', parseFloat(e.target.value) || 0)}
|
|
min={0}
|
|
step={0.01}
|
|
className="w-full text-right px-2 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#941403] focus:border-transparent"
|
|
/>
|
|
</td>
|
|
|
|
<td className="px-2 py-3 text-right">
|
|
<span className="font-semibold text-gray-900 dark:text-white text-sm">
|
|
{calculerTotalLigne(ligne).toFixed(2)} €
|
|
</span>
|
|
</td>
|
|
|
|
<td className="px-1 py-3 text-center">
|
|
<button
|
|
onClick={() => supprimerLigne(index)}
|
|
disabled={lignes.length === 1}
|
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed opacity-0 group-hover:opacity-100"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-start mt-4">
|
|
<button
|
|
onClick={ajouterLigne}
|
|
className="text-sm text-[#007E45] font-medium hover:underline flex items-center gap-1"
|
|
>
|
|
<Plus className="w-4 h-4" /> Ajouter une ligne
|
|
</button>
|
|
|
|
{/* Récapitulatif */}
|
|
<div style={{width: "42vh"}} className="w-72 bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4 space-y-3 border border-blue-100 dark:border-blue-800">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-600 dark:text-gray-400">Client</span>
|
|
<span className="font-medium text-gray-900 dark:text-white truncate max-w-[150px]">
|
|
{clientSelectionne?.intitule || '-'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-600 dark:text-gray-400">Lignes</span>
|
|
<span className="font-medium text-gray-900 dark:text-white">
|
|
{lignesValides}
|
|
</span>
|
|
</div>
|
|
|
|
<Divider className="!my-3" />
|
|
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-600 dark:text-gray-400">Total HT</span>
|
|
<span className="font-medium text-gray-900 dark:text-white">{totalHT.toFixed(2)} €</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-600 dark:text-gray-400">Taux TVA </span>
|
|
<span className="font-medium text-gray-900 dark:text-white">20%</span>
|
|
</div>
|
|
<div className="pt-3 border-t border-blue-200 dark:border-blue-700 flex justify-between">
|
|
<span className="font-bold text-gray-900 dark:text-white">Total TTC</span>
|
|
<span className="font-bold text-blue-600 dark:text-blue-400 text-lg">{totalTTC.toFixed(2)} €</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</FormSection>
|
|
|
|
{/* Erreurs */}
|
|
{error && (
|
|
<Alert severity="error" className="mb-4">{error}</Alert>
|
|
)}
|
|
|
|
{loading && <ModalLoading />}
|
|
</FormModal>
|
|
);
|
|
} |