This commit is contained in:
1 parent
d3517d8f47
commit
f2cbc07738
8 files changed
+637
-106
No files matched your search
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user