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:
@@ -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,6 +116,13 @@ function Row({
|
||||
{isPending ? "Onay bekliyor" : isRejected ? "Reddedildi" : "Onaylandı"}
|
||||
</Badge>
|
||||
</div>
|
||||
<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} />
|
||||
@@ -124,6 +132,7 @@ function Row({
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
+23
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user