espace-paie-odentas/app/(app)/staff/facturation/create/saisie-tableau/page.tsx
odentas 897af4b23a feat: Ajout fonctionnalités virements, facturation, signatures et emails
- Ajout sous-header total net à payer sur page virements-salaires
- Migration transfer_done_at pour tracking précis des virements
- Nouvelle page saisie tableau pour création factures en masse
- APIs bulk pour mise à jour dates signature et jours technicien
- API demande mandat SEPA avec email template
- Webhook DocuSeal pour signature contrats (mode TEST)
- Composants modaux détails et vérification PDF fiches de paie
- Upload/suppression/remplacement PDFs dans PayslipsGrid
- Amélioration affichage colonnes et filtres grilles contrats/paies
- Template email mandat SEPA avec sous-texte CTA
- APIs bulk facturation (création, update statut/date paiement)
- API clients sans facture pour période donnée
- Corrections calculs dates et montants avec auto-remplissage
2025-11-02 23:26:19 +01:00

1045 lines
35 KiB
TypeScript

"use client";
import React, { useCallback, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, Save, Plus, Copy, Trash2, Upload, Download, ChevronDown, ChevronUp, FileText, FileX, Loader2 } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { api } from '@/lib/fetcher';
// ---------------- Types ----------------
type Organization = {
id: string;
structure_api: string;
};
type InvoiceRow = {
id: string;
selected: boolean;
org_id: string;
organization_name: string;
numero: string;
periode: string;
date: string;
due_date: string;
payment_date: string;
sepa_day: string;
montant_ht: string | number;
montant_ttc: string | number;
statut: string;
notes: string;
pdf_s3_key?: string | null;
pdf_uploading?: boolean;
};
// ---------------- Utils ----------------
function uid() {
return Math.random().toString(36).substring(2, 11);
}
function incrementInvoiceNumber(numero: string): string {
if (!numero) return numero;
// Chercher un nombre à la fin du numéro (après un tiret ou directement)
const match = numero.match(/^(.+?)(\d+)$/);
if (match) {
const prefix = match[1];
const number = parseInt(match[2], 10);
const incrementedNumber = number + 1;
// Garder le même nombre de chiffres avec padding
const paddedNumber = incrementedNumber.toString().padStart(match[2].length, '0');
return prefix + paddedNumber;
}
// Si pas de nombre trouvé, ajouter -1 à la fin
return numero + "-1";
}
function calculateDueDate(invoiceDate: string): string {
if (!invoiceDate) return "";
const date = new Date(invoiceDate);
date.setDate(date.getDate() + 7); // J+7
return date.toISOString().split('T')[0];
}
function calculateTTC(montantHT: string | number): string {
if (!montantHT || montantHT === "") return "";
const ht = typeof montantHT === 'string' ? parseFloat(montantHT) : montantHT;
if (isNaN(ht)) return "";
const ttc = ht * 1.20; // +20%
return ttc.toFixed(2);
}
function emptyRow(rest: Partial<InvoiceRow> = {}): InvoiceRow {
const today = new Date().toISOString().split('T')[0];
const dueDate = rest.date ? calculateDueDate(rest.date) : calculateDueDate(today);
return {
org_id: "",
organization_name: "",
numero: "",
periode: "",
date: today,
due_date: dueDate,
payment_date: "",
sepa_day: "",
montant_ht: "",
montant_ttc: "",
statut: "en_cours",
notes: "",
pdf_s3_key: null,
pdf_uploading: false,
...rest,
id: uid(),
selected: false,
};
}
function validateRow(r: InvoiceRow) {
const errors: Record<string, string> = {};
if (!r.org_id) errors.org_id = "Requis";
if (!r.numero) errors.numero = "Requis";
if (!r.montant_ttc || Number(r.montant_ttc) <= 0) errors.montant_ttc = "> 0";
if (!r.date) errors.date = "Requis";
// Validation des dates
if (r.due_date && r.date && r.due_date < r.date) {
errors.due_date = "Échéance < Date";
}
if (r.payment_date && r.date && r.payment_date < r.date) {
errors.payment_date = "Paiement < Date";
}
return errors;
}
function detectDelimiter(text: string): string {
const comma = (text.match(/,/g) || []).length;
const semicolon = (text.match(/;/g) || []).length;
const tab = (text.match(/\t/g) || []).length;
if (tab > 0) return '\t';
return semicolon > comma ? ';' : ',';
}
function parseCSV(text: string): string[][] {
const delim = detectDelimiter(text);
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
const next = text[i + 1];
if (c === '"') {
if (inQuotes && next === '"') {
field += '"';
i++;
continue;
}
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && (c === '\n' || c === '\r')) {
if (c === '\r' && next === '\n') i++;
row.push(field);
field = '';
if (row.length > 1 || row[0] !== '') rows.push(row);
row = [];
continue;
}
if (!inQuotes && c === delim) {
row.push(field);
field = '';
continue;
}
field += c;
}
row.push(field);
if (row.length > 1 || row[0] !== '') rows.push(row);
return rows;
}
function normalizeDate(val: string): string {
if (!val) return "";
const s = String(val).trim();
// DD/MM/YYYY
const m1 = s.match(/^([0-3]?\d)\/([0-1]?\d)\/(\d{4})$/);
if (m1) {
const dd = m1[1].padStart(2, '0');
const mm = m1[2].padStart(2, '0');
const yyyy = m1[3];
return `${yyyy}-${mm}-${dd}`;
}
// YYYY-MM-DD
const m2 = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m2) return s;
return s;
}
const PASTE_ORDER = [
"org_id",
"numero",
"periode",
"date",
"due_date",
"payment_date",
"sepa_day",
"montant_ht",
"montant_ttc",
"statut",
"notes",
] as const;
function rowsFromMatrix(parsed: string[][], organizations: Organization[]): InvoiceRow[] {
if (parsed.length === 0) return [];
return parsed.map((cols) => {
const base = emptyRow();
const assigned: Partial<InvoiceRow> = {};
PASTE_ORDER.forEach((key, i) => {
const val = cols[i];
if (val == null) return;
switch (key) {
case 'org_id':
// Rechercher l'organisation par nom
const org = organizations.find(o =>
o.structure_api.toLowerCase().includes(val.toLowerCase())
);
if (org) {
assigned.org_id = org.id;
assigned.organization_name = org.structure_api;
} else {
assigned.org_id = val;
}
break;
case 'date':
case 'due_date':
case 'payment_date':
case 'sepa_day':
(assigned as any)[key] = normalizeDate(val);
break;
case 'montant_ht':
case 'montant_ttc':
(assigned as any)[key] = val === '' ? '' : Number(String(val).replace(',', '.'));
break;
case 'statut':
const statutNormalized = val.toLowerCase();
if (statutNormalized.includes('emit') || statutNormalized.includes('emise')) assigned.statut = 'emise';
else if (statutNormalized.includes('cours')) assigned.statut = 'en_cours';
else if (statutNormalized.includes('pay')) assigned.statut = 'payee';
else if (statutNormalized.includes('annul')) assigned.statut = 'annulee';
else if (statutNormalized.includes('pret')) assigned.statut = 'prete';
else if (statutNormalized.includes('brouillon')) assigned.statut = 'brouillon';
else assigned.statut = val;
break;
default:
(assigned as any)[key] = val;
}
});
return { ...base, ...assigned } as InvoiceRow;
});
}
const COLUMN_ORDER = [
"org_id",
"numero",
"periode",
"date",
"due_date",
"payment_date",
"sepa_day",
"montant_ht",
"montant_ttc",
"statut",
"notes",
] as const;
// ---------------- Classes ----------------
const thCls = "text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground px-2 py-1 sticky top-0 bg-slate-50 z-10";
const thStickyLeft = "text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground px-2 py-1 sticky top-0 bg-slate-50 z-20 border-r-2 border-slate-200 shadow-[2px_0_4px_rgba(0,0,0,0.05)]";
const tdCls = "px-1.5 py-1 align-middle";
const tdStickyLeft = "px-1.5 py-1 align-middle sticky bg-white z-10 border-r-2 border-slate-200 shadow-[2px_0_4px_rgba(0,0,0,0.05)]";
const inputCls = (error?: boolean) =>
`w-full rounded-md border px-2 py-1 h-8 text-[13px] ${error ? "border-red-500" : ""}`;
const selectCls = "w-full rounded-md border px-2 h-8 text-[13px] bg-white";
const numberCls = (error?: boolean) =>
`w-full rounded-md border px-2 py-1 h-8 text-right text-[13px] ${error ? "border-red-500" : ""}`;
// ---------------- PDF Upload Component ----------------
function PdfUploadCell({
rowId,
pdfKey,
isUploading,
onUpload,
onRemove,
disabled
}: {
rowId: string;
pdfKey?: string | null;
isUploading?: boolean;
onUpload: (rowId: string, file: File) => void;
onRemove: (rowId: string) => void;
disabled?: boolean;
}) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
const pdfFile = files.find(f => f.type === 'application/pdf');
if (pdfFile) {
onUpload(rowId, pdfFile);
} else {
alert('Veuillez déposer un fichier PDF');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onUpload(rowId, file);
}
// Reset input
if (inputRef.current) {
inputRef.current.value = '';
}
};
if (isUploading) {
return (
<div className="flex items-center justify-center h-8 text-blue-600 text-xs">
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1" />
Upload...
</div>
);
}
if (pdfKey) {
return (
<div className="flex items-center justify-between gap-1 bg-green-50 border border-green-200 rounded px-2 py-1">
<div className="flex items-center gap-1 text-green-700 text-xs">
<FileText className="w-3.5 h-3.5" />
<span>PDF</span>
</div>
<button
type="button"
onClick={() => onRemove(rowId)}
className="p-0.5 hover:bg-red-100 rounded"
title="Supprimer"
>
<FileX className="w-3 h-3 text-red-600" />
</button>
</div>
);
}
return (
<>
<input
ref={inputRef}
type="file"
accept=".pdf"
onChange={handleFileSelect}
className="hidden"
/>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
className={`
h-8 flex items-center justify-center border-2 border-dashed rounded cursor-pointer
transition-colors text-xs
${disabled
? 'border-slate-200 bg-slate-50 cursor-not-allowed text-slate-400'
: isDragging
? 'border-blue-400 bg-blue-50 text-blue-600'
: 'border-slate-300 hover:border-slate-400 text-slate-500 hover:bg-slate-50'
}
`}
title={disabled ? "Remplir org + numéro d'abord" : "Cliquer ou glisser un PDF"}
>
<Upload className="w-3.5 h-3.5 mr-1" />
{disabled ? 'Requis' : 'PDF'}
</div>
</>
);
}
// ---------------- Component ----------------
export default function SaisieTableauFacturesPage() {
usePageTitle("Saisie tableau - Factures");
const router = useRouter();
const [rows, setRows] = useState<InvoiceRow[]>([emptyRow(), emptyRow(), emptyRow()]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, Record<string, string>>>({});
const [showHelp, setShowHelp] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Charger les organisations
React.useEffect(() => {
(async () => {
try {
const result = await api<{ organizations: Organization[] }>("/staff/organizations");
setOrganizations(result.organizations || []);
} catch (e) {
console.error("Erreur lors du chargement des organisations:", e);
}
})();
}, []);
// Validation
const validateAll = useCallback(() => {
const newErrors: Record<string, Record<string, string>> = {};
rows.forEach((r) => {
const rowErrors = validateRow(r);
if (Object.keys(rowErrors).length > 0) {
newErrors[r.id] = rowErrors;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [rows]);
// Actions sur les lignes
const updateRow = useCallback((id: string, field: keyof InvoiceRow, value: any) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r;
const updated = { ...r, [field]: value };
// Si on change l'org, mettre à jour le nom
if (field === 'org_id') {
const org = organizations.find(o => o.id === value);
if (org) {
updated.organization_name = org.structure_api;
}
}
// Si on change la date de facture, calculer automatiquement la date d'échéance (J+7)
if (field === 'date' && value) {
updated.due_date = calculateDueDate(value);
}
// Si on change le montant HT, calculer automatiquement le TTC (+20%)
if (field === 'montant_ht' && value !== "") {
updated.montant_ttc = calculateTTC(value);
}
return updated;
})
);
}, [organizations]);
const addRow = useCallback(() => {
setRows((prev) => [...prev, emptyRow()]);
}, []);
const duplicateRow = useCallback((id: string) => {
setRows((prev) => {
const idx = prev.findIndex((r) => r.id === id);
if (idx === -1) return prev;
const original = prev[idx];
const copy = emptyRow({
...original,
numero: incrementInvoiceNumber(original.numero),
pdf_s3_key: null, // Ne pas dupliquer le PDF
pdf_uploading: false,
});
return [...prev.slice(0, idx + 1), copy, ...prev.slice(idx + 1)];
});
}, []);
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id));
}, []);
const toggleSelect = useCallback((id: string) => {
setRows((prev) =>
prev.map((r) => (r.id === id ? { ...r, selected: !r.selected } : r))
);
}, []);
const toggleSelectAll = useCallback(() => {
const allSelected = rows.every((r) => r.selected);
setRows((prev) => prev.map((r) => ({ ...r, selected: !allSelected })));
}, [rows]);
const deleteSelected = useCallback(() => {
setRows((prev) => prev.filter((r) => !r.selected));
}, []);
// Upload PDF pour une ligne
const handlePdfUpload = useCallback(async (rowId: string, file: File) => {
const row = rows.find(r => r.id === rowId);
if (!row) return;
if (file.type !== 'application/pdf') {
alert('Seuls les fichiers PDF sont acceptés.');
return;
}
if (!row.org_id || !row.numero) {
alert('Veuillez remplir l\'organisation et le numéro de facture avant d\'uploader le PDF.');
return;
}
// Marquer comme en cours d'upload
setRows(prev => prev.map(r =>
r.id === rowId ? { ...r, pdf_uploading: true } : r
));
try {
const formData = new FormData();
formData.append('pdf', file);
formData.append('org_id', row.org_id);
formData.append('invoice_number', row.numero);
const response = await fetch('/api/staff/facturation/upload-pdf', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Upload failed');
}
const result = await response.json();
// Mettre à jour la ligne avec le s3_key
setRows(prev => prev.map(r =>
r.id === rowId
? { ...r, pdf_s3_key: result.s3_key, pdf_uploading: false }
: r
));
} catch (error) {
console.error('Upload error:', error);
alert(`Erreur d'upload : ${error instanceof Error ? error.message : 'erreur inconnue'}`);
// Retirer l'état d'upload
setRows(prev => prev.map(r =>
r.id === rowId ? { ...r, pdf_uploading: false } : r
));
}
}, [rows]);
const handleRemovePdf = useCallback((rowId: string) => {
setRows(prev => prev.map(r =>
r.id === rowId ? { ...r, pdf_s3_key: null } : r
));
}, []);
// Import CSV
const handleFileImport = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
const text = evt.target?.result as string;
if (!text) return;
const parsed = parseCSV(text);
if (parsed.length === 0) return;
// Skip header si présent
const hasHeader = parsed[0].some(cell =>
cell.toLowerCase().includes('organisation') ||
cell.toLowerCase().includes('numero') ||
cell.toLowerCase().includes('montant')
);
const dataRows = hasHeader ? parsed.slice(1) : parsed;
const newRows = rowsFromMatrix(dataRows, organizations);
setRows(newRows.length > 0 ? newRows : [emptyRow()]);
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [organizations]);
// Export CSV template
const exportTemplate = useCallback(() => {
const headers = [
"Organisation",
"Numéro",
"Période",
"Date",
"Date échéance",
"Date paiement",
"Date SEPA",
"Montant HT",
"Montant TTC",
"Statut",
"Notes"
];
const example = [
"NomOrganisation",
"FAC-2025-001",
"Janvier 2025",
"2025-01-15",
"2025-02-15",
"",
"",
"1000.00",
"1200.00",
"emise",
"Notes facultatives"
];
const note = [
"",
"Note: Les PDFs doivent être uploadés manuellement via le tableau (colonne PDF)",
"",
"",
"",
"",
"",
"",
"",
"",
""
];
const csv = [headers.join(';'), example.join(';'), note.join(';')].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'template_factures.csv';
link.click();
}, []);
// Soumission
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validateAll()) {
alert("Veuillez corriger les erreurs avant de soumettre.");
return;
}
if (rows.length === 0) {
alert("Aucune facture à créer.");
return;
}
const confirmed = confirm(
`Vous allez créer ${rows.length} facture(s). Continuer ?`
);
if (!confirmed) return;
setIsLoading(true);
try {
const payload = rows.map(r => ({
org_id: r.org_id,
numero: r.numero,
periode: r.periode || null,
date: r.date || null,
due_date: r.due_date || null,
payment_date: r.payment_date || null,
sepa_day: r.sepa_day || null,
montant_ht: Number(r.montant_ht) || 0,
montant_ttc: Number(r.montant_ttc) || 0,
statut: r.statut,
notes: r.notes || null,
pdf_s3_key: r.pdf_s3_key || null,
}));
const result = await api<{ created: number }>("/staff/facturation/bulk-create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invoices: payload }),
});
alert(`${result.created || rows.length} facture(s) créée(s) avec succès !`);
router.push("/staff/facturation");
} catch (error: any) {
console.error("Erreur lors de la création:", error);
alert(`Erreur : ${error.message || "erreur inconnue"}`);
} finally {
setIsLoading(false);
}
}, [rows, validateAll, router]);
const selectedCount = rows.filter((r) => r.selected).length;
return (
<main className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
href="/staff/facturation"
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800"
>
<ArrowLeft className="w-4 h-4" />
Retour
</Link>
<div className="text-slate-400">/</div>
<h1 className="text-xl font-bold text-slate-900">
Création factures - Saisie tableau
</h1>
</div>
<button
onClick={() => setShowHelp(!showHelp)}
className="text-sm text-blue-600 hover:text-blue-800"
>
{showHelp ? "Masquer" : "Afficher"} l'aide
</button>
</div>
{/* Aide */}
{showHelp && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm space-y-2">
<h3 className="font-semibold text-blue-900">Comment utiliser la saisie tableau ?</h3>
<ul className="list-disc list-inside space-y-1 text-blue-800">
<li>Remplissez les lignes directement dans le tableau</li>
<li>Utilisez Tab/Shift+Tab pour naviguer entre les colonnes</li>
<li>Cliquez sur "Ajouter une ligne" pour créer plus de lignes</li>
<li>Importez un fichier CSV pour pré-remplir plusieurs factures</li>
<li>Téléchargez le modèle CSV pour voir le format attendu</li>
<li>Les champs obligatoires : Organisation, Numéro, Montant TTC, Date</li>
<li>Pour chaque ligne, vous pouvez glisser-déposer un PDF ou cliquer sur la colonne PDF</li>
<li>Les PDFs doivent être ajoutés après avoir rempli l'organisation et le numéro</li>
</ul>
<h4 className="font-semibold text-blue-900 mt-3">Automatismes :</h4>
<ul className="list-disc list-inside space-y-1 text-blue-800">
<li>Le statut par défaut est "En cours"</li>
<li>La date d'échéance se calcule automatiquement à J+7 de la date de facture</li>
<li>Le montant TTC se calcule automatiquement (+20% du HT)</li>
<li>Lors de la duplication d'une ligne, le numéro de facture s'incrémente automatiquement</li>
<li>Tous ces champs restent modifiables manuellement</li>
</ul>
</div>
)}
{/* Actions toolbar */}
<div className="bg-white border rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={addRow}
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Plus className="w-4 h-4" />
Ajouter une ligne
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileImport}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-3 py-2 border rounded-lg hover:bg-slate-50 transition-colors text-sm"
>
<Upload className="w-4 h-4" />
Importer CSV
</button>
<button
onClick={exportTemplate}
className="inline-flex items-center gap-2 px-3 py-2 border rounded-lg hover:bg-slate-50 transition-colors text-sm"
>
<Download className="w-4 h-4" />
Modèle CSV
</button>
</div>
<div className="flex items-center gap-2">
{selectedCount > 0 && (
<>
<span className="text-sm text-slate-600">
{selectedCount} ligne{selectedCount > 1 ? "s" : ""} sélectionnée{selectedCount > 1 ? "s" : ""}
</span>
<button
onClick={deleteSelected}
className="inline-flex items-center gap-2 px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors text-sm"
>
<Trash2 className="w-4 h-4" />
Supprimer
</button>
</>
)}
<div className="text-sm text-slate-600">
{rows.length} ligne{rows.length > 1 ? "s" : ""}
</div>
</div>
</div>
</div>
{/* Tableau */}
<form onSubmit={handleSubmit}>
<div className="bg-white border rounded-lg overflow-hidden">
<div className="overflow-x-auto max-h-[600px] overflow-y-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b bg-slate-50">
<th className={thStickyLeft} style={{ width: '40px', left: 0 }}>
<input
type="checkbox"
checked={rows.length > 0 && rows.every((r) => r.selected)}
onChange={toggleSelectAll}
className="rounded"
/>
</th>
<th className={thStickyLeft} style={{ width: '60px', left: '40px' }}>Actions</th>
<th className={thStickyLeft} style={{ minWidth: '200px', left: '100px' }}>Organisation *</th>
<th className={thCls} style={{ minWidth: '150px' }}>Numéro *</th>
<th className={thCls} style={{ minWidth: '120px' }}>Période</th>
<th className={thCls} style={{ minWidth: '130px' }}>Date *</th>
<th className={thCls} style={{ minWidth: '130px' }}>Échéance</th>
<th className={thCls} style={{ minWidth: '130px' }}>Paiement</th>
<th className={thCls} style={{ minWidth: '130px' }}>SEPA</th>
<th className={thCls} style={{ minWidth: '110px' }}>Montant HT</th>
<th className={thCls} style={{ minWidth: '110px' }}>Montant TTC *</th>
<th className={thCls} style={{ minWidth: '120px' }}>Statut</th>
<th className={thCls} style={{ minWidth: '140px' }}>PDF</th>
<th className={thCls} style={{ minWidth: '200px' }}>Notes</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
const rowErrors = errors[row.id] || {};
return (
<tr key={row.id} className="border-b hover:bg-slate-50/50">
<td className={tdStickyLeft} style={{ left: 0 }}>
<input
type="checkbox"
checked={row.selected}
onChange={() => toggleSelect(row.id)}
className="rounded"
/>
</td>
<td className={tdStickyLeft} style={{ left: '40px' }}>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => duplicateRow(row.id)}
className="p-1 hover:bg-slate-100 rounded"
title="Dupliquer"
>
<Copy className="w-3.5 h-3.5 text-slate-600" />
</button>
<button
type="button"
onClick={() => deleteRow(row.id)}
className="p-1 hover:bg-red-100 rounded"
title="Supprimer"
>
<Trash2 className="w-3.5 h-3.5 text-red-600" />
</button>
</div>
</td>
<td className={tdStickyLeft} style={{ left: '100px' }}>
<select
value={row.org_id}
onChange={(e) => updateRow(row.id, "org_id", e.target.value)}
className={`${selectCls} ${rowErrors.org_id ? 'border-red-500' : ''}`}
>
<option value="">-- Sélectionner --</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.structure_api}
</option>
))}
</select>
</td>
<td className={tdCls}>
<input
type="text"
value={row.numero}
onChange={(e) => updateRow(row.id, "numero", e.target.value)}
className={inputCls(!!rowErrors.numero)}
placeholder="FAC-2025-001"
/>
</td>
<td className={tdCls}>
<input
type="text"
value={row.periode}
onChange={(e) => updateRow(row.id, "periode", e.target.value)}
className={inputCls()}
placeholder="Janvier 2025"
/>
</td>
<td className={tdCls}>
<input
type="date"
value={row.date}
onChange={(e) => updateRow(row.id, "date", e.target.value)}
className={inputCls(!!rowErrors.date)}
/>
</td>
<td className={tdCls}>
<input
type="date"
value={row.due_date}
onChange={(e) => updateRow(row.id, "due_date", e.target.value)}
className={inputCls(!!rowErrors.due_date)}
/>
</td>
<td className={tdCls}>
<input
type="date"
value={row.payment_date}
onChange={(e) => updateRow(row.id, "payment_date", e.target.value)}
className={inputCls(!!rowErrors.payment_date)}
/>
</td>
<td className={tdCls}>
<input
type="date"
value={row.sepa_day}
onChange={(e) => updateRow(row.id, "sepa_day", e.target.value)}
className={inputCls()}
/>
</td>
<td className={tdCls}>
<input
type="number"
step="0.01"
value={row.montant_ht}
onChange={(e) => updateRow(row.id, "montant_ht", e.target.value)}
className={numberCls()}
placeholder="0.00"
/>
</td>
<td className={tdCls}>
<input
type="number"
step="0.01"
value={row.montant_ttc}
onChange={(e) => updateRow(row.id, "montant_ttc", e.target.value)}
className={numberCls(!!rowErrors.montant_ttc)}
placeholder="0.00"
/>
</td>
<td className={tdCls}>
<select
value={row.statut}
onChange={(e) => updateRow(row.id, "statut", e.target.value)}
className={selectCls}
>
<option value="brouillon">Brouillon</option>
<option value="en_cours">En cours</option>
<option value="prete">Prête</option>
<option value="emise">Émise</option>
<option value="payee">Payée</option>
<option value="annulee">Annulée</option>
</select>
</td>
<td className={tdCls}>
<PdfUploadCell
rowId={row.id}
pdfKey={row.pdf_s3_key}
isUploading={row.pdf_uploading}
onUpload={handlePdfUpload}
onRemove={handleRemovePdf}
disabled={!row.org_id || !row.numero}
/>
</td>
<td className={tdCls}>
<input
type="text"
value={row.notes}
onChange={(e) => updateRow(row.id, "notes", e.target.value)}
className={inputCls()}
placeholder="Notes..."
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Footer actions */}
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-slate-600">
{Object.keys(errors).length > 0 && (
<span className="text-red-600">
{Object.keys(errors).length} ligne{Object.keys(errors).length > 1 ? "s" : ""} avec erreur{Object.keys(errors).length > 1 ? "s" : ""}
</span>
)}
</div>
<div className="flex items-center gap-3">
<Link
href="/staff/facturation"
className="px-4 py-2 text-slate-600 hover:text-slate-800 transition-colors"
>
Annuler
</Link>
<button
type="submit"
disabled={isLoading || rows.length === 0}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
{isLoading ? "Création..." : `Créer ${rows.length} facture${rows.length > 1 ? "s" : ""}`}
</button>
</div>
</div>
</form>
</main>
);
}