This commit is contained in:
1 parent
d3517d8f47
commit
f2cbc07738
8 files changed
+636
-105
No files matched your search
@@ -121,10 +121,11 @@ class OrderConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OrderTemplates(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"}
|
model_config = {"extra": "allow"}
|
||||||
clipboard: dict[str, Any] = Field(default_factory=dict)
|
clipboard: dict[str, Any] = Field(default_factory=dict)
|
||||||
email: 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):
|
class UserProfileUpdateRequest(BaseModel):
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
BODY_PLACEHOLDERS,
|
BODY_PLACEHOLDERS,
|
||||||
DEFAULT_ENTRY_TEMPLATE,
|
DEFAULT_ENTRY_TEMPLATE,
|
||||||
|
DEFAULT_GROUP_PRINT_COLUMNS,
|
||||||
|
DEFAULT_PRINT_COLUMNS,
|
||||||
DEFAULT_GROUP_ENTRY_TEMPLATE,
|
DEFAULT_GROUP_ENTRY_TEMPLATE,
|
||||||
ENTRY_PLACEHOLDERS,
|
ENTRY_PLACEHOLDERS,
|
||||||
GROUP_PLACEHOLDERS,
|
GROUP_PLACEHOLDERS,
|
||||||
@@ -36,12 +38,15 @@ import {
|
|||||||
buildSectionsPerCategory,
|
buildSectionsPerCategory,
|
||||||
isGroupedMode,
|
isGroupedMode,
|
||||||
renderEmail,
|
renderEmail,
|
||||||
|
renderPrintTable,
|
||||||
renderTemplateText,
|
renderTemplateText,
|
||||||
type TemplateContext,
|
type TemplateContext,
|
||||||
} from "../../lib/orderTemplating";
|
} from "../../lib/orderTemplating";
|
||||||
|
import { PrintSheet } from "../orders/PrintSheet";
|
||||||
import type {
|
import type {
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
OrderTemplates,
|
OrderTemplates,
|
||||||
|
PrintColumn,
|
||||||
TemplateGrouping,
|
TemplateGrouping,
|
||||||
TemplateListSettings,
|
TemplateListSettings,
|
||||||
TemplateRecipients,
|
TemplateRecipients,
|
||||||
@@ -209,10 +214,13 @@ function ListSettingsFields({
|
|||||||
value,
|
value,
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
textOutput = true,
|
||||||
}: {
|
}: {
|
||||||
value: TemplateListSettings;
|
value: TemplateListSettings;
|
||||||
config: OrderFormConfig;
|
config: OrderFormConfig;
|
||||||
onChange: (changes: Partial<TemplateListSettings>) => void;
|
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 categoryOptions = config.categories.map((category) => ({ label: category.label, value: category.id }));
|
||||||
const grouped = isGroupedMode(value.grouping);
|
const grouped = isGroupedMode(value.grouping);
|
||||||
@@ -285,60 +293,142 @@ function ListSettingsFields({
|
|||||||
config={config}
|
config={config}
|
||||||
onChange={(sections) => onChange({ sections })}
|
onChange={(sections) => onChange({ sections })}
|
||||||
/>
|
/>
|
||||||
<Row gutter={12}>
|
{textOutput && (
|
||||||
<Col xs={24} md={16}>
|
<Row gutter={12}>
|
||||||
<Form.Item label={<LabelWithPlaceholders label="Section template" items={SECTION_PLACEHOLDERS} />}>
|
<Col xs={24} md={16}>
|
||||||
<Input.TextArea
|
<Form.Item label={<LabelWithPlaceholders label="Section template" items={SECTION_PLACEHOLDERS} />}>
|
||||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
<Input.TextArea
|
||||||
value={value.sectionTemplate}
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||||
onChange={(event) => onChange({ sectionTemplate: event.target.value })}
|
value={value.sectionTemplate}
|
||||||
/>
|
onChange={(event) => onChange({ sectionTemplate: event.target.value })}
|
||||||
</Form.Item>
|
/>
|
||||||
</Col>
|
</Form.Item>
|
||||||
<Col xs={24} md={8}>
|
</Col>
|
||||||
<Form.Item label="Section separator" tooltip="Use \n for a line break and \t for a tab.">
|
<Col xs={24} md={8}>
|
||||||
<Input
|
<Form.Item label="Section separator" tooltip="Use \n for a line break and \t for a tab.">
|
||||||
value={value.sectionSeparator}
|
<Input
|
||||||
placeholder="\n\n"
|
value={value.sectionSeparator}
|
||||||
onChange={(event) => onChange({ sectionSeparator: event.target.value })}
|
placeholder="\n\n"
|
||||||
/>
|
onChange={(event) => onChange({ sectionSeparator: event.target.value })}
|
||||||
</Form.Item>
|
/>
|
||||||
</Col>
|
</Form.Item>
|
||||||
</Row>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Row gutter={12}>
|
{textOutput && (
|
||||||
<Col xs={24} md={16}>
|
<Row gutter={12}>
|
||||||
<Form.Item
|
<Col xs={24} md={16}>
|
||||||
label={
|
<Form.Item
|
||||||
<LabelWithPlaceholders
|
label={
|
||||||
label={grouped ? "Entry template (per group)" : "Entry template (per submission)"}
|
<LabelWithPlaceholders
|
||||||
items={grouped ? GROUP_PLACEHOLDERS : ENTRY_PLACEHOLDERS}
|
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 })}
|
||||||
/>
|
/>
|
||||||
}
|
</Form.Item>
|
||||||
>
|
</Col>
|
||||||
<Input
|
<Col xs={24} md={8}>
|
||||||
value={value.entryTemplate}
|
<Form.Item label="Entry separator" tooltip="Use \n for a line break and \t for a tab.">
|
||||||
placeholder={grouped ? DEFAULT_GROUP_ENTRY_TEMPLATE : DEFAULT_ENTRY_TEMPLATE}
|
<Input
|
||||||
onChange={(event) => onChange({ entryTemplate: event.target.value })}
|
value={value.separator}
|
||||||
/>
|
placeholder="\n"
|
||||||
</Form.Item>
|
onChange={(event) => onChange({ separator: event.target.value })}
|
||||||
</Col>
|
/>
|
||||||
<Col xs={24} md={8}>
|
</Form.Item>
|
||||||
<Form.Item label="Entry separator" tooltip="Use \n for a line break and \t for a tab.">
|
</Col>
|
||||||
<Input
|
</Row>
|
||||||
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({
|
function RecipientsField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -415,7 +505,7 @@ export default function OrderTemplatesEditor({
|
|||||||
const [draft, setDraft] = useState<OrderTemplates>(templates);
|
const [draft, setDraft] = useState<OrderTemplates>(templates);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [activePanels, setActivePanels] = useState<string[]>(() =>
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -428,11 +518,23 @@ export default function OrderTemplatesEditor({
|
|||||||
setDraft((previous) => ({ ...previous, clipboard: { ...previous.clipboard, ...changes } }));
|
setDraft((previous) => ({ ...previous, clipboard: { ...previous.clipboard, ...changes } }));
|
||||||
const updateEmail = (changes: Partial<OrderTemplates["email"]>) =>
|
const updateEmail = (changes: Partial<OrderTemplates["email"]>) =>
|
||||||
setDraft((previous) => ({ ...previous, email: { ...previous.email, ...changes } }));
|
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
|
const clipboardPreview = previewContext
|
||||||
? renderTemplateText(draft.clipboard.body, draft.clipboard, previewContext)
|
? renderTemplateText(draft.clipboard.body, draft.clipboard, previewContext)
|
||||||
: null;
|
: null;
|
||||||
const emailPreview = previewContext ? renderEmail(draft.email, previewContext) : null;
|
const emailPreview = previewContext ? renderEmail(draft.email, previewContext) : null;
|
||||||
|
const printPreview = previewContext ? renderPrintTable(draft.print, previewContext) : null;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -449,7 +551,7 @@ export default function OrderTemplatesEditor({
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
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
|
<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,
|
DeleteOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
|
PrinterOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
ShoppingOutlined,
|
ShoppingOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
@@ -38,6 +39,7 @@ export default function AdminSubmissionsCard({
|
|||||||
mailtoHref,
|
mailtoHref,
|
||||||
onOpenMailto,
|
onOpenMailto,
|
||||||
onOpenDistribution,
|
onOpenDistribution,
|
||||||
|
onPrint,
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onSelectedRowKeysChange,
|
onSelectedRowKeysChange,
|
||||||
submissions,
|
submissions,
|
||||||
@@ -60,6 +62,7 @@ export default function AdminSubmissionsCard({
|
|||||||
mailtoHref: string;
|
mailtoHref: string;
|
||||||
onOpenMailto: () => void;
|
onOpenMailto: () => void;
|
||||||
onOpenDistribution: () => void;
|
onOpenDistribution: () => void;
|
||||||
|
onPrint: () => void;
|
||||||
selectedRowKeys: Array<string | number>;
|
selectedRowKeys: Array<string | number>;
|
||||||
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
|
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
@@ -105,6 +108,15 @@ export default function AdminSubmissionsCard({
|
|||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "print",
|
||||||
|
label: (
|
||||||
|
<div onClick={onPrint} style={{ width: "100%" }}>
|
||||||
|
<PrinterOutlined style={{ marginRight: 8 }} />
|
||||||
|
{hasSelection ? "Print (selected)" : "Print"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
...(hasSelection
|
...(hasSelection
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
Submission,
|
Submission,
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
TemplateGrouping,
|
TemplateGrouping,
|
||||||
|
PrintColumn,
|
||||||
|
PrintTemplateSettings,
|
||||||
TemplateListSettings,
|
TemplateListSettings,
|
||||||
TemplateRecipients,
|
TemplateRecipients,
|
||||||
TemplateRecipientSource,
|
TemplateRecipientSource,
|
||||||
@@ -29,6 +31,21 @@ const DEFAULT_LIST_SETTINGS: TemplateListSettings = {
|
|||||||
separator: "\\n",
|
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 = {
|
export const DEFAULT_ORDER_TEMPLATES: OrderTemplates = {
|
||||||
clipboard: {
|
clipboard: {
|
||||||
...DEFAULT_LIST_SETTINGS,
|
...DEFAULT_LIST_SETTINGS,
|
||||||
@@ -45,6 +62,14 @@ export const DEFAULT_ORDER_TEMPLATES: OrderTemplates = {
|
|||||||
body: "{entries}\n",
|
body: "{entries}\n",
|
||||||
entryTemplate: "- {order}",
|
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.
|
// Which sort options make sense per entry mode.
|
||||||
@@ -59,8 +84,10 @@ export const ENTRY_PLACEHOLDERS = [
|
|||||||
{ key: "{index}", description: "Running number (1, 2, …)" },
|
{ key: "{index}", description: "Running number (1, 2, …)" },
|
||||||
{ key: "{order}", description: "Formatted order text" },
|
{ key: "{order}", description: "Formatted order text" },
|
||||||
{ key: "{email}", description: "Participant email" },
|
{ key: "{email}", description: "Participant email" },
|
||||||
|
{ key: "{name}", description: "Name from the email, e.g. firstname.lastname@… → Firstname Lastname" },
|
||||||
{ key: "{total}", description: "Estimated amount" },
|
{ key: "{total}", description: "Estimated amount" },
|
||||||
{ key: "{status}", description: "pending / unpaid / paid" },
|
{ 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" },
|
{ 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: "{value}", description: "Grouped order text / category item" },
|
||||||
{ key: "{emails}", description: "Emails of the participants in this group" },
|
{ key: "{emails}", description: "Emails of the participants in this group" },
|
||||||
{ key: "{total}", description: "Estimated amount of the group" },
|
{ key: "{total}", description: "Estimated amount of the group" },
|
||||||
|
{ key: "{box}", description: "Empty box ☐ to tick by hand" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SECTION_PLACEHOLDERS = [
|
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 {
|
function normalizeRecipients(raw: any, defaults: TemplateRecipients): TemplateRecipients {
|
||||||
const sources = Array.isArray(raw?.sources)
|
const sources = Array.isArray(raw?.sources)
|
||||||
? raw.sources.filter((source: unknown) => RECIPIENT_SOURCES.includes(source as TemplateRecipientSource))
|
? 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 value = (raw && typeof raw === "object" ? raw : {}) as any;
|
||||||
const clipboardDefaults = DEFAULT_ORDER_TEMPLATES.clipboard;
|
const clipboardDefaults = DEFAULT_ORDER_TEMPLATES.clipboard;
|
||||||
const emailDefaults = DEFAULT_ORDER_TEMPLATES.email;
|
const emailDefaults = DEFAULT_ORDER_TEMPLATES.email;
|
||||||
|
const printDefaults = DEFAULT_ORDER_TEMPLATES.print;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clipboard: {
|
clipboard: {
|
||||||
@@ -182,6 +222,14 @@ export function normalizeOrderTemplates(raw: unknown): OrderTemplates {
|
|||||||
bcc: normalizeRecipients(value.email?.bcc, emailDefaults.bcc),
|
bcc: normalizeRecipients(value.email?.bcc, emailDefaults.bcc),
|
||||||
body: asString(value.email?.body, emailDefaults.body),
|
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();
|
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 {
|
function getStatus(submission: Submission): SubmissionStatus {
|
||||||
if (submission.paid) return "paid";
|
if (submission.paid) return "paid";
|
||||||
if (submission.accepted) return "unpaid";
|
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]) }];
|
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[] {
|
function renderGroups(groups: Group[], entryTemplate: string): string[] {
|
||||||
return groups
|
return groups
|
||||||
.map((group, index) =>
|
.map((group, index) => replacePlaceholders(entryTemplate, groupResolver(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;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((entry) => entry.trim());
|
.filter((entry) => entry.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,39 +474,54 @@ function sortSubmissions(settings: TemplateListSettings, context: TemplateContex
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPrintableSubmissions(settings: TemplateListSettings, context: TemplateContext): Submission[] {
|
||||||
|
return sortSubmissions(settings, context).filter((submission) => getOrderText(submission));
|
||||||
|
}
|
||||||
|
|
||||||
function renderSubmissionEntries(settings: TemplateListSettings, context: TemplateContext): string[] {
|
function renderSubmissionEntries(settings: TemplateListSettings, context: TemplateContext): string[] {
|
||||||
return sortSubmissions(settings, context)
|
return getPrintableSubmissions(settings, context)
|
||||||
.filter((submission) => getOrderText(submission))
|
|
||||||
.map((submission, index) =>
|
.map((submission, index) =>
|
||||||
replacePlaceholders(settings.entryTemplate, (key, arg) => {
|
replacePlaceholders(settings.entryTemplate, submissionResolver(submission, index, context)),
|
||||||
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;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.filter((entry) => entry.trim());
|
.filter((entry) => entry.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSections(settings: TemplateListSettings, context: TemplateContext): string[] {
|
function buildSectionGroups(settings: TemplateListSettings, context: TemplateContext) {
|
||||||
const entrySeparator = decodeSeparator(settings.separator);
|
|
||||||
|
|
||||||
return settings.sections
|
return settings.sections
|
||||||
.map((section) => {
|
.map((section) => {
|
||||||
const categories = section.categoryIds
|
const categories = section.categoryIds
|
||||||
.map((id) => findCategory(context.config, id))
|
.map((id) => findCategory(context.config, id))
|
||||||
.filter((category): category is OrderFormCategory => !!category);
|
.filter((category): category is OrderFormCategory => !!category);
|
||||||
const groups = collectGroups(context, settings.sortBy, (submission) => getSectionItems(submission, categories));
|
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) => {
|
return replacePlaceholders(settings.sectionTemplate, (key) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "heading": return section.heading;
|
case "heading": return section.heading;
|
||||||
@@ -438,31 +536,15 @@ function renderSections(settings: TemplateListSettings, context: TemplateContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEntries(settings: TemplateListSettings, context: TemplateContext): string {
|
function renderEntries(settings: TemplateListSettings, context: TemplateContext): string {
|
||||||
const entrySeparator = decodeSeparator(settings.separator);
|
if (settings.grouping === "sections") {
|
||||||
|
return renderSections(settings, context).join(decodeSeparator(settings.sectionSeparator));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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 {
|
export function hasRenderableEntries(context: TemplateContext): boolean {
|
||||||
return context.submissions.some((submission) => getOrderText(submission));
|
return context.submissions.some((submission) => getOrderText(submission));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,9 +103,24 @@ export type EmailTemplateSettings = TemplateListSettings & {
|
|||||||
body: string;
|
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 = {
|
export type OrderTemplates = {
|
||||||
clipboard: ClipboardTemplateSettings;
|
clipboard: ClipboardTemplateSettings;
|
||||||
email: EmailTemplateSettings;
|
email: EmailTemplateSettings;
|
||||||
|
print: PrintTemplateSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OrderBase = {
|
export type OrderBase = {
|
||||||
|
|||||||
@@ -43,3 +43,124 @@
|
|||||||
*::-webkit-scrollbar-corner {
|
*::-webkit-scrollbar-corner {
|
||||||
background: var(--scrollbar-track);
|
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,
|
hasRenderableEntries,
|
||||||
normalizeOrderTemplates,
|
normalizeOrderTemplates,
|
||||||
renderEmail,
|
renderEmail,
|
||||||
|
renderPrintTable,
|
||||||
renderTemplateText,
|
renderTemplateText,
|
||||||
|
type PrintTable,
|
||||||
type TemplateContext,
|
type TemplateContext,
|
||||||
} from "../lib/orderTemplating";
|
} from "../lib/orderTemplating";
|
||||||
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||||
import DistributionModal from "../components/modals/DistributionModal";
|
import DistributionModal from "../components/modals/DistributionModal";
|
||||||
|
import { PrintJob } from "../components/orders/PrintSheet";
|
||||||
|
|
||||||
type AdminViewData = Order & {
|
type AdminViewData = Order & {
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
@@ -60,6 +63,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
|
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
|
||||||
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
|
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
|
||||||
const [distributionOpen, setDistributionOpen] = useState(false);
|
const [distributionOpen, setDistributionOpen] = useState(false);
|
||||||
|
const [printTable, setPrintTable] = useState<PrintTable | null>(null);
|
||||||
|
const finishPrint = useCallback(() => setPrintTable(null), []);
|
||||||
|
|
||||||
const loadAdminData = useCallback(
|
const loadAdminData = useCallback(
|
||||||
async (): Promise<AdminViewData> => {
|
async (): Promise<AdminViewData> => {
|
||||||
@@ -304,6 +309,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
? renderTemplateText(clipboardTemplate.body, clipboardTemplate, templateContext)
|
? renderTemplateText(clipboardTemplate.body, clipboardTemplate, templateContext)
|
||||||
: "";
|
: "";
|
||||||
const mailtoHref = hasEntries ? buildMailtoHref(renderEmail(emailTemplate, 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 totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
|
||||||
const value = getEstimatedTotalNumber(submission.estimated_total);
|
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 = () => {
|
const openSubmissionEmailDraft = () => {
|
||||||
if (!copyText) {
|
if (!copyText) {
|
||||||
message.warning("Nothing to email.");
|
message.warning("Nothing to email.");
|
||||||
@@ -395,6 +410,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
mailtoHref={mailtoHref}
|
mailtoHref={mailtoHref}
|
||||||
onOpenMailto={openSubmissionEmailDraft}
|
onOpenMailto={openSubmissionEmailDraft}
|
||||||
onOpenDistribution={() => setDistributionOpen(true)}
|
onOpenDistribution={() => setDistributionOpen(true)}
|
||||||
|
onPrint={printSubmissions}
|
||||||
selectedRowKeys={selectedRowKeys}
|
selectedRowKeys={selectedRowKeys}
|
||||||
onSelectedRowKeysChange={setSelectedRowKeys}
|
onSelectedRowKeysChange={setSelectedRowKeys}
|
||||||
submissions={sortedAndFilteredSubmissions}
|
submissions={sortedAndFilteredSubmissions}
|
||||||
@@ -413,6 +429,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
savingStatusKey={savingStatusKey}
|
savingStatusKey={savingStatusKey}
|
||||||
onUpdateSubmissionStatus={updateSubmissionStatus}
|
onUpdateSubmissionStatus={updateSubmissionStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{printTable && <PrintJob table={printTable} onDone={finishPrint} />}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user