- 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
371 lines
No EOL
13 KiB
TypeScript
371 lines
No EOL
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { api } from "@/lib/fetcher";
|
|
import { ArrowLeft, Save, Plus, Upload, FileX } from "lucide-react";
|
|
|
|
// ---------------- Types ----------------
|
|
type Organization = {
|
|
id: string;
|
|
structure_api: string;
|
|
};
|
|
|
|
type CreateInvoiceForm = {
|
|
org_id: string;
|
|
numero: string;
|
|
periode: string;
|
|
date: string;
|
|
montant_ht: string;
|
|
montant_ttc: string;
|
|
statut: string;
|
|
notes: string;
|
|
};
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<section className="rounded-2xl border bg-white">
|
|
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
|
|
{title}
|
|
</div>
|
|
<div className="p-4">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// -------------- Page --------------
|
|
export default function CreateInvoicePage() {
|
|
const router = useRouter();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [uploadedPdfKey, setUploadedPdfKey] = useState<string | null>(null);
|
|
const [isUploadingPdf, setIsUploadingPdf] = useState(false);
|
|
const [form, setForm] = useState<CreateInvoiceForm>({
|
|
org_id: "",
|
|
numero: "",
|
|
periode: "",
|
|
date: new Date().toISOString().split('T')[0],
|
|
montant_ht: "",
|
|
montant_ttc: "",
|
|
statut: "emise",
|
|
notes: "",
|
|
});
|
|
|
|
// Récupérer la liste des organisations
|
|
const { data: organizationsResponse } = useQuery<{ organizations: Organization[] }>({
|
|
queryKey: ["organizations"],
|
|
queryFn: () => api("/staff/organizations"),
|
|
});
|
|
|
|
const organizations = organizationsResponse?.organizations || [];
|
|
|
|
// Mutation pour créer la facture
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: CreateInvoiceForm & { pdf_s3_key?: string }) =>
|
|
api("/staff/facturation", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
...data,
|
|
montant_ht: parseFloat(data.montant_ht) || 0,
|
|
montant_ttc: parseFloat(data.montant_ttc) || 0,
|
|
}),
|
|
headers: { "Content-Type": "application/json" }
|
|
}),
|
|
onSuccess: (newInvoice: any) => {
|
|
router.push(`/staff/facturation/${newInvoice.id}`);
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!form.org_id || !form.montant_ttc) {
|
|
alert("Veuillez remplir au minimum l'organisation et le montant TTC.");
|
|
return;
|
|
}
|
|
|
|
createMutation.mutate({
|
|
...form,
|
|
pdf_s3_key: uploadedPdfKey || undefined
|
|
});
|
|
};
|
|
|
|
const updateForm = (field: keyof CreateInvoiceForm, value: string) => {
|
|
setForm(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (file.type !== 'application/pdf') {
|
|
alert('Seuls les fichiers PDF sont acceptés.');
|
|
return;
|
|
}
|
|
|
|
if (!form.org_id || !form.numero) {
|
|
alert('Veuillez remplir l\'organisation et le numéro de facture avant d\'uploader le PDF.');
|
|
return;
|
|
}
|
|
|
|
setIsUploadingPdf(true);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('pdf', file);
|
|
formData.append('org_id', form.org_id);
|
|
formData.append('invoice_number', form.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();
|
|
setUploadedPdfKey(result.s3_key);
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
alert(`Erreur d'upload : ${error instanceof Error ? error.message : 'erreur inconnue'}`);
|
|
} finally {
|
|
setIsUploadingPdf(false);
|
|
}
|
|
};
|
|
|
|
const handleRemovePdf = () => {
|
|
setUploadedPdfKey(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="space-y-5">
|
|
{/* Navigation */}
|
|
<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 aux factures
|
|
</Link>
|
|
<div className="text-slate-400">/</div>
|
|
<h1 className="text-xl font-bold text-slate-900">
|
|
Créer une nouvelle facture
|
|
</h1>
|
|
</div>
|
|
<Link
|
|
href="/staff/facturation/create/saisie-tableau"
|
|
className="inline-flex items-center gap-2 text-sm px-3 py-2 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Saisie tableau
|
|
</Link>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
{/* Informations de base */}
|
|
<Section title="Informations de base">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Organisation cliente *
|
|
</label>
|
|
<select
|
|
value={form.org_id}
|
|
onChange={(e) => updateForm("org_id", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
required
|
|
>
|
|
<option value="">Sélectionner une organisation</option>
|
|
{organizations?.map((org) => (
|
|
<option key={org.id} value={org.id}>
|
|
{org.structure_api}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Numéro de facture
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.numero}
|
|
onChange={(e) => updateForm("numero", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="ex: FAC-2025-001"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Période concernée
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.periode}
|
|
onChange={(e) => updateForm("periode", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="ex: Janvier 2025"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Date d'émission
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={form.date}
|
|
onChange={(e) => updateForm("date", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Montant HT (€)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={form.montant_ht}
|
|
onChange={(e) => updateForm("montant_ht", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Montant TTC (€) *
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={form.montant_ttc}
|
|
onChange={(e) => updateForm("montant_ttc", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="0.00"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Statut
|
|
</label>
|
|
<select
|
|
value={form.statut}
|
|
onChange={(e) => updateForm("statut", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white"
|
|
>
|
|
<option value="emise">Émise</option>
|
|
<option value="en_cours">En cours</option>
|
|
<option value="payee">Payée</option>
|
|
<option value="annulee">Annulée</option>
|
|
<option value="prete">Prête</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Notes */}
|
|
<Section title="Notes et PDF">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Notes sur la facture
|
|
</label>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={(e) => updateForm("notes", e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white min-h-[100px]"
|
|
placeholder="Notes additionnelles..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
PDF de la facture
|
|
</label>
|
|
<div className="space-y-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".pdf"
|
|
onChange={handleFileUpload}
|
|
className="hidden"
|
|
/>
|
|
|
|
{uploadedPdfKey ? (
|
|
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
|
|
<span className="text-sm text-green-700">PDF uploadé avec succès</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleRemovePdf}
|
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
|
>
|
|
<FileX className="w-3 h-3" />
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploadingPdf || !form.org_id || !form.numero}
|
|
className="inline-flex items-center gap-2 px-4 py-2 border border-dashed border-slate-300 rounded-lg hover:border-slate-400 transition-colors disabled:opacity-50"
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
{isUploadingPdf ? "Upload en cours..." : "Choisir un fichier PDF"}
|
|
</button>
|
|
)}
|
|
|
|
{!form.org_id || !form.numero ? (
|
|
<p className="text-xs text-slate-500">
|
|
Veuillez d'abord remplir l'organisation et le numéro de facture
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end 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={createMutation.isPending}
|
|
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" />
|
|
{createMutation.isPending ? "Création..." : "Créer la facture"}
|
|
</button>
|
|
</div>
|
|
|
|
{createMutation.isError && (
|
|
<div className="p-3 bg-rose-100 border border-rose-200 rounded-lg text-rose-700">
|
|
Erreur lors de la création : {(createMutation.error as any)?.message || "erreur inconnue"}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</main>
|
|
);
|
|
} |