initial commit after split
This commit is contained in:
272
webui/src/features/templates/TemplatesPage.tsx
Normal file
272
webui/src/features/templates/TemplatesPage.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
type TemplateRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
fields: string[];
|
||||
updatedAt: string;
|
||||
usedBy: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
versions: number;
|
||||
};
|
||||
|
||||
type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "usage" | "versions";
|
||||
|
||||
const templateRecords: TemplateRecord[] = [
|
||||
{
|
||||
id: "monthly-statement",
|
||||
name: "Monthly statement",
|
||||
description: "Reusable subject and body for monthly statement mailings.",
|
||||
type: "Plain text",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "period", "amount"],
|
||||
updatedAt: "2026-06-08 16:42",
|
||||
usedBy: "2 campaigns",
|
||||
subject: "Your statement for {{period}}",
|
||||
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.",
|
||||
versions: 4
|
||||
},
|
||||
{
|
||||
id: "deadline-reminder",
|
||||
name: "Deadline reminder",
|
||||
description: "Short reminder template with one deadline field.",
|
||||
type: "Plain text",
|
||||
status: "draft",
|
||||
fields: ["recipient_name", "deadline"],
|
||||
updatedAt: "2026-06-07 11:18",
|
||||
usedBy: "Not used yet",
|
||||
subject: "Reminder: {{deadline}}",
|
||||
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.",
|
||||
versions: 1
|
||||
},
|
||||
{
|
||||
id: "attachment-notice",
|
||||
name: "Attachment notice",
|
||||
description: "Generic note for campaigns where every recipient receives a file.",
|
||||
type: "HTML-ready",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "file_label", "contact_email"],
|
||||
updatedAt: "2026-06-05 09:05",
|
||||
usedBy: "1 campaign",
|
||||
subject: "Documents for {{recipient_name}}",
|
||||
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.",
|
||||
versions: 3
|
||||
}
|
||||
];
|
||||
|
||||
const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSection>[] => [
|
||||
{
|
||||
items: [{ actionId: "template-library", label: "← Template library", primary: true, onClick: onBack }]
|
||||
},
|
||||
{
|
||||
title: "TEMPLATE",
|
||||
items: [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "content", label: "Content" },
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "usage", label: "Usage" },
|
||||
{ id: "versions", label: "Versions" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] {
|
||||
return [
|
||||
{ id: "template", header: "Template", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
|
||||
{ id: "type", header: "Type", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Campaign", label: "Campaign" }, { value: "Notification", label: "Notification" }, { value: "Attachment", label: "Attachment" }] }, value: (template) => template.type },
|
||||
{ id: "fields", header: "Fields", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
|
||||
{ id: "updated", header: "Updated", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
|
||||
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "archived", label: "Archived" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
|
||||
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (template) => <Button onClick={() => openTemplate(template.id)}>Open</Button> }
|
||||
];
|
||||
}
|
||||
|
||||
function templateFieldColumns(): DataGridColumn<{ id: string; field: string; source: string; required: string }>[] {
|
||||
return [
|
||||
{ id: "field", header: "Field", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
|
||||
{ id: "source", header: "Source", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Detected placeholder", label: "Detected placeholder" }] }, value: (row) => row.source },
|
||||
{ id: "required", header: "Required", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }] }, value: (row) => row.required },
|
||||
{ id: "actions", header: "Actions", width: 130, sticky: "end", render: () => <Button disabled>Configure</Button> }
|
||||
];
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [active, setActive] = useState<TemplateDetailSection>("overview");
|
||||
const selectedTemplate = useMemo(
|
||||
() => templateRecords.find((template) => template.id === selectedId) ?? null,
|
||||
[selectedId]
|
||||
);
|
||||
|
||||
function openTemplate(templateId: string) {
|
||||
setSelectedId(templateId);
|
||||
setActive("overview");
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>{selectedTemplate.name}</PageTitle>
|
||||
<p>{selectedTemplate.description}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Duplicate</Button>
|
||||
<Button variant="primary" disabled>Use in campaign</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
|
||||
{active === "content" && <TemplateContent template={selectedTemplate} />}
|
||||
{active === "fields" && <TemplateFields template={selectedTemplate} />}
|
||||
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
|
||||
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
|
||||
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page module-entry-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Templates</PageTitle>
|
||||
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className="module-card-heading">
|
||||
<h2>All templates</h2>
|
||||
<span>Last loaded: local demo data</span>
|
||||
</div>
|
||||
}
|
||||
actions={<Button disabled>Refresh</Button>}
|
||||
>
|
||||
<DataGrid
|
||||
id="template-library"
|
||||
rows={templateRecords}
|
||||
columns={templateLibraryColumns(openTemplate)}
|
||||
getRowKey={(template) => template.id}
|
||||
emptyText="No templates found."
|
||||
className="compact-table-wrap module-table-wrap module-entry-table"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateOverview({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Template status">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||
<div><dt>Type</dt><dd>{template.type}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div>
|
||||
<div><dt>Versions</dt><dd>{template.versions}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Compatibility notes">
|
||||
<p className="muted">Campaigns can reuse this template, but each campaign still needs a field matching check because campaign fields and template placeholders can drift.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Subject placeholders are detected</span>
|
||||
<span>Body placeholders are compared with campaign fields</span>
|
||||
<span>A mapping wizard can be added later</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateContent({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Template content" actions={<Button disabled>Save changes</Button>}>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<label className="form-field"><FieldLabel className="form-label" help="Read-only subject stored in this reusable template record.">Subject</FieldLabel><input value={template.subject} readOnly /></label>
|
||||
<label className="form-field full-span"><FieldLabel className="form-label" help="Read-only body stored in this reusable template record.">Body</FieldLabel><textarea rows={10} value={template.body} readOnly /></label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateFields({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}>
|
||||
<DataGrid
|
||||
id={`template-${template.id}-fields`}
|
||||
rows={template.fields.map((field) => ({ id: field, field, source: "Detected placeholder", required: "Yes" }))}
|
||||
columns={templateFieldColumns()}
|
||||
getRowKey={(field) => field.id}
|
||||
emptyText="No fields detected."
|
||||
className="compact-table-wrap module-table-wrap module-table"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreview({ template }: { template: TemplateRecord }) {
|
||||
const preview = template.body
|
||||
.replace(/\{\{recipient_name\}\}/g, "Jane Example")
|
||||
.replace(/\{\{period\}\}/g, "May 2026")
|
||||
.replace(/\{\{amount\}\}/g, "123.45 EUR")
|
||||
.replace(/\{\{deadline\}\}/g, "30 June 2026")
|
||||
.replace(/\{\{file_label\}\}/g, "statement")
|
||||
.replace(/\{\{contact_email\}\}/g, "support@example.org");
|
||||
|
||||
return (
|
||||
<Card title="Preview" actions={<Button disabled>Change sample data</Button>}>
|
||||
<div className="message-preview">
|
||||
<strong>{template.subject.replace(/\{\{period\}\}/g, "May 2026").replace(/\{\{deadline\}\}/g, "30 June 2026").replace(/\{\{recipient_name\}\}/g, "Jane Example")}</strong>
|
||||
<pre>{preview}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateUsage({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Usage">
|
||||
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Currently used by</dt><dd>{template.usedBy}</dd></div>
|
||||
<div><dt>Safe to edit</dt><dd>Requires versioning once templates are shared</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateVersions({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Versions and import/export" actions={<div className="button-row compact-actions"><Button disabled>Import</Button><Button disabled>Export</Button></div>}>
|
||||
<div className="placeholder-stack">
|
||||
<span>{template.versions} local versions in the planned model</span>
|
||||
<span>Sent campaigns should keep a fixed template snapshot</span>
|
||||
<span>Draft campaigns can update to a newer template version later</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user