Sage100/src/components/modal/ModalFacture.tsx
2026-01-20 11:05:50 +03:00

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>
);
}