refactor(webui): use central campaign components

This commit is contained in:
2026-07-21 12:04:30 +02:00
parent ce92499333
commit b7653b58f4
9 changed files with 192 additions and 432 deletions

View File

@@ -8,7 +8,7 @@ import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns"; import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types"; import type { CampaignListItem } from "../../types";
@@ -126,15 +126,13 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
width: 70, width: 70,
sticky: "end", sticky: "end",
align: "right", align: "right",
render: (campaign) => render: (campaign) => <TableActionGroup actions={[{
<Link id: "open",
to={`/campaigns/${campaign.id}`} label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id }),
className="btn btn-primary admin-icon-button" icon: <ExternalLink aria-hidden="true" />,
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })} variant: "primary",
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}> onClick: () => navigate(`/campaigns/${campaign.id}`)
}]} />
<ExternalLink aria-hidden="true" />
</Link>
}]; }];

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole } from "lucide-react"; import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -10,7 +10,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { import {
lockCampaignVersionPermanently, lockCampaignVersionPermanently,
@@ -40,6 +40,7 @@ type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null; type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign; const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]); const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
@@ -214,7 +215,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<DataGrid <DataGrid
id={`campaign-${campaignId}-versions`} id={`campaign-${campaignId}-versions`}
rows={versions} rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)} columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
getRowKey={(version) => version.id} getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }} initialSort={{ columnId: "version", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e" emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
@@ -296,7 +297,7 @@ function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback; return typeof value === "string" ? value : fallback;
} }
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] { function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [ return [
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 }, { id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" }, { id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
@@ -307,36 +308,18 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 260, width: 150,
sticky: "end", sticky: "end",
render: (version) => { render: (version) => {
const isCurrent = version.id === currentVersionId; const isCurrent = version.id === currentVersionId;
return ( const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
<div className="button-row compact-actions"> const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
<Link return <TableActionGroup actions={[
to={`send?version=${version.id}`} { id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`} { id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })} { id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}> { id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
]} />;
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ?
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
</> :
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
<LockKeyhole aria-hidden="true" />
</Button> :
null)}
</div>);
} }
}]; }];

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, RotateCcw, Search, X } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
downloadCampaignJobsCsv, downloadCampaignJobsCsv,
@@ -21,7 +22,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui"; import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize } from "./utils/campaignView"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas"; import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
@@ -358,18 +359,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 250, width: 190,
sticky: "end", sticky: "end",
render: (row) => { render: (row) => {
const id = String(row.id ?? ""); const id = String(row.id ?? "");
const status = String(row.send_status ?? ""); const status = String(row.send_status ?? "");
return ( return <TableActionGroup actions={[
<div className="button-row compact-actions"> { id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button> { id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>} { id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>} { id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>} ]} />;
</div>);
} }
}], }],
@@ -473,8 +473,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span> <span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} /> <textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label> </label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label> <ToggleSwitch label="i18n:govoplan-campaign.attach_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label> <ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
</Dialog> </Dialog>
<Dialog <Dialog
@@ -526,39 +526,21 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
} }
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "attempt", header: "#", width: 72, sortable: true, value: (row, index) => Number(row.attempt_number ?? index + 1), render: (row, index) => String(row.attempt_number ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
];
return ( return (
<section className="attempt-history-section"> <section className="attempt-history-section">
<h3>{title}</h3> <h3>{title}</h3>
<div className="attempt-history-wrap"> <DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
<table className="attempt-history-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
<th>i18n:govoplan-campaign.finished.355bcc57</th>
<th>i18n:govoplan-campaign.result.5faa59d4</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
<tr key={String(row.id ?? `${kind}-${index}`)}>
<td>{String(row.attempt_number ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
{String(row.smtp_response ?? row.error_message ?? "—")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>); </section>);
} }

View File

@@ -21,6 +21,7 @@ import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui"; import { SegmentedControl } from "@govoplan/core-webui";
import { TableActionGroup } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -582,24 +583,10 @@ function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, on
<span>{translateText(emptyText)}</span> <span>{translateText(emptyText)}</span>
} }
</div> </div>
<Button <TableActionGroup actions={[
type="button" { id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
className="admin-icon-button recipient-address-inline-button" { id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
disabled={disabled} ]} />
aria-label="Edit addresses"
title="Edit addresses"
onClick={onEdit}>
<Pencil aria-hidden="true" />
</Button>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={!copiedText}
aria-label="Copy addresses"
title="Copy addresses"
onClick={onCopy}>
<Copy aria-hidden="true" />
</Button>
</div> </div>
</div>); </div>);
} }
@@ -783,18 +770,17 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
{column.allowMultiple && draftAddresses.length === 0 && {column.allowMultiple && draftAddresses.length === 0 &&
<div className="recipient-address-empty-row"> <div className="recipient-address-empty-row">
<p className="muted recipient-address-empty">{column.emptyText}</p> <p className="muted recipient-address-empty">{column.emptyText}</p>
<div className="data-grid-row-actions data-grid-empty-row-actions"> <TableActionGroup
<Button className="data-grid-empty-row-actions"
type="button" actions={[{
variant="primary" id: "add",
className="data-grid-row-action is-add" label: translatedAddLabel,
aria-label={translatedAddLabel} icon: <Plus size={16} aria-hidden="true" />,
title={translatedAddLabel} variant: "primary",
disabled={!canAdd} disabled: !canAdd,
onClick={() => addAddress()}> onClick: () => addAddress()
<Plus size={16} aria-hidden="true" /> }]}
</Button> />
</div>
</div> </div>
} }
{draftAddresses.map((address, addressIndex) => {draftAddresses.map((address, addressIndex) =>
@@ -813,48 +799,15 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
aria-label={`${column.label} email ${addressIndex + 1}`} aria-label={`${column.label} email ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} /> onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
{column.allowMultiple && {column.allowMultiple &&
<div className="data-grid-row-actions recipient-address-line-actions"> <TableActionGroup
<Button className="recipient-address-line-actions"
type="button" actions={[
variant="primary" { id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
className="data-grid-row-action is-add" { id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
aria-label={translatedAddLabel} { id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
title={translatedAddLabel} { id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
disabled={!canAdd} ]}
onClick={() => addAddress(addressIndex)}> />
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={locked || addressIndex === 0}
onClick={() => moveAddress(addressIndex, addressIndex - 1)}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={locked || addressIndex >= draftAddresses.length - 1}
onClick={() => moveAddress(addressIndex, addressIndex + 1)}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="danger"
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={locked}
onClick={() => removeAddress(addressIndex)}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>
} }
</div> </div>
)} )}
@@ -1080,34 +1033,25 @@ function AddressSourceImportDialog({ settings, campaignId, sources, initialSourc
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div> <div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div> <div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl> </dl>
<div className="admin-table-surface recipient-import-preview-surface"> <AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>} {snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</> </>
} }
</Dialog>); </Dialog>);
} }
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
];
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
}
type RecipientImportDialogProps = { type RecipientImportDialogProps = {
settings: ApiSettings; settings: ApiSettings;
campaignId: string; campaignId: string;
@@ -1451,6 +1395,30 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
replaceColumnMapping(nextMapping); replaceColumnMapping(nextMapping);
} }
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
id: `${columnIndex}-${header}`,
header,
columnIndex,
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
}));
type MappingRow = (typeof mappingRows)[number];
const mappingColumns: DataGridColumn<MappingRow>[] = [
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
];
type PreviewRow = RecipientImportPreview["rows"][number];
const previewColumns: DataGridColumn<PreviewRow>[] = [
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
];
const stepContent = activeStep === "upload" ? const stepContent = activeStep === "upload" ?
<> <>
<div className="campaign-header-grid recipient-import-upload-grid"> <div className="campaign-header-grid recipient-import-upload-grid">
@@ -1569,51 +1537,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</select> </select>
</FormField> </FormField>
</div> </div>
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.column.65ba00e9</th>
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
<th>i18n:govoplan-campaign.content.4f9be057</th>
<th>i18n:govoplan-campaign.field.c326a466</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" &&
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
}
{mapping.kind === "new_field" &&
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
}
</td>
</tr>);
})}
</tbody>
</table>
</div>
</> : </> :
activeStep === "preview" ? activeStep === "preview" ?
<> <>
@@ -1628,32 +1552,14 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
{preview.fieldNamesToCreate.length > 0 && {preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p> <p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
} }
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid
<table className="recipient-import-preview-table"> id="campaign-recipient-import-preview"
<thead> className="recipient-import-preview-grid"
<tr> rows={preview.rows.slice(0, 20)}
<th>#</th> columns={previewColumns}
<th>i18n:govoplan-campaign.to.ae79ea1e</th> getRowKey={(row) => String(row.rowNumber)}
<th>i18n:govoplan-campaign.name.709a2322</th> rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
<th>i18n:govoplan-campaign.fields.e8b68527</th> />
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) =>
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
</tr>
)}
</tbody>
</table>
</div>
</> </>
} }
</> : </> :
@@ -1743,6 +1649,14 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>; return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
} }
type ResolvedFile = ImportFileLinkResolution["files"][number];
const fileColumns: DataGridColumn<ResolvedFile>[] = [
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
];
return ( return (
<div className="recipient-import-file-step"> <div className="recipient-import-file-step">
<div className="recipient-import-file-actions"> <div className="recipient-import-file-actions">
@@ -1781,30 +1695,13 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
</div> </div>
} }
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid
<table className="recipient-import-preview-table recipient-import-file-link-table"> id="campaign-recipient-import-file-links"
<thead> rows={resolution.files}
<tr> columns={fileColumns}
<th>i18n:govoplan-campaign.file.2c3cafa4</th> getRowKey={(file) => file.id}
<th>i18n:govoplan-campaign.path.519e3913</th> emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
<th>i18n:govoplan-campaign.size.b7152342</th> />
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ?
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
resolution.files.map((file) =>
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
</tr>
)}
</tbody>
</table>
</div>
</> </>
} }
</div>); </div>);
@@ -1820,27 +1717,31 @@ type RecipientImportRawTableProps = {
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) { function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15); const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length)); const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
type RawRow = (typeof rows)[number];
const columns: DataGridColumn<RawRow>[] = [
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
id: `column-${columnIndex + 1}`,
header: String(columnIndex + 1),
width: "minmax(140px, 1fr)",
minWidth: 120,
resizable: true,
filterable: true,
value: (row) => row.cells[columnIndex] ?? ""
}))
];
return ( return (
<div className="admin-table-surface recipient-import-preview-surface"> <DataGrid
<table className="recipient-import-preview-table recipient-import-raw-table"> id="campaign-recipient-import-raw"
<thead> className="recipient-import-raw-grid"
<tr> rows={rows}
<th>#</th> columns={columns}
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)} getRowKey={(row) => String(row.rowIndex)}
</tr> rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
</thead> emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
<tbody> initialFit="content"
{visibleRows.length === 0 ? />);
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
visibleRows.map((row, rowIndex) =>
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
)}
</tbody>
</table>
</div>);
} }

