210 lines
9.5 KiB
TypeScript
210 lines
9.5 KiB
TypeScript
import { useMemo } 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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
|
import { getBool, getText } from "./utils/draftEditor";
|
|
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 version = data.currentVersion;
|
|
const locked = isAuditLockedVersion(version);
|
|
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
|
|
settings,
|
|
campaignId,
|
|
version,
|
|
locked,
|
|
reload,
|
|
setError,
|
|
currentStep: "recipient-data",
|
|
unsavedTitle: "Unsaved recipient data changes",
|
|
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
|
|
});
|
|
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]);
|
|
|
|
|
|
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 }));
|
|
}
|
|
|
|
|
|
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={() => saveDraft("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={() => saveDraft("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 }));
|
|
}
|