445 lines
18 KiB
TypeScript
445 lines
18 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { ExternalLink, Upload } from "lucide-react";
|
|
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
|
|
import { Link } from "react-router";
|
|
import type { ApiSettings, AuthInfo } from "../../types";
|
|
import { Card } from "@govoplan/core-webui";
|
|
import { Button } from "@govoplan/core-webui";
|
|
import { Dialog } from "@govoplan/core-webui";
|
|
import { FormField } from "@govoplan/core-webui";
|
|
import { StatusBadge } from "@govoplan/core-webui";
|
|
import { LoadingFrame } from "@govoplan/core-webui";
|
|
import { DismissibleAlert, PageActionBar, PageLayout, TableActionGroup, ToggleSwitch, hasScope, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|
import {
|
|
createNewCampaign,
|
|
importCampaignPackage,
|
|
listCampaignsDelta,
|
|
previewCampaignImport,
|
|
type CampaignDeltaResponse,
|
|
type CampaignImportPreview,
|
|
type CampaignTransferScope
|
|
} from "../../api/campaigns";
|
|
import type { CampaignListItem } from "../../types";
|
|
|
|
export default function CampaignListPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
|
const navigate = useGuardedNavigate();
|
|
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
|
const [error, setError] = useState<string>("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
|
|
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [importPackage, setImportPackage] = useState<Record<string, unknown> | null>(null);
|
|
const [importPreview, setImportPreview] = useState<CampaignImportPreview | null>(null);
|
|
const [importScopes, setImportScopes] = useState<CampaignTransferScope[]>([]);
|
|
const [importIdentity, setImportIdentity] = useState({ external_id: "", name: "" });
|
|
const [importPreviewStale, setImportPreviewStale] = useState(false);
|
|
const [importBusy, setImportBusy] = useState(false);
|
|
const [importError, setImportError] = useState("");
|
|
const canImport = hasScope(auth, "campaigns:campaign:import") && hasScope(auth, "campaigns:campaign:create");
|
|
const canImportRecipients = hasScope(auth, "campaigns:recipient:import") && hasScope(auth, "campaigns:recipient:write");
|
|
|
|
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
let nextWatermark = forcedSince ?? null;
|
|
let nextCampaigns = campaigns;
|
|
let response: CampaignDeltaResponse;
|
|
do {
|
|
response = await listCampaignsDelta(settings, { since: nextWatermark });
|
|
nextCampaigns = mergeCampaignDelta(
|
|
nextCampaigns,
|
|
response,
|
|
Boolean(response.full && nextWatermark?.startsWith("full:campaigns:"))
|
|
);
|
|
nextWatermark = response.watermark ?? null;
|
|
} while (response.has_more);
|
|
setCampaigns(nextCampaigns);
|
|
setCampaignDeltaWatermark(nextWatermark);
|
|
setLastLoadedAt(formatLoadedAt(new Date()));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function create() {
|
|
setCreating(true);
|
|
setError("");
|
|
try {
|
|
const created = await createNewCampaign(settings);
|
|
navigate(`/campaigns/${created.campaign.id}/wizard/create`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
function openImport() {
|
|
setImportPackage(null);
|
|
setImportPreview(null);
|
|
setImportScopes([]);
|
|
setImportIdentity({ external_id: "", name: "" });
|
|
setImportPreviewStale(false);
|
|
setImportError("");
|
|
setImportOpen(true);
|
|
}
|
|
|
|
async function readImportFile(file: File | undefined) {
|
|
if (!file) return;
|
|
setImportBusy(true);
|
|
setImportError("");
|
|
try {
|
|
if (file.size > 25 * 1024 * 1024) throw new Error("Campaign packages larger than 25 MB must be reviewed and imported through a governed integration.");
|
|
const parsed: unknown = JSON.parse(await file.text());
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Select a JSON object exported as a portable Campaign package.");
|
|
const packageData = parsed as Record<string, unknown>;
|
|
const preview = await previewCampaignImport(settings, { package: packageData });
|
|
const allowedScopes = preview.selected_scopes.filter((scope) => scope !== "recipients" || canImportRecipients);
|
|
setImportPackage(packageData);
|
|
setImportPreview(preview);
|
|
setImportScopes(allowedScopes);
|
|
setImportIdentity({
|
|
external_id: preview.destination.external_id ?? "",
|
|
name: preview.destination.name ?? ""
|
|
});
|
|
setImportPreviewStale(allowedScopes.length !== preview.selected_scopes.length);
|
|
} catch (err) {
|
|
setImportPackage(null);
|
|
setImportPreview(null);
|
|
setImportError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setImportBusy(false);
|
|
}
|
|
}
|
|
|
|
function patchImportIdentity(key: "external_id" | "name", value: string) {
|
|
setImportIdentity((current) => ({ ...current, [key]: value }));
|
|
setImportPreviewStale(true);
|
|
}
|
|
|
|
function toggleImportScope(scope: CampaignTransferScope, checked: boolean) {
|
|
setImportScopes((current) => checked
|
|
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
|
|
: current.filter((item) => item !== scope));
|
|
setImportPreviewStale(true);
|
|
}
|
|
|
|
async function refreshImportPreview() {
|
|
if (!importPackage || importBusy || importScopes.length === 0) return;
|
|
setImportBusy(true);
|
|
setImportError("");
|
|
try {
|
|
const preview = await previewCampaignImport(settings, {
|
|
package: importPackage,
|
|
selected_scopes: importScopes,
|
|
external_id: importIdentity.external_id.trim() || undefined,
|
|
name: importIdentity.name.trim() || undefined
|
|
});
|
|
setImportPreview(preview);
|
|
setImportIdentity({
|
|
external_id: preview.destination.external_id ?? importIdentity.external_id,
|
|
name: preview.destination.name ?? importIdentity.name
|
|
});
|
|
setImportPreviewStale(false);
|
|
} catch (err) {
|
|
setImportError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setImportBusy(false);
|
|
}
|
|
}
|
|
|
|
async function applyImport() {
|
|
if (!importPackage || !importPreview?.compatible || !importPreview.package_sha256 || importPreviewStale || importBusy) return;
|
|
setImportBusy(true);
|
|
setImportError("");
|
|
try {
|
|
const created = await importCampaignPackage(settings, {
|
|
package: importPackage,
|
|
selected_scopes: importScopes,
|
|
external_id: importIdentity.external_id.trim() || undefined,
|
|
name: importIdentity.name.trim() || undefined,
|
|
expected_package_sha256: importPreview.package_sha256
|
|
});
|
|
setImportOpen(false);
|
|
navigate(`/campaigns/${created.campaign.id}`);
|
|
} catch (err) {
|
|
setImportError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setImportBusy(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
setCampaignDeltaWatermark(null);
|
|
load(null);
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
const columns: DataGridColumn<CampaignListItem>[] = [
|
|
{
|
|
id: "campaign",
|
|
header: "i18n:govoplan-campaign.campaign.69390e16",
|
|
width: "minmax(260px, 1.8fr)",
|
|
maxWidth: 720,
|
|
sortable: true,
|
|
filterable: true,
|
|
sticky: "start",
|
|
render: (campaign) =>
|
|
<div>
|
|
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
|
|
{campaign.name || campaign.external_id || campaign.id}
|
|
</Link>
|
|
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
|
|
</div>,
|
|
|
|
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "i18n:govoplan-campaign.status.bae7d5be",
|
|
width: 150,
|
|
sortable: true,
|
|
filterable: true,
|
|
columnType: "from-list",
|
|
list: {
|
|
options: [
|
|
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
|
|
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
|
|
{ value: "completed", label: "i18n:govoplan-campaign.completed.1798b3ba" },
|
|
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }],
|
|
|
|
display: "pill"
|
|
},
|
|
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
|
|
value: (campaign) => campaign.status || "draft"
|
|
},
|
|
{
|
|
id: "current_version",
|
|
header: "i18n:govoplan-campaign.current_version.7be72582",
|
|
width: 170,
|
|
sortable: true,
|
|
filterable: true,
|
|
className: "version-cell mono-small",
|
|
render: (campaign) => <span title={campaign.current_version_id || undefined}>{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}</span>,
|
|
value: (campaign) => campaign.current_version_id ?? ""
|
|
},
|
|
{
|
|
id: "updated",
|
|
header: "i18n:govoplan-campaign.updated.f2f8570d",
|
|
width: 190,
|
|
sortable: true,
|
|
filterable: true,
|
|
filterType: "date",
|
|
className: "updated-cell",
|
|
render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at),
|
|
value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? ""
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
|
width: 120,
|
|
sticky: "end",
|
|
align: "right",
|
|
render: (campaign) => <TableActionGroup actions={[{
|
|
id: "open",
|
|
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id }),
|
|
icon: <ExternalLink aria-hidden="true" />,
|
|
variant: "primary",
|
|
onClick: () => navigate(`/campaigns/${campaign.id}`)
|
|
}]} />
|
|
|
|
}];
|
|
|
|
|
|
return (<>
|
|
<PageLayout
|
|
archetype="collection"
|
|
mode="workspace"
|
|
className="campaigns-page"
|
|
title="i18n:govoplan-campaign.all_campaigns.2bd1ee3a"
|
|
description={<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>}
|
|
headerLoading={loading}
|
|
error={error}
|
|
actions={<PageActionBar
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void load(null), loading }}
|
|
createAction={<>
|
|
{canImport && <Button onClick={openImport} disabled={creating || importBusy}>
|
|
<Upload size={16} aria-hidden="true" />
|
|
Import package
|
|
</Button>}
|
|
<Button variant="primary" onClick={create} disabled={creating}>
|
|
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
|
</Button>
|
|
</>}
|
|
/>}
|
|
>
|
|
|
|
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
|
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
|
|
{campaigns.length === 0 ?
|
|
<div className="empty-state">
|
|
<h2>i18n:govoplan-campaign.no_campaigns_yet.926409b3</h2>
|
|
<p>i18n:govoplan-campaign.start_with_a_guided_campaign_draft_the_webui_wil.c8a675d0</p>
|
|
<Button variant="primary" onClick={create} disabled={creating}>
|
|
i18n:govoplan-campaign.create_first_campaign.9be974a4
|
|
</Button>
|
|
</div> :
|
|
|
|
<div className="admin-table-surface campaigns-table-surface">
|
|
<DataGrid
|
|
id="campaigns"
|
|
rows={campaigns}
|
|
columns={columns}
|
|
getRowKey={(campaign) => campaign.id}
|
|
initialSort={{ columnId: "updated", direction: "desc" }}
|
|
className="campaign-table-wrap"
|
|
emptyText="i18n:govoplan-campaign.no_campaigns_found.22d297f9" />
|
|
|
|
</div>
|
|
}
|
|
</LoadingFrame>
|
|
</Card>
|
|
</PageLayout>
|
|
|
|
<Dialog
|
|
open={importOpen}
|
|
title="Import portable Campaign package"
|
|
className="campaign-copy-dialog campaign-import-dialog"
|
|
helpContextId="campaigns.action.import-package"
|
|
closeDisabled={importBusy}
|
|
onClose={() => setImportOpen(false)}
|
|
footer={<>
|
|
<Button onClick={() => setImportOpen(false)} disabled={importBusy}>Cancel</Button>
|
|
{importPackage && <Button onClick={() => void refreshImportPreview()} disabled={importBusy || importScopes.length === 0 || !importPreviewStale}>
|
|
{importBusy ? "Checking..." : "Refresh preview"}
|
|
</Button>}
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void applyImport()}
|
|
disabled={importBusy || importPreviewStale || !importPreview?.compatible || !importPreview.package_sha256}>
|
|
{importBusy ? "Importing..." : "Create draft"}
|
|
</Button>
|
|
</>}>
|
|
<div className="campaign-copy-form">
|
|
<DismissibleAlert tone="info" resetKey="campaign-portable-import-safety">
|
|
Import always creates a new draft. Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state.
|
|
</DismissibleAlert>
|
|
<FormField label="Portable Campaign package" help="Select a .govoplan-campaign.json file. Packages are integrity-checked before any draft is created.">
|
|
<input type="file" accept="application/json,.json,.govoplan-campaign.json" disabled={importBusy} onChange={(event) => void readImportFile(event.target.files?.[0])} />
|
|
</FormField>
|
|
{importError && <div className="inline-alert is-error" role="alert">{importError}</div>}
|
|
{importPreview && <>
|
|
<div className="campaign-copy-identity">
|
|
<FormField label="Campaign name">
|
|
<input value={importIdentity.name} disabled={importBusy} onChange={(event) => patchImportIdentity("name", event.target.value)} />
|
|
</FormField>
|
|
<FormField label="Campaign ID">
|
|
<input value={importIdentity.external_id} disabled={importBusy} onChange={(event) => patchImportIdentity("external_id", event.target.value)} />
|
|
</FormField>
|
|
</div>
|
|
<div className="campaign-copy-options">
|
|
{importPreview.available_scopes.map((scope) => <div className="campaign-copy-option" key={scope}>
|
|
<div>
|
|
<strong>{transferScopeLabel(scope)}</strong>
|
|
<small>{transferScopeDescription(scope)}</small>
|
|
</div>
|
|
<ToggleSwitch
|
|
label={`Import ${transferScopeLabel(scope)}`}
|
|
checked={importScopes.includes(scope)}
|
|
disabled={importBusy || scope === "recipients" && !canImportRecipients}
|
|
onChange={(checked) => toggleImportScope(scope, checked)} />
|
|
</div>)}
|
|
</div>
|
|
{importPreviewStale && <p className="muted small-note">Identity or scope choices changed. Refresh the preview before importing.</p>}
|
|
{!importPreview.compatible && <div className="inline-alert is-error" role="alert">
|
|
<strong>This package cannot be imported.</strong>
|
|
<ul>{importPreview.errors.map((item) => <li key={item}>{item}</li>)}</ul>
|
|
</div>}
|
|
{importPreview.warnings.length > 0 && <div className="inline-alert is-warning">
|
|
<strong>Review before import</strong>
|
|
<ul>{importPreview.warnings.map((item) => <li key={item}>{item}</li>)}</ul>
|
|
</div>}
|
|
<div className="campaign-import-plan">
|
|
<ImportPlan title="Will create" items={importPreview.will_create} />
|
|
<ImportPlan title="Will skip" items={importPreview.will_skip} />
|
|
</div>
|
|
<p className="muted mono-small">Package {importPreview.package_id ?? "unknown"} · SHA-256 {importPreview.package_sha256 ?? "unavailable"}</p>
|
|
</>}
|
|
</div>
|
|
</Dialog>
|
|
</>);
|
|
|
|
}
|
|
|
|
function shortId(value: string): string {
|
|
if (value.length <= 20) return value;
|
|
return `${value.slice(0, 12)}…${value.slice(-6)}`;
|
|
}
|
|
|
|
function ImportPlan({ title, items }: {title: string;items: CampaignImportPreview["will_create"];}) {
|
|
return <div>
|
|
<h3>{title}</h3>
|
|
{items.length === 0
|
|
? <p className="muted">Nothing.</p>
|
|
: <ul>{items.map((item) => <li key={`${item.scope}:${item.code}`}>
|
|
<strong>{transferScopeLabel(item.scope)}</strong>: {item.summary}
|
|
{typeof item.item_count === "number" ? ` (${item.item_count})` : ""}
|
|
</li>)}</ul>}
|
|
</div>;
|
|
}
|
|
|
|
function transferScopeLabel(scope: CampaignTransferScope): string {
|
|
return ({
|
|
metadata: "Metadata",
|
|
template_config: "Template and configuration",
|
|
recipients: "Recipients",
|
|
attachments: "Attachments",
|
|
review_state: "Review state",
|
|
delivery_history: "Delivery history"
|
|
} satisfies Record<CampaignTransferScope, string>)[scope];
|
|
}
|
|
|
|
function transferScopeDescription(scope: CampaignTransferScope): string {
|
|
return ({
|
|
metadata: "Identity and source description for the new draft.",
|
|
template_config: "Portable fields, templates, policies, and delivery settings.",
|
|
recipients: "Campaign-local recipient rows and import provenance.",
|
|
attachments: "Attachment rules and references; never file content.",
|
|
review_state: "Historical evidence retained in the package, never replayed.",
|
|
delivery_history: "Historical outcomes retained in the package, never replayed."
|
|
} satisfies Record<CampaignTransferScope, string>)[scope];
|
|
}
|
|
|
|
function formatDateTime(value?: string): string {
|
|
return formatPlatformDateTime(value);
|
|
}
|
|
|
|
function formatLoadedAt(value: Date): string {
|
|
return formatDateTimeFromDate(value, { second: "2-digit" });
|
|
}
|
|
|
|
function mergeCampaignDelta(
|
|
current: CampaignListItem[],
|
|
response: CampaignDeltaResponse,
|
|
continuingFullSnapshot = false
|
|
): CampaignListItem[] {
|
|
if (response.full && !continuingFullSnapshot) return response.campaigns;
|
|
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
|
|
deletedResourceType: "campaign",
|
|
sort: sortCampaignsByUpdatedDesc
|
|
});
|
|
}
|
|
|
|
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
|
|
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
|
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
|
|
);
|
|
}
|