From 353d93ad560695a97d057b028e38905fb8eb1e13 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 16:12:09 +0300 Subject: [PATCH] feat(finance): printable receipt page for a payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clinics' accountants want a paper / PDF for every payment they record. Rather than pull in a PDF lib (jspdf / react-pdf etc.) and ship another ~150KB to every user, did this with print-CSS: a server-rendered receipt page that prints cleanly. - /finance/payments/[paymentId]/receipt: server component, loads the Payment row, refuses 404/notFound unless the viewer is one of the two parties on it. Resolves lab vs clinic by direction (inflow ⇒ tenant is lab) and pulls each side's tenant_settings (companyName, taxId, address) for the header. - Layout: card with header (lab name + VKN + address), two-column block (tahsil edilen / ödeme tarihi), bold amount, method + status row, optional note. Footer shows the receipt id + creation date. - ReceiptControls (client island): back-to-finance button and a 'Yazdır / PDF' button calling window.print(). Both hidden in print via 'print:hidden'. - my-pending-payments-card gets a 'Makbuz' link per row alongside 'Geri al', so a clinic can grab a printable copy of any payment they've submitted — pending or confirmed. --- .../components/my-pending-payments-card.tsx | 29 ++- .../receipt/components/receipt-controls.tsx | 23 +++ .../payments/[paymentId]/receipt/page.tsx | 186 ++++++++++++++++++ 3 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/app/(dashboard)/finance/payments/[paymentId]/receipt/components/receipt-controls.tsx create mode 100644 src/app/(dashboard)/finance/payments/[paymentId]/receipt/page.tsx diff --git a/src/app/(dashboard)/finance/components/my-pending-payments-card.tsx b/src/app/(dashboard)/finance/components/my-pending-payments-card.tsx index a832786..845fdef 100644 --- a/src/app/(dashboard)/finance/components/my-pending-payments-card.tsx +++ b/src/app/(dashboard)/finance/components/my-pending-payments-card.tsx @@ -1,8 +1,9 @@ "use client"; import { useActionState, useEffect } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Loader2, Trash2 } from "lucide-react"; +import { FileText, Loader2, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -115,15 +116,23 @@ function Row({ {isPending ? "Onay bekliyor" : isRejected ? "Reddedildi" : "Onaylandı"} - {isPending && ( -
- - -
- )} +
+ + {isPending && ( +
+ + +
+ )} +
); } diff --git a/src/app/(dashboard)/finance/payments/[paymentId]/receipt/components/receipt-controls.tsx b/src/app/(dashboard)/finance/payments/[paymentId]/receipt/components/receipt-controls.tsx new file mode 100644 index 0000000..290e568 --- /dev/null +++ b/src/app/(dashboard)/finance/payments/[paymentId]/receipt/components/receipt-controls.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, Printer } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +export function ReceiptControls() { + return ( +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/finance/payments/[paymentId]/receipt/page.tsx b/src/app/(dashboard)/finance/payments/[paymentId]/receipt/page.tsx new file mode 100644 index 0000000..b196808 --- /dev/null +++ b/src/app/(dashboard)/finance/payments/[paymentId]/receipt/page.tsx @@ -0,0 +1,186 @@ +import { notFound, redirect } from "next/navigation"; +import { Query } from "node-appwrite"; + +import { ReceiptControls } from "./components/receipt-controls"; +import { + DATABASE_ID, + TABLES, + type Payment, + type TenantSettings, +} from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { PAYMENT_METHOD_LABELS } from "@/lib/appwrite/payment-types"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; + +export const metadata = { + title: "DLS — Makbuz", +}; + +const dateFormatter = new Intl.DateTimeFormat("tr-TR", { + day: "2-digit", + month: "long", + year: "numeric", +}); + +function formatMoney(amount: number, currency: string): string { + try { + return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount); + } catch { + return `${amount.toFixed(2)} ${currency}`; + } +} + +async function loadTenantSettings(tenantId: string): Promise { + const { tablesDB } = createAdminClient(); + try { + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + return (result.rows[0] as unknown as TenantSettings) ?? null; + } catch { + return null; + } +} + +export default async function PaymentReceiptPage({ + params, +}: { + params: Promise<{ paymentId: string }>; +}) { + const { paymentId } = await params; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const { tablesDB } = createAdminClient(); + let payment: Payment; + try { + payment = (await tablesDB.getRow( + DATABASE_ID, + TABLES.payments, + paymentId, + )) as unknown as Payment; + } catch { + notFound(); + } + + // Only the two parties can see the receipt. + if ( + payment.tenantId !== ctx.tenantId && + payment.counterpartTenantId !== ctx.tenantId + ) { + notFound(); + } + + // 'inflow' means the lab received money from the clinic. + // From the row alone we know whose tenantId is which side because the lab + // always issues inflow and the clinic outflow. Resolve them so the + // receipt header reads naturally regardless of who recorded the row. + const labId = payment.direction === "inflow" ? payment.tenantId : payment.counterpartTenantId; + const clinicId = payment.direction === "inflow" ? payment.counterpartTenantId : payment.tenantId; + const [lab, clinic] = await Promise.all([ + loadTenantSettings(labId), + loadTenantSettings(clinicId), + ]); + + const statusLabel = + payment.status === "confirmed" + ? "Onaylı" + : payment.status === "pending" + ? "Onay bekliyor" + : payment.status === "rejected" + ? "Reddedildi" + : "—"; + + return ( +
+
+ +
+
+

+ Tahsilat Makbuzu +

+

+ {lab?.companyName ?? "Laboratuvar"} +

+ {lab?.companyTaxId && ( +

VKN: {lab.companyTaxId}

+ )} + {lab?.companyAddress && ( +

+ {lab.companyAddress} +

+ )} +
+ +
+
+

+ Tahsil edilen +

+

{clinic?.companyName ?? "Klinik"}

+ {clinic?.companyTaxId && ( +

VKN: {clinic.companyTaxId}

+ )} +
+
+

+ Ödeme tarihi +

+

+ {dateFormatter.format(new Date(payment.paymentDate))} +

+
+
+ +
+

+ Tutar +

+

+ {formatMoney(payment.amount, payment.currency)} +

+
+ +
+
+

+ Ödeme yöntemi +

+

+ {payment.method + ? (PAYMENT_METHOD_LABELS[payment.method] ?? payment.method) + : "—"} +

+
+
+

+ Durum +

+

{statusLabel}

+
+ {payment.notes && ( +
+

+ Not +

+

{payment.notes}

+
+ )} +
+ + +
+
+
+ ); +}