refactor(campaign): split review and recipient boundaries

This commit is contained in:
2026-07-30 01:01:26 +02:00
parent 23b9a531d5
commit 961d5d1130
12 changed files with 1860 additions and 1179 deletions
@@ -0,0 +1,229 @@
import { useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
import { humanize } from "../utils/campaignView";
export type FlowState =
| "complete"
| "warning"
| "danger"
| "active"
| "locked"
| "running"
| "partial"
| "stale"
| "pending";
export type FlowStageDefinition = {
id: string;
title: string;
shortTitle: string;
description: string;
icon: LucideIcon;
state: FlowState;
connectorState?: FlowState;
stateLabel: string;
lockReason?: string;
};
const stateColors: Record<FlowState, string> = {
complete: "var(--green)",
warning: "var(--amber)",
danger: "var(--red)",
active: "var(--blue)",
locked: "var(--line-dark)",
running: "var(--blue)",
partial: "var(--review-flow-partial)",
stale: "var(--review-flow-partial)",
pending: "var(--muted)"
};
export function WorkflowNavigation({
stages,
onSelect
}: {
stages: FlowStageDefinition[];
onSelect: (id: string) => void;
}) {
return (
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
<div className="review-flow-navigation-track">
{stages.map((stage, index) => {
const Icon = stage.icon;
const nextStage = stages[index + 1];
const connectorState = stageConnectorState(stage);
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
const title = i18nMessage(stage.title);
const shortTitle = i18nMessage(stage.shortTitle);
const stateText = i18nMessage(stage.stateLabel);
const style = {
"--review-nav-color": stateColors[stage.state],
"--review-nav-line-color": stateColors[connectorState],
"--review-nav-line-next-color": stateColors[nextConnectorState]
} as CSSProperties;
const showSecondaryState = !["active", "locked"].includes(stage.state);
return (
<div className="review-flow-navigation-group" key={stage.id} style={style}>
<button
type="button"
className="review-flow-navigation-item"
data-state={stage.state}
onClick={() => onSelect(stage.id)}
title={`${title}: ${stateText}`}
>
<span className="review-flow-navigation-icon">
<Icon size={17} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="review-flow-navigation-copy">
<strong>
<span>{shortTitle}</span>
{stage.state === "locked" && (
<LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />
)}
</strong>
{showSecondaryState && <small>{stateText}</small>}
</span>
</button>
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
</div>
);
})}
</div>
</nav>
);
}
export function stageConnectorState(stage: FlowStageDefinition): FlowState {
return stage.connectorState ?? stage.state;
}
export function WorkflowStage({
stage,
nextState,
nextConnectorState,
children
}: {
stage: FlowStageDefinition;
nextState?: FlowState;
nextConnectorState?: FlowState;
children: ReactNode;
}) {
const Icon = stage.icon;
const locked = stage.state === "locked";
const [collapsed, setCollapsed] = useState(false);
const title = i18nMessage(stage.title);
const description = i18nMessage(stage.description);
const stateText = i18nMessage(stage.stateLabel);
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
const connectorState = stageConnectorState(stage);
const style = {
"--review-stage-color": stateColors[stage.state],
"--review-stage-line-color": stateColors[connectorState],
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
} as CSSProperties;
return (
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
<div className="review-flow-stage-marker" aria-hidden="true">
<div className="review-flow-stage-node">
<Icon size={20} strokeWidth={1.8} />
</div>
{nextState && <div className="review-flow-stage-line" />}
</div>
<article
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
aria-disabled={locked || undefined}
>
<header className="card-header review-flow-stage-header">
<h2>
<span>{title}</span>
{locked && (
<LockKeyhole
className="review-flow-title-lock"
size={15}
aria-label="i18n:govoplan-campaign.locked.a798882f"
/>
)}
{!locked && (
<span
className="review-flow-state-badge"
data-state={stage.state}
aria-label={stateText}
title={stateText}
>
{stateText}
</span>
)}
<InlineHelp>{description}</InlineHelp>
</h2>
<div className="review-flow-stage-header-actions">
<button
type="button"
className="card-collapse-toggle"
aria-expanded={!collapsed}
aria-label={
collapsed
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
}
title={
collapsed
? "i18n:govoplan-campaign.show_content.0528d8d2"
: "i18n:govoplan-campaign.show_header_only.24afefca"
}
onClick={() => setCollapsed((value) => !value)}
>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>
</div>
</header>
{!collapsed && (
<>
<div className="card-body review-flow-stage-content">{children}</div>
{locked && (
<div className="review-flow-lock-message">
<span className="review-flow-lock-icon">
<LockKeyhole size={20} aria-hidden="true" />
</span>
<span>{lockReason}</span>
</div>
)}
</>
)}
</article>
</section>
);
}
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="review-flow-fact">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function stateLabel(state: FlowState): string {
switch (state) {
case "complete":
return "i18n:govoplan-campaign.passed.271d60f4";
case "warning":
return "i18n:govoplan-campaign.warnings.1430f976";
case "danger":
return "i18n:govoplan-campaign.blocked.99613c74";
case "active":
return "i18n:govoplan-campaign.available.7c62a142";
case "locked":
return "i18n:govoplan-campaign.locked.a798882f";
case "running":
return "i18n:govoplan-campaign.running.73989d9c";
case "partial":
return "i18n:govoplan-campaign.partial.65de2e2a";
case "stale":
return "i18n:govoplan-campaign.stale.189cc40c";
default:
return humanize(state);
}
}