mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
380 lines
17 KiB
TypeScript
380 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
import { ArrowDownToLineIcon, MailCheckIcon, MailIcon, PencilIcon, Search } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogDescription, 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 { sendInvoiceEmail, 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";
|
|
import { formatDateTime } from "@/modules/shared/lib/info-format";
|
|
|
|
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 SendInvoiceDialog({
|
|
profileId,
|
|
invoice,
|
|
recipientEmail,
|
|
}: {
|
|
profileId: string;
|
|
invoice: InvoiceListItem;
|
|
recipientEmail: string | null;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pending, startTransition] = useTransition();
|
|
const cancelled = invoice.status === "cancelled";
|
|
const label = invoice.emailSentAt
|
|
? `Per E-Mail gesendet am ${formatDateTime(invoice.emailSentAt)}`
|
|
: "Per E-Mail senden";
|
|
|
|
function handleOpenChange(nextOpen: boolean) {
|
|
setOpen(nextOpen);
|
|
if (nextOpen) setError(null);
|
|
}
|
|
|
|
function handleSend() {
|
|
startTransition(async () => {
|
|
const result = await sendInvoiceEmail(profileId, invoice.id);
|
|
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={label} title={label} disabled={cancelled || !recipientEmail} />}
|
|
>
|
|
{invoice.emailSentAt ? <MailCheckIcon className="size-4" /> : <MailIcon className="size-4" />}
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{invoice.invoiceNumber} per E-Mail senden</DialogTitle>
|
|
<DialogDescription>
|
|
Die Rechnung geht als PDF an <span className="font-medium break-all text-foreground">{recipientEmail}</span>.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{invoice.emailSentAt && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Bereits gesendet am {formatDateTime(invoice.emailSentAt)}
|
|
{invoice.emailSentTo ? ` an ${invoice.emailSentTo}` : ""}.
|
|
</p>
|
|
)}
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" onClick={handleSend} disabled={pending}>
|
|
{pending ? "Wird gesendet..." : invoice.emailSentAt ? "Erneut senden" : "Senden"}
|
|
</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,
|
|
recipientEmail = null,
|
|
actions,
|
|
}: {
|
|
profileId: string;
|
|
invoices: InvoiceListItem[];
|
|
isAdmin: boolean;
|
|
recipientEmail?: string | null;
|
|
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-20" />}
|
|
</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 gap-1">
|
|
<SendInvoiceDialog profileId={profileId} invoice={invoice} recipientEmail={recipientEmail} />
|
|
<EditStatusDialog profileId={profileId} invoice={invoice} />
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|