feat(finance): printable receipt page for a payment

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.
This commit is contained in:
kovakmedya
2026-05-22 16:12:09 +03:00
parent 88a42c9d06
commit 353d93ad56
3 changed files with 228 additions and 10 deletions
@@ -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ı"}
</Badge>
</div>
{isPending && (
<form action={action}>
<input type="hidden" name="id" value={payment.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Geri al
</Button>
</form>
)}
<div className="flex gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/finance/payments/${payment.$id}/receipt`}>
<FileText className="size-4" />
Makbuz
</Link>
</Button>
{isPending && (
<form action={action}>
<input type="hidden" name="id" value={payment.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Geri al
</Button>
</form>
)}
</div>
</li>
);
}
@@ -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 (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 print:hidden">
<Button asChild variant="outline" size="sm">
<Link href="/finance">
<ArrowLeft className="size-4" />
Finansa dön
</Link>
</Button>
<Button onClick={() => window.print()} size="sm">
<Printer className="size-4" />
Yazdır / PDF
</Button>
</div>
);
}
@@ -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<TenantSettings | null> {
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 (
<div className="bg-muted/40 min-h-screen px-6 py-8 print:bg-white print:p-0">
<div className="mx-auto max-w-2xl">
<ReceiptControls />
<article className="bg-card text-card-foreground rounded-lg border p-8 shadow-sm print:rounded-none print:border-0 print:shadow-none">
<header className="border-b pb-4">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
Tahsilat Makbuzu
</p>
<h1 className="text-2xl font-bold tracking-tight">
{lab?.companyName ?? "Laboratuvar"}
</h1>
{lab?.companyTaxId && (
<p className="text-muted-foreground text-sm">VKN: {lab.companyTaxId}</p>
)}
{lab?.companyAddress && (
<p className="text-muted-foreground whitespace-pre-wrap text-sm">
{lab.companyAddress}
</p>
)}
</header>
<section className="grid gap-4 py-6 sm:grid-cols-2">
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Tahsil edilen
</p>
<p className="font-medium">{clinic?.companyName ?? "Klinik"}</p>
{clinic?.companyTaxId && (
<p className="text-muted-foreground text-xs">VKN: {clinic.companyTaxId}</p>
)}
</div>
<div className="sm:text-right">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Ödeme tarihi
</p>
<p className="font-medium">
{dateFormatter.format(new Date(payment.paymentDate))}
</p>
</div>
</section>
<section className="border-y py-6">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Tutar
</p>
<p className="text-3xl font-semibold tabular-nums">
{formatMoney(payment.amount, payment.currency)}
</p>
</section>
<section className="grid gap-4 py-6 sm:grid-cols-2 text-sm">
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Ödeme yöntemi
</p>
<p>
{payment.method
? (PAYMENT_METHOD_LABELS[payment.method] ?? payment.method)
: "—"}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Durum
</p>
<p>{statusLabel}</p>
</div>
{payment.notes && (
<div className="sm:col-span-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Not
</p>
<p className="whitespace-pre-wrap">{payment.notes}</p>
</div>
)}
</section>
<footer className="text-muted-foreground border-t pt-4 text-xs">
Makbuz no: {payment.$id} · Düzenlendi: {dateFormatter.format(new Date(payment.$createdAt))}
</footer>
</article>
</div>
</div>
);
}