mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
added payment page with invoice tracking
This commit is contained in:
parent
4e48871547
commit
9927b74781
21 changed files with 2568 additions and 4 deletions
989
package-lock.json
generated
989
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,7 @@
|
|||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@stripe/stripe-js": "^9.5.0",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
"@tiptap/extension-underline": "^3.13.0",
|
||||
"@tiptap/react": "^3.13.0",
|
||||
"@tiptap/starter-kit": "^3.13.0",
|
||||
"archiver": "^8.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -64,6 +66,7 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Business" ADD COLUMN "invoiceCounter" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CourseBooking" ADD COLUMN "invoiceNumber" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CourseBooking_invoiceNumber_key" ON "CourseBooking"("invoiceNumber");
|
||||
|
|
@ -101,6 +101,7 @@ model Business {
|
|||
isPublic Boolean @default(false)
|
||||
stripeAccountId String? @unique // Stripe Connect Account ID
|
||||
stripeAccountStatus String? // "pending" | "active" | "restricted"
|
||||
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
|
||||
manager Manager @relation(fields: [managerId], references: [id])
|
||||
courses Course[]
|
||||
createdAt DateTime @default(now())
|
||||
|
|
@ -175,6 +176,7 @@ model CourseBooking {
|
|||
stripePaymentIntentId String? @unique
|
||||
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
|
||||
platformFee Int? // unsere Service-Fee in Cents
|
||||
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
|
||||
|
|
|
|||
41
src/app/(main)/zahlungen/loading.tsx
Normal file
41
src/app/(main)/zahlungen/loading.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
|
||||
export default function ZahlungenLoading() {
|
||||
return (
|
||||
<div className="space-y-6 pt-4 p-2">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-xl p-4"
|
||||
>
|
||||
<Skeleton className="h-3 w-24 mb-3" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-9 w-60 rounded-xl" />
|
||||
<Skeleton className="h-9 w-40 rounded-xl" />
|
||||
<Skeleton className="h-9 w-40 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="divide-y divide-white/30">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-4">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-52" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/app/(main)/zahlungen/page.tsx
Normal file
35
src/app/(main)/zahlungen/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Suspense } from "react";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { getZahlungen } from "@/src/modules/zahlungen/actions/zahlungen-actions";
|
||||
import { getZahlungMetrics } from "@/src/modules/zahlungen/actions/zahlungen-metrics-actions";
|
||||
import ZahlungenView from "@/src/modules/zahlungen/ui/views/zahlungen-view";
|
||||
import ZahlungenLoading from "./loading";
|
||||
|
||||
const ZahlungenPage = async () => {
|
||||
const manager = await requireManager();
|
||||
|
||||
const [zahlungenResult, metricsResult] = await Promise.all([
|
||||
getZahlungen({}),
|
||||
getZahlungMetrics({}),
|
||||
]);
|
||||
|
||||
if (!zahlungenResult.success) {
|
||||
throw new Error(zahlungenResult.error);
|
||||
}
|
||||
if (!metricsResult.success) {
|
||||
throw new Error(metricsResult.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ZahlungenLoading />}>
|
||||
<ZahlungenView
|
||||
initialZahlungen={zahlungenResult.zahlungen}
|
||||
initialCursor={zahlungenResult.nextCursor}
|
||||
initialMetrics={metricsResult.metrics}
|
||||
businesses={manager.businesses}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungenPage;
|
||||
41
src/app/api/zahlungen/[bookingId]/invoice/route.tsx
Normal file
41
src/app/api/zahlungen/[bookingId]/invoice/route.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { assembleInvoiceData } from "@/src/modules/zahlungen/actions/invoice-actions";
|
||||
import { InvoicePdfDocument } from "@/src/modules/zahlungen/ui/components/invoice-pdf-document";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ bookingId: string }> }
|
||||
) {
|
||||
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect aus,
|
||||
// der als spezieller Error implementiert ist - darf nicht im try/catch landen.
|
||||
await requireManager();
|
||||
|
||||
const { bookingId } = await params;
|
||||
|
||||
try {
|
||||
const invoiceData = await assembleInvoiceData(bookingId);
|
||||
|
||||
if (!invoiceData) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rechnung nur für bezahlte Zahlungen verfügbar." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="Rechnung-${invoiceData.invoiceNumber}.pdf"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen der Rechnung:", error);
|
||||
return NextResponse.json({ error: "Rechnung konnte nicht erstellt werden." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
107
src/app/api/zahlungen/invoices/zip/route.tsx
Normal file
107
src/app/api/zahlungen/invoices/zip/route.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import {
|
||||
assembleInvoiceData,
|
||||
findInvoiceableBookingIds,
|
||||
} from "@/src/modules/zahlungen/actions/invoice-actions";
|
||||
import type {
|
||||
ZahlungenFilters,
|
||||
ZahlungStatus,
|
||||
} from "@/src/modules/zahlungen/actions/zahlungen-actions";
|
||||
import { InvoicePdfDocument } from "@/src/modules/zahlungen/ui/components/invoice-pdf-document";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import * as archiver from "archiver";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const VALID_STATUSES: ZahlungStatus[] = ["paid", "pending", "refunded", "failed"];
|
||||
|
||||
function parseFilters(searchParams: URLSearchParams): ZahlungenFilters {
|
||||
const status = searchParams.get("status");
|
||||
const dateFrom = searchParams.get("dateFrom");
|
||||
const dateTo = searchParams.get("dateTo");
|
||||
const customerQuery = searchParams.get("customerQuery");
|
||||
const businessId = searchParams.get("businessId");
|
||||
|
||||
return {
|
||||
status:
|
||||
status && VALID_STATUSES.includes(status as ZahlungStatus)
|
||||
? (status as ZahlungStatus)
|
||||
: undefined,
|
||||
dateFrom: dateFrom ?? undefined,
|
||||
dateTo: dateTo ?? undefined,
|
||||
customerQuery: customerQuery ?? undefined,
|
||||
businessId: businessId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect aus,
|
||||
// der als spezieller Error implementiert ist - darf nicht im try/catch landen.
|
||||
await requireManager();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const filters = parseFilters(searchParams);
|
||||
|
||||
try {
|
||||
const { ids, truncated } = await findInvoiceableBookingIds(filters);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Keine bezahlten Zahlungen für diesen Filter gefunden." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const archive = new archiver.ZipArchive({ zlib: { level: 9 } });
|
||||
const passthrough = new PassThrough();
|
||||
archive.pipe(passthrough);
|
||||
|
||||
// Läuft im Hintergrund weiter, während die Response bereits gestreamt wird.
|
||||
(async () => {
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
for (const bookingId of ids) {
|
||||
try {
|
||||
const invoiceData = await assembleInvoiceData(bookingId);
|
||||
if (!invoiceData) continue;
|
||||
|
||||
const buffer = await renderToBuffer(<InvoicePdfDocument data={invoiceData} />);
|
||||
|
||||
let fileName = `Rechnung-${invoiceData.invoiceNumber}.pdf`;
|
||||
if (usedNames.has(fileName)) {
|
||||
fileName = `Rechnung-${invoiceData.invoiceNumber}-${bookingId.slice(0, 8)}.pdf`;
|
||||
}
|
||||
usedNames.add(fileName);
|
||||
|
||||
archive.append(buffer, { name: fileName });
|
||||
} catch (err) {
|
||||
console.error(`Fehler beim Generieren der Rechnung für Buchung ${bookingId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
archive.append(
|
||||
`Es gab mehr als ${ids.length} Zahlungen für den gewählten Filter. Bitte den Zeitraum eingrenzen, um alle Rechnungen zu erhalten.`,
|
||||
{ name: "HINWEIS.txt" }
|
||||
);
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
})();
|
||||
|
||||
return new NextResponse(Readable.toWeb(passthrough) as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="Rechnungen-${new Date().toISOString().slice(0, 10)}.zip"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen des Rechnungs-ZIPs:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Rechnungen konnten nicht gesammelt werden." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/src/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
181
src/modules/zahlungen/actions/invoice-actions.ts
Normal file
181
src/modules/zahlungen/actions/invoice-actions.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
|
||||
import type { ZahlungenFilters } from "./zahlungen-actions";
|
||||
|
||||
// Obergrenze für den ZIP-Sammel-Export, damit ein sehr weiter Filter nicht
|
||||
// eine unbegrenzte Zahl an PDFs auf einmal rendert.
|
||||
const MAX_ZIP_INVOICES = 500;
|
||||
|
||||
export type InvoiceCustomer = {
|
||||
name: string;
|
||||
email: string;
|
||||
isGuest: boolean;
|
||||
street: string | null;
|
||||
houseNumber: string | null;
|
||||
plz: string | null;
|
||||
city: string | null;
|
||||
};
|
||||
|
||||
export type InvoiceData = {
|
||||
invoiceNumber: string;
|
||||
issueDate: string; // ISO
|
||||
business: {
|
||||
name: string;
|
||||
address: string;
|
||||
email: string;
|
||||
ustId: string | null;
|
||||
banking: string | null;
|
||||
};
|
||||
course: {
|
||||
name: string;
|
||||
date: string; // ISO
|
||||
};
|
||||
customer: InvoiceCustomer;
|
||||
amountCents: number;
|
||||
};
|
||||
|
||||
function formatInvoiceNumber(businessId: string, counter: number): string {
|
||||
const year = new Date().getFullYear();
|
||||
return `INV-${businessId.slice(0, 8).toUpperCase()}-${year}-${String(counter).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
async function ensureInvoiceNumber(bookingId: string, businessId: string): Promise<string> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const booking = await tx.courseBooking.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
select: { invoiceNumber: true },
|
||||
});
|
||||
|
||||
if (booking.invoiceNumber) return booking.invoiceNumber;
|
||||
|
||||
const business = await tx.business.update({
|
||||
where: { id: businessId },
|
||||
data: { invoiceCounter: { increment: 1 } },
|
||||
select: { invoiceCounter: true },
|
||||
});
|
||||
|
||||
const invoiceNumber = formatInvoiceNumber(businessId, business.invoiceCounter);
|
||||
|
||||
await tx.courseBooking.update({
|
||||
where: { id: bookingId },
|
||||
data: { invoiceNumber },
|
||||
});
|
||||
|
||||
return invoiceNumber;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Daten für den PDF-Beleg und generiert bei Bedarf die Rechnungsnummer.
|
||||
* Wirft, wenn die Buchung nicht existiert, nicht bezahlt ist, oder nicht zu einem
|
||||
* Business des angefragten Managers gehört.
|
||||
*/
|
||||
export async function assembleInvoiceData(bookingId: string): Promise<InvoiceData | null> {
|
||||
const manager = await requireManager();
|
||||
const businessIds = manager.businesses.map((b) => b.id);
|
||||
|
||||
const booking = await prisma.courseBooking.findFirst({
|
||||
where: {
|
||||
id: bookingId,
|
||||
course: { businessId: { in: businessIds } },
|
||||
},
|
||||
select: {
|
||||
amountPaid: true,
|
||||
paymentStatus: true,
|
||||
course: {
|
||||
select: {
|
||||
name: true,
|
||||
date: true,
|
||||
price: true,
|
||||
business: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
address: true,
|
||||
email: true,
|
||||
ustId: true,
|
||||
banking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: { name: true, email: true, street: true, houseNumber: true, plz: true, city: true },
|
||||
},
|
||||
guestLead: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking || !booking.course.business) return null;
|
||||
if (booking.paymentStatus !== "paid") return null;
|
||||
|
||||
const invoiceNumber = await ensureInvoiceNumber(bookingId, booking.course.business.id);
|
||||
|
||||
const customer: InvoiceCustomer = booking.user
|
||||
? {
|
||||
name: booking.user.name ?? booking.user.email,
|
||||
email: booking.user.email,
|
||||
isGuest: false,
|
||||
street: booking.user.street,
|
||||
houseNumber: booking.user.houseNumber,
|
||||
plz: booking.user.plz,
|
||||
city: booking.user.city,
|
||||
}
|
||||
: {
|
||||
name: booking.guestLead?.name ?? booking.guestLead?.email ?? "Gast",
|
||||
email: booking.guestLead?.email ?? "–",
|
||||
isGuest: true,
|
||||
street: null,
|
||||
houseNumber: null,
|
||||
plz: null,
|
||||
city: null,
|
||||
};
|
||||
|
||||
return {
|
||||
invoiceNumber,
|
||||
issueDate: new Date().toISOString(),
|
||||
business: {
|
||||
name: booking.course.business.name,
|
||||
address: booking.course.business.address,
|
||||
email: booking.course.business.email,
|
||||
ustId: booking.course.business.ustId,
|
||||
banking: booking.course.business.banking,
|
||||
},
|
||||
course: {
|
||||
name: booking.course.name,
|
||||
date: booking.course.date.toISOString(),
|
||||
},
|
||||
customer,
|
||||
amountCents: booking.amountPaid ?? Math.round(booking.course.price * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die IDs aller bezahlten Buchungen, die zu den übergebenen
|
||||
* Zahlungen-Filtern passen und zu einem Business des Managers gehören.
|
||||
* Basis für den ZIP-Sammel-Download aller Rechnungen.
|
||||
*/
|
||||
export async function findInvoiceableBookingIds(
|
||||
filters: ZahlungenFilters
|
||||
): Promise<{ ids: string[]; truncated: boolean }> {
|
||||
const manager = await requireManager();
|
||||
const businessIds = manager.businesses.map((b) => b.id);
|
||||
|
||||
if (businessIds.length === 0) return { ids: [], truncated: false };
|
||||
|
||||
const where = buildZahlungenWhere(filters, businessIds);
|
||||
where.paymentStatus = "paid"; // Rechnungen gibt es nur für bezahlte Buchungen
|
||||
|
||||
const bookings = await prisma.courseBooking.findMany({
|
||||
where,
|
||||
take: MAX_ZIP_INVOICES + 1,
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const truncated = bookings.length > MAX_ZIP_INVOICES;
|
||||
const ids = (truncated ? bookings.slice(0, MAX_ZIP_INVOICES) : bookings).map((b) => b.id);
|
||||
|
||||
return { ids, truncated };
|
||||
}
|
||||
106
src/modules/zahlungen/actions/zahlungen-actions.ts
Normal file
106
src/modules/zahlungen/actions/zahlungen-actions.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
|
||||
|
||||
export type ZahlungStatus = "paid" | "pending" | "refunded" | "failed";
|
||||
|
||||
export type ZahlungenFilters = {
|
||||
status?: ZahlungStatus;
|
||||
dateFrom?: string; // ISO
|
||||
dateTo?: string; // ISO
|
||||
customerQuery?: string;
|
||||
businessId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ZahlungRow = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
isGuest: boolean;
|
||||
courseName: string;
|
||||
businessId: string;
|
||||
businessName: string;
|
||||
amountPaid: number | null;
|
||||
paymentStatus: string;
|
||||
invoiceNumber: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
|
||||
export async function getZahlungen(
|
||||
filters: ZahlungenFilters
|
||||
): Promise<
|
||||
| { success: true; zahlungen: ZahlungRow[]; nextCursor: string | null }
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
const manager = await requireManager();
|
||||
const businessIds = manager.businesses.map((b) => b.id);
|
||||
|
||||
if (businessIds.length === 0) {
|
||||
return { success: true, zahlungen: [], nextCursor: null };
|
||||
}
|
||||
|
||||
const limit = filters.limit ?? DEFAULT_LIMIT;
|
||||
const where = buildZahlungenWhere(filters, businessIds);
|
||||
|
||||
try {
|
||||
const bookings = await prisma.courseBooking.findMany({
|
||||
where,
|
||||
take: limit + 1,
|
||||
...(filters.cursor ? { cursor: { id: filters.cursor }, skip: 1 } : {}),
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
paymentStatus: true,
|
||||
amountPaid: true,
|
||||
invoiceNumber: true,
|
||||
course: {
|
||||
select: {
|
||||
name: true,
|
||||
business: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
user: { select: { name: true, email: true } },
|
||||
guestLead: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = bookings.length > limit;
|
||||
const page = hasMore ? bookings.slice(0, limit) : bookings;
|
||||
|
||||
const zahlungen: ZahlungRow[] = page.map((b) => {
|
||||
const isGuest = !b.user;
|
||||
const customerName =
|
||||
b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–";
|
||||
const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–";
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
customerName,
|
||||
customerEmail,
|
||||
isGuest,
|
||||
courseName: b.course.name,
|
||||
businessId: b.course.business?.id ?? "",
|
||||
businessName: b.course.business?.name ?? "–",
|
||||
amountPaid: b.amountPaid,
|
||||
paymentStatus: b.paymentStatus,
|
||||
invoiceNumber: b.invoiceNumber,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
zahlungen,
|
||||
nextCursor: hasMore ? page[page.length - 1].id : null,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, error: "Zahlungen konnten nicht geladen werden." };
|
||||
}
|
||||
}
|
||||
85
src/modules/zahlungen/actions/zahlungen-metrics-actions.ts
Normal file
85
src/modules/zahlungen/actions/zahlungen-metrics-actions.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { startOfMonth } from "date-fns";
|
||||
|
||||
export type ZahlungMetricsFilters = {
|
||||
businessId?: string;
|
||||
};
|
||||
|
||||
export type ZahlungMetrics = {
|
||||
revenueThisMonth: number; // Cents
|
||||
pendingCount: number;
|
||||
pendingAmount: number; // Cents
|
||||
paidCount: number;
|
||||
};
|
||||
|
||||
export async function getZahlungMetrics(
|
||||
filters: ZahlungMetricsFilters
|
||||
): Promise<{ success: true; metrics: ZahlungMetrics } | { success: false; error: string }> {
|
||||
const manager = await requireManager();
|
||||
const businessIds = manager.businesses.map((b) => b.id);
|
||||
|
||||
if (businessIds.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
metrics: { revenueThisMonth: 0, pendingCount: 0, pendingAmount: 0, paidCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const scopedBusinessIds =
|
||||
filters.businessId && businessIds.includes(filters.businessId)
|
||||
? [filters.businessId]
|
||||
: businessIds;
|
||||
|
||||
const fetchMetrics = unstable_cache(
|
||||
async (): Promise<ZahlungMetrics> => {
|
||||
const monthStart = startOfMonth(new Date());
|
||||
|
||||
const [paidAgg, pendingBookings] = await Promise.all([
|
||||
prisma.courseBooking.aggregate({
|
||||
where: {
|
||||
course: { businessId: { in: scopedBusinessIds } },
|
||||
paymentStatus: "paid",
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
_sum: { amountPaid: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { businessId: { in: scopedBusinessIds } },
|
||||
paymentStatus: "pending",
|
||||
},
|
||||
select: { course: { select: { price: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const pendingAmount = pendingBookings.reduce(
|
||||
(sum, b) => sum + Math.round(b.course.price * 100),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
revenueThisMonth: paidAgg._sum.amountPaid ?? 0,
|
||||
pendingCount: pendingBookings.length,
|
||||
pendingAmount,
|
||||
paidCount: paidAgg._count,
|
||||
};
|
||||
},
|
||||
[`zahlungen-metrics-${manager.id}-${scopedBusinessIds.join(",")}`],
|
||||
{
|
||||
tags: [`zahlungen-metrics-${manager.id}`],
|
||||
revalidate: 60,
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const metrics = await fetchMetrics();
|
||||
return { success: true, metrics };
|
||||
} catch {
|
||||
return { success: false, error: "Kennzahlen konnten nicht geladen werden." };
|
||||
}
|
||||
}
|
||||
47
src/modules/zahlungen/lib/build-zahlungen-where.ts
Normal file
47
src/modules/zahlungen/lib/build-zahlungen-where.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { ZahlungenFilters } from "../actions/zahlungen-actions";
|
||||
|
||||
/**
|
||||
* Baut die Prisma-where-Klausel für die Zahlungsliste aus den UI-Filtern.
|
||||
* Wird sowohl von getZahlungen (Tabelle) als auch vom ZIP-Sammel-Export
|
||||
* verwendet, damit beide immer exakt dieselbe Ergebnismenge sehen.
|
||||
*
|
||||
* Liegt außerhalb der "use server"-Datei, weil Server-Action-Module nur
|
||||
* async Funktionen exportieren dürfen.
|
||||
*/
|
||||
export function buildZahlungenWhere(
|
||||
filters: ZahlungenFilters,
|
||||
businessIds: string[]
|
||||
): Prisma.CourseBookingWhereInput {
|
||||
const scopedBusinessIds =
|
||||
filters.businessId && businessIds.includes(filters.businessId)
|
||||
? [filters.businessId]
|
||||
: businessIds;
|
||||
|
||||
const where: Prisma.CourseBookingWhereInput = {
|
||||
course: { businessId: { in: scopedBusinessIds } },
|
||||
};
|
||||
|
||||
if (filters.status) {
|
||||
where.paymentStatus = filters.status;
|
||||
}
|
||||
|
||||
if (filters.dateFrom || filters.dateTo) {
|
||||
where.createdAt = {
|
||||
...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}),
|
||||
...(filters.dateTo ? { lte: new Date(filters.dateTo) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (filters.customerQuery) {
|
||||
const q = filters.customerQuery;
|
||||
where.OR = [
|
||||
{ user: { name: { contains: q, mode: "insensitive" } } },
|
||||
{ user: { email: { contains: q, mode: "insensitive" } } },
|
||||
{ guestLead: { name: { contains: q, mode: "insensitive" } } },
|
||||
{ guestLead: { email: { contains: q, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
60
src/modules/zahlungen/ui/components/date-range-picker.tsx
Normal file
60
src/modules/zahlungen/ui/components/date-range-picker.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Calendar } from "@/src/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
|
||||
|
||||
export type DateRangeValue = { from?: Date; to?: Date };
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
|
||||
const DateRangePicker = ({ value, onChange }: DateRangePickerProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const label =
|
||||
value.from && value.to
|
||||
? `${formatDate(value.from)} – ${formatDate(value.to)}`
|
||||
: value.from
|
||||
? `Ab ${formatDate(value.from)}`
|
||||
: "Zeitraum";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start gap-2 bg-white/60 backdrop-blur-xl border-white/40 font-normal"
|
||||
>
|
||||
<CalendarIcon size={16} />
|
||||
{label}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={value as DateRange}
|
||||
onSelect={(range) => onChange(range ?? {})}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
{(value.from || value.to) && (
|
||||
<div className="p-2 border-t border-border">
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => onChange({})}>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangePicker;
|
||||
130
src/modules/zahlungen/ui/components/invoice-pdf-document.tsx
Normal file
130
src/modules/zahlungen/ui/components/invoice-pdf-document.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
|
||||
import type { InvoiceData } from "../../actions/invoice-actions";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 48, fontSize: 10, color: "#1a1a1a", fontFamily: "Helvetica" },
|
||||
header: { marginBottom: 32 },
|
||||
businessName: { fontSize: 14, fontWeight: 700, marginBottom: 4 },
|
||||
muted: { color: "#666666" },
|
||||
title: { fontSize: 18, fontWeight: 700, marginTop: 32, marginBottom: 4 },
|
||||
metaRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
|
||||
metaBlock: { flexDirection: "column" },
|
||||
label: { fontSize: 8, color: "#999999", textTransform: "uppercase", marginBottom: 2 },
|
||||
value: { fontSize: 10, marginBottom: 8 },
|
||||
table: { marginTop: 16, borderTop: "1px solid #e0e0e0" },
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottom: "1px solid #e0e0e0",
|
||||
paddingVertical: 8,
|
||||
},
|
||||
tableHeaderRow: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 8,
|
||||
borderBottom: "1px solid #1a1a1a",
|
||||
},
|
||||
colDescription: { flex: 3 },
|
||||
colAmount: { flex: 1, textAlign: "right" },
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
paddingTop: 12,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
totalLabel: { fontSize: 11, fontWeight: 700, marginRight: 24 },
|
||||
totalValue: { fontSize: 11, fontWeight: 700 },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 48,
|
||||
left: 48,
|
||||
right: 48,
|
||||
borderTop: "1px solid #e0e0e0",
|
||||
paddingTop: 12,
|
||||
},
|
||||
footerText: { fontSize: 8, color: "#999999", marginBottom: 2 },
|
||||
});
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface InvoicePdfDocumentProps {
|
||||
data: InvoiceData;
|
||||
}
|
||||
|
||||
export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
|
||||
const { business, course, customer, invoiceNumber, issueDate, amountCents } = data;
|
||||
|
||||
const customerAddressLine =
|
||||
!customer.isGuest && customer.street && customer.plz && customer.city
|
||||
? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.businessName}>{business.name}</Text>
|
||||
<Text style={styles.muted}>{business.address}</Text>
|
||||
<Text style={styles.muted}>{business.email}</Text>
|
||||
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Rechnung</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaBlock}>
|
||||
<Text style={styles.label}>Rechnungsempfänger</Text>
|
||||
<Text style={styles.value}>{customer.name}</Text>
|
||||
<Text style={styles.value}>{customer.email}</Text>
|
||||
{customerAddressLine ? <Text style={styles.value}>{customerAddressLine}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.metaBlock}>
|
||||
<Text style={styles.label}>Rechnungsnummer</Text>
|
||||
<Text style={styles.value}>{invoiceNumber}</Text>
|
||||
<Text style={styles.label}>Rechnungsdatum</Text>
|
||||
<Text style={styles.value}>{formatDate(issueDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeaderRow}>
|
||||
<Text style={[styles.colDescription, { fontWeight: 700 }]}>Leistung</Text>
|
||||
<Text style={[styles.colAmount, { fontWeight: 700 }]}>Betrag</Text>
|
||||
</View>
|
||||
<View style={styles.tableRow}>
|
||||
<View style={styles.colDescription}>
|
||||
<Text>{course.name}</Text>
|
||||
<Text style={styles.muted}>Termin: {formatDate(course.date)}</Text>
|
||||
</View>
|
||||
<Text style={styles.colAmount}>{formatPrice(amountCents)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Gesamtbetrag</Text>
|
||||
<Text style={styles.totalValue}>{formatPrice(amountCents)}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}>
|
||||
{business.ustId
|
||||
? "Alle Preise inkl. gesetzlicher Umsatzsteuer."
|
||||
: "Gemäß §19 UStG (Kleinunternehmerregelung) wird keine Umsatzsteuer berechnet."}
|
||||
</Text>
|
||||
|
||||
<View style={styles.footer}>
|
||||
{business.banking ? (
|
||||
<Text style={styles.footerText}>Bankverbindung: {business.banking}</Text>
|
||||
) : null}
|
||||
<Text style={styles.footerText}>Diese Rechnung wurde automatisch generiert.</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
89
src/modules/zahlungen/ui/components/zahlungen-filter-bar.tsx
Normal file
89
src/modules/zahlungen/ui/components/zahlungen-filter-bar.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import DateRangePicker, { type DateRangeValue } from "./date-range-picker";
|
||||
import type { ZahlungStatus } from "../../actions/zahlungen-actions";
|
||||
|
||||
export type ZahlungenFilterState = {
|
||||
search: string;
|
||||
status: ZahlungStatus | "all";
|
||||
dateRange: DateRangeValue;
|
||||
businessId: string | "all";
|
||||
};
|
||||
|
||||
interface ZahlungenFilterBarProps {
|
||||
filters: ZahlungenFilterState;
|
||||
onChange: (filters: ZahlungenFilterState) => void;
|
||||
businesses: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const ZahlungenFilterBar = ({ filters, onChange, businesses }: ZahlungenFilterBarProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:flex-wrap">
|
||||
<div className="relative w-full sm:w-60">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none text-muted-foreground"
|
||||
size={16}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Kunde suchen"
|
||||
className="pl-9 bg-white/60 backdrop-blur-xl border-white/40 supports-backdrop-filter:bg-white/50 placeholder:text-gray-400 focus-visible:ring-white/40 focus-visible:bg-white/80"
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(status) =>
|
||||
onChange({ ...filters, status: status as ZahlungenFilterState["status"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-40">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Status</SelectItem>
|
||||
<SelectItem value="paid">Bezahlt</SelectItem>
|
||||
<SelectItem value="pending">Ausstehend</SelectItem>
|
||||
<SelectItem value="refunded">Storniert</SelectItem>
|
||||
<SelectItem value="failed">Fehlgeschlagen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DateRangePicker
|
||||
value={filters.dateRange}
|
||||
onChange={(dateRange) => onChange({ ...filters, dateRange })}
|
||||
/>
|
||||
|
||||
{businesses.length > 1 && (
|
||||
<Select
|
||||
value={filters.businessId}
|
||||
onValueChange={(businessId) => onChange({ ...filters, businessId })}
|
||||
>
|
||||
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-48">
|
||||
<SelectValue placeholder="Business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Businesses</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungenFilterBar;
|
||||
49
src/modules/zahlungen/ui/components/zahlungen-metrics.tsx
Normal file
49
src/modules/zahlungen/ui/components/zahlungen-metrics.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { TrendingUp, Clock, CheckCircle2 } from "lucide-react";
|
||||
import type { ZahlungMetrics } from "../../actions/zahlungen-metrics-actions";
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
|
||||
}
|
||||
|
||||
interface ZahlungenMetricsProps {
|
||||
metrics: ZahlungMetrics;
|
||||
}
|
||||
|
||||
const ZahlungenMetrics = ({ metrics }: ZahlungenMetricsProps) => {
|
||||
const tiles = [
|
||||
{
|
||||
label: "Umsatz diesen Monat",
|
||||
value: formatPrice(metrics.revenueThisMonth),
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: "Ausstehende Zahlungen",
|
||||
value: `${metrics.pendingCount} · ${formatPrice(metrics.pendingAmount)}`,
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
label: "Bezahlte Buchungen",
|
||||
value: String(metrics.paidCount),
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
{tiles.map((tile) => (
|
||||
<div
|
||||
key={tile.label}
|
||||
className="rounded-xl border border-white/40 bg-white/60 backdrop-blur-xl p-4 shadow-sm supports-backdrop-filter:bg-white/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-500 mb-2">
|
||||
<tile.icon size={14} />
|
||||
<span className="text-xs font-medium">{tile.label}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">{tile.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungenMetrics;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: string }> = {
|
||||
paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" },
|
||||
pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" },
|
||||
refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
|
||||
failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" },
|
||||
};
|
||||
|
||||
interface ZahlungStatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
const ZahlungStatusBadge = ({ status }: ZahlungStatusBadgeProps) => {
|
||||
const style = PAYMENT_STATUS[status] ?? PAYMENT_STATUS.pending;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block text-xs font-semibold px-2.5 py-1 rounded-full ${style.bg} ${style.text}`}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungStatusBadge;
|
||||
166
src/modules/zahlungen/ui/components/zahlungen-table.tsx
Normal file
166
src/modules/zahlungen/ui/components/zahlungen-table.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/src/components/ui/table";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Calendar, User, Dumbbell, Building2, Wallet, Tag, Download } from "lucide-react";
|
||||
import ZahlungStatusBadge from "./zahlungen-status-badge";
|
||||
import type { ZahlungRow } from "../../actions/zahlungen-actions";
|
||||
|
||||
function formatPrice(cents: number | null): string {
|
||||
if (cents === null) return "–";
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface ZahlungenTableProps {
|
||||
zahlungen: ZahlungRow[];
|
||||
showBusinessColumn: boolean;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
const ZahlungenTable = ({
|
||||
zahlungen,
|
||||
showBusinessColumn,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: ZahlungenTableProps) => {
|
||||
const columnCount = showBusinessColumn ? 7 : 6;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl overflow-hidden border border-white/40 bg-white/60 backdrop-blur-xl shadow-sm supports-backdrop-filter:bg-white/50">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-white/30 hover:bg-white/30 border-b-0">
|
||||
<TableHead className="font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar size={14} className="text-gray-400" />
|
||||
Datum
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<User size={14} className="text-gray-400" />
|
||||
Kunde
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Dumbbell size={14} className="text-gray-400" />
|
||||
Kurs
|
||||
</span>
|
||||
</TableHead>
|
||||
{showBusinessColumn && (
|
||||
<TableHead className="hidden lg:table-cell font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 size={14} className="text-gray-400" />
|
||||
Business
|
||||
</span>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Wallet size={14} className="text-gray-400" />
|
||||
Betrag
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag size={14} className="text-gray-400" />
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
<tr aria-hidden>
|
||||
<td colSpan={columnCount} className="p-0">
|
||||
<hr className="border-none bg-linear-to-r from-transparent via-gray-300/70 to-transparent" />
|
||||
</td>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zahlungen.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount} className="text-center py-12 text-muted-foreground">
|
||||
Keine Zahlungen gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
zahlungen.map((z) => (
|
||||
<TableRow
|
||||
key={z.id}
|
||||
className="border-b border-white/30 transition-colors duration-150 hover:bg-white/70"
|
||||
>
|
||||
<TableCell className="text-muted-foreground">{formatDate(z.createdAt)}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<span className="flex flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
{z.customerName}
|
||||
{z.isGuest && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
|
||||
Gast
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{z.customerEmail}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{z.courseName}
|
||||
</TableCell>
|
||||
{showBusinessColumn && (
|
||||
<TableCell className="hidden lg:table-cell text-muted-foreground">
|
||||
{z.businessName}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{formatPrice(z.amountPaid)}</TableCell>
|
||||
<TableCell>
|
||||
<ZahlungStatusBadge status={z.paymentStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{z.paymentStatus === "paid" && (
|
||||
<a href={`/api/zahlungen/${z.id}/invoice`} download>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-white/60"
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center p-4 border-t border-white/30">
|
||||
<Button variant="outline" onClick={onLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Lädt…" : "Mehr laden"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungenTable;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileArchive } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import type { ZahlungenFilterState } from "./zahlungen-filter-bar";
|
||||
|
||||
interface ZahlungenZipDownloadButtonProps {
|
||||
filters: ZahlungenFilterState;
|
||||
}
|
||||
|
||||
function buildQueryString(filters: ZahlungenFilterState): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status !== "all") params.set("status", filters.status);
|
||||
if (filters.businessId !== "all") params.set("businessId", filters.businessId);
|
||||
if (filters.search) params.set("customerQuery", filters.search);
|
||||
if (filters.dateRange.from) params.set("dateFrom", filters.dateRange.from.toISOString());
|
||||
if (filters.dateRange.to) params.set("dateTo", filters.dateRange.to.toISOString());
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
const ZahlungenZipDownloadButton = ({ filters }: ZahlungenZipDownloadButtonProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = buildQueryString(filters);
|
||||
const response = await fetch(`/api/zahlungen/invoices/zip${query ? `?${query}` : ""}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
toast.error(body?.error ?? "Rechnungen konnten nicht gesammelt werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `Rechnungen-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Rechnungen konnten nicht gesammelt werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-white/60 backdrop-blur-xl border-white/40"
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
>
|
||||
<FileArchive size={16} />
|
||||
{loading ? "Erstellt ZIP…" : "Alle Rechnungen (ZIP)"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungenZipDownloadButton;
|
||||
152
src/modules/zahlungen/ui/views/zahlungen-view.tsx
Normal file
152
src/modules/zahlungen/ui/views/zahlungen-view.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import ZahlungenMetrics from "../components/zahlungen-metrics";
|
||||
import ZahlungenFilterBar, { type ZahlungenFilterState } from "../components/zahlungen-filter-bar";
|
||||
import ZahlungenTable from "../components/zahlungen-table";
|
||||
import ZahlungenZipDownloadButton from "../components/zahlungen-zip-download-button";
|
||||
import { getZahlungen, type ZahlungRow } from "../../actions/zahlungen-actions";
|
||||
import { getZahlungMetrics, type ZahlungMetrics } from "../../actions/zahlungen-metrics-actions";
|
||||
|
||||
interface ZahlungenViewProps {
|
||||
initialZahlungen: ZahlungRow[];
|
||||
initialCursor: string | null;
|
||||
initialMetrics: ZahlungMetrics;
|
||||
businesses: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
// Freies Tippen im Suchfeld soll erst nach einer Tipp-Pause eine DB-Abfrage auslösen,
|
||||
// damit nicht bei jedem Buchstaben eine Query läuft. Status/Zeitraum/Business sind
|
||||
// diskrete Klicks (kein "Tippen"), die sollen sofort greifen.
|
||||
const SEARCH_DEBOUNCE_MS = 600;
|
||||
|
||||
const ZahlungenView = ({
|
||||
initialZahlungen,
|
||||
initialCursor,
|
||||
initialMetrics,
|
||||
businesses,
|
||||
}: ZahlungenViewProps) => {
|
||||
const [filters, setFilters] = useState<ZahlungenFilterState>({
|
||||
search: "",
|
||||
status: "all",
|
||||
dateRange: {},
|
||||
businessId: "all",
|
||||
});
|
||||
// Die tatsächlich für Queries verwendeten Filter: Status/Zeitraum/Business werden
|
||||
// direkt beim Ändern übernommen (siehe handleFilterChange), "search" erst nach
|
||||
// der Tipp-Pause (siehe Effect unten).
|
||||
const [queryFilters, setQueryFilters] = useState(filters);
|
||||
const [zahlungen, setZahlungen] = useState(initialZahlungen);
|
||||
const [cursor, setCursor] = useState(initialCursor);
|
||||
const [metrics, setMetrics] = useState(initialMetrics);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const handleFilterChange = (next: ZahlungenFilterState) => {
|
||||
setFilters(next);
|
||||
// Nicht-Text-Filter sind diskrete Klicks (kein "Tippen") und lösen sofort
|
||||
// eine neue Abfrage aus; nur "search" läuft über den debounced Effect unten.
|
||||
setQueryFilters((prev) => ({
|
||||
...prev,
|
||||
status: next.status,
|
||||
dateRange: next.dateRange,
|
||||
businessId: next.businessId,
|
||||
}));
|
||||
};
|
||||
|
||||
// Suchtext: erst nach Tipp-Pause übernehmen.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setQueryFilters((prev) => ({ ...prev, search: filters.search }));
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [filters.search]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(async () => {
|
||||
const businessId = queryFilters.businessId !== "all" ? queryFilters.businessId : undefined;
|
||||
const status = queryFilters.status !== "all" ? queryFilters.status : undefined;
|
||||
|
||||
const [zahlungenResult, metricsResult] = await Promise.all([
|
||||
getZahlungen({
|
||||
status,
|
||||
businessId,
|
||||
customerQuery: queryFilters.search || undefined,
|
||||
dateFrom: queryFilters.dateRange.from?.toISOString(),
|
||||
dateTo: queryFilters.dateRange.to?.toISOString(),
|
||||
}),
|
||||
getZahlungMetrics({ businessId }),
|
||||
]);
|
||||
|
||||
if (zahlungenResult.success) {
|
||||
setZahlungen(zahlungenResult.zahlungen);
|
||||
setCursor(zahlungenResult.nextCursor);
|
||||
} else {
|
||||
toast.error(zahlungenResult.error);
|
||||
}
|
||||
|
||||
if (metricsResult.success) {
|
||||
setMetrics(metricsResult.metrics);
|
||||
}
|
||||
});
|
||||
}, [queryFilters]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const businessId = queryFilters.businessId !== "all" ? queryFilters.businessId : undefined;
|
||||
const status = queryFilters.status !== "all" ? queryFilters.status : undefined;
|
||||
|
||||
const result = await getZahlungen({
|
||||
status,
|
||||
businessId,
|
||||
customerQuery: queryFilters.search || undefined,
|
||||
dateFrom: queryFilters.dateRange.from?.toISOString(),
|
||||
dateTo: queryFilters.dateRange.to?.toISOString(),
|
||||
cursor,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setZahlungen((prev) => [...prev, ...result.zahlungen]);
|
||||
setCursor(result.nextCursor);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const showBusinessColumn = businesses.length > 1;
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-4xl font-bold">Zahlungen</h1>
|
||||
</div>
|
||||
|
||||
<ZahlungenMetrics metrics={metrics} />
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<ZahlungenFilterBar
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
businesses={businesses}
|
||||
/>
|
||||
<ZahlungenZipDownloadButton filters={queryFilters} />
|
||||
</div>
|
||||
|
||||
<div className={isPending ? "opacity-60 transition-opacity" : "transition-opacity"}>
|
||||
<ZahlungenTable
|
||||
zahlungen={zahlungen}
|
||||
showBusinessColumn={showBusinessColumn}
|
||||
hasMore={cursor !== null}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZahlungenView;
|
||||
Loading…
Add table
Add a link
Reference in a new issue