Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions
@@ -1,33 +1,39 @@
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole } from "lucide-react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
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 { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
canUnlockValidationVersion,
formatDateTime,
getCampaignJson,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
@@ -43,6 +49,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState("");
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
useEffect(() => {
if (!campaign || identityDirty) return;
@@ -125,6 +132,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 current-version-metrics">
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" />
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div>
<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" />
@@ -132,7 +146,23 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Campaign identity">
<Card title="Current version state" actions={<Link
to={`send?version=${campaign?.current_version_id}`}
className={`btn btn-primary`}
aria-label={`Open curent version`}
title={`Open curent version`}
>
Open
</Link>}>
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
</Card>
<Card title="Campaign identity" collapsible>
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
@@ -151,25 +181,18 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
</div>
</Card>
<Card title="Version history">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
</Card>
<Card title="Current version state">
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
<Card title="Version history" collapsible>
<div className="admin-table-surface">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
</div>
</Card>
</LoadingFrame>
@@ -188,6 +211,63 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
);
}
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
type CampaignVersionMetrics = {
fieldCount: number;
recipientCount: number;
templateHealthValue: string;
templateHealthTone: TemplateHealthTone;
templateHealthDetail: string;
};
function campaignVersionMetrics(version: CampaignVersionDetail | null): CampaignVersionMetrics {
const campaignJson = getCampaignJson(version);
const fields = asArray(campaignJson.fields).map(asRecord);
const entries = asRecord(campaignJson.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const template = asRecord(campaignJson.template);
const globalValues = asRecord(campaignJson.global_values);
const bodyMode = normalizeTemplateBodyMode(textValue(template.body_mode, "both"));
const subject = textValue(template.subject);
const textBody = bodyMode !== "html" ? textValue(template.text) : "";
const htmlBody = bodyMode !== "text" ? textValue(template.html) : "";
const subjectOk = subject.trim().length > 0;
const bodyOk = bodyMode === "html" ? htmlBody.trim().length > 0 : bodyMode === "text" ? textBody.trim().length > 0 : Boolean(textBody.trim() || htmlBody.trim());
const localFieldNames = fields.map((field) => textValue(field.name || field.id)).filter(Boolean);
const globalFieldNames = Object.keys(globalValues).filter(Boolean);
const addressFieldNames = recipientAddressTemplateFieldOptions().map((field) => field.name);
const localAvailableNames = new Set([...localFieldNames, ...addressFieldNames]);
const globalAvailableNames = new Set(globalFieldNames);
const allAvailableNames = new Set([...localAvailableNames, ...globalAvailableNames]);
const placeholders = extractTemplatePlaceholders([subject, textBody, htmlBody].join("\n"));
const undefinedPlaceholders = buildUndefinedPlaceholders(placeholders, allAvailableNames, {
local: localAvailableNames,
global: globalAvailableNames
});
const placeholdersOk = undefinedPlaceholders.length === 0;
const score = [subjectOk, bodyOk, placeholdersOk].filter(Boolean).length;
return {
fieldCount: localFieldNames.length,
recipientCount: inlineEntries.filter((entry) => entry.active !== false).length,
templateHealthValue: `${score}/3`,
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
templateHealthDetail: [
subjectOk ? "Subject OK" : "Subject missing",
bodyOk ? "Body OK" : "Body missing",
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined`
].join(" · ")
};
}
function normalizeTemplateBodyMode(value: string): "text" | "html" | "both" {
return value === "text" || value === "html" || value === "both" ? value : "both";
}
function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
@@ -199,20 +279,34 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{
id: "actions",
header: "Actions",
width: 310,
width: 260,
sticky: "end",
render: (version) => {
const isCurrent = version.id === currentVersionId;
return (
<div className="button-row compact-actions">
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
<Link
to={`send?version=${version.id}`}
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
aria-label={`Open version ${version.version_number}`}
title={`Open version ${version.version_number}`}
>
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (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>
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={`Temporarily lock version ${version.version_number}`}
title="Temporarily lock version"
>
<LockKeyhole aria-hidden="true" />
</Button>
) : null)}
</div>
);