358 lines
9.8 KiB
TypeScript
358 lines
9.8 KiB
TypeScript
import * as React from "react"
|
|
|
|
interface InvoiceAddress {
|
|
name: string
|
|
address: string[]
|
|
phone: string
|
|
email: string
|
|
}
|
|
|
|
interface InvoicePaymentRow {
|
|
label: string
|
|
amount: number
|
|
}
|
|
|
|
interface InvoicePayment {
|
|
subtotal: number
|
|
/**
|
|
* Empty when the plan is not split into installments.
|
|
* If populated, split-payment rows will be shown.
|
|
*/
|
|
payments: InvoicePaymentRow[]
|
|
totalPaid: number
|
|
balanceDue: number
|
|
}
|
|
|
|
interface GymInvoiceProps {
|
|
invoiceNo: string
|
|
invoiceDate: string
|
|
dueDate: string
|
|
/** Retained for API compatibility; the invoice no longer renders a logo. */
|
|
logoSrc?: string
|
|
logoAlt?: string
|
|
from: InvoiceAddress
|
|
billTo: InvoiceAddress
|
|
plan: {
|
|
gymPlanName: string
|
|
type: string
|
|
memberName: string
|
|
validityPeriod: string
|
|
}
|
|
payment: InvoicePayment
|
|
paymentMethod: string
|
|
preparedBy: string
|
|
notes: string[]
|
|
footerItems: string[]
|
|
className?: string
|
|
formatAmount: (amount: number) => string
|
|
|
|
/**
|
|
* This ref now captures the full A4 invoice page.
|
|
* The PDF download uses this exact node.
|
|
*/
|
|
bodyRef?: React.Ref<HTMLDivElement>
|
|
|
|
/**
|
|
* Kept for compatibility with existing caller.
|
|
* The footer is still inside the same invoice page.
|
|
*/
|
|
footerRef?: React.Ref<HTMLDivElement>
|
|
}
|
|
|
|
function GymInvoice({
|
|
invoiceNo,
|
|
invoiceDate,
|
|
dueDate,
|
|
from,
|
|
billTo,
|
|
plan,
|
|
payment,
|
|
paymentMethod,
|
|
preparedBy,
|
|
notes,
|
|
footerItems,
|
|
formatAmount,
|
|
bodyRef,
|
|
footerRef,
|
|
className = "",
|
|
}: GymInvoiceProps) {
|
|
return (
|
|
<div className={`mx-auto flex flex-col overflow-hidden bg-[#f4f4f4] px-6 py-6 font-sans ${className}`}>
|
|
<div
|
|
className="
|
|
mx-auto
|
|
h-[calc(1123px*var(--invoice-preview-scale))]
|
|
w-[calc(794px*var(--invoice-preview-scale))]
|
|
[--invoice-preview-scale:0.46]
|
|
sm:[--invoice-preview-scale:0.62]
|
|
md:[--invoice-preview-scale:1]
|
|
"
|
|
>
|
|
<div
|
|
className="
|
|
origin-top-left
|
|
transform-[scale(var(--invoice-preview-scale))]
|
|
"
|
|
>
|
|
<div
|
|
ref={bodyRef}
|
|
className="mx-auto flex h-280.75 w-198.5 flex-col overflow-hidden bg-white px-13 pt-10 pb-8 text-[#111111] font-sans"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<h1 className="text-[40px] leading-none font-extrabold tracking-[2px] uppercase">
|
|
Invoice
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Invoice meta */}
|
|
<div className="mt-7 grid gap-3">
|
|
<MetaRow label="Invoice No." value={invoiceNo} />
|
|
<MetaRow label="Invoice Date" value={invoiceDate} />
|
|
<MetaRow label="Due Date" value={dueDate} />
|
|
</div>
|
|
|
|
<Divider className="mt-7" />
|
|
|
|
{/* From / Bill To */}
|
|
<div className="mt-8 grid grid-cols-2 gap-16">
|
|
<InfoBlock title="From" data={from} />
|
|
<InfoBlock title="Bill To" data={billTo} />
|
|
</div>
|
|
|
|
{/* Membership / Service Details */}
|
|
<div className="mt-8">
|
|
<SectionTitle title="Membership / Service Details" />
|
|
|
|
<div className="mt-6 border-t-2 border-b border-t-[#111111] border-b-[#d1d1d1]">
|
|
<div className="grid grid-cols-[30%_18%_26%_26%] border-b-[1.5px] border-b-[#111111] [&>div:not(:last-child)]:border-r [&>div:not(:last-child)]:border-r-[#d8d8d8]">
|
|
<TableHeadCell>Gym Plan Name</TableHeadCell>
|
|
<TableHeadCell>Type</TableHeadCell>
|
|
<TableHeadCell>Name of Member(s)</TableHeadCell>
|
|
<TableHeadCell>Expiry Date / Validity Period</TableHeadCell>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[30%_18%_26%_26%] [&>div:not(:last-child)]:border-r [&>div:not(:last-child)]:border-r-[#d8d8d8]">
|
|
<TableCell>{plan.gymPlanName}</TableCell>
|
|
<TableCell>{plan.type}</TableCell>
|
|
<TableCell>{plan.memberName}</TableCell>
|
|
<TableCell>{plan.validityPeriod}</TableCell>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes / Terms + Payment Summary */}
|
|
<div className="mt-8 grid grid-cols-[1fr_300px] items-start gap-10">
|
|
<div>
|
|
<SectionTitle title="Notes / Terms" />
|
|
|
|
<div className="mt-6 [&>p+p]:mt-4 [&>p:last-child]:border-b-0 [&>p:last-child]:pb-0">
|
|
{notes.map((note, i) => (
|
|
<p
|
|
key={i}
|
|
className="m-0 border-b border-b-[#d1d1d1] pb-3 text-[13px] leading-[1.45]"
|
|
>
|
|
{note}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<PaymentSummary payment={payment} formatAmount={formatAmount} />
|
|
</div>
|
|
|
|
{/* Payment Method / Prepared By */}
|
|
<div className="mt-8 grid grid-cols-2 gap-16">
|
|
<div>
|
|
<SectionTitle title="Payment Method" />
|
|
<p className="mt-5 text-[14px]">{paymentMethod || "-"}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<SectionTitle title="Prepared By" />
|
|
<p className="mt-5 text-[14px]">{preparedBy || "-"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer stays inside the same one-page A4 receipt */}
|
|
<div ref={footerRef} className="mt-auto bg-white pt-6">
|
|
<Divider />
|
|
|
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-5 text-[13px] text-[#555555]">
|
|
{footerItems.map((item, i) => (
|
|
<span key={i} className="flex items-center gap-5">
|
|
{i > 0 && <span aria-hidden>|</span>}
|
|
{item}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Divider({ className = "" }: { className?: string }) {
|
|
return (
|
|
<div
|
|
className={`h-1.5 border-t-2 border-b-[3px] border-t-[#111111] border-b-[#8e1821] ${className}`}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="grid grid-cols-[130px_16px_1fr] items-center gap-x-3">
|
|
<span className="text-[13px] font-extrabold uppercase">{label}</span>
|
|
<span className="text-[13px]">:</span>
|
|
<p className="m-0 text-[14px]">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SectionTitle({ title }: { title: string }) {
|
|
return (
|
|
<div>
|
|
<h2 className="m-0 text-[16px] font-extrabold tracking-[0.2px]">
|
|
{title}
|
|
</h2>
|
|
<span className="mt-1 block h-0.75 w-12 bg-[#8e1821]" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoBlock({ title, data }: { title: string; data: InvoiceAddress }) {
|
|
return (
|
|
<div className="min-w-0">
|
|
<SectionTitle title={title} />
|
|
|
|
<div className="mt-6">
|
|
<h3 className="m-0 mb-1.75 text-[14px] font-extrabold">
|
|
{data.name}
|
|
</h3>
|
|
|
|
{data.address.map((line, i) => (
|
|
<p
|
|
key={i}
|
|
className="m-0 mb-1.75 wrap-break-word text-[14px] leading-tight"
|
|
>
|
|
{line}
|
|
</p>
|
|
))}
|
|
|
|
<div className="mt-5">
|
|
<p className="m-0 mb-1.75 text-[14px] leading-tight">
|
|
{data.phone}
|
|
</p>
|
|
<p className="m-0 mb-1.75 wrap-break-word text-[14px] leading-tight">
|
|
{data.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TableHeadCell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="box-border flex min-h-14 items-center justify-center px-3 py-3.5 text-center text-[12px] leading-tight font-extrabold">
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TableCell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="box-border flex min-h-13.5 items-center justify-center wrap-break-word px-3 py-3.5 text-center text-[13px] leading-[1.35]">
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PaymentSummary({
|
|
payment,
|
|
formatAmount,
|
|
}: {
|
|
payment: InvoicePayment
|
|
formatAmount: (amount: number) => string
|
|
}) {
|
|
const hasBreakdown = payment.payments.length > 0
|
|
|
|
return (
|
|
<div className="border border-[#111111]">
|
|
<SummaryRow label="Subtotal" amount={formatAmount(payment.subtotal)} />
|
|
|
|
{hasBreakdown ? (
|
|
<>
|
|
{payment.payments.map((row, i) => (
|
|
<SummaryRow
|
|
key={i}
|
|
label={row.label}
|
|
amount={formatAmount(row.amount)}
|
|
/>
|
|
))}
|
|
|
|
<SummaryRow
|
|
label="Total Paid"
|
|
amount={formatAmount(payment.totalPaid)}
|
|
/>
|
|
|
|
<SummaryRow
|
|
label="Balance Due"
|
|
amount={formatAmount(payment.balanceDue)}
|
|
highlight
|
|
/>
|
|
</>
|
|
) : (
|
|
<SummaryRow
|
|
label="Total"
|
|
amount={formatAmount(payment.subtotal)}
|
|
highlight
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SummaryRow({
|
|
label,
|
|
amount,
|
|
highlight = false,
|
|
}: {
|
|
label: string
|
|
amount: string
|
|
highlight?: boolean
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`grid min-h-12 grid-cols-[1fr_auto] items-center gap-5 border-b border-b-[#d1d1d1] px-5 last:border-b-0 ${highlight
|
|
? "border-t-[1.5px] border-t-[#111111] bg-[#f7f7f7]"
|
|
: ""
|
|
}`}
|
|
>
|
|
<span
|
|
className={`font-extrabold uppercase ${highlight ? "text-[15px] font-black" : "text-[13px]"
|
|
}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
|
|
<strong
|
|
className={`font-bold ${highlight ? "text-[15px] font-black" : "text-[13px]"
|
|
}`}
|
|
>
|
|
{amount}
|
|
</strong>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export { GymInvoice }
|
|
export type {
|
|
GymInvoiceProps,
|
|
InvoiceAddress,
|
|
InvoicePayment,
|
|
InvoicePaymentRow,
|
|
} |