first commit
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user