since3-portal/modules/shared/ui/components/invoices-table.tsx
2026-09-29 22:56:37 +02:00

309 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { ArrowDownToLineIcon, PencilIcon, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { updateInvoiceStatus } from "@/modules/shared/actions/invoices";
import { INVOICE_STATUSES, type InvoiceListItem, type InvoiceStatus } from "@/modules/shared/schemas/invoice";
import { formatCurrency, formatDate } from "@/modules/shared/lib/invoice-math";
const STATUS_LABELS: Record<InvoiceListItem["displayStatus"], string> = {
pending: "Offen",
paid: "Bezahlt",
cancelled: "Storniert",
overdue: "Überfällig",
};
const STATUS_VARIANTS = {
pending: "neutral",
paid: "success",
cancelled: "error",
overdue: "warning",
} as const;
const checkboxClassName = "size-4 cursor-pointer accent-primary align-middle";
function EditStatusDialog({ profileId, invoice }: { profileId: string; invoice: InvoiceListItem }) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<InvoiceStatus>(invoice.status);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (nextOpen) {
setStatus(invoice.status);
setError(null);
}
}
function handleSave() {
startTransition(async () => {
const result = await updateInvoiceStatus(profileId, invoice.id, status);
if ("error" in result) {
setError(result.error);
return;
}
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Status bearbeiten" />}>
<PencilIcon className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{invoice.invoiceNumber} bearbeiten</DialogTitle>
</DialogHeader>
<Field>
<FieldLabel htmlFor={`status-${invoice.id}`}>Status</FieldLabel>
<select
id={`status-${invoice.id}`}
value={status}
onChange={(event) => setStatus(event.target.value as InvoiceStatus)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none"
>
{INVOICE_STATUSES.map((value) => (
<option key={value} value={value}>
{STATUS_LABELS[value]}
</option>
))}
</select>
</Field>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" onClick={handleSave} disabled={pending || status === invoice.status}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function invoiceUrl(invoiceId: string) {
return `/api/invoices/pdf?id=${invoiceId}&inline`;
}
function openInvoice(event: React.MouseEvent<HTMLTableRowElement>, invoiceId: string) {
const target = event.target as HTMLElement;
// Klicks aus Portalen (Status-Dialog) bubbeln im React-Baum mit hoch, liegen im DOM aber außerhalb der Zeile.
if (!event.currentTarget.contains(target)) return;
if (target.closest("a, button, input, select, label")) return;
window.open(invoiceUrl(invoiceId), "_blank", "noopener,noreferrer");
}
function fileNameFromResponse(response: Response) {
const disposition = response.headers.get("Content-Disposition") ?? "";
return /filename="([^"]+)"/.exec(disposition)?.[1] ?? "Rechnungen";
}
export function InvoicesTable({
profileId,
invoices,
isAdmin,
actions,
}: {
profileId: string;
invoices: InvoiceListItem[];
isAdmin: boolean;
actions?: React.ReactNode;
}) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const selectAllRef = useRef<HTMLInputElement>(null);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return invoices;
return invoices.filter(
(invoice) =>
invoice.invoiceNumber.toLowerCase().includes(needle) ||
STATUS_LABELS[invoice.displayStatus].toLowerCase().includes(needle)
);
}, [invoices, query]);
const selectedVisible = visible.filter((invoice) => selected.has(invoice.id));
const allVisibleSelected = visible.length > 0 && selectedVisible.length === visible.length;
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedVisible.length > 0 && !allVisibleSelected;
}
}, [selectedVisible.length, allVisibleSelected]);
function toggle(id: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAllVisible() {
setSelected((current) => {
const next = new Set(current);
for (const invoice of visible) {
if (allVisibleSelected) next.delete(invoice.id);
else next.add(invoice.id);
}
return next;
});
}
async function handleDownload() {
if (selectedVisible.length === 0) return;
setDownloading(true);
setDownloadError(null);
try {
const params = new URLSearchParams(selectedVisible.map((invoice) => ["id", invoice.id]));
const response = await fetch(`/api/invoices/pdf?${params}`);
if (!response.ok) {
const body = await response.json().catch(() => null);
setDownloadError(body?.error ?? "Download fehlgeschlagen.");
return;
}
const url = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = url;
link.download = fileNameFromResponse(response);
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch {
setDownloadError("Download fehlgeschlagen.");
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="w-full max-w-lg">
<InputGroup className="rounded-md focus-visible:ring-0">
<InputGroupInput
placeholder="Rechnungsnummer oder Status suchen..."
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Rechnungen durchsuchen"
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={handleDownload} disabled={selectedVisible.length === 0 || downloading}>
<ArrowDownToLineIcon />
{downloading
? "Wird erstellt..."
: selectedVisible.length > 0
? `Download (${selectedVisible.length})`
: "Download"}
</Button>
{actions}
</div>
</div>
{downloadError && <p className="text-sm text-destructive">{downloadError}</p>}
{invoices.length === 0 ? (
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
Noch keine Rechnungen vorhanden.
</p>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-10">
<input
ref={selectAllRef}
type="checkbox"
className={checkboxClassName}
checked={allVisibleSelected}
onChange={toggleAllVisible}
disabled={visible.length === 0}
aria-label="Alle auswählen"
/>
</TableHead>
<TableHead>Rechnung</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead className="hidden sm:table-cell">Fällig am</TableHead>
{isAdmin && <TableHead className="w-12" />}
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={isAdmin ? 6 : 5} className="py-8 text-center text-muted-foreground wrap-anywhere">
Keine Treffer für „{query}“.
</TableCell>
</TableRow>
) : (
visible.map((invoice) => (
<TableRow
key={invoice.id}
data-state={selected.has(invoice.id) ? "selected" : undefined}
className="cursor-pointer"
onClick={(event) => openInvoice(event, invoice.id)}
>
<TableCell>
<input
type="checkbox"
className={checkboxClassName}
checked={selected.has(invoice.id)}
onChange={() => toggle(invoice.id)}
aria-label={`${invoice.invoiceNumber} auswählen`}
/>
</TableCell>
<TableCell className="font-medium">
<a
href={invoiceUrl(invoice.id)}
target="_blank"
rel="noopener noreferrer"
className="whitespace-nowrap hover:underline underline-offset-4"
>
{invoice.invoiceNumber}
</a>
<span className="block text-small font-normal whitespace-nowrap text-muted-foreground tabular-nums sm:hidden">
fällig {formatDate(invoice.dueDate)}
</span>
</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[invoice.displayStatus]}>
{STATUS_LABELS[invoice.displayStatus]}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums whitespace-nowrap">{formatCurrency(invoice.grossTotal)}</TableCell>
<TableCell className="hidden tabular-nums text-muted-foreground sm:table-cell">{formatDate(invoice.dueDate)}</TableCell>
{isAdmin && (
<TableCell>
<div className="flex justify-end">
<EditStatusDialog profileId={profileId} invoice={invoice} />
</div>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}