- 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
1045 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|