Structural changes
This commit is contained in:
@@ -11,6 +11,7 @@ import SettingsPage from "./features/settings/SettingsPage";
|
||||
import AdminPage from "./features/admin/AdminPage";
|
||||
import TemplatesPage from "./features/templates/TemplatesPage";
|
||||
import FilesPage from "./features/files/FilesPage";
|
||||
import AddressBookPage from "./features/addressbook/AddressBookPage";
|
||||
import PlaceholderPage from "./features/PlaceholderPage";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
|
||||
@@ -104,6 +105,7 @@ export default function App() {
|
||||
<Route path="/campaigns/:campaignId/*" element={<CampaignWorkspace settings={settings} />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/address-book" element={<AddressBookPage />} />
|
||||
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
|
||||
<Route path="/settings" element={<SettingsPage settings={settings} onSettingsChange={updateSettings} />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
|
||||
100
src/features/addressbook/AddressBookPage.tsx
Normal file
100
src/features/addressbook/AddressBookPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
|
||||
const personalContacts = [
|
||||
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
|
||||
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
|
||||
];
|
||||
|
||||
const groupContacts = [
|
||||
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
|
||||
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
|
||||
];
|
||||
|
||||
const tenantContacts = [
|
||||
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
|
||||
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
|
||||
];
|
||||
|
||||
export default function AddressBookPage() {
|
||||
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace module-workspace-single">
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Address Book</PageTitle>
|
||||
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Add contact</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card>
|
||||
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card>
|
||||
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card>
|
||||
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Address book scopes">
|
||||
<div className="address-book-scope-list">
|
||||
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
|
||||
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
|
||||
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Planned address actions">
|
||||
<div className="placeholder-stack">
|
||||
<span>Choose addresses from personal, group or tenant source</span>
|
||||
<span>Remember addresses used in campaigns after opt-in</span>
|
||||
<span>Share selected contacts with a group</span>
|
||||
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}>
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Email</th><th>Scope</th><th>Tags</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allContacts.map((contact) => (
|
||||
<tr key={`${contact.source}-${contact.email}`}>
|
||||
<td><strong>{contact.name}</strong></td>
|
||||
<td>{contact.email}</td>
|
||||
<td>{contact.source}</td>
|
||||
<td>{contact.tags}</td>
|
||||
<td><StatusBadge status="mock" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
|
||||
return (
|
||||
<div className="address-book-scope-card">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,27 +4,56 @@ import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
|
||||
type AdminSection = "overview" | "users" | "groups" | "roles" | "tenants" | "api-keys" | "audit" | "system";
|
||||
type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit";
|
||||
|
||||
const sections: { id: AdminSection; label: string }[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
const adminSections: { id: AdminSection; label: string }[] = [
|
||||
{ id: "system", label: "System" },
|
||||
{ id: "tenants", label: "Tenants" }
|
||||
];
|
||||
|
||||
const tenantSections: { id: AdminSection; label: string }[] = [
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "groups", label: "Groups" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "tenants", label: "Tenants" },
|
||||
{ id: "api-keys", label: "API keys" },
|
||||
{ id: "audit", label: "Audit" },
|
||||
{ id: "system", label: "System" }
|
||||
{ id: "mail-servers", label: "Mail servers" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
{ id: "audit", label: "Audit" }
|
||||
];
|
||||
|
||||
const sectionTitles: Record<AdminSection, { title: string; description: string }> = {
|
||||
overview: { title: "Overview", description: "Administrative entry point for system-wide and tenant-scoped management." },
|
||||
system: { title: "System", description: "Instance-wide health, workers, storage and diagnostics." },
|
||||
tenants: { title: "Tenants", description: "Create and maintain tenant spaces for separated campaign work." },
|
||||
users: { title: "Users", description: "Tenant users, invitations and account status." },
|
||||
groups: { title: "Groups", description: "Tenant groups for ownership, sharing and operational workflows." },
|
||||
roles: { title: "Roles", description: "Role definitions and permission assignments inside the active tenant." },
|
||||
"api-keys": { title: "API keys", description: "Tenant-scoped API keys for automation and integrations." },
|
||||
"mail-servers": { title: "Mail servers", description: "Reusable tenant SMTP/IMAP profiles and sending infrastructure." },
|
||||
settings: { title: "Settings", description: "Tenant-level defaults, limits and policy configuration." },
|
||||
audit: { title: "Audit", description: "Administrative audit trail for tenant and system changes." }
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [active, setActive] = useState<AdminSection>("overview");
|
||||
const heading = sectionTitles[active];
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<aside className="section-sidebar">
|
||||
<div className="section-title">ADMIN</div>
|
||||
{sections.map((section) => (
|
||||
<button className={`section-link ${active === "overview" ? "active" : ""}`} onClick={() => setActive("overview")}>
|
||||
Overview
|
||||
</button>
|
||||
|
||||
<div className="section-title section-title-lower">ADMIN</div>
|
||||
{adminSections.map((section) => (
|
||||
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="section-title section-title-lower">TENANT</div>
|
||||
{tenantSections.map((section) => (
|
||||
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
|
||||
{section.label}
|
||||
</button>
|
||||
@@ -34,51 +63,164 @@ export default function AdminPage() {
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Administration</PageTitle>
|
||||
<p>Tenant, user, role and operational administration surfaces.</p>
|
||||
<PageTitle>Admin · {heading.title}</PageTitle>
|
||||
<p>{heading.description}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions"><Button disabled>Refresh</Button></div>
|
||||
</div>
|
||||
|
||||
{active === "overview" && <Overview />}
|
||||
{active === "users" && <PlaceholderAdminTable title="Users" columns={["User", "Tenant admin", "Status", "Last activity"]} rows={["admin@example.local|Yes|Active|Development seed"]} action="Create user" />}
|
||||
{active === "groups" && <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Status"]} rows={["Default administrators|1|All campaigns|Seed data"]} action="Create group" />}
|
||||
{active === "roles" && <PlaceholderAdminTable title="Roles and permissions" columns={["Role", "Permissions", "Scope", "Status"]} rows={["Owner|All current permissions|Tenant|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Planned"]} action="Create role" />}
|
||||
{active === "tenants" && <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Status"]} rows={["Default|default|1|Active"]} action="Create tenant" />}
|
||||
{active === "api-keys" && <ApiKeys />}
|
||||
{active === "audit" && <Audit />}
|
||||
{active === "overview" && <AdminOverview onSelect={setActive} />}
|
||||
{active === "system" && <System />}
|
||||
{active === "tenants" && <Tenants />}
|
||||
{active === "users" && <Users />}
|
||||
{active === "groups" && <Groups />}
|
||||
{active === "roles" && <Roles />}
|
||||
{active === "api-keys" && <ApiKeys />}
|
||||
{active === "mail-servers" && <MailServers />}
|
||||
{active === "settings" && <TenantSettings />}
|
||||
{active === "audit" && <Audit />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview() {
|
||||
function AdminOverview({ onSelect }: { onSelect: (section: AdminSection) => void }) {
|
||||
const adminLinks: { id: AdminSection; title: string; text: string }[] = [
|
||||
{ id: "system", title: "System", text: "Health, workers, storage and diagnostics." },
|
||||
{ id: "tenants", title: "Tenants", text: "Separated tenant spaces and instance-level tenant lifecycle." },
|
||||
{ id: "users", title: "Users", text: "Tenant users, invitations and account state." },
|
||||
{ id: "groups", title: "Groups", text: "Ownership, sharing and operational groups." },
|
||||
{ id: "roles", title: "Roles", text: "Permissions, assignments and tenant roles." },
|
||||
{ id: "api-keys", title: "API keys", text: "Automation keys and integration access." },
|
||||
{ id: "mail-servers", title: "Mail servers", text: "Reusable tenant SMTP and IMAP profiles." },
|
||||
{ id: "settings", title: "Tenant settings", text: "Defaults, limits and policies for the active tenant." },
|
||||
{ id: "audit", title: "Audit", text: "Administrative audit trail and later filters." }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="metric-grid">
|
||||
<Card title="Users"><strong className="module-big-number">—</strong><p className="muted">Backend list endpoint pending.</p></Card>
|
||||
<Card title="Groups"><strong className="module-big-number">—</strong><p className="muted">Backend list endpoint pending.</p></Card>
|
||||
<Card title="API keys"><strong className="module-big-number">Create-only</strong><p className="muted">Creation endpoint exists; listing/revocation UI pending.</p></Card>
|
||||
<Card title="Audit"><strong className="module-big-number">Available</strong><p className="muted">Audit search can be wired next.</p></Card>
|
||||
<Card title="System scope"><strong className="module-big-number">2</strong><p className="muted">System and tenant registry areas.</p></Card>
|
||||
<Card title="Tenant scope"><strong className="module-big-number">7</strong><p className="muted">Tenant administration areas prepared.</p></Card>
|
||||
<Card title="Production state"><strong className="module-big-number">Mock</strong><p className="muted">Layouts are ready for backend wiring.</p></Card>
|
||||
<Card title="Audit"><strong className="module-big-number">Planned</strong><p className="muted">Tenant and system filters will reuse audit endpoints.</p></Card>
|
||||
</div>
|
||||
<Card title="Administration roadmap">
|
||||
<div className="placeholder-stack">
|
||||
<span>User and invitation management</span>
|
||||
<span>Group and campaign sharing permissions</span>
|
||||
<span>Role assignment and tenant administration</span>
|
||||
<span>API key lifecycle: create, label, revoke, rotate</span>
|
||||
<Card title="Administration areas">
|
||||
<div className="admin-overview-grid">
|
||||
{adminLinks.map((item) => (
|
||||
<button key={item.id} className="admin-overview-link" onClick={() => onSelect(item.id)}>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderAdminTable({ title, columns, rows, action }: { title: string; columns: string[]; rows: string[]; action: string }) {
|
||||
function System() {
|
||||
return (
|
||||
<>
|
||||
<div className="metric-grid">
|
||||
<Card title="Backend"><strong className="module-big-number">Connected</strong><p className="muted">Connection status is currently checked in Settings → Connection.</p></Card>
|
||||
<Card title="Queue"><strong className="module-big-number">Planned</strong><p className="muted">Worker and queue telemetry will be wired to backend endpoints.</p></Card>
|
||||
<Card title="Storage"><strong className="module-big-number">Planned</strong><p className="muted">Garage/S3 storage browser and capacity checks will appear here.</p></Card>
|
||||
<Card title="Audit"><strong className="module-big-number">Available</strong><p className="muted">System-wide filters are mocked until the admin audit API is finalized.</p></Card>
|
||||
</div>
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="System health">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>API</dt><dd>Use health endpoint</dd></div>
|
||||
<div><dt>Database</dt><dd>Planned status check</dd></div>
|
||||
<div><dt>Redis</dt><dd>Planned status check</dd></div>
|
||||
<div><dt>Workers</dt><dd>Planned status check</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Operational actions">
|
||||
<div className="button-row compact-actions stacked-actions">
|
||||
<Button disabled>Run health check</Button>
|
||||
<Button disabled>View worker status</Button>
|
||||
<Button disabled>Export diagnostics</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Tenants() {
|
||||
return <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Storage", "Status"]} rows={["Default|default|1|Local dev|Active"]} action="Create tenant" note="Tenant administration is system-wide. Backend list, create and suspension endpoints can be wired here later." />;
|
||||
}
|
||||
|
||||
function Users() {
|
||||
return <PlaceholderAdminTable title="Users" columns={["User", "Groups", "Roles", "Status", "Last activity"]} rows={["admin@example.local|Default administrators|Owner|Active|Development seed"]} action="Invite user" note="Tenant users should support invitations, activation state, group membership and role assignments." />;
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
return <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Default role", "Status"]} rows={["Default administrators|1|All campaigns|Owner|Seed data"]} action="Create group" note="Groups should later become the main unit for campaign ownership, sharing and storage spaces." />;
|
||||
}
|
||||
|
||||
function Roles() {
|
||||
return <PlaceholderAdminTable title="Roles" columns={["Role", "Permissions", "Scope", "Assignable", "Status"]} rows={["Owner|All current permissions|Tenant|No|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Yes|Planned"]} action="Create role" note="Role definitions are mocked until permission discovery and assignment endpoints are available." />;
|
||||
}
|
||||
|
||||
function ApiKeys() {
|
||||
return (
|
||||
<Card title="API keys" actions={<Button disabled>Create API key</Button>}>
|
||||
<p className="muted">The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.</p>
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Name</th><th>Scope</th><th>Owner</th><th>Status</th><th>Last used</th></tr></thead>
|
||||
<tbody><tr><td>Development key</td><td>Automation</td><td>Tenant</td><td><StatusBadge status="dev" /></td><td>Local only</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MailServers() {
|
||||
return <PlaceholderAdminTable title="Mail servers" columns={["Profile", "SMTP", "IMAP", "Default", "Status"]} rows={["Campaign-local settings|Configured per campaign|Optional per campaign|No|Current behavior", "Tenant default SMTP|Planned|Planned|Planned|Mock"]} action="Add mail server" note="Tenant mail server profiles can later prefill campaign Server settings while campaigns remain self-contained." />;
|
||||
}
|
||||
|
||||
function TenantSettings() {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Tenant defaults">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Default sender policy</dt><dd>Campaign-local for now</dd></div>
|
||||
<div><dt>Default attachment behavior</dt><dd>Ask before build/send</dd></div>
|
||||
<div><dt>Default retention</dt><dd>Not configured</dd></div>
|
||||
<div><dt>Default locale</dt><dd>Browser/application default</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Policy switches">
|
||||
<div className="placeholder-stack">
|
||||
<span>Require API key for queueing campaigns</span>
|
||||
<span>Require validation before sending</span>
|
||||
<span>Require audit lock before final send</span>
|
||||
<span>Allow campaign-local SMTP overrides</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Audit() {
|
||||
return (
|
||||
<Card title="Administrative audit">
|
||||
<p className="muted">Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.</p>
|
||||
<div className="placeholder-stack"><span>User changes</span><span>Group and role changes</span><span>API key lifecycle</span><span>Tenant and mail server settings</span></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderAdminTable({ title, columns, rows, action, note }: { title: string; columns: string[]; rows: string[]; action: string; note?: string }) {
|
||||
return (
|
||||
<Card title={title} actions={<Button disabled>{action}</Button>}>
|
||||
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
|
||||
{note && <p className="muted">{note}</p>}
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
|
||||
@@ -90,42 +232,3 @@ function PlaceholderAdminTable({ title, columns, rows, action }: { title: string
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeys() {
|
||||
return (
|
||||
<Card title="API keys" actions={<Button disabled>Create API key</Button>}>
|
||||
<p className="muted">The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.</p>
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Name</th><th>Scope</th><th>Status</th><th>Last used</th></tr></thead>
|
||||
<tbody><tr><td>Development key</td><td>Automation</td><td><StatusBadge status="dev" /></td><td>Local only</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Audit() {
|
||||
return (
|
||||
<Card title="Administrative audit">
|
||||
<p className="muted">Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.</p>
|
||||
<div className="placeholder-stack"><span>User changes</span><span>Role changes</span><span>API key lifecycle</span><span>Tenant settings</span></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function System() {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="System health">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Backend</dt><dd>Check via Settings → Connection</dd></div>
|
||||
<div><dt>Queue</dt><dd>Planned</dd></div>
|
||||
<div><dt>Storage</dt><dd>Planned</dd></div>
|
||||
<div><dt>Mail tests</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Operational actions"><div className="button-row compact-actions stacked-actions"><Button disabled>Run health check</Button><Button disabled>View worker status</Button><Button disabled>Export diagnostics</Button></div></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +175,11 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
|
||||
<div className="related-link-grid">
|
||||
<Link to="../recipients" className="related-link-card">
|
||||
<strong>Recipients</strong>
|
||||
<span>Recipient rows, global recipient headers and recipient-specific header overrides.</span>
|
||||
<span>Recipient address profiles, global recipient headers and header overrides.</span>
|
||||
</Link>
|
||||
<Link to="../recipient-data" className="related-link-card">
|
||||
<strong>Recipient data</strong>
|
||||
<span>Maintain recipient-specific field values and attachment rules.</span>
|
||||
</Link>
|
||||
<Link to="../global-settings" className="related-link-card">
|
||||
<strong>Global settings</strong>
|
||||
|
||||
@@ -6,6 +6,7 @@ import CampaignDataPage from "./CampaignDataPage";
|
||||
import CampaignFieldsPage from "./CampaignFieldsPage";
|
||||
import GlobalSettingsPage from "./GlobalSettingsPage";
|
||||
import RecipientDataPage from "./RecipientDataPage";
|
||||
import RecipientDetailsPage from "./RecipientDetailsPage";
|
||||
import TemplateDataPage from "./TemplateDataPage";
|
||||
import AttachmentsDataPage from "./AttachmentsDataPage";
|
||||
import MailSettingsPage from "./MailSettingsPage";
|
||||
@@ -25,6 +26,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
"global-settings": "global-settings",
|
||||
fields: "fields",
|
||||
recipients: "recipients",
|
||||
"recipient-data": "recipient-data",
|
||||
template: "template",
|
||||
files: "files",
|
||||
"mail-settings": "mail-settings",
|
||||
@@ -66,6 +68,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
|
||||
<Route path="data" element={<CampaignDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||
@@ -100,6 +103,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
if (section === "global-settings" || section === "settings") return "global-settings";
|
||||
if (section === "fields") return "fields";
|
||||
if (section === "recipients") return "recipients";
|
||||
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
|
||||
if (section === "template") return "template";
|
||||
if (section === "files" || section === "attachments") return "files";
|
||||
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
||||
|
||||
@@ -9,11 +9,9 @@ import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
||||
import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
@@ -24,13 +22,16 @@ const recipientHeaderRows = [
|
||||
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
|
||||
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
|
||||
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
|
||||
];
|
||||
] as const;
|
||||
|
||||
type FieldDefinition = {
|
||||
name: string;
|
||||
type RecipientAddressKey = "to" | "cc" | "bcc";
|
||||
|
||||
type EntryAddressColumn = {
|
||||
key: RecipientAddressKey | "from" | "reply_to";
|
||||
label: string;
|
||||
type: string;
|
||||
can_override: boolean;
|
||||
allowMultiple: boolean;
|
||||
addLabel: string;
|
||||
emptyText: string;
|
||||
};
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
@@ -47,21 +48,20 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
||||
const attachmentSection = asRecord(displayDraft.attachments);
|
||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||
const individualAttachmentBasePaths = useMemo(() => {
|
||||
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
||||
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
||||
}, [attachmentBasePaths]);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
to: addressesFromValue(recipientsSection.to),
|
||||
cc: addressesFromValue(recipientsSection.cc),
|
||||
bcc: addressesFromValue(recipientsSection.bcc)
|
||||
};
|
||||
const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc");
|
||||
const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc");
|
||||
const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
|
||||
const columns: EntryAddressColumn[] = [{ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address." }];
|
||||
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC." });
|
||||
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC." });
|
||||
if (getBool(recipientsSection, "allow_individual_from")) columns.push({ key: "from", label: "Sender", allowMultiple: false, addLabel: "Set sender", emptyText: "Uses default sender." });
|
||||
if (getBool(recipientsSection, "allow_individual_reply_to")) columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: false, addLabel: "Set Reply-To", emptyText: "Uses global Reply-To." });
|
||||
return columns;
|
||||
}, [recipientsSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
@@ -87,10 +87,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
|
||||
active: true,
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
name: "",
|
||||
email: "",
|
||||
fields: {},
|
||||
attachments: []
|
||||
from: {},
|
||||
reply_to: []
|
||||
};
|
||||
replaceInlineEntries([...inlineEntries, newEntry]);
|
||||
}
|
||||
@@ -100,9 +102,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) {
|
||||
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
|
||||
updateEntry(index, (entry) => {
|
||||
const nextEntry = { ...entry, [key]: addresses };
|
||||
if (key === "from") {
|
||||
return { ...entry, from: addresses[0] ?? { name: "", email: "" } };
|
||||
}
|
||||
const nextEntry = { ...entry, [key]: key === "reply_to" ? addresses.slice(0, 1) : addresses };
|
||||
if (key === "to") {
|
||||
const address = addresses[0] ?? { name: "", email: "" };
|
||||
return {
|
||||
@@ -115,20 +120,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
});
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
fields: {
|
||||
...asRecord(entry.fields),
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
@@ -142,7 +133,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
||||
campaign_json: draft,
|
||||
current_flow: "manual",
|
||||
current_step: "recipient-data",
|
||||
current_step: "recipients",
|
||||
workflow_state: "editing",
|
||||
is_complete: false
|
||||
});
|
||||
@@ -160,7 +151,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
title: "Unsaved recipient changes",
|
||||
message: "Recipients or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
message: "Recipient addresses or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
onSave: () => saveRecipients("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
@@ -213,113 +204,53 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Recipients"
|
||||
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
|
||||
title="Recipient profiles"
|
||||
actions={[<Button key="import" disabled>Import</Button>, <Button key="add" variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
|
||||
>
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet.</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
|
||||
)}
|
||||
{inlineEntries.length > 0 && (
|
||||
<div className="app-table-wrap recipient-table-wrap">
|
||||
<table className="app-table recipient-table recipient-editor-table">
|
||||
<table className="app-table recipient-table recipient-address-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Recipients</th>
|
||||
<th>Attachments</th>
|
||||
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
|
||||
{entryAddressColumns.map((column) => <th key={column.key}>{column.label}</th>)}
|
||||
<th>Active</th>
|
||||
<th aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inlineEntries.slice(0, 100).map((entry, index) => {
|
||||
const to = addressesFromValue(entry.to);
|
||||
const cc = addressesFromValue(entry.cc);
|
||||
const bcc = addressesFromValue(entry.bcc);
|
||||
const fields = asRecord(entry.fields);
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<tr key={String(entry.id || index)}>
|
||||
<td className="mono-small">{index + 1}</td>
|
||||
<td className="recipient-cell">
|
||||
<div className="recipient-address-stack">
|
||||
<div className="recipient-address-line">
|
||||
<span className="recipient-address-label">To</span>
|
||||
<EmailAddressInput
|
||||
value={to.length ? to : fallbackRecipientAddress(entry)}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel={to.length ? "Replace" : "Add"}
|
||||
emptyText="No recipient address."
|
||||
onChange={(addresses) => updateEntryAddressList(index, "to", addresses)}
|
||||
/>
|
||||
</div>
|
||||
{allowIndividualCc && (
|
||||
<div className="recipient-address-line">
|
||||
<span className="recipient-address-label">CC</span>
|
||||
<EmailAddressInput
|
||||
value={cc}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel="Add CC"
|
||||
emptyText="No CC."
|
||||
onChange={(addresses) => updateEntryAddressList(index, "cc", addresses)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{allowIndividualBcc && (
|
||||
<div className="recipient-address-line">
|
||||
<span className="recipient-address-label">BCC</span>
|
||||
<EmailAddressInput
|
||||
value={bcc}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel="Add BCC"
|
||||
emptyText="No BCC."
|
||||
onChange={(addresses) => updateEntryAddressList(index, "bcc", addresses)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
label="Active"
|
||||
checked={entry.active !== false}
|
||||
disabled={locked}
|
||||
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<AttachmentRulesOverlay
|
||||
title={`Attachments for recipient ${index + 1}`}
|
||||
rules={attachments}
|
||||
{inlineEntries.slice(0, 100).map((entry, index) => (
|
||||
<tr key={String(entry.id || index)}>
|
||||
<td className="mono-small">{index + 1}</td>
|
||||
{entryAddressColumns.map((column) => (
|
||||
<td key={column.key}>
|
||||
<EmailAddressInput
|
||||
value={getEntryAddresses(entry, column.key)}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={column.allowMultiple}
|
||||
compact
|
||||
disabled={locked}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
addLabel={column.addLabel}
|
||||
emptyText={column.emptyText}
|
||||
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
|
||||
/>
|
||||
</td>
|
||||
{fieldDefinitions.map((field) => (
|
||||
<td key={field.name}>
|
||||
<FieldValueInput
|
||||
className="recipient-field-input"
|
||||
fieldType={field.type}
|
||||
value={fields[field.name]}
|
||||
disabled={locked || field.can_override === false}
|
||||
placeholder={field.can_override === false ? "Uses global value" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
<td>
|
||||
<ToggleSwitch
|
||||
label="Active"
|
||||
checked={entry.active !== false}
|
||||
disabled={locked}
|
||||
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
|
||||
/>
|
||||
</td>
|
||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -335,20 +266,14 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
|
||||
return asArray(draft?.fields)
|
||||
.map((field) => asRecord(field))
|
||||
.map((field) => ({
|
||||
name: getText(field, "name") || getText(field, "id"),
|
||||
label: getText(field, "label"),
|
||||
type: normalizeFieldType(getText(field, "type", "string")),
|
||||
can_override: getBool(field, "can_override", true)
|
||||
}))
|
||||
.filter((field) => Boolean(field.name));
|
||||
}
|
||||
|
||||
function normalizeFieldType(value: string): string {
|
||||
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
|
||||
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
|
||||
if (key === "to") {
|
||||
const explicit = addressesFromValue(entry.to);
|
||||
return explicit.length ? explicit : fallbackRecipientAddress(entry);
|
||||
}
|
||||
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
|
||||
if (key === "reply_to") return addressesFromValue(entry.reply_to).slice(0, 1);
|
||||
return addressesFromValue(entry[key]);
|
||||
}
|
||||
|
||||
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
|
||||
@@ -356,30 +281,6 @@ function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddres
|
||||
return direct?.email ? [direct] : [];
|
||||
}
|
||||
|
||||
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.filter(isRecord).map((basePath, index) => ({
|
||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||
source: getText(basePath, "source"),
|
||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||
allow_individual: getBool(basePath, "allow_individual")
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
id: "base-path-campaign",
|
||||
name: "Campaign files",
|
||||
path: getText(attachments, "base_path", "."),
|
||||
allow_individual: getBool(attachments, "allow_individual", true)
|
||||
}];
|
||||
}
|
||||
|
||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).map((rule) => ({ ...rule }));
|
||||
}
|
||||
|
||||
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
|
||||
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
if (!existing.has(preferred)) return preferred;
|
||||
|
||||
248
src/features/campaigns/RecipientDetailsPage.tsx
Normal file
248
src/features/campaigns/RecipientDetailsPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
||||
import { addressesFromValue } from "../../utils/emailAddresses";
|
||||
|
||||
type FieldDefinition = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
can_override: boolean;
|
||||
};
|
||||
|
||||
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [localError, setLocalError] = useState("");
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
||||
const attachmentSection = asRecord(displayDraft.attachments);
|
||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||
const individualAttachmentBasePaths = useMemo(() => {
|
||||
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
||||
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
||||
}, [attachmentBasePaths]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
setDraft(ensureCampaignDraft(version));
|
||||
setDirty(false);
|
||||
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
|
||||
}, [version]);
|
||||
|
||||
function patch(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
}
|
||||
|
||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
fields: {
|
||||
...asRecord(entry.fields),
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
||||
}
|
||||
|
||||
async function saveRecipientData(mode: "auto" | "manual" = "manual"): Promise<boolean> {
|
||||
if (!draft || !version || locked) return false;
|
||||
setSaveState("Saving…");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
||||
campaign_json: draft,
|
||||
current_flow: "manual",
|
||||
current_step: "recipient-data",
|
||||
workflow_state: "editing",
|
||||
is_complete: false
|
||||
});
|
||||
setDraft(ensureCampaignDraft(saved));
|
||||
setDirty(false);
|
||||
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
setSaveState("Save failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
title: "Unsaved recipient data changes",
|
||||
message: "Recipient field values or attachment rules have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
onSave: () => saveRecipientData("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Recipient data</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveRecipientData("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
<Card title="Recipient field values and attachments">
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<div className="alert info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</div>
|
||||
)}
|
||||
{inlineEntries.length > 0 && (
|
||||
<div className="app-table-wrap recipient-table-wrap recipient-data-table-wrap">
|
||||
<table className="app-table recipient-table recipient-data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Recipient</th>
|
||||
<th>Attachments</th>
|
||||
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inlineEntries.slice(0, 100).map((entry, index) => {
|
||||
const fields = asRecord(entry.fields);
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<tr key={String(entry.id || index)}>
|
||||
<td className="mono-small recipient-index-cell">{index + 1}</td>
|
||||
<td className="recipient-data-identity-cell">
|
||||
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
|
||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
|
||||
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<AttachmentRulesOverlay
|
||||
title={`Attachments for recipient ${index + 1}`}
|
||||
rules={attachments}
|
||||
disabled={locked}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
/>
|
||||
</td>
|
||||
{fieldDefinitions.map((field) => (
|
||||
<td key={field.name}>
|
||||
<FieldValueInput
|
||||
className="recipient-field-input"
|
||||
fieldType={field.type}
|
||||
value={fields[field.name]}
|
||||
disabled={locked || field.can_override === false}
|
||||
placeholder={field.can_override === false ? "Uses global value" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveRecipientData("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
|
||||
return asArray(draft?.fields)
|
||||
.map((field) => asRecord(field))
|
||||
.map((field) => ({
|
||||
name: getText(field, "name") || getText(field, "id"),
|
||||
label: getText(field, "label"),
|
||||
type: normalizeFieldType(getText(field, "type", "string")),
|
||||
can_override: getBool(field, "can_override", true)
|
||||
}))
|
||||
.filter((field) => Boolean(field.name));
|
||||
}
|
||||
|
||||
function normalizeFieldType(value: string): string {
|
||||
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
|
||||
}
|
||||
|
||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
||||
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
|
||||
}
|
||||
|
||||
function extraRecipientCount(entry: Record<string, unknown>): number {
|
||||
const count = addressesFromValue(entry.to).length;
|
||||
return Math.max(0, count - 1);
|
||||
}
|
||||
|
||||
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.filter(isRecord).map((basePath, index) => ({
|
||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||
source: getText(basePath, "source"),
|
||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||
allow_individual: getBool(basePath, "allow_individual")
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
id: "base-path-campaign",
|
||||
name: "Campaign files",
|
||||
path: getText(attachments, "base_path", "."),
|
||||
allow_individual: getBool(attachments, "allow_individual", true)
|
||||
}];
|
||||
}
|
||||
|
||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).map((rule) => ({ ...rule }));
|
||||
}
|
||||
@@ -7,23 +7,23 @@ import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { apiFetch } from "../../api/client";
|
||||
|
||||
type SettingsSection = "connection" | "mail-accounts" | "address-book" | "storage" | "retention" | "notifications";
|
||||
type SettingsSection = "interface" | "workspace" | "local-connection" | "notifications";
|
||||
|
||||
const sections: { id: SettingsSection; label: string }[] = [
|
||||
{ id: "connection", label: "Connection" },
|
||||
{ id: "mail-accounts", label: "Mail accounts" },
|
||||
{ id: "address-book", label: "Address book" },
|
||||
{ id: "storage", label: "Storage" },
|
||||
{ id: "retention", label: "Retention" },
|
||||
{ id: "interface", label: "Interface" },
|
||||
{ id: "workspace", label: "Workspace" },
|
||||
{ id: "local-connection", label: "Local connection" },
|
||||
{ id: "notifications", label: "Notifications" }
|
||||
];
|
||||
|
||||
export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) {
|
||||
const [active, setActive] = useState<SettingsSection>("connection");
|
||||
const [active, setActive] = useState<SettingsSection>("interface");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
const [rememberAddresses, setRememberAddresses] = useState(false);
|
||||
const [addressBookSync, setAddressBookSync] = useState(false);
|
||||
const [compactTables, setCompactTables] = useState(false);
|
||||
const [showHelpHints, setShowHelpHints] = useState(true);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [stickySections, setStickySections] = useState(true);
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
@@ -41,7 +41,7 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<aside className="section-sidebar">
|
||||
<div className="section-title">SETTINGS</div>
|
||||
<div className="section-title">UI SETTINGS</div>
|
||||
{sections.map((section) => (
|
||||
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
|
||||
{section.label}
|
||||
@@ -53,13 +53,78 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Settings</PageTitle>
|
||||
<p>Personal, local and tenant-level settings for the WebUI.</p>
|
||||
<p>Personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "connection" && (
|
||||
{active === "interface" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="API connection / automation key">
|
||||
<Card title="Interface preferences">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Compact tables"
|
||||
help="Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally."
|
||||
checked={compactTables}
|
||||
onChange={setCompactTables}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show inline help hints"
|
||||
help="Controls contextual UI help markers once persisted user preferences are available."
|
||||
checked={showHelpHints}
|
||||
onChange={setShowHelpHints}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Reduce motion"
|
||||
help="Prepared preference for users who prefer fewer animations."
|
||||
checked={reduceMotion}
|
||||
onChange={setReduceMotion}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Theme and language">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Theme</dt><dd>System default for now</dd></div>
|
||||
<div><dt>Accent color</dt><dd>Default brand accent</dd></div>
|
||||
<div><dt>Language</dt><dd>Browser/application default</dd></div>
|
||||
<div><dt>Density</dt><dd>{compactTables ? "Compact preview" : "Comfortable"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "workspace" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Campaign workspace">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Sticky section sidebars"
|
||||
help="Keeps campaign and admin section navigation in view while scrolling."
|
||||
checked={stickySections}
|
||||
onChange={setStickySections}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Keep page shell visible while loading"
|
||||
help="The current UI already keeps the section shell visible and overlays loading indicators during refresh."
|
||||
checked
|
||||
disabled
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Editor behavior">
|
||||
<div className="placeholder-stack">
|
||||
<span>Manual save with unsaved-change guard</span>
|
||||
<span>Readable chooser fields for file/path selection</span>
|
||||
<span>Field-type-aware recipient data inputs</span>
|
||||
<span>Template placeholder chips and preview overlays</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "local-connection" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Local API connection">
|
||||
<div className="form-grid">
|
||||
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
@@ -79,106 +144,26 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
|
||||
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
|
||||
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Logout and tenant switching are handled in the title bar. More session-management controls can be added when backend endpoints exist.</p>
|
||||
<p className="muted small-note">Tenant, user, mail-server and policy administration has moved to Admin. This page keeps browser-local configuration.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "mail-accounts" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Reusable mail accounts">
|
||||
<p className="muted">Campaigns can currently keep SMTP/IMAP data in their working draft. Later, reusable encrypted mail accounts should live here and be shared per user, group or tenant.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Personal SMTP/IMAP accounts</span>
|
||||
<span>Group sender identities</span>
|
||||
<span>Tenant-wide defaults</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Planned account actions">
|
||||
<div className="button-row compact-actions stacked-actions">
|
||||
<Button disabled>Add mail account</Button>
|
||||
<Button disabled>Test selected account</Button>
|
||||
<Button disabled>Share with group…</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "address-book" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Address book and suggestions">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Remember previously used addresses"
|
||||
help="Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only."
|
||||
checked={rememberAddresses}
|
||||
onChange={setRememberAddresses}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="External address-book sync"
|
||||
help="Placeholder for CardDAV/Google/LDAP-style address sources. No external address book is connected yet."
|
||||
checked={addressBookSync}
|
||||
onChange={setAddressBookSync}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Address sources">
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Source</th><th>Status</th><th>Scope</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Campaign-local addresses</td><td>Active</td><td>Current campaign</td></tr>
|
||||
<tr><td>Previously used addresses</td><td>Planned</td><td>User</td></tr>
|
||||
<tr><td>External address books</td><td>Planned</td><td>User / tenant</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "storage" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Storage backends">
|
||||
<p className="muted">Campaign files are still represented by paths and draft JSON. This area is prepared for Garage/S3-backed tenant, group and campaign storage.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Local development storage</span>
|
||||
<span>Garage/S3 tenant bucket</span>
|
||||
<span>Per-campaign file area</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Quotas and cleanup">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>User quota</dt><dd>Planned</dd></div>
|
||||
<div><dt>Campaign file quota</dt><dd>Planned</dd></div>
|
||||
<div><dt>Orphan cleanup</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "retention" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Draft and version retention">
|
||||
<p className="muted">Old editable versions can later be pruned unless they have been sent, partially sent or locked for audit. This needs backend support before destructive actions are exposed.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Prune unsent autosave drafts</span>
|
||||
<span>Keep locked/sent versions</span>
|
||||
<span>Export audit-safe campaign package</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Backup hooks"><p className="muted">Backup settings for campaign JSON, reports and audit data will be added once storage and retention backends are implemented.</p></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "notifications" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Notification preferences">
|
||||
<p className="muted">Prepared for later background notifications: queue complete, send failures, IMAP append failures and report delivery.</p>
|
||||
<p className="muted">Prepared for later personal notification preferences. Backend and browser notification wiring are not active yet.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>In-app notifications</span>
|
||||
<span>Email summary after campaign completion</span>
|
||||
<span>Failure alerts</span>
|
||||
<span>In-app completion notices</span>
|
||||
<span>Email summary preferences</span>
|
||||
<span>Failure and warning alerts</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Quiet UI mode">
|
||||
<div className="placeholder-stack">
|
||||
<span>Mute non-critical banners</span>
|
||||
<span>Batch repetitive notices</span>
|
||||
<span>Keep validation and send warnings visible</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ const campaignRouteLabels: Record<string, string> = {
|
||||
"global-settings": "Global settings",
|
||||
fields: "Fields",
|
||||
recipients: "Recipients",
|
||||
"recipient-data": "Recipient data",
|
||||
template: "Template",
|
||||
files: "Attachments",
|
||||
attachments: "Attachments",
|
||||
@@ -50,6 +51,7 @@ const topLevelRouteLabels: Record<string, string> = {
|
||||
dashboard: "Dashboard",
|
||||
templates: "Templates",
|
||||
files: "Files",
|
||||
"address-book": "Address Book",
|
||||
reports: "Reports",
|
||||
settings: "Settings",
|
||||
admin: "Admin",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Form, FileText, Folder, LayoutDashboard, MailCheck, Settings, Shield, Users } from "lucide-react";
|
||||
import { FileText, Folder, Form, LayoutDashboard, MailCheck, Settings, Shield, Users } from "lucide-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
const items = [
|
||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/campaigns", label: "Campaigns", icon: MailCheck },
|
||||
{ to: "/templates", label: "Templates", icon: Form },
|
||||
{ to: "/files", label: "Files", icon: Folder },
|
||||
{ to: "/address-book", label: "Address Book", icon: Users },
|
||||
{ to: "/templates", label: "Templates", icon: Form },
|
||||
{ to: "/reports", label: "Reports", icon: FileText },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
{ to: "/admin", label: "Admin", icon: Shield }
|
||||
];
|
||||
|
||||
@@ -26,7 +26,9 @@ export default function IconRail({ compact = false }: { compact?: boolean }) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="icon-rail-bottom">
|
||||
<Users size={18} />
|
||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
|
||||
<Settings size={20} />
|
||||
</NavLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "files", label: "Attachments" },
|
||||
{ id: "recipients", label: "Recipients" },
|
||||
{ id: "recipient-data", label: "Recipient data" },
|
||||
{ id: "template", label: "Template" },
|
||||
];
|
||||
|
||||
|
||||
@@ -918,3 +918,116 @@
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Recipient/profile split pages. */
|
||||
.recipient-address-table th:nth-child(1),
|
||||
.recipient-address-table td:nth-child(1) { width: 42px; }
|
||||
.recipient-address-table th:last-child,
|
||||
.recipient-address-table td:last-child { width: 123px; }
|
||||
.recipient-address-table td { vertical-align: top; }
|
||||
.recipient-address-table .email-address-input { min-width: 220px; }
|
||||
.recipient-address-table .toggle-switch { white-space: nowrap; }
|
||||
|
||||
.recipient-data-table th:nth-child(1),
|
||||
.recipient-data-table td:nth-child(1) { min-width: 240px; }
|
||||
.recipient-data-table th:nth-child(2),
|
||||
.recipient-data-table td:nth-child(2) { min-width: 192px; }
|
||||
.recipient-data-table td { vertical-align: top; }
|
||||
.recipient-data-identity-cell { white-space: normal !important; }
|
||||
.recipient-data-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 260px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.recipient-data-identity:hover .recipient-data-address {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.recipient-data-number,
|
||||
.recipient-extra-bubble {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recipient-data-address {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Recipient data split: align the identifier column with the recipient profile table. */
|
||||
.recipient-data-table th:nth-child(1),
|
||||
.recipient-data-table td:nth-child(1) { min-width: 72px; width: 72px; }
|
||||
.recipient-data-table th:nth-child(2),
|
||||
.recipient-data-table td:nth-child(2) { min-width: 240px; }
|
||||
.recipient-data-table th:nth-child(3),
|
||||
.recipient-data-table td:nth-child(3) { min-width: 192px; }
|
||||
.recipient-index-cell { white-space: nowrap; }
|
||||
|
||||
/* Admin overview and address book mock module. */
|
||||
.admin-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-overview-link {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-overview-link:hover {
|
||||
background: #fff;
|
||||
border-color: var(--line-dark);
|
||||
}
|
||||
.admin-overview-link strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.admin-overview-link span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.module-workspace-single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.address-book-scope-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.address-book-scope-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
}
|
||||
.address-book-scope-card strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.address-book-scope-card p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -140,3 +140,12 @@
|
||||
.page-heading.split { align-items: flex-start; flex-direction: column; }
|
||||
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Side rail: settings lives as the bottom utility entry. */
|
||||
.icon-rail-bottom {
|
||||
width: 100%;
|
||||
padding: 12px 0 20px;
|
||||
}
|
||||
.icon-rail-bottom .icon-nav-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -55,11 +55,12 @@ export type LoginResponse = AuthInfo & {
|
||||
export type NavSection =
|
||||
| "dashboard"
|
||||
| "campaigns"
|
||||
| "templates"
|
||||
| "files"
|
||||
| "address-book"
|
||||
| "templates"
|
||||
| "reports"
|
||||
| "settings"
|
||||
| "admin";
|
||||
| "admin"
|
||||
| "settings";
|
||||
|
||||
export type CampaignWorkspaceSection =
|
||||
| "overview"
|
||||
@@ -67,6 +68,7 @@ export type CampaignWorkspaceSection =
|
||||
| "global-settings"
|
||||
| "fields"
|
||||
| "recipients"
|
||||
| "recipient-data"
|
||||
| "template"
|
||||
| "files"
|
||||
| "mail-settings"
|
||||
|
||||
@@ -12,6 +12,7 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
files: { id: "campaign.attachments", title: "Attachments" },
|
||||
attachments: { id: "campaign.attachments", title: "Attachments" },
|
||||
recipients: { id: "campaign.recipients", title: "Recipients" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
mail: { id: "campaign.server-settings", title: "Server settings" },
|
||||
@@ -30,6 +31,7 @@ const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
campaigns: { id: "campaigns.list", title: "Campaigns" },
|
||||
templates: { id: "templates.list", title: "Templates" },
|
||||
files: { id: "files.list", title: "Files" },
|
||||
"address-book": { id: "address-book.list", title: "Address Book" },
|
||||
reports: { id: "reports.list", title: "Reports" },
|
||||
settings: { id: "app.settings", title: "Settings" },
|
||||
admin: { id: "app.admin", title: "Admin" }
|
||||
|
||||
Reference in New Issue
Block a user