DataGrid list type; Review/Send refinment; User lock; Table actions

This commit is contained in:
2026-06-13 19:28:48 +02:00
parent 5937dfe97e
commit c72df498e7
15 changed files with 1318 additions and 218 deletions

View File

@@ -6,15 +6,32 @@ import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog";
import FormField from "../../components/FormField";
import LoadingFrame from "../../components/LoadingFrame";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { publishCampaignVersion, updateCampaignMetadata, type CampaignVersionListItem } from "../../api/campaigns";
import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionListItem,
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
@@ -23,7 +40,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false);
const [lockingVersion, setLockingVersion] = useState<CampaignVersionListItem | null>(null);
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState("");
@@ -33,7 +50,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? ""
description: campaign.description ?? "",
});
}, [campaign, identityDirty]);
@@ -53,7 +70,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
external_id: identity.external_id,
name: identity.name,
status: identity.status,
description: identity.description
description: identity.description,
});
setIdentityDirty(false);
await reload();
@@ -64,16 +81,24 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
}
}
async function lockAuditSnapshot() {
const version = lockingVersion;
if (!version || lockBusy) return;
async function applyLockAction() {
const pending = pendingLockAction;
if (!pending || lockBusy) return;
setLockBusy(true);
setError("");
setMessage("");
try {
await publishCampaignVersion(settings, campaignId, version.id);
setMessage(`Version #${version.version_number} locked as audit-safe snapshot.`);
setLockingVersion(null);
if (pending.action === "temporary") {
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
} else if (pending.action === "unlock") {
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
} else {
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} permanently locked.`);
}
setPendingLockAction(null);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -100,6 +125,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview…">
<div className="metric-grid campaign-overview-metrics">
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
@@ -123,7 +155,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setLockingVersion)}
columns={versionColumns(setPendingLockAction)}
getRowKey={(version) => version.id}
emptyText="No versions found."
className="version-history-table"
@@ -142,50 +174,55 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
</LoadingFrame>
<ConfirmDialog
open={Boolean(lockingVersion)}
title="Lock audit-safe snapshot?"
message="This is a user-requested final lock. The version cannot be validated, unlocked, built, dry-run or sent afterwards. Create an editable copy for future changes."
confirmLabel="Lock snapshot"
tone="danger"
open={Boolean(pendingLockAction)}
title={lockDialogTitle(pendingLockAction)}
message={lockDialogMessage(pendingLockAction)}
confirmLabel={lockDialogLabel(pendingLockAction)}
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
busy={lockBusy}
onCancel={() => setLockingVersion(null)}
onConfirm={() => void lockAuditSnapshot()}
onCancel={() => setPendingLockAction(null)}
onConfirm={() => void applyLockAction()}
/>
</div>
);
}
function versionColumns(setLockingVersion: (version: CampaignVersionListItem) => void): DataGridColumn<CampaignVersionListItem>[] {
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
{ id: "lock", header: "Lock", width: 170, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel },
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel },
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, render: validationLabel, value: validationLabel },
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, render: buildLabel, value: buildLabel },
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
{
id: "actions",
header: "Actions",
width: 190,
width: 310,
sticky: "end",
render: (version) => (
<div className="button-row compact-actions">
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link>
<Button
onClick={() => setLockingVersion(version)}
disabled={isUserLockedVersion(version) || isFinalLockedVersion(version) || canUnlockValidationVersion(version)}
>Lock</Button>
{isTemporaryUserLockedVersion(version) ? (
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
</>
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
) : null}
</div>
)
}
),
},
];
}
function versionLockLabel(version: CampaignVersionListItem): string {
if (isUserLockedVersion(version)) return "User locked";
if (isFinalLockedVersion(version)) return "Final";
if (canUnlockValidationVersion(version)) return "Validation lock";
if (version.locked_at) return "Locked";
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
if (version.locked_at) return "Temporarily locked";
return "Editable";
}
@@ -193,7 +230,7 @@ function validationLabel(version: CampaignVersionListItem): string {
const validation = version.validation_summary ?? {};
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
if (validation.ok === false) return "Needs attention";
if (validation.ok === true) return "Invalid for delivery";
if (validation.ok === true) return "Passed, unavailable while user-locked";
return "Not validated";
}
@@ -202,6 +239,33 @@ function buildLabel(version: CampaignVersionListItem): string {
return String(build.built_count ?? build.ready_count ?? "Not built");
}
function lockDialogTitle(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Temporarily lock version?";
if (pending?.action === "unlock") return "Unlock version?";
if (pending?.action === "permanent") return "Lock version permanently?";
return "Confirm lock action";
}
function lockDialogMessage(pending: PendingLockAction): string {
if (pending?.action === "temporary") {
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
}
if (pending?.action === "unlock") {
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
}
if (pending?.action === "permanent") {
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
}
return "Continue?";
}
function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Lock temporarily";
if (pending?.action === "unlock") return "Unlock";
if (pending?.action === "permanent") return "Lock permanently";
return "Confirm";
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">