480 lines
No EOL
18 KiB
TypeScript
480 lines
No EOL
18 KiB
TypeScript
import React from "react";
|
|
import { Plus, Trash2, PenLine, Package, Lock, EyeOff } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Tooltip, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
|
import { Textarea } from "@/components/ui/FormModal";
|
|
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
|
|
import StickyTotals from "@/components/document-entry/StickyTotals";
|
|
import { Facture } from "@/types/factureType";
|
|
|
|
export interface LigneForm {
|
|
id: string;
|
|
article_code: string;
|
|
quantite: number;
|
|
prix_unitaire_ht: number;
|
|
total_taxes: number;
|
|
taux_taxe1: number;
|
|
montant_ligne_ht: number;
|
|
remise_pourcentage: number;
|
|
designation: string;
|
|
articles: any | null;
|
|
isManual: boolean;
|
|
}
|
|
|
|
export interface Note {
|
|
publique: string;
|
|
prive: string;
|
|
}
|
|
|
|
interface FactureContentProps {
|
|
// Données
|
|
facture: Facture;
|
|
editLignes: LigneForm[];
|
|
note: Note;
|
|
|
|
// États
|
|
isEditing: boolean;
|
|
isPdfPreviewVisible: boolean;
|
|
activeLineId: string | null;
|
|
description: string | null;
|
|
error: string | null;
|
|
|
|
// Totaux
|
|
editTotalHT: number;
|
|
editTotalTVA: number;
|
|
editTotalTTC: number;
|
|
|
|
// Handlers
|
|
onToggleLineMode: (lineId: string) => void;
|
|
onUpdateLigne: (lineId: string, field: keyof LigneForm, value: any) => void;
|
|
onAjouterLigne: () => void;
|
|
onSupprimerLigne: (lineId: string) => void;
|
|
onSetActiveLineId: (lineId: string | null) => void;
|
|
onSetDescription: (value: string | null) => void;
|
|
onSetNote: (note: Note) => void;
|
|
onOpenArticleModal: (lineId: string) => void;
|
|
|
|
// Fonction de calcul
|
|
calculerTotalLigne: (ligne: LigneForm) => number;
|
|
}
|
|
|
|
const FactureContent: React.FC<FactureContentProps> = ({
|
|
facture,
|
|
editLignes,
|
|
note,
|
|
isEditing,
|
|
isPdfPreviewVisible,
|
|
activeLineId,
|
|
description,
|
|
error,
|
|
editTotalHT,
|
|
editTotalTVA,
|
|
editTotalTTC,
|
|
onToggleLineMode,
|
|
onUpdateLigne,
|
|
onAjouterLigne,
|
|
onSupprimerLigne,
|
|
onSetActiveLineId,
|
|
onSetDescription,
|
|
onSetNote,
|
|
onOpenArticleModal,
|
|
calculerTotalLigne,
|
|
}) => {
|
|
const textSizeClass = isPdfPreviewVisible ? "text-xs" : "text-sm";
|
|
|
|
return (
|
|
<div className="flex flex-col h-full p-2 ">
|
|
{/* Zone scrollable - SEULEMENT ici le scroll s'active */}
|
|
<div className="flex-1 overflow-y-auto px-4 mx-auto w-full sm:px-6 lg:px-8 scroll-smooth">
|
|
<div className="mt-6 pb-4 w-full">
|
|
{/* Tableau des lignes */}
|
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
|
|
<div className="overflow-x-auto mb-2">
|
|
<table className="w-full">
|
|
<thead className="text-xs font-semibold tracking-wider text-gray-500 uppercase bg-gray-50 border-b dark:bg-gray-900/50">
|
|
<tr className={isEditing ? "text-[10px]" : ""}>
|
|
{isEditing && <th className="px-2 py-3 w-5 text-center">Mode</th>}
|
|
<th className="px-4 py-3 text-left w-[25%]">Désignation</th>
|
|
<th className="px-0 py-3 text-left w-[20%]">
|
|
Description {!isEditing && "détaillée"}
|
|
</th>
|
|
<th className="px-4 py-3 text-right w-[8%]">Qté</th>
|
|
<th className="px-4 py-3 text-right w-[15%]">P.U. HT</th>
|
|
<th className="px-4 py-3 text-right w-[8%]">Remise</th>
|
|
<th className="px-4 py-3 text-right w-[8%]">TVA</th>
|
|
<th className="px-4 py-3 text-right w-[15%]">Total HT</th>
|
|
{isEditing && <th className="px-2 py-3 w-12"></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{isEditing
|
|
? editLignes.map((item) => (
|
|
<EditableLigneRow
|
|
key={item.id}
|
|
item={item}
|
|
isPdfPreviewVisible={isPdfPreviewVisible}
|
|
activeLineId={activeLineId}
|
|
description={description}
|
|
onToggleLineMode={onToggleLineMode}
|
|
onUpdateLigne={onUpdateLigne}
|
|
onSupprimerLigne={onSupprimerLigne}
|
|
onSetActiveLineId={onSetActiveLineId}
|
|
onSetDescription={onSetDescription}
|
|
onOpenArticleModal={onOpenArticleModal}
|
|
calculerTotalLigne={calculerTotalLigne}
|
|
canDelete={editLignes.length > 1}
|
|
/>
|
|
))
|
|
: facture.lignes?.map((line, index) => (
|
|
<ReadOnlyLigneRow
|
|
key={index}
|
|
line={line}
|
|
textSizeClass={textSizeClass}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Bouton ajouter ligne */}
|
|
{isEditing && (
|
|
<div className="m-6">
|
|
<button
|
|
onClick={onAjouterLigne}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#2A6F4F] bg-white border border-[#2A6F4F] rounded-lg hover:bg-green-50 transition-all shadow-sm"
|
|
>
|
|
<Plus className="w-4 h-4" /> Ajouter une ligne
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Message d'erreur */}
|
|
{error && isEditing && (
|
|
<div className="p-4 m-4 text-sm text-red-800 bg-red-50 rounded-xl border border-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Section Notes */}
|
|
<NotesSection
|
|
note={note}
|
|
isEditing={isEditing}
|
|
onSetNote={onSetNote}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer Totaux - Reste fixe naturellement en bas */}
|
|
<div className="flex-none mt-5 bg-white dark:bg-gray-950 border-t border-gray-200 dark:border-gray-800 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-20">
|
|
<div className="px-4 mx-auto w-full sm:px-6 lg:px-8">
|
|
<StickyTotals
|
|
total_ht_calcule={isEditing ? editTotalHT : facture.total_ht_calcule}
|
|
total_taxes_calcule={isEditing ? editTotalTVA : facture.total_taxes_calcule}
|
|
total_ttc_calcule={isEditing ? editTotalTTC : facture.total_ttc_calcule}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// SOUS-COMPOSANTS
|
|
// ============================================
|
|
|
|
interface EditableLigneRowProps {
|
|
item: LigneForm;
|
|
isPdfPreviewVisible: boolean;
|
|
activeLineId: string | null;
|
|
description: string | null;
|
|
canDelete: boolean;
|
|
onToggleLineMode: (lineId: string) => void;
|
|
onUpdateLigne: (lineId: string, field: keyof LigneForm, value: any) => void;
|
|
onSupprimerLigne: (lineId: string) => void;
|
|
onSetActiveLineId: (lineId: string | null) => void;
|
|
onSetDescription: (value: string | null) => void;
|
|
onOpenArticleModal: (lineId: string) => void;
|
|
calculerTotalLigne: (ligne: LigneForm) => number;
|
|
}
|
|
|
|
const EditableLigneRow: React.FC<EditableLigneRowProps> = ({
|
|
item,
|
|
isPdfPreviewVisible,
|
|
activeLineId,
|
|
description,
|
|
canDelete,
|
|
onToggleLineMode,
|
|
onUpdateLigne,
|
|
onSupprimerLigne,
|
|
onSetActiveLineId,
|
|
onSetDescription,
|
|
onOpenArticleModal,
|
|
calculerTotalLigne,
|
|
}) => {
|
|
const textSizeClass = isPdfPreviewVisible ? "text-xs" : "text-sm";
|
|
|
|
return (
|
|
<tr className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
|
{/* Mode Toggle */}
|
|
<td className="px-2 py-3">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => onToggleLineMode(item.id)}
|
|
className={cn(
|
|
"p-1.5 rounded-md transition-all duration-200",
|
|
item.isManual
|
|
? "text-amber-600 bg-amber-50 hover:bg-amber-100"
|
|
: "text-[#007E45] bg-green-50 hover:bg-green-100"
|
|
)}
|
|
>
|
|
{item.isManual ? (
|
|
<PenLine className="w-4 h-4" />
|
|
) : (
|
|
<Package className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</td>
|
|
|
|
{/* Article / Désignation */}
|
|
<td className="px-4 py-3 min-w-[280px]">
|
|
{item.isManual ? (
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={item.designation}
|
|
onChange={(e) => onUpdateLigne(item.id, "designation", e.target.value)}
|
|
onFocus={() => onSetActiveLineId(item.id)}
|
|
onBlur={() => setTimeout(() => onSetActiveLineId(null), 150)}
|
|
className={cn(
|
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900`
|
|
)}
|
|
placeholder="Saisir une désignation..."
|
|
/>
|
|
|
|
{/* Menu dropdown */}
|
|
{activeLineId === item.id && item.designation && (
|
|
<ArticleCreationDropdown
|
|
designation={item.designation}
|
|
onCreateArticle={() => onOpenArticleModal(item.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
<ArticleAutocomplete
|
|
value={item.articles}
|
|
onChange={(article) => onUpdateLigne(item.id, "articles", article)}
|
|
required
|
|
className={textSizeClass}
|
|
/>
|
|
</div>
|
|
)}
|
|
</td>
|
|
|
|
{/* Description */}
|
|
<td className="px-1">
|
|
<input
|
|
type="text"
|
|
value={item.isManual ? description! : item.designation || ""}
|
|
onChange={(e) =>
|
|
item.isManual
|
|
? onSetDescription(e.target.value)
|
|
: onUpdateLigne(item.id, "designation", e.target.value)
|
|
}
|
|
placeholder="Description détaillée..."
|
|
className={cn(
|
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-left`
|
|
)}
|
|
/>
|
|
</td>
|
|
|
|
{/* Quantité */}
|
|
<td className="px-1" style={{ width: "100px" }}>
|
|
<input
|
|
type="number"
|
|
value={item.quantite}
|
|
onChange={(e) =>
|
|
onUpdateLigne(item.id, "quantite", parseFloat(e.target.value) || 0)
|
|
}
|
|
min={0}
|
|
className={cn(
|
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-right`
|
|
)}
|
|
/>
|
|
</td>
|
|
|
|
{/* Prix Unitaire */}
|
|
<td className="px-1 text-center" style={{ width: "60px" }}>
|
|
<input
|
|
type="number"
|
|
value={item.prix_unitaire_ht || item.articles?.prix_vente || 0}
|
|
onChange={(e) =>
|
|
onUpdateLigne(item.id, "prix_unitaire_ht", parseFloat(e.target.value) || 0)
|
|
}
|
|
min={0}
|
|
step={0.01}
|
|
className={cn(
|
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-right`
|
|
)}
|
|
/>
|
|
</td>
|
|
|
|
{/* Remise */}
|
|
<td className="px-1 text-center" style={{ width: "60px" }}>
|
|
<input
|
|
type="number"
|
|
value={item.remise_pourcentage || 0}
|
|
onChange={(e) =>
|
|
onUpdateLigne(item.id, "remise_pourcentage", parseFloat(e.target.value) || 0)
|
|
}
|
|
min={0}
|
|
step={1}
|
|
className={cn(
|
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-right`
|
|
)}
|
|
/>
|
|
</td>
|
|
|
|
{/* TVA */}
|
|
<td className={cn("px-1 text-center text-gray-500 text-right", textSizeClass)}>
|
|
{item.taux_taxe1}%
|
|
</td>
|
|
|
|
{/* Total */}
|
|
<td className="px-1 text-right">
|
|
<span className={cn("font-bold font-mono text-gray-900", textSizeClass)}>
|
|
{calculerTotalLigne(item).toFixed(2)} €
|
|
</span>
|
|
</td>
|
|
|
|
{/* Delete */}
|
|
<td className="px-1 text-center">
|
|
<button
|
|
onClick={() => onSupprimerLigne(item.id)}
|
|
disabled={!canDelete}
|
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
};
|
|
|
|
interface ReadOnlyLigneRowProps {
|
|
line: any;
|
|
textSizeClass: string;
|
|
}
|
|
|
|
const ReadOnlyLigneRow: React.FC<ReadOnlyLigneRowProps> = ({ line, textSizeClass }) => (
|
|
<tr className="group hover:bg-gray-50/50">
|
|
<td className={cn("px-4 py-3 font-bold font-mono", textSizeClass)}>
|
|
{line.article_code}
|
|
</td>
|
|
<td className={cn("px-4 py-3 text-gray-600", textSizeClass)}>
|
|
{line.designation}
|
|
</td>
|
|
<td className={cn("px-4 py-3 text-right", textSizeClass)}>
|
|
{line.quantite}
|
|
</td>
|
|
<td className={cn("px-4 py-3 text-right font-mono", textSizeClass)}>
|
|
{line.prix_unitaire_ht?.toFixed(2)} €
|
|
</td>
|
|
<td className={cn("px-4 py-3 text-right text-gray-500", textSizeClass)}>
|
|
{line.remise_valeur1 || 0}%
|
|
</td>
|
|
<td className={cn("px-4 py-3 text-right text-gray-500", textSizeClass)}>
|
|
{line.taux_taxe1}%
|
|
</td>
|
|
<td className={cn("px-4 py-3 text-right font-bold font-mono", textSizeClass)}>
|
|
{line.montant_ligne_ht?.toFixed(2)} €
|
|
</td>
|
|
</tr>
|
|
);
|
|
|
|
interface ArticleCreationDropdownProps {
|
|
designation: string;
|
|
onCreateArticle: () => void;
|
|
}
|
|
|
|
const ArticleCreationDropdown: React.FC<ArticleCreationDropdownProps> = ({
|
|
designation,
|
|
onCreateArticle,
|
|
}) => (
|
|
<div className="overflow-hidden mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-lg">
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={onCreateArticle}
|
|
className="flex gap-3 items-center px-4 py-3 w-full text-left transition-colors hover:bg-green-50"
|
|
>
|
|
<div className="p-1.5 bg-[#007E45] rounded-lg text-white">
|
|
<Plus className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-900">
|
|
Créer l'article "<span className="text-[#007E45]">{designation}</span>"
|
|
</span>
|
|
</button>
|
|
<div className="border-t border-gray-100" />
|
|
<div className="flex flex-row gap-3 justify-center items-center py-2 w-full text-xs text-center text-gray-400">
|
|
<div className="p-1.5 rounded-lg">
|
|
<PenLine className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-xs font-medium">Texte libre accepté</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
interface NotesSectionProps {
|
|
note: Note;
|
|
isEditing: boolean;
|
|
onSetNote: (note: Note) => void;
|
|
}
|
|
|
|
const NotesSection: React.FC<NotesSectionProps> = ({ note, isEditing, onSetNote }) => (
|
|
<div className="grid grid-cols-1 gap-6 p-6 mt-8 bg-gray-50 rounded-2xl border border-gray-100 md:grid-cols-2 dark:bg-gray-900/30 dark:border-gray-800">
|
|
{/* Notes publiques */}
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<label className="flex gap-2 items-center text-sm font-semibold text-gray-900 dark:text-white">
|
|
Notes publiques <Lock className="w-3 h-3 text-gray-400" />
|
|
</label>
|
|
<span className="text-xs text-gray-500">Visible sur le PDF</span>
|
|
</div>
|
|
<Textarea
|
|
rows={4}
|
|
disabled={!isEditing}
|
|
placeholder="Conditions de paiement, délais de livraison..."
|
|
className="bg-white resize-none dark:bg-gray-950"
|
|
value={note.publique}
|
|
onChange={(e) => onSetNote({ ...note, publique: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes privées */}
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<label className="flex gap-2 items-center text-sm font-semibold text-gray-900 dark:text-white">
|
|
Notes privées <EyeOff className="w-3 h-3 text-gray-400" />
|
|
</label>
|
|
<span className="text-xs text-gray-500">Interne uniquement</span>
|
|
</div>
|
|
<Textarea
|
|
rows={4}
|
|
disabled={!isEditing}
|
|
placeholder="Notes internes, marge de négociation..."
|
|
className="bg-white border-yellow-200 resize-none dark:bg-gray-950 focus:border-yellow-400"
|
|
value={note.prive}
|
|
onChange={(e) => onSetNote({ ...note, prive: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export default FactureContent; |