1109 lines
No EOL
42 KiB
TypeScript
1109 lines
No EOL
42 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/lib/fetcher";
|
|
import { Loader2, ArrowLeft, Edit, Save, X, FileDown, Trash2, Upload, FileX, ExternalLink, AlertTriangle, Send, Mail } from "lucide-react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
// ---------------- Types ----------------
|
|
type InvoiceDetail = {
|
|
id: string;
|
|
numero: string | null;
|
|
periode: string | null;
|
|
date: string | null;
|
|
montant_ht: number;
|
|
montant_ttc: number;
|
|
statut: "payee" | "annulee" | "prete" | "emise" | "en_cours" | string | null;
|
|
notified?: boolean | null;
|
|
gocardless_payment_id?: string | null;
|
|
pdf?: string | null;
|
|
org_id: string;
|
|
organization_name?: string;
|
|
payment_date?: string | null;
|
|
payment_method?: "sepa" | "cb" | "virement" | null;
|
|
due_date?: string | null;
|
|
sepa_day?: string | null;
|
|
invoice_type?: "paie_mensuelle" | "paie_ouverture" | "studio_site_web" | "studio_renouvellement" | null;
|
|
site_name?: string | null;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
notes?: string | null;
|
|
};
|
|
|
|
// -------------- Helpers --------------
|
|
const fmtEUR = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
|
|
function fmtDateFR(iso?: string) {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
}
|
|
|
|
function fmtDateTimeCompleteFR(iso?: string) {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString("fr-FR", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
});
|
|
}
|
|
|
|
// Calcule la date d'échéance à J+7 de la date d'émission
|
|
function calculateDueDate(emissionDate?: string): string {
|
|
if (!emissionDate) return "";
|
|
const date = new Date(emissionDate);
|
|
date.setDate(date.getDate() + 7);
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
|
|
const cls =
|
|
tone === "ok"
|
|
? "bg-emerald-100 text-emerald-800"
|
|
: tone === "warn"
|
|
? "bg-amber-100 text-amber-800"
|
|
: tone === "error"
|
|
? "bg-rose-100 text-rose-800"
|
|
: "bg-slate-100 text-slate-700";
|
|
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// -------------- Data hooks --------------
|
|
function useInvoiceDetail(id: string) {
|
|
return useQuery<InvoiceDetail>({
|
|
queryKey: ["staff-invoice", id],
|
|
queryFn: () => api(`/staff/facturation/${id}`),
|
|
staleTime: 15_000,
|
|
});
|
|
}
|
|
|
|
// -------------- Page --------------
|
|
export default function StaffFacturationDetailPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
const id = params.id as string;
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isUploadingPdf, setIsUploadingPdf] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [showLaunchModal, setShowLaunchModal] = useState(false);
|
|
const [showNotifyModal, setShowNotifyModal] = useState(false);
|
|
const [showGoCardlessModal, setShowGoCardlessModal] = useState(false);
|
|
const [editForm, setEditForm] = useState({
|
|
numero: "",
|
|
periode: "",
|
|
date: "",
|
|
montant_ht: "",
|
|
montant_ttc: "",
|
|
statut: "",
|
|
notified: false,
|
|
payment_date: "",
|
|
payment_method: "sepa",
|
|
due_date: "",
|
|
sepa_day: "",
|
|
invoice_type: "paie_mensuelle",
|
|
site_name: "",
|
|
notes: "",
|
|
});
|
|
|
|
const { data: invoice, isLoading, isError, error } = useInvoiceDetail(id);
|
|
|
|
// Activer automatiquement le mode édition si le paramètre edit=true est présent
|
|
useEffect(() => {
|
|
if (searchParams.get('edit') === 'true' && invoice && !isEditing) {
|
|
startEditing();
|
|
}
|
|
}, [searchParams, invoice, isEditing]);
|
|
|
|
// Recalculer automatiquement la date d'échéance quand la date d'émission change
|
|
useEffect(() => {
|
|
if (isEditing && editForm.date && !editForm.due_date) {
|
|
const newDueDate = calculateDueDate(editForm.date);
|
|
setEditForm(prev => ({ ...prev, due_date: newDueDate }));
|
|
}
|
|
}, [editForm.date, isEditing]);
|
|
|
|
// Mutation pour la mise à jour
|
|
const updateMutation = useMutation({
|
|
mutationFn: (data: Partial<InvoiceDetail>) =>
|
|
api(`/staff/facturation/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
headers: { "Content-Type": "application/json" }
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
|
|
setIsEditing(false);
|
|
},
|
|
});
|
|
|
|
// Mutation pour la suppression
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => api(`/staff/facturation/${id}`, { method: "DELETE" }),
|
|
onSuccess: () => {
|
|
// Invalider le cache des factures
|
|
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
|
|
// Fermer le modal
|
|
setShowDeleteModal(false);
|
|
// Rediriger vers la liste avec un message de succès
|
|
router.push("/staff/facturation");
|
|
},
|
|
onError: (error: any) => {
|
|
// Fermer le modal même en cas d'erreur
|
|
setShowDeleteModal(false);
|
|
alert(`Erreur lors de la suppression : ${error.message || "erreur inconnue"}`);
|
|
},
|
|
});
|
|
|
|
// Mutation pour l'upload de PDF
|
|
const uploadPdfMutation = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
const formData = new FormData();
|
|
formData.append('pdf', file);
|
|
|
|
const response = await fetch(`/api/staff/facturation/${id}/upload-pdf`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || 'Upload failed');
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
setIsUploadingPdf(false);
|
|
},
|
|
});
|
|
|
|
// Mutation pour supprimer le PDF
|
|
const deletePdfMutation = useMutation({
|
|
mutationFn: () => fetch(`/api/staff/facturation/${id}/upload-pdf`, { method: 'DELETE' }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
},
|
|
});
|
|
|
|
// Mutation pour lancer la facture (GoCardless + Email)
|
|
const launchInvoiceMutation = useMutation({
|
|
mutationFn: () => api(`/staff/facturation/${id}/launch`, { method: "POST" }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
|
|
setShowLaunchModal(false);
|
|
},
|
|
onError: (error: any) => {
|
|
setShowLaunchModal(false);
|
|
// Gestion spécifique pour les factures déjà lancées
|
|
if (error.message?.includes('already_launched') || error.message?.includes('Cette facture a déjà été lancée')) {
|
|
alert('Cette facture a déjà été lancée. La page va se rafraîchir.');
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
} else {
|
|
alert(`Erreur lors du lancement : ${error.message || "erreur inconnue"}`);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Mutation pour notifier seulement (Email sans GoCardless)
|
|
const notifyInvoiceMutation = useMutation({
|
|
mutationFn: () => api(`/staff/facturation/${id}/notify`, { method: "POST" }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
|
|
alert('Notification envoyée avec succès !');
|
|
},
|
|
onError: (error: any) => {
|
|
alert(`Erreur lors de l'envoi de la notification : ${error.message || "erreur inconnue"}`);
|
|
},
|
|
});
|
|
|
|
// Mutation pour créer seulement le paiement GoCardless (sans email)
|
|
const createGoCardlessPaymentMutation = useMutation({
|
|
mutationFn: () => api(`/staff/facturation/${id}/gocardless`, { method: "POST" }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["staff-invoice", id] });
|
|
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
|
|
alert('Paiement GoCardless créé avec succès !');
|
|
},
|
|
onError: (error: any) => {
|
|
if (error.message?.includes('payment_already_exists')) {
|
|
alert('Un paiement GoCardless existe déjà pour cette facture.');
|
|
} else {
|
|
alert(`Erreur lors de la création du paiement : ${error.message || "erreur inconnue"}`);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Initialiser le formulaire d'édition
|
|
const startEditing = () => {
|
|
if (!invoice) return;
|
|
const dateValue = invoice.date ? invoice.date.split('T')[0] : "";
|
|
setEditForm({
|
|
numero: invoice.numero || "",
|
|
periode: invoice.periode || "",
|
|
date: dateValue,
|
|
montant_ht: invoice.montant_ht?.toString() || "",
|
|
montant_ttc: invoice.montant_ttc?.toString() || "",
|
|
statut: invoice.statut || "",
|
|
notified: invoice.notified || false,
|
|
payment_date: invoice.payment_date ? invoice.payment_date.split('T')[0] : "",
|
|
payment_method: invoice.payment_method || "sepa",
|
|
due_date: invoice.due_date ? invoice.due_date.split('T')[0] : (dateValue ? calculateDueDate(dateValue) : ""),
|
|
sepa_day: invoice.sepa_day ? invoice.sepa_day.split('T')[0] : "",
|
|
invoice_type: invoice.invoice_type || "paie_mensuelle",
|
|
site_name: invoice.site_name || "",
|
|
notes: invoice.notes || "",
|
|
});
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const updateData: Partial<InvoiceDetail> = {
|
|
numero: editForm.numero || null,
|
|
periode: editForm.periode || null,
|
|
date: editForm.date || null,
|
|
montant_ht: parseFloat(editForm.montant_ht) || 0,
|
|
montant_ttc: parseFloat(editForm.montant_ttc) || 0,
|
|
statut: editForm.statut as any,
|
|
notified: editForm.notified,
|
|
payment_date: editForm.payment_date || null,
|
|
payment_method: editForm.payment_method as any || null,
|
|
due_date: editForm.due_date || null,
|
|
sepa_day: editForm.sepa_day || null,
|
|
invoice_type: editForm.invoice_type as any || null,
|
|
site_name: editForm.site_name || null,
|
|
notes: editForm.notes || null,
|
|
};
|
|
updateMutation.mutate(updateData);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
deleteMutation.mutate();
|
|
setShowDeleteModal(false);
|
|
};
|
|
|
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) {
|
|
if (file.type !== 'application/pdf') {
|
|
alert('Seuls les fichiers PDF sont acceptés.');
|
|
return;
|
|
}
|
|
setIsUploadingPdf(true);
|
|
uploadPdfMutation.mutate(file);
|
|
}
|
|
};
|
|
|
|
const handleDeletePdf = () => {
|
|
if (confirm("Êtes-vous sûr de vouloir supprimer ce PDF ?")) {
|
|
deletePdfMutation.mutate();
|
|
}
|
|
};
|
|
|
|
const handleLaunchInvoice = () => {
|
|
setShowLaunchModal(true);
|
|
};
|
|
|
|
const confirmLaunchInvoice = () => {
|
|
launchInvoiceMutation.mutate();
|
|
};
|
|
|
|
const handleNotifyInvoice = () => {
|
|
setShowNotifyModal(true);
|
|
};
|
|
|
|
const confirmNotifyInvoice = () => {
|
|
notifyInvoiceMutation.mutate();
|
|
setShowNotifyModal(false);
|
|
};
|
|
|
|
const handleCreateGoCardlessPayment = () => {
|
|
setShowGoCardlessModal(true);
|
|
};
|
|
|
|
const confirmCreateGoCardlessPayment = () => {
|
|
createGoCardlessPaymentMutation.mutate();
|
|
setShowGoCardlessModal(false);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<main className="space-y-5">
|
|
<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>
|
|
<div className="text-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" />
|
|
<p className="text-slate-600">Chargement de la facture...</p>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (isError || !invoice) {
|
|
return (
|
|
<main className="space-y-5">
|
|
<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>
|
|
<div className="text-center py-12">
|
|
<p className="text-rose-600">Impossible de charger la facture : {(error as any)?.message || "erreur"}</p>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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">
|
|
{invoice.numero || `Facture ${invoice.id.slice(0, 8)}`}
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{!isEditing ? (
|
|
<>
|
|
{!invoice.gocardless_payment_id && invoice.statut !== "emise" && (
|
|
<button
|
|
onClick={handleLaunchInvoice}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
Lancer facture
|
|
</button>
|
|
)}
|
|
{!invoice.notified && (
|
|
<button
|
|
onClick={handleNotifyInvoice}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
<Mail className="w-4 h-4" />
|
|
Notifier
|
|
</button>
|
|
)}
|
|
{invoice.statut !== "emise" && (
|
|
<button
|
|
onClick={handleCreateGoCardlessPayment}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
+ GoCardless
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={startEditing}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
Modifier
|
|
</button>
|
|
{invoice.statut !== "emise" && (
|
|
<button
|
|
onClick={handleDelete}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Supprimer facture
|
|
</button>
|
|
)}
|
|
{invoice.statut === "emise" && (
|
|
<div className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-slate-100 text-slate-600 rounded-lg">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
Facture émise - Actions limitées
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={updateMutation.isPending}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{updateMutation.isPending ? "Sauvegarde..." : "Sauvegarder"}
|
|
</button>
|
|
<button
|
|
onClick={() => setIsEditing(false)}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-slate-600 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Annuler
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Informations de la facture */}
|
|
<Section title="Informations générales">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Numéro</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editForm.numero}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, numero: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="Numéro de facture"
|
|
/>
|
|
) : (
|
|
<div className="font-mono">{invoice.numero || "—"}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Client</div>
|
|
<div className="font-medium">{invoice.organization_name || "—"}</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Période</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editForm.periode}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, periode: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="ex: Août 2025"
|
|
/>
|
|
) : (
|
|
<div>{invoice.periode || "—"}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Date d'émission</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="date"
|
|
value={editForm.date}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, date: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
) : (
|
|
<div>{fmtDateFR(invoice.date || undefined)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Mode de paiement</div>
|
|
{isEditing ? (
|
|
<select
|
|
value={editForm.payment_method}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, payment_method: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
>
|
|
<option value="sepa">Prélèvement SEPA</option>
|
|
<option value="cb">CB</option>
|
|
<option value="virement">Virement</option>
|
|
</select>
|
|
) : (
|
|
<div>
|
|
{invoice.payment_method === "sepa" ? "Prélèvement SEPA" :
|
|
invoice.payment_method === "cb" ? "CB" :
|
|
invoice.payment_method === "virement" ? "Virement" :
|
|
"Prélèvement SEPA"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Date d'échéance</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="date"
|
|
value={editForm.due_date}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, due_date: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
) : (
|
|
<div>{fmtDateFR(invoice.due_date || undefined)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Date de prélèvement SEPA</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="date"
|
|
value={editForm.sepa_day}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, sepa_day: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
) : (
|
|
<div>{fmtDateFR(invoice.sepa_day || undefined)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Type de facture</div>
|
|
{isEditing ? (
|
|
<select
|
|
value={editForm.invoice_type}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, invoice_type: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
>
|
|
<option value="paie_mensuelle">Paie - mensuelle</option>
|
|
<option value="paie_ouverture">Paie - ouverture</option>
|
|
<option value="studio_site_web">Studio - site web</option>
|
|
<option value="studio_renouvellement">Studio - renouvellement</option>
|
|
</select>
|
|
) : (
|
|
<div>
|
|
{invoice.invoice_type === "paie_mensuelle" ? "Paie - mensuelle" :
|
|
invoice.invoice_type === "paie_ouverture" ? "Paie - ouverture" :
|
|
invoice.invoice_type === "studio_site_web" ? "Studio - site web" :
|
|
invoice.invoice_type === "studio_renouvellement" ? "Studio - renouvellement" :
|
|
"Paie - mensuelle"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Champ conditionnel pour le site web */}
|
|
{(isEditing && (editForm.invoice_type === "studio_site_web" || editForm.invoice_type === "studio_renouvellement")) ||
|
|
(!isEditing && (invoice.invoice_type === "studio_site_web" || invoice.invoice_type === "studio_renouvellement")) ? (
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Site web concerné</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editForm.site_name}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, site_name: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
placeholder="Nom du site web"
|
|
/>
|
|
) : (
|
|
<div>{invoice.site_name || "—"}</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Statut</div>
|
|
{isEditing ? (
|
|
<select
|
|
value={editForm.statut}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, statut: e.target.value }))}
|
|
className="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>
|
|
{invoice.statut === "payee" ? (
|
|
<Badge tone="ok">Payée</Badge>
|
|
) : invoice.statut === "annulee" ? (
|
|
<Badge tone="error">Annulée</Badge>
|
|
) : invoice.statut === "en_cours" ? (
|
|
<Badge tone="warn">En cours</Badge>
|
|
) : (
|
|
<Badge>Émise</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Notifié</div>
|
|
{isEditing ? (
|
|
<select
|
|
value={editForm.notified ? "true" : "false"}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, notified: e.target.value === "true" }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
>
|
|
<option value="false">Non</option>
|
|
<option value="true">Oui</option>
|
|
</select>
|
|
) : (
|
|
<div>
|
|
{invoice.notified ? (
|
|
<Badge tone="ok">Oui</Badge>
|
|
) : (
|
|
<Badge tone="error">Non</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Montant HT</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={editForm.montant_ht}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, montant_ht: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
) : (
|
|
<div className="text-lg font-semibold">{fmtEUR.format(invoice.montant_ht || 0)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Montant TTC</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={editForm.montant_ttc}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, montant_ttc: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
) : (
|
|
<div className="text-lg font-semibold text-blue-600">{fmtEUR.format(invoice.montant_ttc || 0)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">Date de paiement</div>
|
|
{isEditing ? (
|
|
<input
|
|
type="date"
|
|
value={editForm.payment_date}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, payment_date: e.target.value }))}
|
|
className="px-3 py-2 border rounded-lg bg-white"
|
|
/>
|
|
) : (
|
|
<div>{fmtDateFR(invoice.payment_date || undefined)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[140px_1fr] gap-2 items-center">
|
|
<div className="text-slate-500 font-medium">PDF</div>
|
|
<div className="space-y-2">
|
|
{invoice.pdf ? (
|
|
<div className="flex items-center gap-2">
|
|
<a
|
|
href={`/api/staff/facturation/${id}/view-pdf`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Ouvrir le PDF
|
|
</a>
|
|
<a
|
|
href={`/api/facturation/${id}/download`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-2 text-green-600 hover:text-green-800 underline"
|
|
>
|
|
<FileDown className="w-4 h-4" />
|
|
Test téléchargement
|
|
</a>
|
|
<button
|
|
onClick={handleDeletePdf}
|
|
disabled={deletePdfMutation.isPending}
|
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-rose-100 text-rose-700 rounded hover:bg-rose-200 transition-colors disabled:opacity-50"
|
|
>
|
|
<FileX className="w-3 h-3" />
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="text-slate-400">Aucun PDF disponible</span>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".pdf"
|
|
onChange={handleFileUpload}
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploadingPdf || uploadPdfMutation.isPending}
|
|
className="inline-flex items-center gap-2 text-xs px-3 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
|
>
|
|
<Upload className="w-3 h-3" />
|
|
{isUploadingPdf ? "Upload..." : invoice.pdf ? "Remplacer le PDF" : "Ajouter un PDF"}
|
|
</button>
|
|
</div>
|
|
|
|
{uploadPdfMutation.isError && (
|
|
<div className="text-xs text-rose-600">
|
|
Erreur d'upload : {(uploadPdfMutation.error as any)?.message || "erreur inconnue"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Notes */}
|
|
<Section title="Notes">
|
|
{isEditing ? (
|
|
<textarea
|
|
value={editForm.notes}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, notes: e.target.value }))}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white min-h-[100px]"
|
|
placeholder="Notes sur la facture..."
|
|
/>
|
|
) : (
|
|
<div className="text-slate-700 whitespace-pre-wrap">
|
|
{invoice.notes || "Aucune note."}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Métadonnées */}
|
|
<Section title="Métadonnées">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 text-sm">
|
|
<div>
|
|
<div className="text-slate-500 font-medium">ID de la facture</div>
|
|
<div className="font-mono">{invoice.id}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-500 font-medium">Créée le</div>
|
|
<div>{fmtDateTimeCompleteFR(invoice.created_at)}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-500 font-medium">Modifiée le</div>
|
|
<div>{fmtDateTimeCompleteFR(invoice.updated_at)}</div>
|
|
</div>
|
|
{invoice.gocardless_payment_id && (
|
|
<div>
|
|
<div className="text-slate-500 font-medium">ID Paiement GoCardless</div>
|
|
<div className="font-mono text-emerald-600">{invoice.gocardless_payment_id}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Modal de confirmation de suppression */}
|
|
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="text-rose-500" size={20} />
|
|
Supprimer la facture
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-slate-600">
|
|
<p className="mb-3">
|
|
Êtes-vous sûr de vouloir supprimer cette facture ?
|
|
</p>
|
|
|
|
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
|
<div className="font-medium text-slate-900">
|
|
Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`}
|
|
</div>
|
|
<div className="text-slate-600">
|
|
Client : {invoice?.organization_name || "—"}
|
|
</div>
|
|
<div className="text-slate-600">
|
|
Montant : {fmtEUR.format(invoice?.montant_ttc || 0)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 p-3 bg-rose-50 border border-rose-200 rounded-lg">
|
|
<div className="text-rose-800 text-sm font-medium mb-2">
|
|
⚠️ Cette action supprimera définitivement :
|
|
</div>
|
|
<ul className="text-rose-700 text-sm space-y-1 list-disc list-inside">
|
|
<li>La facture de la base de données</li>
|
|
<li>Le fichier PDF associé (si présent)</li>
|
|
</ul>
|
|
<div className="mt-2 text-rose-800 text-sm font-medium">
|
|
Cette action est irréversible.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowDeleteModal(false)}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={confirmDelete}
|
|
disabled={deleteMutation.isPending}
|
|
className="bg-rose-600 hover:bg-rose-700 text-white"
|
|
>
|
|
{deleteMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Suppression...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Supprimer définitivement
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Modal de confirmation de lancement */}
|
|
<Dialog open={showLaunchModal} onOpenChange={setShowLaunchModal}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Send className="text-emerald-500" size={20} />
|
|
Lancer la facture
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-slate-600">
|
|
<p className="mb-3">
|
|
Êtes-vous sûr de vouloir lancer cette facture ?
|
|
</p>
|
|
|
|
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
|
<div className="font-medium text-slate-900">
|
|
Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`}
|
|
</div>
|
|
<div className="text-slate-600">
|
|
Client : {invoice?.organization_name || "—"}
|
|
</div>
|
|
<div className="text-slate-600">
|
|
Montant : {fmtEUR.format(invoice?.montant_ttc || 0)}
|
|
</div>
|
|
{invoice?.sepa_day && (
|
|
<div className="text-slate-600">
|
|
Prélèvement SEPA : {fmtDateFR(invoice.sepa_day)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg">
|
|
<div className="text-emerald-800 text-sm font-medium mb-2">
|
|
✅ Cette action va :
|
|
</div>
|
|
<ul className="text-emerald-700 text-sm space-y-1 list-disc list-inside">
|
|
<li>Créer le prélèvement dans GoCardless</li>
|
|
<li>Envoyer une notification par email au client</li>
|
|
<li>Marquer la facture comme notifiée</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowLaunchModal(false)}
|
|
disabled={launchInvoiceMutation.isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={confirmLaunchInvoice}
|
|
disabled={launchInvoiceMutation.isPending}
|
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
>
|
|
{launchInvoiceMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Lancement...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send className="w-4 h-4 mr-2" />
|
|
Lancer la facture
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Modal de confirmation de notification */}
|
|
<Dialog open={showNotifyModal} onOpenChange={setShowNotifyModal}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Mail className="text-blue-500" size={20} />
|
|
Notifier la facture
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-slate-600">
|
|
<p className="mb-3">
|
|
Êtes-vous sûr de vouloir envoyer une notification pour cette facture ?
|
|
</p>
|
|
|
|
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
|
<div className="font-medium text-slate-900">
|
|
Client : {invoice.organization_name}
|
|
</div>
|
|
<div className="text-slate-700">
|
|
Montant : {invoice.montant_ttc?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 rounded-lg p-3 mt-3">
|
|
<div className="text-blue-800 text-sm font-medium mb-2">
|
|
📧 Cette action va :
|
|
</div>
|
|
<ul className="text-blue-700 text-sm space-y-1 list-disc list-inside">
|
|
<li>Envoyer une notification par email au client</li>
|
|
<li>Marquer la facture comme notifiée</li>
|
|
<li>Ne PAS créer de prélèvement GoCardless</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowNotifyModal(false)}
|
|
disabled={notifyInvoiceMutation.isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={confirmNotifyInvoice}
|
|
disabled={notifyInvoiceMutation.isPending}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{notifyInvoiceMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Envoi...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Mail className="w-4 h-4 mr-2" />
|
|
Notifier
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Modal de confirmation GoCardless */}
|
|
<Dialog open={showGoCardlessModal} onOpenChange={setShowGoCardlessModal}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Send className="text-orange-500" size={20} />
|
|
Créer paiement GoCardless
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-slate-600">
|
|
<p className="mb-3">
|
|
Êtes-vous sûr de vouloir créer un paiement GoCardless pour cette facture ?
|
|
</p>
|
|
|
|
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
|
<div className="font-medium text-slate-900">
|
|
Client : {invoice.organization_name}
|
|
</div>
|
|
<div className="text-slate-700">
|
|
Montant : {invoice.montant_ttc?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-orange-50 rounded-lg p-3 mt-3">
|
|
<div className="text-orange-800 text-sm font-medium mb-2">
|
|
💳 Cette action va :
|
|
</div>
|
|
<ul className="text-orange-700 text-sm space-y-1 list-disc list-inside">
|
|
<li>Créer le prélèvement dans GoCardless</li>
|
|
<li>Marquer la facture comme émise</li>
|
|
<li>Ne PAS envoyer de notification email</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowGoCardlessModal(false)}
|
|
disabled={createGoCardlessPaymentMutation.isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={confirmCreateGoCardlessPayment}
|
|
disabled={createGoCardlessPaymentMutation.isPending}
|
|
className="bg-orange-600 hover:bg-orange-700 text-white"
|
|
>
|
|
{createGoCardlessPaymentMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Création...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send className="w-4 h-4 mr-2" />
|
|
Créer paiement
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</main>
|
|
);
|
|
} |