refactor(webui): use central campaign components
This commit is contained in:
@@ -8,7 +8,7 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { PageTitle } 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 { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
||||
import type { CampaignListItem } from "../../types";
|
||||
@@ -126,15 +126,13 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (campaign) =>
|
||||
<Link
|
||||
to={`/campaigns/${campaign.id}`}
|
||||
className="btn btn-primary admin-icon-button"
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
|
||||
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
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}`)
|
||||
}]} />
|
||||
|
||||
}];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { ApiSettings } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
@@ -10,7 +10,7 @@ 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, 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 {
|
||||
lockCampaignVersionPermanently,
|
||||
@@ -40,6 +40,7 @@ type LockAction = "temporary" | "unlock" | "permanent";
|
||||
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
||||
|
||||
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 campaign = data.campaign;
|
||||
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
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
rows={versions}
|
||||
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
||||
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
|
||||
getRowKey={(version) => version.id}
|
||||
initialSort={{ columnId: "version", direction: "desc" }}
|
||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||
@@ -296,7 +297,7 @@ function textValue(value: unknown, fallback = ""): string {
|
||||
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 [
|
||||
{ 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" },
|
||||
@@ -307,36 +308,18 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 260,
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
render: (version) => {
|
||||
const isCurrent = version.id === currentVersionId;
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Link
|
||||
to={`send?version=${version.id}`}
|
||||
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
|
||||
|
||||
<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>);
|
||||
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
|
||||
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
|
||||
return <TableActionGroup actions={[
|
||||
{ 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}`) },
|
||||
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
|
||||
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
|
||||
{ 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" }) }
|
||||
]} />;
|
||||
|
||||
}
|
||||
}];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, RotateCcw, Search, X } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
downloadCampaignJobsCsv,
|
||||
@@ -21,7 +22,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
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 { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||
@@ -358,18 +359,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 250,
|
||||
width: 190,
|
||||
sticky: "end",
|
||||
render: (row) => {
|
||||
const id = String(row.id ?? "");
|
||||
const status = String(row.send_status ?? "");
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
||||
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||
</div>);
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
|
||||
{ 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]) },
|
||||
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
|
||||
{ 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" }) }
|
||||
]} />;
|
||||
|
||||
}
|
||||
}],
|
||||
@@ -473,8 +473,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<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} />
|
||||
</label>
|
||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
|
||||
<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_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
|
||||
</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 (
|
||||
<section className="attempt-history-section">
|
||||
<h3>{title}</h3>
|
||||
<div className="attempt-history-wrap">
|
||||
<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>
|
||||
<DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { Dialog } 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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
@@ -582,24 +583,10 @@ function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, on
|
||||
<span>{translateText(emptyText)}</span>
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="admin-icon-button recipient-address-inline-button"
|
||||
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>
|
||||
<TableActionGroup actions={[
|
||||
{ id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
|
||||
{ id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
|
||||
]} />
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -783,18 +770,17 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
{column.allowMultiple && draftAddresses.length === 0 &&
|
||||
<div className="recipient-address-empty-row">
|
||||
<p className="muted recipient-address-empty">{column.emptyText}</p>
|
||||
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="data-grid-row-action is-add"
|
||||
aria-label={translatedAddLabel}
|
||||
title={translatedAddLabel}
|
||||
disabled={!canAdd}
|
||||
onClick={() => addAddress()}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<TableActionGroup
|
||||
className="data-grid-empty-row-actions"
|
||||
actions={[{
|
||||
id: "add",
|
||||
label: translatedAddLabel,
|
||||
icon: <Plus size={16} aria-hidden="true" />,
|
||||
variant: "primary",
|
||||
disabled: !canAdd,
|
||||
onClick: () => addAddress()
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{draftAddresses.map((address, addressIndex) =>
|
||||
@@ -813,48 +799,15 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
aria-label={`${column.label} email ${addressIndex + 1}`}
|
||||
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
|
||||
{column.allowMultiple &&
|
||||
<div className="data-grid-row-actions recipient-address-line-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="data-grid-row-action is-add"
|
||||
aria-label={translatedAddLabel}
|
||||
title={translatedAddLabel}
|
||||
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>
|
||||
<TableActionGroup
|
||||
className="recipient-address-line-actions"
|
||||
actions={[
|
||||
{ id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
|
||||
{ id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
|
||||
{ id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
|
||||
{ id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
@@ -1080,34 +1033,25 @@ function AddressSourceImportDialog({ settings, campaignId, sources, initialSourc
|
||||
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
||||
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
||||
</dl>
|
||||
<div className="admin-table-surface recipient-import-preview-surface">
|
||||
<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>
|
||||
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
||||
</>
|
||||
}
|
||||
</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 = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
@@ -1451,6 +1395,30 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
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" ?
|
||||
<>
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
@@ -1569,51 +1537,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="admin-table-surface recipient-import-preview-surface">
|
||||
<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>
|
||||
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
|
||||
</> :
|
||||
activeStep === "preview" ?
|
||||
<>
|
||||
@@ -1628,32 +1552,14 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
||||
{preview.fieldNamesToCreate.length > 0 &&
|
||||
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
|
||||
}
|
||||
<div className="admin-table-surface recipient-import-preview-surface">
|
||||
<table className="recipient-import-preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>i18n:govoplan-campaign.to.ae79ea1e</th>
|
||||
<th>i18n:govoplan-campaign.name.709a2322</th>
|
||||
<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>
|
||||
<DataGrid
|
||||
id="campaign-recipient-import-preview"
|
||||
className="recipient-import-preview-grid"
|
||||
rows={preview.rows.slice(0, 20)}
|
||||
columns={previewColumns}
|
||||
getRowKey={(row) => String(row.rowNumber)}
|
||||
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</> :
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="recipient-import-file-step">
|
||||
<div className="recipient-import-file-actions">
|
||||
@@ -1781,30 +1695,13 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="admin-table-surface recipient-import-preview-surface">
|
||||
<table className="recipient-import-preview-table recipient-import-file-link-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>i18n:govoplan-campaign.file.2c3cafa4</th>
|
||||
<th>i18n:govoplan-campaign.path.519e3913</th>
|
||||
<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>
|
||||
<DataGrid
|
||||
id="campaign-recipient-import-file-links"
|
||||
rows={resolution.files}
|
||||
columns={fileColumns}
|
||||
getRowKey={(file) => file.id}
|
||||
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</div>);
|
||||
@@ -1820,27 +1717,31 @@ type RecipientImportRawTableProps = {
|
||||
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
|
||||
const visibleRows = tableRows.slice(0, 15);
|
||||
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 (
|
||||
<div className="admin-table-surface recipient-import-preview-surface">
|
||||
<table className="recipient-import-preview-table recipient-import-raw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>);
|
||||
<DataGrid
|
||||
id="campaign-recipient-import-raw"
|
||||
className="recipient-import-raw-grid"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => String(row.rowIndex)}
|
||||
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
|
||||
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
|
||||
initialFit="content"
|
||||
/>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
PackageCheck,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
X,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
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 { ConfirmDialog } 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: "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: "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: "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: "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: "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 ?? "")) }]} /> }];
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { KeyRound, Trash2 } from "lucide-react";
|
||||
import {
|
||||
fetchResourceAccessExplanation,
|
||||
getCampaignShares,
|
||||
@@ -18,7 +18,7 @@ import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import { Dialog } 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";
|
||||
|
||||
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: "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]);
|
||||
|
||||
if (available === false) return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
@@ -9,7 +9,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } 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";
|
||||
|
||||
type OperatorRow = {
|
||||
@@ -165,16 +165,13 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 270,
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
render: (row) =>
|
||||
<div className="button-row compact-actions">
|
||||
<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 })}>
|
||||
<ExternalLink />
|
||||
</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>
|
||||
render: (row) => <TableActionGroup 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`) },
|
||||
{ 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") },
|
||||
{ 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") }
|
||||
]} />
|
||||
|
||||
}],
|
||||
[busy, navigate]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } 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 ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
@@ -97,10 +97,12 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (template) =>
|
||||
<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 })}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
render: (template) => <TableActionGroup actions={[{
|
||||
id: "open",
|
||||
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name }),
|
||||
icon: <ExternalLink aria-hidden="true" />,
|
||||
onClick: () => openTemplate(template.id)
|
||||
}]} />
|
||||
|
||||
}];
|
||||
|
||||
@@ -115,8 +117,7 @@ function templateFieldColumns(): DataGridColumn<{id: string;field: string;source
|
||||
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: "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: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
|
||||
{ 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 }];
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/* 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; }
|
||||
|
||||
.wizard-action-grid {
|
||||
@@ -80,7 +78,6 @@
|
||||
.standalone-wizard-body { min-height: auto; }
|
||||
|
||||
/* Editable campaign data surfaces. */
|
||||
.responsive-form-grid { align-items: start; }
|
||||
.json-edit-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -129,7 +126,6 @@
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.template-editor-grid { grid-template-columns: 1fr; }
|
||||
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Campaign data layout refinement. */
|
||||
@@ -322,22 +318,6 @@
|
||||
.campaigns-page .card-body {
|
||||
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 {
|
||||
padding-top: 18px;
|
||||
}
|
||||
@@ -580,7 +560,7 @@
|
||||
box-shadow: var(--shadow-campaign-small);
|
||||
}
|
||||
.field-chip-button.used {
|
||||
background: var(--green-soft);
|
||||
background: var(--success-soft);
|
||||
border-color: var(--success-border-soft);
|
||||
color: var(--success-text-strong);
|
||||
}
|
||||
@@ -745,14 +725,6 @@
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.recipient-address-inline-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.recipient-address-inline-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.recipient-add-row td {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -771,15 +743,6 @@
|
||||
}
|
||||
|
||||
/* 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-address-stack {
|
||||
display: grid;
|
||||
@@ -861,8 +824,6 @@
|
||||
.attachment-sources-table td:last-child { width: 123px; }
|
||||
|
||||
@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; }
|
||||
}
|
||||
.attachment-rules-table th:nth-child(1),
|
||||
@@ -894,7 +855,7 @@
|
||||
.attachment-rules-main {
|
||||
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);
|
||||
}
|
||||
.attachment-file-browser-panel {
|
||||
@@ -940,7 +901,7 @@
|
||||
.field-with-action input.chooser-display-input {
|
||||
background: var(--panel-soft);
|
||||
border-color: var(--line-dark);
|
||||
color: var(--ink);
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
caret-color: transparent;
|
||||
user-select: none;
|
||||
@@ -1130,7 +1091,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 260px;
|
||||
color: var(--ink);
|
||||
color: var(--text-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
.recipient-data-identity:hover .recipient-data-address {
|
||||
@@ -1279,7 +1240,7 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.version-history-table .current-version-row td {
|
||||
.version-history-table .data-grid-body-cell.current-version-row {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -1297,7 +1258,7 @@
|
||||
border: 1px solid var(--line-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-glass);
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1379,7 +1340,7 @@
|
||||
border-radius: 12px;
|
||||
background: var(--panel-glass);
|
||||
padding: 0.55rem 0.7rem;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.attachment-file-chip strong {
|
||||
@@ -1401,7 +1362,7 @@
|
||||
.message-preview-raw summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 720px), (max-height: 700px) {
|
||||
@@ -1446,25 +1407,6 @@
|
||||
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-page {
|
||||
--review-flow-partial: var(--review-flow-purple, #9b86c7);
|
||||
@@ -2654,54 +2596,20 @@
|
||||
min-height: 78px;
|
||||
padding: 16px;
|
||||
}
|
||||
.recipient-import-preview-surface {
|
||||
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 {
|
||||
.recipient-import-preview-grid .data-grid-body-cell.is-invalid {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
.recipient-import-raw-table td {
|
||||
|
||||
.recipient-import-raw-grid .data-grid-body-cell {
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
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);
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -2793,17 +2701,6 @@
|
||||
margin: 4px 0 0;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user