diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 918c1fe..0e14c15 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -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): diff --git a/src/frontend/src/components/forms/OrderTemplatesEditor.tsx b/src/frontend/src/components/forms/OrderTemplatesEditor.tsx index 91876b8..8a30ceb 100644 --- a/src/frontend/src/components/forms/OrderTemplatesEditor.tsx +++ b/src/frontend/src/components/forms/OrderTemplatesEditor.tsx @@ -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) => 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 })} /> - - - }> - onChange({ sectionTemplate: event.target.value })} - /> - - - - - onChange({ sectionSeparator: event.target.value })} - /> - - - + {textOutput && ( + + + }> + onChange({ sectionTemplate: event.target.value })} + /> + + + + + onChange({ sectionSeparator: event.target.value })} + /> + + + + )} )} - - - + + + } + > + onChange({ entryTemplate: event.target.value })} /> - } - > - onChange({ entryTemplate: event.target.value })} - /> - - - - - onChange({ separator: event.target.value })} - /> - - - + + + + + onChange({ separator: event.target.value })} + /> + + + + )} ); } +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) => + 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 ( + + } + > + + {columns.map((column, index) => ( + + updateColumn(index, { header: event.target.value })} + /> + updateColumn(index, { template: event.target.value })} + /> + + + + + + + ); +} + function RecipientsField({ label, value, @@ -415,7 +505,7 @@ export default function OrderTemplatesEditor({ const [draft, setDraft] = useState(templates); const [saving, setSaving] = useState(false); const [activePanels, setActivePanels] = useState(() => - (["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) => setDraft((previous) => ({ ...previous, email: { ...previous.email, ...changes } })); + const updatePrint = (changes: Partial) => + 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({ ), }, + { + key: "print", + label: "Print templating", + extra: updatePrint({ enabled })} />, + children: ( + <> + + updatePrint({ columns })} + /> + + + }> + updatePrint({ title: event.target.value })} /> + + + + }> + updatePrint({ subtitle: event.target.value })} + /> + + + + }> + updatePrint({ footer: event.target.value })} + /> + + {printPreview && ( + +
+ +
+
+ )} + + ), + }, ]} /> diff --git a/src/frontend/src/components/orders/PrintSheet.tsx b/src/frontend/src/components/orders/PrintSheet.tsx new file mode 100644 index 0000000..92ce512 --- /dev/null +++ b/src/frontend/src/components/orders/PrintSheet.tsx @@ -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 ( +
+ {(table.title || table.subtitle) && ( +
+ {table.title &&

{table.title}

} + {table.subtitle &&

{table.subtitle}

} +
+ )} + + + + {table.headers.map((header, index) => ( + + ))} + + + + {table.rows.length === 0 && ( + + + + )} + {table.rows.map((row, rowIndex) => + row.type === "section" ? ( + + + + ) : ( + + {row.cells.map((cell, cellIndex) => ( + + ))} + + ), + )} + +
+ {header} +
+ No entries +
{row.text}
+ {cell} +
+ {table.footer &&
{table.footer}
} +
+ ); +} + +// 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( +
+ +
, + document.body, + ); +} diff --git a/src/frontend/src/components/views/AdminSubmissionsCard.tsx b/src/frontend/src/components/views/AdminSubmissionsCard.tsx index 47aa75f..1f11476 100644 --- a/src/frontend/src/components/views/AdminSubmissionsCard.tsx +++ b/src/frontend/src/components/views/AdminSubmissionsCard.tsx @@ -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; onSelectedRowKeysChange: (keys: Array) => void; submissions: Submission[]; @@ -105,6 +108,15 @@ export default function AdminSubmissionsCard({ ), }, + { + key: "print", + label: ( +
+ + {hasSelection ? "Print (selected)" : "Print"} +
+ ), + }, ...(hasSelection ? [ { diff --git a/src/frontend/src/lib/orderTemplating.ts b/src/frontend/src/lib/orderTemplating.ts index db2c060..58fe001 100644 --- a/src/frontend/src/lib/orderTemplating.ts +++ b/src/frontend/src/lib/orderTemplating.ts @@ -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)); } diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index e05bba9..183fe32 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -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 = { diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index 1bb5abe..530edbb 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -42,4 +42,125 @@ *::-webkit-scrollbar-corner { background: var(--scrollbar-track); -} \ No newline at end of file +} +/* ==================== 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; + } +} diff --git a/src/frontend/src/views/AdminView.tsx b/src/frontend/src/views/AdminView.tsx index 7c34043..f5a7770 100644 --- a/src/frontend/src/views/AdminView.tsx +++ b/src/frontend/src/views/AdminView.tsx @@ -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("all"); const [sortBy, setSortBy] = useState("time"); const [distributionOpen, setDistributionOpen] = useState(false); + const [printTable, setPrintTable] = useState(null); + const finishPrint = useCallback(() => setPrintTable(null), []); const loadAdminData = useCallback( async (): Promise => { @@ -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 && } ); }