import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router"; export const handle = { breadcrumbs: (data: { invoice: { companyId: string; number: string | null; company: { name: string } } }) => [ { label: "Mandanten", href: "/companies" }, { label: data.invoice.company.name, href: `/companies/${data.invoice.companyId}` }, { label: "Rechnungen", href: `/companies/${data.invoice.companyId}/invoices` }, { label: data.invoice.number ?? "-" }, ], }; import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; import { formatCurrency, formatDate } from "@/lib/tax"; import { ChevronLeft, Download, CheckCircle, Send, Trash2, RotateCcw, Pencil } from "lucide-react"; import type { InvoiceStatus } from "@prisma/client"; export async function loader({ request, params, }: { request: Request; params: { id: string; invoiceId: string }; }) { const user = await requireUser(request); const { id, invoiceId } = params; const invoice = await prisma.invoice.findFirst({ where: { id: invoiceId, companyId: id, company: { userId: user.id } }, include: { items: { orderBy: { position: "asc" } }, customer: true, company: true, }, }); if (!invoice) throw new Response("Not Found", { status: 404 }); return { isAdmin: user.role === "ADMIN", invoice: { ...invoice, netTotal: Number(invoice.netTotal), taxTotal: Number(invoice.taxTotal), grossTotal: Number(invoice.grossTotal), kleinunternehmer: invoice.kleinunternehmer, issueDate: invoice.issueDate.toISOString(), dueDate: invoice.dueDate.toISOString(), deliveryDate: invoice.deliveryDate?.toISOString() ?? null, items: invoice.items.map((item) => ({ ...item, quantity: Number(item.quantity), unitPrice: Number(item.unitPrice), taxRate: Number(item.taxRate), netAmount: Number(item.netAmount), taxAmount: Number(item.taxAmount), grossAmount: Number(item.grossAmount), })), }, }; } /** * InvoiceDetailPage * * This page displays the details of a single invoice, including the customer, items, and totals. * It also allows the user to download the invoice as a PDF and to update the status of the invoice. * * The page will only be accessible if the user is logged in and has the necessary permissions. * The page will automatically revalidate when the user updates the status of the invoice. * * @returns {JSX.Element} The JSX element representing the InvoiceDetailPage. */ export default function InvoiceDetailPage() { const { invoice, isAdmin } = useLoaderData(); const navigate = useNavigate(); const { revalidate } = useRevalidator(); const [loading, setLoading] = useState(false); const id = invoice.companyId; const taxGroups = invoice.items.reduce( (acc, item) => { const rate = item.taxRate; if (!acc[rate]) acc[rate] = { net: 0, tax: 0 }; acc[rate].net += item.netAmount; acc[rate].tax += item.taxAmount; return acc; }, {} as Record ); /** * Updates the status of an invoice. * * This function sends a PATCH request to the API to update the status of the invoice. * It sets the loading state to true before sending the request and to false after the request is finished. * It also revalidates the page after the request is finished, so the user is shown the updated status. * * @param {InvoiceStatus} status The new status of the invoice. */ async function updateStatus(status: InvoiceStatus) { setLoading(true); await fetch(`/api/invoices/${invoice.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }), }); setLoading(false); revalidate(); } /** * Soft deletes an invoice. * * This function shows a confirmation dialog to the user and if the user confirms, it sends a PATCH request to the API to update the status of the invoice to "DELETED". * It sets the loading state to true before sending the request and to false after the request is finished. * It also revalidates the page after the request is finished, so the user is shown the updated status. */ async function handleSoftDelete() { if (!confirm("Rechnung in den Papierkorb verschieben?")) return; setLoading(true); await fetch(`/api/invoices/${invoice.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "DELETED" }), }); setLoading(false); revalidate(); } /** * Restores an invoice to the "DRAFT" status. * * This function sends a PATCH request to the API to update the status of the invoice to "DRAFT". * It sets the loading state to true before sending the request and to false after the request is finished. * It also revalidates the page after the request is finished, so the user is shown the updated status. */ async function handleRestore() { setLoading(true); await fetch(`/api/invoices/${invoice.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "DRAFT" }), }); setLoading(false); revalidate(); } /** * Hard deletes an invoice. * * This function shows a confirmation dialog to the user and if the user confirms, it sends a DELETE request to the API to delete the invoice. * It then navigates to the invoice list page. */ async function handleHardDelete() { if (!confirm("Rechnung endgültig löschen? Dies kann nicht rückgängig gemacht werden.")) return; await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" }); navigate(`/companies/${id}/invoices`); } /** * Downloads the invoice as a PDF file. * * This function sends a GET request to the API to get the PDF file. * It then creates a blob URL from the response and creates a new anchor element with the blob URL and a download attribute with the filename. * It then simulates a click event on the anchor element, so the user is prompted to download the PDF file. */ async function downloadFile(url: string, filename: string) { const res = await fetch(url); if (!res.ok) return; const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objectUrl; a.download = filename; a.click(); URL.revokeObjectURL(objectUrl); } function downloadPdf() { return downloadFile(`/api/invoices/${invoice.id}/pdf`, `rechnung-${invoice.number ?? invoice.id}.pdf`); } function downloadXml() { return downloadFile(`/api/invoices/${invoice.id}/xml`, `rechnung-${invoice.number ?? invoice.id}.xml`); } return (
Zurück zu Rechnungen

{invoice.number ?? "-"}

{invoice.customer.name} · {formatDate(invoice.issueDate)}

{/* Invoice Actions */}
{invoice.status !== "DELETED" && ( <> )} {invoice.status === "DRAFT" && ( )} {invoice.status === "DRAFT" && ( )} {invoice.status === "SENT" && ( )} {/* Soft-Delete für alle nicht-gelöschten Status */} {invoice.status !== "DELETED" && ( )} {/* Gelöschte Rechnung: Wiederherstellen + endgültig löschen (Admin) */} {invoice.status === "DELETED" && ( <> {isAdmin && ( )} )}

Absender

{invoice.company.name}

{invoice.company.legalForm &&

{invoice.company.legalForm}

}

{invoice.company.address}

{invoice.company.zip} {invoice.company.city}

{invoice.company.taxId && (

St.-Nr.: {invoice.company.taxId}

)} {invoice.company.vatId && (

USt-IdNr.: {invoice.company.vatId}

)}

Rechnungsempfänger

{invoice.customer.name}

{invoice.customer.address}

{invoice.customer.zip} {invoice.customer.city}

Rechnungsnummer

{invoice.number}

Rechnungsdatum

{formatDate(invoice.issueDate)}

{invoice.deliveryDate && (

Leistungsdatum

{formatDate(invoice.deliveryDate)}

)}

Fällig am

{formatDate(invoice.dueDate)}

{invoice.items.map((item) => ( ))}
# Beschreibung Menge EP (netto) MwSt. Betrag (brutto)
{item.position} {item.description} {item.quantity} {formatCurrency(item.unitPrice)} {item.taxRate}% {formatCurrency(item.grossAmount)}
{invoice.kleinunternehmer ? ( <>
Gesamtbetrag {formatCurrency(invoice.grossTotal)}

Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt.

) : ( <>
Nettobetrag {formatCurrency(invoice.netTotal)}
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
MwSt. {rate}% auf {formatCurrency(net)} {formatCurrency(tax)}
))}
Gesamtbetrag (brutto) {formatCurrency(invoice.grossTotal)}
)}
{invoice.notes && (

Hinweise

{invoice.notes}

)} {invoice.company.bankIban && (

Bankverbindung: {invoice.company.bankName && `${invoice.company.bankName} · `} IBAN: {invoice.company.bankIban} {invoice.company.bankBic && ` · BIC: ${invoice.company.bankBic}`}

)}
Zusammenfassung {invoice.kleinunternehmer ? ( <>

Gesamtbetrag

{formatCurrency(invoice.grossTotal)}

Keine USt. gem. §19 UStG

) : ( <>

Netto

{formatCurrency(invoice.netTotal)}

MwSt.

{formatCurrency(invoice.taxTotal)}

Brutto

{formatCurrency(invoice.grossTotal)}

)}
); }