add printing
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m49s

This commit is contained in:
sim committed 2026-10-20 21:33:45 +02:00
1 parent d3517d8f47
commit f2cbc07738
8 files changed
+637 -106

No files matched your search

+2 -1
View File
@@ -121,10 +121,11 @@ class OrderConfig(BaseModel):
class OrderTemplates(BaseModel):
"""Admin-only clipboard / email templating settings (validated by the frontend)."""
"""Admin-only clipboard / email / print templating settings (validated by the frontend)."""
model_config = {"extra": "allow"}
clipboard: dict[str, Any] = Field(default_factory=dict)
email: dict[str, Any] = Field(default_factory=dict)
print: dict[str, Any] = Field(default_factory=dict)
class UserProfileUpdateRequest(BaseModel):
@@ -28,6 +28,8 @@ import {
import {
BODY_PLACEHOLDERS,
DEFAULT_ENTRY_TEMPLATE,
DEFAULT_GROUP_PRINT_COLUMNS,
DEFAULT_PRINT_COLUMNS,
DEFAULT_GROUP_ENTRY_TEMPLATE,
ENTRY_PLACEHOLDERS,
GROUP_PLACEHOLDERS,
@@ -36,12 +38,15 @@ import {
buildSectionsPerCategory,
isGroupedMode,
renderEmail,
renderPrintTable,
renderTemplateText,
type TemplateContext,
} from "../../lib/orderTemplating";
import { PrintSheet } from "../orders/PrintSheet";
import type {
OrderFormConfig,
OrderTemplates,
PrintColumn,
TemplateGrouping,
TemplateListSettings,
TemplateRecipients,
@@ -209,10 +214,13 @@ function ListSettingsFields({
value,
config,
onChange,
textOutput = true,
}: {
value: TemplateListSettings;
config: OrderFormConfig;
onChange: (changes: Partial<TemplateListSettings>) => void;
// Text output (clipboard / email) has entry & section templates; tables define columns instead.
textOutput?: boolean;
}) {
const categoryOptions = config.categories.map((category) => ({ label: category.label, value: category.id }));
const grouped = isGroupedMode(value.grouping);
@@ -285,60 +293,142 @@ function ListSettingsFields({
config={config}
onChange={(sections) => onChange({ sections })}
/>
<Row gutter={12}>
<Col xs={24} md={16}>
<Form.Item label={<LabelWithPlaceholders label="Section template" items={SECTION_PLACEHOLDERS} />}>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
value={value.sectionTemplate}
onChange={(event) => onChange({ sectionTemplate: event.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="Section separator" tooltip="Use \n for a line break and \t for a tab.">
<Input
value={value.sectionSeparator}
placeholder="\n\n"
onChange={(event) => onChange({ sectionSeparator: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
{textOutput && (
<Row gutter={12}>
<Col xs={24} md={16}>
<Form.Item label={<LabelWithPlaceholders label="Section template" items={SECTION_PLACEHOLDERS} />}>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
value={value.sectionTemplate}
onChange={(event) => onChange({ sectionTemplate: event.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="Section separator" tooltip="Use \n for a line break and \t for a tab.">
<Input
value={value.sectionSeparator}
placeholder="\n\n"
onChange={(event) => onChange({ sectionSeparator: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
)}
</>
)}
<Row gutter={12}>
<Col xs={24} md={16}>
<Form.Item
label={
<LabelWithPlaceholders
label={grouped ? "Entry template (per group)" : "Entry template (per submission)"}
items={grouped ? GROUP_PLACEHOLDERS : ENTRY_PLACEHOLDERS}
{textOutput && (
<Row gutter={12}>
<Col xs={24} md={16}>
<Form.Item
label={
<LabelWithPlaceholders
label={grouped ? "Entry template (per group)" : "Entry template (per submission)"}
items={grouped ? GROUP_PLACEHOLDERS : ENTRY_PLACEHOLDERS}
/>
}
>
<Input
value={value.entryTemplate}
placeholder={grouped ? DEFAULT_GROUP_ENTRY_TEMPLATE : DEFAULT_ENTRY_TEMPLATE}
onChange={(event) => onChange({ entryTemplate: event.target.value })}
/>
}
>
<Input
value={value.entryTemplate}
placeholder={grouped ? DEFAULT_GROUP_ENTRY_TEMPLATE : DEFAULT_ENTRY_TEMPLATE}
onChange={(event) => onChange({ entryTemplate: event.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="Entry separator" tooltip="Use \n for a line break and \t for a tab.">
<Input
value={value.separator}
placeholder="\n"
onChange={(event) => onChange({ separator: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="Entry separator" tooltip="Use \n for a line break and \t for a tab.">
<Input
value={value.separator}
placeholder="\n"
onChange={(event) => onChange({ separator: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
)}
</>
);
}
function getDefaultPrintColumns(grouping: TemplateGrouping): PrintColumn[] {
return isGroupedMode(grouping) ? DEFAULT_GROUP_PRINT_COLUMNS : DEFAULT_PRINT_COLUMNS;
}
function PrintColumnsEditor({
columns,
grouping,
onChange,
}: {
columns: PrintColumn[];
grouping: TemplateGrouping;
onChange: (columns: PrintColumn[]) => void;
}) {
const grouped = isGroupedMode(grouping);
const updateColumn = (index: number, changes: Partial<PrintColumn>) =>
onChange(columns.map((column, i) => (i === index ? { ...column, ...changes } : column)));
const moveColumn = (index: number, offset: number) => {
const next = [...columns];
const [moved] = next.splice(index, 1);
next.splice(index + offset, 0, moved);
onChange(next);
};
return (
<Form.Item
label={
<LabelWithPlaceholders
label={grouped ? "Columns (per group)" : "Columns (per submission)"}
items={grouped ? GROUP_PLACEHOLDERS : ENTRY_PLACEHOLDERS}
/>
}
>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
{columns.map((column, index) => (
<Flex key={index} gap={8} wrap align="center">
<Input
value={column.header}
placeholder="Header"
style={{ flex: "1 1 120px" }}
onChange={(event) => updateColumn(index, { header: event.target.value })}
/>
<Input
value={column.template}
placeholder={grouped ? "{count}" : "{order}"}
style={{ flex: "2 1 200px" }}
onChange={(event) => updateColumn(index, { template: event.target.value })}
/>
<Space size={4}>
<Button size="small" icon={<ArrowUpOutlined />} disabled={index === 0} onClick={() => moveColumn(index, -1)} />
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={index === columns.length - 1}
onClick={() => moveColumn(index, 1)}
/>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => onChange(columns.filter((_, i) => i !== index))}
/>
</Space>
</Flex>
))}
<Flex gap={8} wrap>
<Button size="small" icon={<PlusOutlined />} onClick={() => onChange([...columns, { header: "", template: "" }])}>
Add column
</Button>
<Button size="small" onClick={() => onChange(getDefaultPrintColumns(grouping))}>
Reset to default columns
</Button>
</Flex>
</Space>
</Form.Item>
);
}
function RecipientsField({
label,
value,
@@ -415,7 +505,7 @@ export default function OrderTemplatesEditor({
const [draft, setDraft] = useState<OrderTemplates>(templates);
const [saving, setSaving] = useState(false);
const [activePanels, setActivePanels] = useState<string[]>(() =>
(["clipboard", "email"] as const).filter((key) => templates[key].enabled),
(["clipboard", "email", "print"] as const).filter((key) => templates[key].enabled),
);
useEffect(() => {
@@ -428,11 +518,23 @@ export default function OrderTemplatesEditor({
setDraft((previous) => ({ ...previous, clipboard: { ...previous.clipboard, ...changes } }));
const updateEmail = (changes: Partial<OrderTemplates["email"]>) =>
setDraft((previous) => ({ ...previous, email: { ...previous.email, ...changes } }));
const updatePrint = (changes: Partial<OrderTemplates["print"]>) =>
setDraft((previous) => {
const next = { ...previous.print, ...changes };
// Untouched default columns follow the entry mode (per submission vs. per group).
const usesDefaultColumns =
JSON.stringify(previous.print.columns) === JSON.stringify(getDefaultPrintColumns(previous.print.grouping));
if (changes.grouping && !changes.columns && usesDefaultColumns) {
next.columns = getDefaultPrintColumns(changes.grouping);
}
return { ...previous, print: next };
});
const clipboardPreview = previewContext
? renderTemplateText(draft.clipboard.body, draft.clipboard, previewContext)
: null;
const emailPreview = previewContext ? renderEmail(draft.email, previewContext) : null;
const printPreview = previewContext ? renderPrintTable(draft.print, previewContext) : null;
const handleSave = async () => {
setSaving(true);
@@ -449,7 +551,7 @@ export default function OrderTemplatesEditor({
<Alert
type="info"
showIcon
message="Templates shape the Copy and Email actions of the submissions list. The current search, status filter, sort order and selection apply."
message="Templates shape the Copy, Email and Print actions of the submissions list. The current search, status filter, sort order and selection apply."
/>
<Collapse
@@ -516,6 +618,55 @@ export default function OrderTemplatesEditor({
</>
),
},
{
key: "print",
label: "Print templating",
extra: <EnabledSwitch checked={draft.print.enabled} onChange={(enabled) => updatePrint({ enabled })} />,
children: (
<>
<ListSettingsFields value={draft.print} config={config} onChange={updatePrint} textOutput={false} />
<PrintColumnsEditor
columns={draft.print.columns}
grouping={draft.print.grouping}
onChange={(columns) => updatePrint({ columns })}
/>
<Row gutter={12}>
<Col xs={24} md={12}>
<Form.Item label={<LabelWithPlaceholders label="Title" items={BODY_PLACEHOLDERS} />}>
<Input value={draft.print.title} onChange={(event) => updatePrint({ title: event.target.value })} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={<LabelWithPlaceholders label="Subtitle" items={BODY_PLACEHOLDERS} />}>
<Input
value={draft.print.subtitle}
onChange={(event) => updatePrint({ subtitle: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
<Form.Item label={<LabelWithPlaceholders label="Footer" items={BODY_PLACEHOLDERS} />}>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 8 }}
value={draft.print.footer}
placeholder="Optional, e.g. {tally:Drink}"
onChange={(event) => updatePrint({ footer: event.target.value })}
/>
</Form.Item>
{printPreview && (
<Form.Item
label="Preview"
style={{ marginBottom: 0 }}
extra={draft.print.enabled ? undefined : "Templating is off, Print uses the default layout."}
>
<div className="print-preview">
<PrintSheet table={printPreview} />
</div>
</Form.Item>
)}
</>
),
},
]}
/>
@@ -0,0 +1,85 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import type { PrintTable } from "../../lib/orderTemplating";
// Columns whose cells are all short (index, qty, amount, check boxes) shrink to fit,
// so the remaining width goes to the long text columns.
function getCompactColumns(table: PrintTable): boolean[] {
return table.headers.map((header, columnIndex) => {
const cells = table.rows.flatMap((row) => (row.type === "row" ? [row.cells[columnIndex] || ""] : []));
return header.length <= 12 && cells.every((cell) => cell.length <= 12);
});
}
export function PrintSheet({ table }: { table: PrintTable }) {
const compactColumns = getCompactColumns(table);
const columnCount = Math.max(table.headers.length, 1);
return (
<div className="print-sheet">
{(table.title || table.subtitle) && (
<header className="print-sheet__header">
{table.title && <h1>{table.title}</h1>}
{table.subtitle && <p>{table.subtitle}</p>}
</header>
)}
<table>
<thead>
<tr>
{table.headers.map((header, index) => (
<th key={index} className={compactColumns[index] ? "print-sheet__compact" : undefined}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.length === 0 && (
<tr>
<td colSpan={columnCount} className="print-sheet__empty">
No entries
</td>
</tr>
)}
{table.rows.map((row, rowIndex) =>
row.type === "section" ? (
<tr key={rowIndex} className="print-sheet__section">
<th colSpan={columnCount}>{row.text}</th>
</tr>
) : (
<tr key={rowIndex}>
{row.cells.map((cell, cellIndex) => (
<td key={cellIndex} className={compactColumns[cellIndex] ? "print-sheet__compact" : undefined}>
{cell}
</td>
))}
</tr>
),
)}
</tbody>
</table>
{table.footer && <footer className="print-sheet__footer">{table.footer}</footer>}
</div>
);
}
// Renders the sheet into a print-only container, opens the browser print dialog and
// calls onDone once printing finished or was cancelled.
export function PrintJob({ table, onDone }: { table: PrintTable; onDone: () => void }) {
useEffect(() => {
window.addEventListener("afterprint", onDone, { once: true });
const frameId = window.requestAnimationFrame(() => window.print());
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener("afterprint", onDone);
};
}, [table, onDone]);
return createPortal(
<div className="print-root">
<PrintSheet table={table} />
</div>,
document.body,
);
}
@@ -5,6 +5,7 @@ import {
DeleteOutlined,
DownOutlined,
MailOutlined,
PrinterOutlined,
ReloadOutlined,
ShoppingOutlined,
} from "@ant-design/icons";
@@ -38,6 +39,7 @@ export default function AdminSubmissionsCard({
mailtoHref,
onOpenMailto,
onOpenDistribution,
onPrint,
selectedRowKeys,
onSelectedRowKeysChange,
submissions,
@@ -60,6 +62,7 @@ export default function AdminSubmissionsCard({
mailtoHref: string;
onOpenMailto: () => void;
onOpenDistribution: () => void;
onPrint: () => void;
selectedRowKeys: Array<string | number>;
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
submissions: Submission[];
@@ -105,6 +108,15 @@ export default function AdminSubmissionsCard({
</a>
),
},
{
key: "print",
label: (
<div onClick={onPrint} style={{ width: "100%" }}>
<PrinterOutlined style={{ marginRight: 8 }} />
{hasSelection ? "Print (selected)" : "Print"}
</div>
),
},
...(hasSelection
? [
{
+184 -56
View File
@@ -7,6 +7,8 @@ import type {
Submission,
SubmissionStatus,
TemplateGrouping,
PrintColumn,
PrintTemplateSettings,
TemplateListSettings,
TemplateRecipients,
TemplateRecipientSource,
@@ -29,6 +31,21 @@ const DEFAULT_LIST_SETTINGS: TemplateListSettings = {
separator: "\\n",
};
export const DEFAULT_PRINT_COLUMNS: PrintColumn[] = [
{ header: "#", template: "{index}" },
{ header: "Name", template: "{name}" },
{ header: "Email", template: "{email}" },
{ header: "Order", template: "{order}" },
{ header: "Amount", template: "{total}" },
];
export const DEFAULT_GROUP_PRINT_COLUMNS: PrintColumn[] = [
{ header: "Qty", template: "{count}" },
{ header: "Item", template: "{value}" },
{ header: "Names", template: "{emails}" },
{ header: "✓", template: "{box}" },
];
export const DEFAULT_ORDER_TEMPLATES: OrderTemplates = {
clipboard: {
...DEFAULT_LIST_SETTINGS,
@@ -45,6 +62,14 @@ export const DEFAULT_ORDER_TEMPLATES: OrderTemplates = {
body: "{entries}\n",
entryTemplate: "- {order}",
},
print: {
...DEFAULT_LIST_SETTINGS,
enabled: false,
title: "{title}",
subtitle: "Created by {creator} · {date} · {count} orders · {total}",
columns: DEFAULT_PRINT_COLUMNS,
footer: "",
},
};
// Which sort options make sense per entry mode.
@@ -59,8 +84,10 @@ export const ENTRY_PLACEHOLDERS = [
{ key: "{index}", description: "Running number (1, 2, …)" },
{ key: "{order}", description: "Formatted order text" },
{ key: "{email}", description: "Participant email" },
{ key: "{name}", description: "Name from the email, e.g. firstname.lastname@… → Firstname Lastname" },
{ key: "{total}", description: "Estimated amount" },
{ key: "{status}", description: "pending / unpaid / paid" },
{ key: "{box}", description: "Empty box ☐ to tick by hand" },
{ key: "{cat:Label}", description: "Selected value(s) of the category with that label" },
];
@@ -70,6 +97,7 @@ export const GROUP_PLACEHOLDERS = [
{ key: "{value}", description: "Grouped order text / category item" },
{ key: "{emails}", description: "Emails of the participants in this group" },
{ key: "{total}", description: "Estimated amount of the group" },
{ key: "{box}", description: "Empty box ☐ to tick by hand" },
];
export const SECTION_PLACEHOLDERS = [
@@ -151,6 +179,17 @@ function normalizeListSettings(raw: any, defaults: TemplateListSettings): Templa
};
}
function normalizePrintColumns(raw: unknown, defaults: PrintColumn[]): PrintColumn[] {
if (!Array.isArray(raw)) return defaults;
return raw
.filter((column) => column && typeof column === "object")
.map((column: any) => ({
header: asString(column.header, ""),
template: asString(column.template, ""),
}));
}
function normalizeRecipients(raw: any, defaults: TemplateRecipients): TemplateRecipients {
const sources = Array.isArray(raw?.sources)
? raw.sources.filter((source: unknown) => RECIPIENT_SOURCES.includes(source as TemplateRecipientSource))
@@ -166,6 +205,7 @@ export function normalizeOrderTemplates(raw: unknown): OrderTemplates {
const value = (raw && typeof raw === "object" ? raw : {}) as any;
const clipboardDefaults = DEFAULT_ORDER_TEMPLATES.clipboard;
const emailDefaults = DEFAULT_ORDER_TEMPLATES.email;
const printDefaults = DEFAULT_ORDER_TEMPLATES.print;
return {
clipboard: {
@@ -182,6 +222,14 @@ export function normalizeOrderTemplates(raw: unknown): OrderTemplates {
bcc: normalizeRecipients(value.email?.bcc, emailDefaults.bcc),
body: asString(value.email?.body, emailDefaults.body),
},
print: {
...normalizeListSettings(value.print, printDefaults),
enabled: !!value.print?.enabled,
title: asString(value.print?.title, printDefaults.title),
subtitle: asString(value.print?.subtitle, printDefaults.subtitle),
columns: normalizePrintColumns(value.print?.columns, printDefaults.columns),
footer: asString(value.print?.footer, printDefaults.footer),
},
};
}
@@ -213,6 +261,15 @@ function getEmail(submission: Submission): string {
return String(submission.email || "").trim();
}
function getNameFromEmail(email: string): string {
const localPart = email.split("@")[0] || "";
return localPart
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function getStatus(submission: Submission): SubmissionStatus {
if (submission.paid) return "paid";
if (submission.accepted) return "unpaid";
@@ -353,20 +410,46 @@ function getSectionItems(submission: Submission, categories: OrderFormCategory[]
return [{ value: parts.join(" "), amount: sumAmounts(amounts), rank: getItemRank(primary, primaryValues[0]) }];
}
const BOX_EMPTY = "☐";
type PlaceholderResolver = (key: string, arg: string | undefined) => string | undefined;
function groupResolver(group: Group, index: number): PlaceholderResolver {
return (key) => {
switch (key) {
case "index": return String(index + 1);
case "count": return String(group.count);
case "value": return group.value;
case "emails": return group.emails.join(", ");
case "total": return formatAmount(sumAmounts(group.amounts));
case "box": return BOX_EMPTY;
default: return undefined;
}
};
}
function submissionResolver(submission: Submission, index: number, context: TemplateContext): PlaceholderResolver {
return (key, arg) => {
switch (key) {
case "index": return String(index + 1);
case "order": return getOrderText(submission);
case "email": return getEmail(submission);
case "name": return getNameFromEmail(getEmail(submission));
case "total": return formatAmount(toNumber(submission.estimated_total));
case "status": return getStatus(submission);
case "box": return BOX_EMPTY;
case "cat": {
const category = arg ? findCategory(context.config, arg) : undefined;
return category ? getCategoryValues(submission, category).join(", ") : undefined;
}
default: return undefined;
}
};
}
function renderGroups(groups: Group[], entryTemplate: string): string[] {
return groups
.map((group, index) =>
replacePlaceholders(entryTemplate, (key) => {
switch (key) {
case "index": return String(index + 1);
case "count": return String(group.count);
case "value": return group.value;
case "emails": return group.emails.join(", ");
case "total": return formatAmount(sumAmounts(group.amounts));
default: return undefined;
}
}),
)
.map((group, index) => replacePlaceholders(entryTemplate, groupResolver(group, index)))
.filter((entry) => entry.trim());
}
@@ -391,39 +474,54 @@ function sortSubmissions(settings: TemplateListSettings, context: TemplateContex
return list;
}
function getPrintableSubmissions(settings: TemplateListSettings, context: TemplateContext): Submission[] {
return sortSubmissions(settings, context).filter((submission) => getOrderText(submission));
}
function renderSubmissionEntries(settings: TemplateListSettings, context: TemplateContext): string[] {
return sortSubmissions(settings, context)
.filter((submission) => getOrderText(submission))
return getPrintableSubmissions(settings, context)
.map((submission, index) =>
replacePlaceholders(settings.entryTemplate, (key, arg) => {
switch (key) {
case "index": return String(index + 1);
case "order": return getOrderText(submission);
case "email": return getEmail(submission);
case "total": return formatAmount(toNumber(submission.estimated_total));
case "status": return getStatus(submission);
case "cat": {
const category = arg ? findCategory(context.config, arg) : undefined;
return category ? getCategoryValues(submission, category).join(", ") : undefined;
}
default: return undefined;
}
}),
replacePlaceholders(settings.entryTemplate, submissionResolver(submission, index, context)),
)
.filter((entry) => entry.trim());
}
function renderSections(settings: TemplateListSettings, context: TemplateContext): string[] {
const entrySeparator = decodeSeparator(settings.separator);
function buildSectionGroups(settings: TemplateListSettings, context: TemplateContext) {
return settings.sections
.map((section) => {
const categories = section.categoryIds
.map((id) => findCategory(context.config, id))
.filter((category): category is OrderFormCategory => !!category);
const groups = collectGroups(context, settings.sortBy, (submission) => getSectionItems(submission, categories));
if (groups.length === 0) return "";
return { section, groups };
})
.filter(({ groups }) => groups.length > 0);
}
// Groups for the "order" / "category" modes; null for modes without a flat group list.
function buildFlatGroups(settings: TemplateListSettings, context: TemplateContext): Group[] | null {
if (settings.grouping === "order") {
return collectGroups(context, settings.sortBy, (submission) => {
const value = getOrderText(submission);
return value ? [{ value, amount: toNumber(submission.estimated_total), rank: 0 }] : [];
});
}
if (settings.grouping === "category") {
const category = findCategory(context.config, settings.groupCategoryId);
return category
? collectGroups(context, settings.sortBy, (submission) => getCategoryItems(submission, category))
: [];
}
return null;
}
function renderSections(settings: TemplateListSettings, context: TemplateContext): string[] {
const entrySeparator = decodeSeparator(settings.separator);
return buildSectionGroups(settings, context)
.map(({ section, groups }) => {
return replacePlaceholders(settings.sectionTemplate, (key) => {
switch (key) {
case "heading": return section.heading;
@@ -438,31 +536,15 @@ function renderSections(settings: TemplateListSettings, context: TemplateContext
}
function renderEntries(settings: TemplateListSettings, context: TemplateContext): string {
const entrySeparator = decodeSeparator(settings.separator);
switch (settings.grouping) {
case "sections":
return renderSections(settings, context).join(decodeSeparator(settings.sectionSeparator));
case "order":
return renderGroups(
collectGroups(context, settings.sortBy, (submission) => {
const value = getOrderText(submission);
return value ? [{ value, amount: toNumber(submission.estimated_total), rank: 0 }] : [];
}),
settings.entryTemplate,
).join(entrySeparator);
case "category": {
const category = findCategory(context.config, settings.groupCategoryId);
if (!category) return "";
return renderGroups(
collectGroups(context, settings.sortBy, (submission) => getCategoryItems(submission, category)),
settings.entryTemplate,
).join(entrySeparator);
}
case "none":
default:
return renderSubmissionEntries(settings, context).join(entrySeparator);
if (settings.grouping === "sections") {
return renderSections(settings, context).join(decodeSeparator(settings.sectionSeparator));
}
const entrySeparator = decodeSeparator(settings.separator);
const groups = buildFlatGroups(settings, context);
return groups
? renderGroups(groups, settings.entryTemplate).join(entrySeparator)
: renderSubmissionEntries(settings, context).join(entrySeparator);
}
function renderTally(categoryLabel: string, context: TemplateContext): string | undefined {
@@ -502,6 +584,52 @@ export function renderTemplateText(
});
}
export type PrintTableRow =
| { type: "section"; text: string }
| { type: "row"; cells: string[] };
export type PrintTable = {
title: string;
subtitle: string;
footer: string;
headers: string[];
rows: PrintTableRow[];
};
export function renderPrintTable(settings: PrintTemplateSettings, context: TemplateContext): PrintTable {
const columns = settings.columns;
const toRow = (resolve: PlaceholderResolver): PrintTableRow => ({
type: "row",
cells: columns.map((column) => replacePlaceholders(column.template, resolve)),
});
let rows: PrintTableRow[];
if (settings.grouping === "sections") {
rows = buildSectionGroups(settings, context).flatMap(({ section, groups }) => {
const itemCount = groups.reduce((sum, group) => sum + group.count, 0);
return [
{ type: "section" as const, text: `${section.heading} (${itemCount})` },
...groups.map((group, index) => toRow(groupResolver(group, index))),
];
});
} else {
const groups = buildFlatGroups(settings, context);
rows = groups
? groups.map((group, index) => toRow(groupResolver(group, index)))
: getPrintableSubmissions(settings, context).map((submission, index) =>
toRow(submissionResolver(submission, index, context)),
);
}
return {
title: renderTemplateText(settings.title, settings, context).trim(),
subtitle: renderTemplateText(settings.subtitle, settings, context).trim(),
footer: renderTemplateText(settings.footer, settings, context).trim(),
headers: columns.map((column) => column.header),
rows,
};
}
export function hasRenderableEntries(context: TemplateContext): boolean {
return context.submissions.some((submission) => getOrderText(submission));
}
+15
View File
@@ -103,9 +103,24 @@ export type EmailTemplateSettings = TemplateListSettings & {
body: string;
};
export type PrintColumn = {
header: string;
template: string;
};
// Printing renders a table: one row per entry (or group), one cell per column template.
export type PrintTemplateSettings = TemplateListSettings & {
enabled: boolean;
title: string;
subtitle: string;
columns: PrintColumn[];
footer: string;
};
export type OrderTemplates = {
clipboard: ClipboardTemplateSettings;
email: EmailTemplateSettings;
print: PrintTemplateSettings;
};
export type OrderBase = {
+122 -1
View File
@@ -42,4 +42,125 @@
*::-webkit-scrollbar-corner {
background: var(--scrollbar-track);
}
}
/* ==================== Print sheet ==================== */
/* Ink-friendly: black text, thin grey rules, no fills. ~45 single-line rows fit on one A4 page;
longer lists continue on further pages with the table header repeated. */
.print-sheet {
color: #000;
background: #fff;
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 8.5pt;
line-height: 1.25;
}
.print-sheet__header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 2mm 6mm;
border-bottom: 1.5pt solid #000;
padding-bottom: 1.5mm;
margin-bottom: 2mm;
}
.print-sheet__header h1 {
font-size: 14pt;
margin: 0;
color: #000;
}
.print-sheet__header p {
margin: 0;
color: #333;
}
.print-sheet table {
width: 100%;
border-collapse: collapse;
font-size: inherit;
}
.print-sheet thead {
display: table-header-group;
}
.print-sheet th,
.print-sheet td {
text-align: left;
vertical-align: top;
padding: 0.7mm 1.2mm;
border-bottom: 0.5pt solid #999;
}
.print-sheet thead th {
font-weight: 700;
border-bottom: 1pt solid #000;
}
.print-sheet tr {
break-inside: avoid;
page-break-inside: avoid;
}
.print-sheet .print-sheet__compact {
width: 1%;
white-space: nowrap;
}
.print-sheet thead .print-sheet__compact {
white-space: normal;
}
.print-sheet__section th {
padding-top: 3mm;
font-size: 10pt;
border-bottom: 1pt solid #000;
break-after: avoid;
page-break-after: avoid;
}
.print-sheet__empty {
text-align: center;
color: #666;
}
.print-sheet__footer {
margin-top: 3mm;
white-space: pre-wrap;
}
.print-preview {
max-height: 420px;
overflow: auto;
padding: 16px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.print-root {
display: none;
}
@media print {
@page {
size: A4;
margin: 12mm;
}
html,
body {
background: #fff !important;
}
body > *:not(.print-root) {
display: none !important;
}
.print-root {
display: block;
}
}
+18
View File
@@ -27,13 +27,16 @@ import {
hasRenderableEntries,
normalizeOrderTemplates,
renderEmail,
renderPrintTable,
renderTemplateText,
type PrintTable,
type TemplateContext,
} from "../lib/orderTemplating";
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
import DistributionModal from "../components/modals/DistributionModal";
import { PrintJob } from "../components/orders/PrintSheet";
type AdminViewData = Order & {
submissions: Submission[];
@@ -60,6 +63,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
const [distributionOpen, setDistributionOpen] = useState(false);
const [printTable, setPrintTable] = useState<PrintTable | null>(null);
const finishPrint = useCallback(() => setPrintTable(null), []);
const loadAdminData = useCallback(
async (): Promise<AdminViewData> => {
@@ -304,6 +309,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
? renderTemplateText(clipboardTemplate.body, clipboardTemplate, templateContext)
: "";
const mailtoHref = hasEntries ? buildMailtoHref(renderEmail(emailTemplate, templateContext)) : "";
const printTemplate = templates.print.enabled ? templates.print : DEFAULT_ORDER_TEMPLATES.print;
const totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
const value = getEstimatedTotalNumber(submission.estimated_total);
@@ -345,6 +351,15 @@ export default function AdminView({ orderId }: { orderId: string }) {
);
};
const printSubmissions = () => {
if (!hasEntries) {
message.warning("Nothing to print.");
return;
}
setPrintTable(renderPrintTable(printTemplate, templateContext));
};
const openSubmissionEmailDraft = () => {
if (!copyText) {
message.warning("Nothing to email.");
@@ -395,6 +410,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
mailtoHref={mailtoHref}
onOpenMailto={openSubmissionEmailDraft}
onOpenDistribution={() => setDistributionOpen(true)}
onPrint={printSubmissions}
selectedRowKeys={selectedRowKeys}
onSelectedRowKeysChange={setSelectedRowKeys}
submissions={sortedAndFilteredSubmissions}
@@ -413,6 +429,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
savingStatusKey={savingStatusKey}
onUpdateSubmissionStatus={updateSubmissionStatus}
/>
{printTable && <PrintJob table={printTable} onDone={finishPrint} />}
</Space>
);
}