import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { pinned, type ColSpec } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { fitSize, FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Txt } from "../../blocks/Txt";
import { AccentRule } from "../../decor/AccentRule";
import type { TypeToken } from "../../fonts/roles";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import { includesName } from "../logoName";
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
import { LABEL_GAP, NOTES_FLOW_GAP, PARTY_GUTTER, payableLines, planMarble, REVERSE_GAP, signatureOptions, stepOf, taxLabel, type MarblePlan } from "./plan";
import { RULE, ZEBRA, type MarbleVariant } from "./tokens";
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
const NOTES_HOLD = 70;
/** Layout-tree ids of the blocks that are Marble's own (the harness ids are in classic/ids.ts). */
export const IDS_ID = "marble-ids";
export const PAYABLE_ID = "marble-payable";
export const BILL_ID = "marble-bill";
interface Ctx {
model: TemplateProps["model"];
plan: MarblePlan;
v: MarbleVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? : null;
}
function Rule({ ctx }: { ctx: Ctx }) {
return ;
}
/**
* One right-aligned line of tracked text. textkit adds the tracking after the last glyph too, so the box is
* widened by one step and pulled back with a negative margin: the ink then ends on the edge.
*/
function RightText({ role, color, width, children }: { role: TypeToken; color: string; width: number; children: string }) {
const step = stepOf(role);
const fit = fitSize(role, children, width + step);
if (!fit.fits) {
return (
{children}
);
}
const sized = { ...role, size: fit.size };
const s = stepOf(sized);
return (
{children}
);
}
// --- header -----------------------------------------------------------------------------------------------------
/** The vendor name (Lustria caps) or its logo; the design leaves this corner empty. */
function Brand({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
const hd = v.head;
return (
{logo ? (
) : null}
{h.showName ? (
{model.supplier.name}
) : null}
);
}
function Stack({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
const r = v.roles;
return (
{model.docTitle}
{h.lines.map((l, i) => (
{l.text}
))}
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
return (
);
}
// --- parties ----------------------------------------------------------------------------------------------------
function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
const c = model.client;
return (
Bill to:
{c.name}
{c.addressLines.map((line, i) => (
{line}
))}
{c.poNumber ? {`PO: ${c.poNumber}`} : null}
);
}
function PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
return (
Payable to:
{payableLines(model).map((line, i) => (
{line}
))}
);
}
function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
Supplier:
{s.pan ? (
{`PAN: ${s.pan}`}
) : null}
);
}
function Parties({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const cols = plan.info.cols;
const text = cols.map((w) => Math.max(40, w - PARTY_GUTTER));
const blocks: ReactNode[] = [];
let k = 1;
if (plan.info.hasBank) blocks.push();
if (plan.info.hasIds) blocks.push();
const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w }));
return (
{blocks}
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
const { plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const labelW = t.boxW - t.valueW;
return (
{label}
{amount}
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
return (
{t.stack.map((row, i) => (
))}
} />
{t.dueText}
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const o = v.totals;
return (
{t.reverseH > 0 ? 0 ? REVERSE_GAP : 0 }} /> : null}
);
}
// --- closing ----------------------------------------------------------------------------------------------------
/** Notes: a label held with the first line, every line unbreakable (a cut Text would leave a zero-line node). */
function Notes({ ctx, width }: { ctx: Ctx; width: number }) {
const { plan, v } = ctx;
const r = v.roles;
const lines = plan.closing.notes;
if (lines.length === 0) return null;
return (
Notes:
{lines[0]}
{lines.slice(1).map((line, i) => (
{line}
))}
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const cl = v.closing;
const c = plan.closing;
const opts = signatureOptions(v);
return (
<>
{c.notesBeside ? : null}
{model.cancelled ? (
{CANCELLED_SENTENCE}
) : null}
>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function MarbleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MarbleVariant }) {
const plan = planMarble(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const t = v.table;
const notesFlow: ReactNode =
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
<>
{/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */}
>
);
return (
<>
(i % 2 === 0 ? { backgroundColor: ZEBRA } : {})}
headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)}
headerTail={}
headerTailH={t.headGap}
tailH={plan.tailH}
tail={}
/>
{notesFlow}
>
);
}