View File

@@ -7,6 +7,7 @@ import {
Link2, Link2,
LockKeyhole, LockKeyhole,
PackageCheck, PackageCheck,
Search,
Send, Send,
ShieldCheck, ShieldCheck,
X, X,
@@ -37,7 +38,7 @@ import {
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
@@ -1940,7 +1941,7 @@ reviewedKeys: Set<string>)
}, },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) }, { id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" }, { id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => openMessage(row)}>i18n:govoplan-campaign.review.e29a79fe</Button> }]; { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
} }
@@ -2132,7 +2133,7 @@ function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): Da
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") }, { id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") }, { id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") }, { id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>i18n:govoplan-campaign.details.dc3decbb</Button> }]; { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
} }
@@ -2146,7 +2147,7 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` }, { id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) }, { id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>i18n:govoplan-campaign.review.e29a79fe</Button> }]; { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types"; import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
import { KeyRound } from "lucide-react"; import { KeyRound, Trash2 } from "lucide-react";
import { import {
fetchResourceAccessExplanation, fetchResourceAccessExplanation,
getCampaignShares, getCampaignShares,
@@ -18,7 +18,7 @@ import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group"; type TargetType = "user" | "group";
export default function CampaignAccessCard({ export default function CampaignAccessCard({
@@ -196,7 +196,7 @@ export default function CampaignAccessCard({
} }
}, },
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> }, { id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }], { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, resizable: false, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "remove", label: "i18n:govoplan-campaign.remove.e963907d", icon: <Trash2 aria-hidden="true" />, variant: "danger", onClick: () => setRevokeTarget(row) }]} /> }],
[targetMap]); [targetMap]);
if (available === false) return null; if (available === false) return null;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink } from "lucide-react"; import { ExternalLink, RotateCcw, Send } from "lucide-react";
import type { ApiSettings, CampaignListItem } from "../../types"; import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns"; import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -9,7 +9,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView"; import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = { type OperatorRow = {
@@ -165,16 +165,13 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 270, width: 150,
sticky: "end", sticky: "end",
render: (row) => render: (row) => <TableActionGroup actions={[
<div className="button-row compact-actions"> { id: "open", label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }), icon: <ExternalLink aria-hidden="true" />, onClick: () => navigate(`/campaigns/${row.campaign.id}/report`) },
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}> { id: "retry", label: "i18n:govoplan-campaign.retry.9f5cd8a2", icon: <RotateCcw aria-hidden="true" />, applicable: row.failed > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "retry") },
<ExternalLink /> { id: "queue-unsent", label: "i18n:govoplan-campaign.queue_unsent.b0e98610", icon: <Send aria-hidden="true" />, applicable: row.notAttempted > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "unattempted") }
</Button> ]} />
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
</div>
}], }],
[busy, navigate]); [busy, navigate]);

View File

@@ -4,7 +4,7 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
import { usePlatformLanguage } from "@govoplan/core-webui"; import { usePlatformLanguage } from "@govoplan/core-webui";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -97,10 +97,12 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
width: 70, width: 70,
sticky: "end", sticky: "end",
align: "right", align: "right",
render: (template) => render: (template) => <TableActionGroup actions={[{
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}> id: "open",
<ExternalLink /> label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name }),
</Button> icon: <ExternalLink aria-hidden="true" />,
onClick: () => openTemplate(template.id)
}]} />
}]; }];
@@ -115,8 +117,7 @@ function templateFieldColumns(): DataGridColumn<{id: string;field: string;source
return [ return [
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field }, { id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source }, { id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }, { id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
} }

View File

@@ -1,6 +1,4 @@
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */ /* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
.small-note { font-size: 12px; } .small-note { font-size: 12px; }
.wizard-action-grid { .wizard-action-grid {
@@ -80,7 +78,6 @@
.standalone-wizard-body { min-height: auto; } .standalone-wizard-body { min-height: auto; }
/* Editable campaign data surfaces. */ /* Editable campaign data surfaces. */
.responsive-form-grid { align-items: start; }
.json-edit-block { .json-edit-block {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -129,7 +126,6 @@
@media (max-width: 1100px) { @media (max-width: 1100px) {
.template-editor-grid { grid-template-columns: 1fr; } .template-editor-grid { grid-template-columns: 1fr; }
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
} }
/* Campaign data layout refinement. */ /* Campaign data layout refinement. */
@@ -322,22 +318,6 @@
.campaigns-page .card-body { .campaigns-page .card-body {
padding: 0; padding: 0;
} }
.campaign-table-wrap {
margin: 0;
}
.campaign-table thead,
.campaign-table thead th {
top: 64px;
}
.campaign-table th:first-child,
.campaign-table td:first-child {
padding-left: 24px;
}
.campaign-table th:last-child,
.campaign-table td:last-child {
padding-right: 24px;
}
.module-workspace .section-sidebar { .module-workspace .section-sidebar {
padding-top: 18px; padding-top: 18px;
} }
@@ -580,7 +560,7 @@
box-shadow: var(--shadow-campaign-small); box-shadow: var(--shadow-campaign-small);
} }
.field-chip-button.used { .field-chip-button.used {
background: var(--green-soft); background: var(--success-soft);
border-color: var(--success-border-soft); border-color: var(--success-border-soft);
color: var(--success-text-strong); color: var(--success-text-strong);
} }
@@ -745,14 +725,6 @@
color: var(--muted); color: var(--muted);
font-style: italic; font-style: italic;
} }
.recipient-address-inline-button {
width: 28px;
height: 28px;
}
.recipient-address-inline-button svg {
width: 16px;
height: 16px;
}
.recipient-add-row td { .recipient-add-row td {
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -771,15 +743,6 @@
} }
/* Recipient editor compaction and reusable attachment rule overlay. */ /* Recipient editor compaction and reusable attachment rule overlay. */
.recipient-editor-table th:nth-child(1),
.recipient-editor-table td:nth-child(1) { width: 42px; }
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 500px; }
.recipient-editor-table th:nth-child(3),
.recipient-editor-table td:nth-child(3) { min-width: 192px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 123px; }
.recipient-editor-table td { vertical-align: top; }
.recipient-cell { white-space: normal !important; } .recipient-cell { white-space: normal !important; }
.recipient-address-stack { .recipient-address-stack {
display: grid; display: grid;
@@ -861,8 +824,6 @@
.attachment-sources-table td:last-child { width: 123px; } .attachment-sources-table td:last-child { width: 123px; }
@media (max-width: 900px) { @media (max-width: 900px) {
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
.recipient-address-stack { min-width: 260px; } .recipient-address-stack { min-width: 260px; }
} }
.attachment-rules-table th:nth-child(1), .attachment-rules-table th:nth-child(1),
@@ -894,7 +855,7 @@
.attachment-rules-main { .attachment-rules-main {
min-width: 0; min-width: 0;
} }
.attachment-rules-table tr.is-choosing-file td { .attachment-rules-table .data-grid-body-cell.is-choosing-file {
background: var(--panel-soft); background: var(--panel-soft);
} }
.attachment-file-browser-panel { .attachment-file-browser-panel {
@@ -940,7 +901,7 @@
.field-with-action input.chooser-display-input { .field-with-action input.chooser-display-input {
background: var(--panel-soft); background: var(--panel-soft);
border-color: var(--line-dark); border-color: var(--line-dark);
color: var(--ink); color: var(--text-strong);
cursor: pointer; cursor: pointer;
caret-color: transparent; caret-color: transparent;
user-select: none; user-select: none;
@@ -1130,7 +1091,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
max-width: 260px; max-width: 260px;
color: var(--ink); color: var(--text-strong);
text-decoration: none; text-decoration: none;
} }
.recipient-data-identity:hover .recipient-data-address { .recipient-data-identity:hover .recipient-data-address {
@@ -1279,7 +1240,7 @@
overflow: auto; overflow: auto;
} }
.version-history-table .current-version-row td { .version-history-table .data-grid-body-cell.current-version-row {
font-weight: 700; font-weight: 700;
} }
@@ -1297,7 +1258,7 @@
border: 1px solid var(--line-subtle); border: 1px solid var(--line-subtle);
border-radius: 12px; border-radius: 12px;
background: var(--panel-glass); background: var(--panel-glass);
color: var(--text-primary); color: var(--text);
padding: 12px; padding: 12px;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -1379,7 +1340,7 @@
border-radius: 12px; border-radius: 12px;
background: var(--panel-glass); background: var(--panel-glass);
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
color: var(--text-primary); color: var(--text);
} }
.attachment-file-chip strong { .attachment-file-chip strong {
@@ -1401,7 +1362,7 @@
.message-preview-raw summary { .message-preview-raw summary {
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text);
} }
@media (max-width: 720px), (max-height: 700px) { @media (max-width: 720px), (max-height: 700px) {
@@ -1446,25 +1407,6 @@
color: var(--warning); color: var(--warning);
} }
/* Managed attachment chooser refinements. */
.split-field-action {
gap: 0;
}
.split-field-action > input,
.split-field-action > select,
.split-field-action > textarea {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-field-action > button,
.split-field-action > .button,
.split-field-action > .btn {
align-self: stretch;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
}
/* Review & Send workflow page. */ /* Review & Send workflow page. */
.review-send-page { .review-send-page {
--review-flow-partial: var(--review-flow-purple, #9b86c7); --review-flow-partial: var(--review-flow-purple, #9b86c7);
@@ -2654,54 +2596,20 @@
min-height: 78px; min-height: 78px;
padding: 16px; padding: 16px;
} }
.recipient-import-preview-surface { .recipient-import-preview-grid .data-grid-body-cell.is-invalid {
overflow: auto;
}
.recipient-import-preview-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
.recipient-import-preview-table th,
.recipient-import-preview-table td {
border-bottom: var(--border-line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.recipient-import-preview-table th {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.recipient-import-preview-table td {
color: var(--text);
font-size: 13px;
word-break: break-word;
}
.recipient-import-preview-table tr.is-invalid td {
color: var(--danger-text); color: var(--danger-text);
} }
.recipient-import-raw-table td {
.recipient-import-raw-grid .data-grid-body-cell {
white-space: pre; white-space: pre;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px; font-size: 12px;
} }
.recipient-import-raw-table tr.is-header-row td {
.recipient-import-raw-grid .data-grid-body-cell.is-header-row {
background: color-mix(in srgb, var(--accent) 8%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent);
font-weight: 700; font-weight: 700;
} }
.recipient-import-mapping-table select,
.recipient-import-mapping-table input {
width: 100%;
min-width: 160px;
}
.recipient-import-mapping-table td:nth-child(2) {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-import-file-step { .recipient-import-file-step {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -2793,17 +2701,6 @@
margin: 4px 0 0; margin: 4px 0 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.admin-managed-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 7px;
border-radius: 999px;
background: var(--info-muted-bg);
color: var(--info-text-muted);
font-size: 11px;
font-weight: 800;
}
.admin-governance-mode { .admin-governance-mode {
display: grid; display: grid;
gap: 8px; gap: 8px;