DataGrid list type; Review/Send refinment; User lock; Table actions

This commit is contained in:
2026-06-13 19:28:48 +02:00
parent 5937dfe97e
commit c72df498e7
15 changed files with 1318 additions and 218 deletions

View File

@@ -48,6 +48,9 @@ export type CampaignVersionListItem = {
published_at?: string | null; published_at?: string | null;
locked_at?: string | null; locked_at?: string | null;
locked_by_user_id?: string | null; locked_by_user_id?: string | null;
user_lock_state?: "temporary" | "permanent" | null;
user_locked_at?: string | null;
user_locked_by_user_id?: string | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
validation_summary?: Record<string, unknown> | null; validation_summary?: Record<string, unknown> | null;
@@ -186,6 +189,11 @@ export type CampaignMockSendPayload = {
check_files?: boolean; check_files?: boolean;
}; };
export type CampaignReviewStatePayload = {
inspection_complete: boolean;
reviewed_message_keys: string[];
};
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> { export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns"); const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
@@ -261,6 +269,36 @@ export async function unlockCampaignVersionValidation(
}); });
} }
export async function lockCampaignVersionTemporarily(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
method: "POST"
});
}
export async function unlockCampaignVersionUserLock(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
method: "POST"
});
}
export async function lockCampaignVersionPermanently(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
method: "POST"
});
}
export async function updateCampaignVersion( export async function updateCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -372,8 +410,25 @@ export async function getCampaignSummary(settings: ApiSettings, campaignId: stri
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`); return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`);
} }
export async function getCampaignJobs(settings: ApiSettings, campaignId: string): Promise<{ jobs: Record<string, unknown>[] }> { export async function getCampaignJobs(
return apiFetch<{ jobs: Record<string, unknown>[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs`); settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<{ jobs: Record<string, unknown>[] }> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<{ jobs: Record<string, unknown>[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
}
export async function updateCampaignReviewState(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignReviewStatePayload
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
method: "POST",
body: JSON.stringify(payload)
});
} }
export async function queueCampaign( export async function queueCampaign(

View File

@@ -1,3 +1,3 @@
export default function StatusBadge({ status }: { status: string }) { export default function StatusBadge({ status, label }: { status: string; label?: string }) {
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{status}</span>; return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{label ?? status}</span>;
} }

View File

@@ -1,9 +1,25 @@
import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, X } from "lucide-react"; import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge";
export type DataGridSortDirection = "asc" | "desc"; export type DataGridSortDirection = "asc" | "desc";
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date"; export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
export type DataGridListOption = {
value: string;
label: string;
disabled?: boolean;
};
export type DataGridListConfig<T> = {
options: DataGridListOption[];
display?: "text" | "pill";
editable?: boolean;
onChange?: (row: T, index: number, value: string) => void;
optionsEditable?: boolean;
onOptionsChange?: (options: DataGridListOption[]) => void;
};
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after"; type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
@@ -17,6 +33,8 @@ export type DataGridColumn<T> = {
sortable?: boolean; sortable?: boolean;
filterable?: boolean; filterable?: boolean;
filterType?: DataGridFilterType; filterType?: DataGridFilterType;
columnType?: "default" | "from-list";
list?: DataGridListConfig<T>;
sticky?: "start" | "end"; sticky?: "start" | "end";
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
className?: string; className?: string;
@@ -44,6 +62,20 @@ type DataGridProps<T> = {
className?: string; className?: string;
rowClassName?: (row: T, index: number) => string | undefined; rowClassName?: (row: T, index: number) => string | undefined;
storageKey?: string; storageKey?: string;
initialFilters?: Record<string, string | string[]>;
};
export type DataGridRowActionsProps = {
disabled?: boolean;
removeDisabled?: boolean;
onAddBelow: () => void;
onRemove: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
addLabel?: string;
removeLabel?: string;
moveUpLabel?: string;
moveDownLabel?: string;
}; };
type FilterPosition = { type FilterPosition = {
@@ -65,10 +97,13 @@ export default function DataGrid<T>({
fit = "container", fit = "container",
className = "", className = "",
rowClassName, rowClassName,
storageKey storageKey,
initialFilters = {}
}: DataGridProps<T>) { }: DataGridProps<T>) {
const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`; const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`;
const [state, setState] = useState<DataGridState>(() => loadState(localStorageKey)); const initialFiltersKey = JSON.stringify(initialFilters);
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
const [state, setState] = useState<DataGridState>(() => mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters));
const [resizeState, setResizeState] = useState<{ const [resizeState, setResizeState] = useState<{
columnId: string; columnId: string;
startX: number; startX: number;
@@ -93,6 +128,10 @@ export default function DataGrid<T>({
setState((current) => sanitizePersistedColumnState(columns, current)); setState((current) => sanitizePersistedColumnState(columns, current));
}, [columns]); }, [columns]);
useEffect(() => {
setState((current) => mergeInitialFilters(current, normalizedInitialFilters));
}, [normalizedInitialFilters]);
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state)); window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -247,7 +286,9 @@ export default function DataGrid<T>({
const filterTypes = useMemo(() => { const filterTypes = useMemo(() => {
const result: Record<string, DataGridFilterType> = {}; const result: Record<string, DataGridFilterType> = {};
for (const column of columns) result[column.id] = column.filterType ?? inferFilterType(column, rows); for (const column of columns) {
result[column.id] = column.columnType === "from-list" ? "list" : column.filterType ?? inferFilterType(column, rows);
}
return result; return result;
}, [columns, rows]); }, [columns, rows]);
@@ -301,10 +342,13 @@ export default function DataGrid<T>({
setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } })); setState((current) => ({ ...current, filters: { ...(current.filters ?? {}), [columnId]: value } }));
} }
function clearFilter(columnId: string) { function clearFilter(columnId: string, defaults: Record<string, string>) {
setState((current) => { setState((current) => {
const nextFilters = { ...(current.filters ?? {}) }; const nextFilters = { ...(current.filters ?? {}) };
delete nextFilters[columnId]; // Keep an explicit empty value when the column has an initial filter so
// clearing it remains a user choice after remounting the grid.
if (Object.prototype.hasOwnProperty.call(defaults, columnId)) nextFilters[columnId] = "";
else delete nextFilters[columnId];
return { ...current, filters: nextFilters }; return { ...current, filters: nextFilters };
}); });
} }
@@ -422,7 +466,7 @@ export default function DataGrid<T>({
className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()} className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
style={stickyStyle(column, stickyOffsets[columnIndex])} style={stickyStyle(column, stickyOffsets[columnIndex])}
> >
{column.render ? column.render(row, originalIndex) : stringifyCell(column.value?.(row, originalIndex))} {renderCell(column, row, originalIndex)}
</div> </div>
)); ));
})} })}
@@ -433,9 +477,13 @@ export default function DataGrid<T>({
column={activeFilterColumn} column={activeFilterColumn}
filterType={filterTypes[activeFilterColumn.id]} filterType={filterTypes[activeFilterColumn.id]}
value={state.filters?.[activeFilterColumn.id] ?? ""} value={state.filters?.[activeFilterColumn.id] ?? ""}
listOptions={listOptionsForColumn(activeFilterColumn, rows)}
listDisplay={activeFilterColumn.list?.display ?? "text"}
optionsEditable={Boolean(activeFilterColumn.list?.optionsEditable && activeFilterColumn.list?.onOptionsChange)}
position={filterPosition} position={filterPosition}
onChange={(value) => patchFilter(activeFilterColumn.id, value)} onChange={(value) => patchFilter(activeFilterColumn.id, value)}
onClear={() => clearFilter(activeFilterColumn.id)} onClear={() => clearFilter(activeFilterColumn.id, normalizedInitialFilters)}
onOptionsChange={activeFilterColumn.list?.onOptionsChange}
onClose={() => setOpenFilterColumnId(null)} onClose={() => setOpenFilterColumnId(null)}
/>, />,
document.body document.body
@@ -444,18 +492,84 @@ export default function DataGrid<T>({
); );
} }
export function DataGridRowActions({
disabled = false,
removeDisabled = false,
onAddBelow,
onRemove,
onMoveUp,
onMoveDown,
addLabel = "Add row below",
removeLabel = "Remove row",
moveUpLabel = "Move row up",
moveDownLabel = "Move row down"
}: DataGridRowActionsProps) {
return (
<div className="data-grid-row-actions">
<button
type="button"
className="data-grid-row-action is-add"
aria-label={addLabel}
title={addLabel}
disabled={disabled}
onClick={onAddBelow}
>
<Plus size={15} aria-hidden="true" />
</button>
<button
type="button"
className={`data-grid-row-action is-reorder${onMoveUp ? "" : " is-placeholder"}`}
aria-label={moveUpLabel}
title={moveUpLabel}
aria-hidden={!onMoveUp}
tabIndex={onMoveUp ? 0 : -1}
disabled={disabled || !onMoveUp}
onClick={onMoveUp}
>
<ArrowUp size={15} aria-hidden="true" />
</button>
<button
type="button"
className={`data-grid-row-action is-reorder${onMoveDown ? "" : " is-placeholder"}`}
aria-label={moveDownLabel}
title={moveDownLabel}
aria-hidden={!onMoveDown}
tabIndex={onMoveDown ? 0 : -1}
disabled={disabled || !onMoveDown}
onClick={onMoveDown}
>
<ArrowDown size={15} aria-hidden="true" />
</button>
<button
type="button"
className="data-grid-row-action is-remove"
aria-label={removeLabel}
title={removeLabel}
disabled={disabled || removeDisabled}
onClick={onRemove}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
);
}
type FilterPopoverProps = { type FilterPopoverProps = {
column: Pick<DataGridColumn<unknown>, "id" | "header">; column: Pick<DataGridColumn<unknown>, "id" | "header">;
filterType: DataGridFilterType; filterType: DataGridFilterType;
value: string; value: string;
listOptions: DataGridListOption[];
listDisplay: "text" | "pill";
optionsEditable: boolean;
position: FilterPosition; position: FilterPosition;
onChange: (value: string) => void; onChange: (value: string) => void;
onClear: () => void; onClear: () => void;
onOptionsChange?: (options: DataGridListOption[]) => void;
onClose: () => void; onClose: () => void;
}; };
const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function FilterPopover( const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function FilterPopover(
{ column, filterType, value, position, onChange, onClear, onClose }, { column, filterType, value, listOptions, listDisplay, optionsEditable, position, onChange, onClear, onOptionsChange, onClose },
ref ref
) { ) {
const parsed = parseTypedFilter(value, filterType); const parsed = parseTypedFilter(value, filterType);
@@ -472,15 +586,23 @@ const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function Fi
<strong>Filter {column.header}</strong> <strong>Filter {column.header}</strong>
<button type="button" aria-label="Close filter" onClick={onClose}><X size={15} /></button> <button type="button" aria-label="Close filter" onClick={onClose}><X size={15} /></button>
</div> </div>
{filterType === "boolean" ? ( {filterType === "list" ? (
<label className="data-grid-filter-field"> <ListFilterEditor
<span>Value</span> options={listOptions}
<select value={value} onChange={(event) => onChange(event.target.value)} autoFocus> display={listDisplay}
<option value="">All</option> value={value}
<option value="true">Yes / active</option> editable={optionsEditable}
<option value="false">No / inactive</option> onChange={onChange}
</select> onOptionsChange={onOptionsChange}
</label> />
) : filterType === "boolean" ? (
<ListFilterEditor
options={BOOLEAN_FILTER_OPTIONS}
display="text"
value={value}
editable={false}
onChange={onChange}
/>
) : filterType === "number" || filterType === "integer" || filterType === "date" ? ( ) : filterType === "number" || filterType === "integer" || filterType === "date" ? (
<div className="data-grid-filter-stack"> <div className="data-grid-filter-stack">
<label className="data-grid-filter-field"> <label className="data-grid-filter-field">
@@ -519,12 +641,132 @@ const FilterPopover = forwardRef<HTMLDivElement, FilterPopoverProps>(function Fi
); );
}); });
function ListFilterEditor({
options,
display,
value,
editable,
onChange,
onOptionsChange,
}: {
options: DataGridListOption[];
display: "text" | "pill";
value: string;
editable: boolean;
onChange: (value: string) => void;
onOptionsChange?: (options: DataGridListOption[]) => void;
}) {
const [newValue, setNewValue] = useState("");
const selected = parseListFilter(value, options.map((option) => option.value));
const selectedSet = new Set(selected);
function toggleOption(optionValue: string) {
const next = new Set(selectedSet);
if (next.has(optionValue)) next.delete(optionValue);
else next.add(optionValue);
onChange(formatListSelection([...next], options));
}
function addOption() {
const normalized = newValue.trim();
if (!normalized || !onOptionsChange || options.some((option) => option.value === normalized)) return;
onOptionsChange([...options, { value: normalized, label: humanizeListValue(normalized) }]);
setNewValue("");
}
function removeOption(optionValue: string) {
if (!onOptionsChange) return;
onOptionsChange(options.filter((option) => option.value !== optionValue));
if (selectedSet.has(optionValue)) {
const nextOptions = options.filter((option) => option.value !== optionValue);
onChange(formatListSelection(selected.filter((candidate) => candidate !== optionValue), nextOptions));
}
}
return (
<div className="data-grid-list-filter">
<div className="data-grid-list-filter-actions">
<button type="button" onClick={() => onChange("")}>Select all</button>
<button type="button" onClick={() => onChange(formatListFilter([]))}>Deselect all</button>
</div>
<div className="data-grid-list-filter-options" role="group" aria-label="Allowed values">
{options.length === 0 ? (
<p className="muted small-note">No values are configured for this column.</p>
) : options.map((option) => (
<div className="data-grid-list-filter-row" key={option.value}>
<label>
<input
type="checkbox"
checked={selectedSet.has(option.value)}
disabled={option.disabled}
onChange={() => toggleOption(option.value)}
/>
{display === "pill" ? <StatusBadge status={option.value} label={option.label} /> : <span>{option.label}</span>}
</label>
{editable && (
<button type="button" className="data-grid-list-option-remove" aria-label={`Remove ${option.label}`} onClick={() => removeOption(option.value)}>
<Trash2 size={14} aria-hidden="true" />
</button>
)}
</div>
))}
</div>
{editable && (
<div className="data-grid-list-option-add">
<input
value={newValue}
placeholder="Add value"
onChange={(event) => setNewValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addOption();
}
}}
/>
<button type="button" aria-label="Add list value" onClick={addOption} disabled={!newValue.trim()}>
<Plus size={15} aria-hidden="true" />
</button>
</div>
)}
</div>
);
}
function SortIcon({ direction }: { direction?: DataGridSortDirection }) { function SortIcon({ direction }: { direction?: DataGridSortDirection }) {
if (direction === "asc") return <ArrowUp size={14} aria-label="Sorted ascending" />; if (direction === "asc") return <ArrowUp size={14} aria-label="Sorted ascending" />;
if (direction === "desc") return <ArrowDown size={14} aria-label="Sorted descending" />; if (direction === "desc") return <ArrowDown size={14} aria-label="Sorted descending" />;
return <ChevronsUpDown size={14} aria-hidden="true" />; return <ChevronsUpDown size={14} aria-hidden="true" />;
} }
function renderCell<T>(column: DataGridColumn<T>, row: T, index: number): ReactNode {
if (column.render) return column.render(row, index);
if (column.columnType === "from-list" && column.list) {
const rawValue = stringifyCell(column.value?.(row, index));
const option = column.list.options.find((candidate) => candidate.value === rawValue);
const label = option?.label ?? humanizeListValue(rawValue);
if (column.list.editable && column.list.onChange) {
const cellOptions = option || !rawValue
? column.list.options
: [...column.list.options, { value: rawValue, label }];
return (
<select
className="data-grid-list-select"
value={rawValue}
onChange={(event) => column.list?.onChange?.(row, index, event.target.value)}
aria-label={`Select ${String(column.header)}`}
>
{cellOptions.map((candidate) => (
<option key={candidate.value} value={candidate.value} disabled={candidate.disabled}>{candidate.label}</option>
))}
</select>
);
}
return column.list.display === "pill" ? <StatusBadge status={rawValue} label={label} /> : label;
}
return stringifyCell(column.value?.(row, index));
}
function loadState(key: string): DataGridState { function loadState(key: string): DataGridState {
try { try {
const value = window.localStorage.getItem(key); const value = window.localStorage.getItem(key);
@@ -535,6 +777,69 @@ function loadState(key: string): DataGridState {
} }
} }
function normalizeInitialFilters(filters: Record<string, string | string[]>): Record<string, string> {
return Object.fromEntries(
Object.entries(filters).map(([columnId, value]) => [
columnId,
Array.isArray(value) ? formatListFilter(value) : value,
])
);
}
function mergeInitialFilters(state: DataGridState, initialFilters: Record<string, string>): DataGridState {
const currentFilters = state.filters ?? {};
let changed = false;
const nextFilters = { ...currentFilters };
for (const [columnId, value] of Object.entries(initialFilters)) {
if (!Object.prototype.hasOwnProperty.call(currentFilters, columnId)) {
nextFilters[columnId] = value;
changed = true;
}
}
if (!changed) return state;
return { ...state, filters: nextFilters };
}
function formatListFilter(values: string[]): string {
return `list:${JSON.stringify([...new Set(values)])}`;
}
function formatListSelection(values: string[], options: DataGridListOption[]): string {
const optionValues = [...new Set(options.map((option) => option.value))];
const selected = [...new Set(values)].filter((value) => optionValues.includes(value));
if (optionValues.length > 0 && optionValues.every((value) => selected.includes(value))) return "";
return formatListFilter(selected);
}
function parseListFilter(value: string, allValues: string[]): string[] {
if (!value.trim()) return [...allValues];
if (value.startsWith("list:")) {
try {
const parsed = JSON.parse(value.slice(5));
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
}
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
function listOptionsForColumn<T>(column: DataGridColumn<T>, rows: T[]): DataGridListOption[] {
if (column.columnType !== "from-list") return [];
const configured = column.list?.options ?? [];
const known = new Set(configured.map((option) => option.value));
const observed = rows
.map((row, index) => stringifyCell(valueForFilter(column, row, index)).trim())
.filter((value) => value && !known.has(value))
.map((value) => ({ value, label: humanizeListValue(value) }));
return [...configured, ...observed.filter((option, index, all) => all.findIndex((candidate) => candidate.value === option.value) === index)];
}
function humanizeListValue(value: string): string {
if (!value) return "—";
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function sanitizePersistedColumnState<T>(columns: DataGridColumn<T>[], state: DataGridState): DataGridState { function sanitizePersistedColumnState<T>(columns: DataGridColumn<T>[], state: DataGridState): DataGridState {
const resizableColumnIds = new Set(columns.filter((column) => column.resizable).map((column) => column.id)); const resizableColumnIds = new Set(columns.filter((column) => column.resizable).map((column) => column.id));
const nextWidths = Object.fromEntries( const nextWidths = Object.fromEntries(
@@ -703,7 +1008,17 @@ function valueForFilter<T>(column: DataGridColumn<T>, row: T, rowIndex: number):
function matchesFilter(value: unknown, filterValue: string, filterType: DataGridFilterType): boolean { function matchesFilter(value: unknown, filterValue: string, filterType: DataGridFilterType): boolean {
if (!filterValue.trim()) return true; if (!filterValue.trim()) return true;
if (filterType === "list") {
const selected = parseListFilter(filterValue, []);
return selected.includes(stringifyCell(value));
}
if (filterType === "boolean") { if (filterType === "boolean") {
if (filterValue.startsWith("list:")) {
const selected = parseListFilter(filterValue, BOOLEAN_FILTER_OPTIONS.map((option) => option.value));
const actual = normalizeBoolean(value);
if (actual === null) return false;
return selected.includes(actual ? "true" : "false");
}
const expected = parseBooleanFilter(filterValue); const expected = parseBooleanFilter(filterValue);
if (expected === null) return stringifyCell(value).toLowerCase().includes(filterValue.toLowerCase()); if (expected === null) return stringifyCell(value).toLowerCase().includes(filterValue.toLowerCase());
const actual = normalizeBoolean(value); const actual = normalizeBoolean(value);
@@ -809,6 +1124,11 @@ const DATE_OPERATORS: { value: TypedFilterOperator; label: string }[] = [
{ value: "after", label: "After" } { value: "after", label: "After" }
]; ];
const BOOLEAN_FILTER_OPTIONS: DataGridListOption[] = [
{ value: "true", label: "Yes / active" },
{ value: "false", label: "No / inactive" }
];
function compareValues(a: unknown, b: unknown): number { function compareValues(a: unknown, b: unknown): number {
if (typeof a === "number" && typeof b === "number") return a - b; if (typeof a === "number" && typeof b === "number") return a - b;
const aDate = typeof a === "string" ? Date.parse(a) : NaN; const aDate = typeof a === "string" ? Date.parse(a) : NaN;

View File

@@ -9,14 +9,15 @@ import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { 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";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor"; import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser"; import ManagedFileChooser from "./components/ManagedFileChooser";
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, nextAttachmentLabel, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments"; import { countIndividualAttachmentRules, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
type PathChooserState = { index: number }; type PathChooserState = { index: number };
@@ -65,17 +66,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
} }
function addBasePath() { function addBasePath(afterIndex = basePaths.length - 1) {
patchBasePaths([...basePaths, createAttachmentBasePath("New attachment source", ".")]); patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", ".")));
} }
function removeBasePath(index: number) { function removeBasePath(index: number) {
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index)); patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
} }
function addGlobalAttachmentRule() { function moveBasePath(index: number, targetIndex: number) {
if (locked) return; if (locked || index === targetIndex) return;
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules), basePaths[0]?.id ?? "")]); patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
} }
@@ -100,24 +101,18 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>
<Card <Card title="Attachment sources">
title="Attachment sources"
actions={<Button variant="primary" onClick={addBasePath} disabled={locked}>Add base path</Button>}
>
<DataGrid <DataGrid
id={`campaign-${campaignId}-attachment-sources`} id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths} rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser })} columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id} getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured." emptyText={<div className="data-grid-empty-action"><span>No attachment sources configured.</span><Button variant="primary" onClick={() => addBasePath(-1)} disabled={locked}>Add attachment source</Button></div>}
className="attachment-sources-table-wrap attachment-sources-table" className="attachment-sources-table-wrap attachment-sources-table"
/> />
</Card> </Card>
<Card <Card title="Global Attachments">
title="Global Attachments"
actions={<Button variant="primary" onClick={addGlobalAttachmentRule} disabled={locked}>Add file</Button>}
>
<AttachmentRulesDataGrid <AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`} id={`campaign-${campaignId}-global-attachments`}
rules={globalRules} rules={globalRules}
@@ -178,11 +173,13 @@ type AttachmentSourceColumnContext = {
basePaths: AttachmentBasePath[]; basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[]; fileSpaces: FileSpace[];
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void; patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
addBasePath: (afterIndex?: number) => void;
moveBasePath: (index: number, targetIndex: number) => void;
removeBasePath: (index: number) => void; removeBasePath: (index: number) => void;
setPathChooser: (state: PathChooserState | null) => void; setPathChooser: (state: PathChooserState | null) => void;
}; };
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] { function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [ return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, { id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{ {
@@ -216,7 +213,26 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
}, },
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" }, { id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" }, { id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_basePath, index) => <Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button> } {
id: "actions",
header: "Actions",
width: 150,
sticky: "end",
render: (_basePath, index) => (
<DataGridRowActions
disabled={locked}
removeDisabled={basePaths.length <= 1}
onAddBelow={() => addBasePath(index)}
onRemove={() => removeBasePath(index)}
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
addLabel="Add attachment source below"
removeLabel="Remove attachment source"
moveUpLabel="Move attachment source up"
moveDownLabel="Move attachment source down"
/>
)
}
]; ];
} }
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string { function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {

View File

@@ -13,8 +13,9 @@ import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor"; import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput"; import FieldValueInput from "./components/FieldValueInput";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions"; import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
@@ -97,10 +98,11 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
markDirty(); markDirty();
} }
function addField() { function addField(afterIndex = fields.length - 1) {
const name = uniqueFieldName(fields); const name = uniqueFieldName(fields);
const nextFields = [...fields, { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }]; const nextField = { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true };
fieldValueKeys.current = [...fieldValueKeys.current, name]; const nextFields = insertAfter(fields, afterIndex, nextField);
fieldValueKeys.current = insertAfter(fieldValueKeys.current, afterIndex, name);
const nextGlobalValues = { ...globalValues, [name]: "" }; const nextGlobalValues = { ...globalValues, [name]: "" };
setDraft((current) => { setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
@@ -109,6 +111,12 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
markDirty(); markDirty();
} }
function moveField(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
fieldValueKeys.current = moveArrayItem(fieldValueKeys.current, index, targetIndex);
patchFields(moveArrayItem(fields, index, targetIndex));
}
function deleteField(index: number) { function deleteField(index: number) {
const field = fields[index]; const field = fields[index];
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index); const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
@@ -163,16 +171,13 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>
<Card <Card title="Fields and global values">
title="Fields and global values"
actions={<Button variant="primary" onClick={addField} disabled={locked}>Add field</Button>}
>
<DataGrid <DataGrid
id={`campaign-${campaignId}-fields`} id={`campaign-${campaignId}-fields`}
rows={fields} rows={fields}
columns={fieldColumns({ locked, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, deleteField })} columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
getRowKey={(_field, index) => `field-row-${index}`} getRowKey={(_field, index) => `field-row-${index}`}
emptyText="No campaign fields configured yet." emptyText={<div className="data-grid-empty-action"><span>No campaign fields configured yet.</span><Button variant="primary" onClick={() => addField(-1)} disabled={locked}>Add field</Button></div>}
className="field-editor-table-wrap field-editor-table" className="field-editor-table-wrap field-editor-table"
/> />
</Card> </Card>
@@ -188,15 +193,18 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
type FieldColumnContext = { type FieldColumnContext = {
locked: boolean; locked: boolean;
fields: CampaignFieldDefinition[];
globalValues: Record<string, unknown>; globalValues: Record<string, unknown>;
renameField: (index: number, nextName: string) => void; renameField: (index: number, nextName: string) => void;
setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => void; setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => void;
setGlobalValue: (key: string, value: unknown) => void; setGlobalValue: (key: string, value: unknown) => void;
setOverrideAllowed: (index: number, allowed: boolean) => void; setOverrideAllowed: (index: number, allowed: boolean) => void;
addField: (afterIndex?: number) => void;
moveField: (index: number, targetIndex: number) => void;
deleteField: (index: number) => void; deleteField: (index: number) => void;
}; };
function fieldColumns({ locked, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] { function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
return [ return [
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name }, { id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label }, { id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
@@ -204,7 +212,25 @@ function fieldColumns({ locked, globalValues, renameField, setField, setGlobalVa
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" }, { id: "required", header: "Required", width: 140, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") }, { id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" }, { id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_field, index) => <Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Remove</Button> } {
id: "actions",
header: "Actions",
width: 150,
sticky: "end",
render: (_field, index) => (
<DataGridRowActions
disabled={locked}
onAddBelow={() => addField(index)}
onRemove={() => deleteField(index)}
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
addLabel="Add field below"
removeLabel="Remove field"
moveUpLabel="Move field up"
moveDownLabel="Move field down"
/>
)
}
]; ];
} }

View File

@@ -6,15 +6,32 @@ import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog"; import ConfirmDialog from "../../components/ConfirmDialog";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import LoadingFrame from "../../components/LoadingFrame"; import LoadingFrame from "../../components/LoadingFrame";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle"; import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge"; import StatusBadge from "../../components/StatusBadge";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { publishCampaignVersion, updateCampaignMetadata, type CampaignVersionListItem } from "../../api/campaigns"; import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionListItem,
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView"; import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
const campaignModeOptions = ["draft", "test", "send"]; const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
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 { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
@@ -23,7 +40,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" }); const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false); const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false); const [savingIdentity, setSavingIdentity] = useState(false);
const [lockingVersion, setLockingVersion] = useState<CampaignVersionListItem | null>(null); const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false); const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@@ -33,7 +50,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
external_id: campaign.external_id ?? "", external_id: campaign.external_id ?? "",
name: campaign.name ?? "", name: campaign.name ?? "",
status: campaign.status ?? "", status: campaign.status ?? "",
description: campaign.description ?? "" description: campaign.description ?? "",
}); });
}, [campaign, identityDirty]); }, [campaign, identityDirty]);
@@ -53,7 +70,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
external_id: identity.external_id, external_id: identity.external_id,
name: identity.name, name: identity.name,
status: identity.status, status: identity.status,
description: identity.description description: identity.description,
}); });
setIdentityDirty(false); setIdentityDirty(false);
await reload(); await reload();
@@ -64,16 +81,24 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
} }
} }
async function lockAuditSnapshot() { async function applyLockAction() {
const version = lockingVersion; const pending = pendingLockAction;
if (!version || lockBusy) return; if (!pending || lockBusy) return;
setLockBusy(true); setLockBusy(true);
setError(""); setError("");
setMessage(""); setMessage("");
try { try {
await publishCampaignVersion(settings, campaignId, version.id); if (pending.action === "temporary") {
setMessage(`Version #${version.version_number} locked as audit-safe snapshot.`); await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
setLockingVersion(null); setMessage(`Version #${pending.version.version_number} temporarily locked.`);
} else if (pending.action === "unlock") {
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
} else {
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} permanently locked.`);
}
setPendingLockAction(null);
await reload(); await reload();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -100,6 +125,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>} {message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview…"> <LoadingFrame loading={loading} label="Loading campaign overview…">
<div className="metric-grid campaign-overview-metrics">
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Campaign identity"> <Card title="Campaign identity">
<div className="form-grid campaign-identity-grid"> <div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID"> <FormField label="Campaign ID">
@@ -123,7 +155,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<DataGrid <DataGrid
id={`campaign-${campaignId}-versions`} id={`campaign-${campaignId}-versions`}
rows={versions} rows={versions}
columns={versionColumns(setLockingVersion)} columns={versionColumns(setPendingLockAction)}
getRowKey={(version) => version.id} getRowKey={(version) => version.id}
emptyText="No versions found." emptyText="No versions found."
className="version-history-table" className="version-history-table"
@@ -142,50 +174,55 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
</LoadingFrame> </LoadingFrame>
<ConfirmDialog <ConfirmDialog
open={Boolean(lockingVersion)} open={Boolean(pendingLockAction)}
title="Lock audit-safe snapshot?" title={lockDialogTitle(pendingLockAction)}
message="This is a user-requested final lock. The version cannot be validated, unlocked, built, dry-run or sent afterwards. Create an editable copy for future changes." message={lockDialogMessage(pendingLockAction)}
confirmLabel="Lock snapshot" confirmLabel={lockDialogLabel(pendingLockAction)}
tone="danger" tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
busy={lockBusy} busy={lockBusy}
onCancel={() => setLockingVersion(null)} onCancel={() => setPendingLockAction(null)}
onConfirm={() => void lockAuditSnapshot()} onConfirm={() => void applyLockAction()}
/> />
</div> </div>
); );
} }
function versionColumns(setLockingVersion: (version: CampaignVersionListItem) => void): DataGridColumn<CampaignVersionListItem>[] { function versionColumns(setPendingLockAction: (action: PendingLockAction) => void): DataGridColumn<CampaignVersionListItem>[] {
return [ return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 }, { id: "version", header: "Version", width: 110, sortable: true, filterable: true, sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" }, { id: "state", header: "State", width: 140, sortable: true, filterable: true, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
{ id: "lock", header: "Lock", width: 170, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel }, { id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel },
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, render: validationLabel, value: validationLabel }, { id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, render: validationLabel, value: validationLabel },
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, render: buildLabel, value: buildLabel }, { id: "build", header: "Build", width: 140, sortable: true, filterable: true, render: buildLabel, value: buildLabel },
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" }, { id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
width: 190, width: 310,
sticky: "end", sticky: "end",
render: (version) => ( render: (version) => (
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link> <Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link>
<Button {isTemporaryUserLockedVersion(version) ? (
onClick={() => setLockingVersion(version)} <>
disabled={isUserLockedVersion(version) || isFinalLockedVersion(version) || canUnlockValidationVersion(version)} <Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
>Lock</Button> <Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
</>
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
) : null}
</div> </div>
) ),
} },
]; ];
} }
function versionLockLabel(version: CampaignVersionListItem): string { function versionLockLabel(version: CampaignVersionListItem): string {
if (isUserLockedVersion(version)) return "User locked"; if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
if (isFinalLockedVersion(version)) return "Final"; if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
if (canUnlockValidationVersion(version)) return "Validation lock"; if (isFinalLockedVersion(version)) return "Permanent delivery lock";
if (version.locked_at) return "Locked"; if (canUnlockValidationVersion(version)) return "Temporary validation lock";
if (version.locked_at) return "Temporarily locked";
return "Editable"; return "Editable";
} }
@@ -193,7 +230,7 @@ function validationLabel(version: CampaignVersionListItem): string {
const validation = version.validation_summary ?? {}; const validation = version.validation_summary ?? {};
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed"; if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
if (validation.ok === false) return "Needs attention"; if (validation.ok === false) return "Needs attention";
if (validation.ok === true) return "Invalid for delivery"; if (validation.ok === true) return "Passed, unavailable while user-locked";
return "Not validated"; return "Not validated";
} }
@@ -202,6 +239,33 @@ function buildLabel(version: CampaignVersionListItem): string {
return String(build.built_count ?? build.ready_count ?? "Not built"); return String(build.built_count ?? build.ready_count ?? "Not built");
} }
function lockDialogTitle(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Temporarily lock version?";
if (pending?.action === "unlock") return "Unlock version?";
if (pending?.action === "permanent") return "Lock version permanently?";
return "Confirm lock action";
}
function lockDialogMessage(pending: PendingLockAction): string {
if (pending?.action === "temporary") {
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
}
if (pending?.action === "unlock") {
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
}
if (pending?.action === "permanent") {
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
}
return "Continue?";
}
function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Lock temporarily";
if (pending?.action === "unlock") return "Unlock";
if (pending?.action === "permanent") return "Lock permanently";
return "Confirm";
}
function SummaryTile({ label, value }: { label: string; value: string | number }) { function SummaryTile({ label, value }: { label: string; value: string | number }) {
return ( return (
<div className="summary-tile"> <div className="summary-tile">

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom"; import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type { ApiSettings, CampaignWorkspaceSection } from "../../types"; import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar"; import SectionSidebar from "../../layout/SectionSidebar";
@@ -50,11 +51,32 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
const { requestNavigation } = useCampaignUnsavedChanges(); const { requestNavigation } = useCampaignUnsavedChanges();
const location = useLocation(); const location = useLocation();
const active = sectionFromPath(location.pathname); const active = sectionFromPath(location.pathname);
const urlVersionId = new URLSearchParams(location.search).get("version");
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(urlVersionId);
useEffect(() => {
if (urlVersionId) setSelectedVersionId(urlVersionId);
}, [urlVersionId]);
// Relative links inside the workspace historically dropped ?version=. Keep
// the selected version stable for the lifetime of this campaign workspace,
// while still opening the latest version on a fresh campaign entry.
useEffect(() => {
if (urlVersionId || !selectedVersionId) return;
const params = new URLSearchParams(location.search);
params.set("version", selectedVersionId);
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
function select(section: CampaignWorkspaceSection) { function select(section: CampaignWorkspaceSection) {
const path = sectionPaths[section]; const path = sectionPaths[section];
const target = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`; const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
if (location.pathname === target) return; const params = new URLSearchParams(location.search);
const versionId = urlVersionId ?? selectedVersionId;
if (versionId) params.set("version", versionId);
const query = params.toString();
const target = query ? `${pathname}?${query}` : pathname;
if (`${location.pathname}${location.search}` === target) return;
requestNavigation(() => navigate(target)); requestNavigation(() => navigate(target));
} }

View File

@@ -10,7 +10,7 @@ import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput"; import EmailAddressInput from "../../components/email/EmailAddressInput";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { 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";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -20,6 +20,7 @@ import {
collectCampaignAddressSuggestions, collectCampaignAddressSuggestions,
type MailboxAddress type MailboxAddress
} from "../../utils/emailAddresses"; } from "../../utils/emailAddresses";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
const recipientHeaderRows = [ const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." }, { key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
@@ -79,7 +80,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
patch(["entries", "inline"], nextEntries); patch(["entries", "inline"], nextEntries);
} }
function addRecipient() { function addRecipient(afterIndex = inlineEntries.length - 1) {
const nextIndex = inlineEntries.length + 1; const nextIndex = inlineEntries.length + 1;
const newEntry = { const newEntry = {
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
@@ -92,7 +93,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
from: {}, from: {},
reply_to: [] reply_to: []
}; };
replaceInlineEntries([...inlineEntries, newEntry]); replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry));
} }
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) { function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
@@ -122,6 +123,11 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index)); replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
} }
function moveEntry(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
return ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
@@ -222,19 +228,18 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
<Card <Card
title="Recipient profiles" title="Recipient profiles"
actions={[<Button key="import" disabled>Import</Button>, <Button key="add" variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]} actions={<Button disabled>Import</Button>}
> >
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && ( {inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert> <DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
)} )}
{inlineEntries.length > 0 && ( {!source.type && (
<DataGrid <DataGrid
id={`campaign-${campaignId}-recipient-profiles`} id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries.slice(0, 100)} rows={inlineEntries.slice(0, 100)}
columns={recipientProfileColumns({ locked, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, removeEntry })} columns={recipientProfileColumns({ locked, entries: inlineEntries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)} getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient profiles are stored in the current version yet." emptyText={<div className="data-grid-empty-action"><span>No recipient profiles are stored in the current version yet.</span><Button variant="primary" onClick={() => addRecipient(-1)} disabled={locked}>Add recipient</Button></div>}
className="recipient-table-wrap recipient-address-table" className="recipient-table-wrap recipient-address-table"
/> />
)} )}
@@ -251,14 +256,17 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
type RecipientProfileColumnContext = { type RecipientProfileColumnContext = {
locked: boolean; locked: boolean;
entries: Record<string, unknown>[];
entryAddressColumns: EntryAddressColumn[]; entryAddressColumns: EntryAddressColumn[];
addressSuggestions: MailboxAddress[]; addressSuggestions: MailboxAddress[];
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void; updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
addRecipient: (afterIndex?: number) => void;
moveEntry: (index: number, targetIndex: number) => void;
removeEntry: (index: number) => void; removeEntry: (index: number) => void;
}; };
function recipientProfileColumns({ locked, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] { function recipientProfileColumns({ locked, entries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [ return [
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 }, { id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({ ...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
@@ -282,7 +290,25 @@ function recipientProfileColumns({ locked, entryAddressColumns, addressSuggestio
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ") value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
})), })),
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" }, { id: "active", header: "Active", width: 130, sortable: true, filterable: true, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_entry, index) => <Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button> } {
id: "actions",
header: "Actions",
width: 150,
sticky: "end",
render: (_entry, index) => (
<DataGridRowActions
disabled={locked}
onAddBelow={() => addRecipient(index)}
onRemove={() => removeEntry(index)}
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
addLabel="Add recipient below"
removeLabel="Remove recipient"
moveUpLabel="Move recipient up"
moveDownLabel="Move recipient down"
/>
)
}
]; ];
} }

View File

@@ -4,6 +4,8 @@ import {
AlertTriangle, AlertTriangle,
BarChart3, BarChart3,
Check, Check,
ChevronDown,
ChevronRight,
FlaskConical, FlaskConical,
LockKeyhole, LockKeyhole,
MailSearch, MailSearch,
@@ -14,16 +16,24 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns"; import {
buildVersion,
getCampaignJobs,
mockSendCampaign,
updateCampaignReviewState,
validateVersion,
type CampaignVersionDetail,
} from "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import Button from "../../components/Button"; import Button from "../../components/Button";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import LoadingFrame from "../../components/LoadingFrame"; import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle"; import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge"; import StatusBadge from "../../components/StatusBadge";
import InlineHelp from "../../components/help/InlineHelp"; import InlineHelp from "../../components/help/InlineHelp";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { import {
@@ -76,6 +86,19 @@ const stateColors: Record<FlowState, string> = {
pending: "var(--muted)", pending: "var(--muted)",
}; };
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
{ value: "ready", label: "Ready" },
{ value: "warning", label: "Warning" },
{ value: "needs_review", label: "Needs review" },
{ value: "blocked", label: "Blocked" },
{ value: "excluded", label: "Excluded" },
{ value: "inactive", label: "Inactive" },
];
const MESSAGE_REVIEW_DEFAULT_FILTER = MESSAGE_VALIDATION_OPTIONS
.filter((option) => option.value !== "ready")
.map((option) => option.value);
export default function ReviewSendDevelopmentPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function ReviewSendDevelopmentPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion; const version = data.currentVersion;
@@ -93,21 +116,28 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const [busy, setBusy] = useState<WorkflowBusy>(""); const [busy, setBusy] = useState<WorkflowBusy>("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [messageReviewComplete, setMessageReviewComplete] = useState(false); const [messageReviewComplete, setMessageReviewComplete] = useState(false);
const [reviewResult, setReviewResult] = useState<Record<string, unknown> | null>(null); const [builtReviewRows, setBuiltReviewRows] = useState<Record<string, unknown>[]>([]);
const [jobsLoadedVersionId, setJobsLoadedVersionId] = useState("");
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set()); const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null); const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null); const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null); const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const persistedReview = storedMessageReviewState(version);
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
useEffect(() => { useEffect(() => {
setMessageReviewComplete(false); setBuiltReviewRows([]);
setReviewResult(null); setJobsLoadedVersionId("");
setReviewedMessageKeys(new Set());
setSelectedBuiltIndex(null); setSelectedBuiltIndex(null);
setMockResult(null); setMockResult(null);
setSelectedMockMessage(null); setSelectedMockMessage(null);
}, [version?.id]); }, [version?.id]);
useEffect(() => {
setMessageReviewComplete(persistedReview.inspectionComplete);
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
}, [version?.id, persistedReviewKey]);
const validationPresent = Object.keys(validation).length > 0; const validationPresent = Object.keys(validation).length > 0;
const validationOk = validation.ok === true; const validationOk = validation.ok === true;
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]); const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
@@ -122,6 +152,11 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]); const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built"); const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built");
useEffect(() => {
if (!version?.id || !hasBuild || jobsLoadedVersionId === version.id) return;
void loadBuiltMessages(true);
}, [version?.id, hasBuild, jobsLoadedVersionId]);
const jobsTotal = cards?.jobs_total ?? inlineEntries.length; const jobsTotal = cards?.jobs_total ?? inlineEntries.length;
const sentCount = cards?.sent ?? 0; const sentCount = cards?.sent ?? 0;
const failedCount = cards?.failed ?? 0; const failedCount = cards?.failed ?? 0;
@@ -133,10 +168,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const finalVersion = isFinalLockedVersion(version); const finalVersion = isFinalLockedVersion(version);
const userLockedVersion = isUserLockedVersion(version); const userLockedVersion = isUserLockedVersion(version);
const reviewBuild = asRecord(reviewResult?.build);
const builtReviewRows = asArray(reviewBuild.messages).map(asRecord);
const reviewedCount = builtReviewRows.reduce((count, row, index) => count + (reviewedMessageKeys.has(builtMessageKey(row, index)) ? 1 : 0), 0);
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null; const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview);
const reviewedRequiredCount = reviewRequiredRows.reduce(
(count, row) => count + (reviewedMessageKeys.has(builtMessageKey(row, builtReviewRows.indexOf(row))) ? 1 : 0),
0
);
const automaticInspectionComplete = builtReviewRows.length > 0 && reviewRequiredRows.length === 0;
const mockSend = asRecord(mockResult?.send); const mockSend = asRecord(mockResult?.send);
const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]); const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
@@ -183,7 +221,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
: "active"; : "active";
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = messageReviewComplete || downstreamDeliveryActivity; const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
const inspectState: FlowState = !hasBuild const inspectState: FlowState = !hasBuild
? "locked" ? "locked"
@@ -336,8 +374,9 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
setError(""); setError("");
try { try {
const result = await buildVersion(settings, version.id, true); const result = await buildVersion(settings, version.id, true);
const review = await requestBuiltMessageReview(); const review = await getCampaignJobs(settings, campaignId, version.id);
setReviewResult(review); setBuiltReviewRows(review.jobs.map(asRecord));
setJobsLoadedVersionId(version.id);
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`); setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`);
setMessageReviewComplete(false); setMessageReviewComplete(false);
setReviewedMessageKeys(new Set()); setReviewedMessageKeys(new Set());
@@ -352,39 +391,27 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
} }
async function loadBuiltMessages() { async function loadBuiltMessages(silent = false) {
if (!version || busy || !hasBuild) return; if (!version || busy || !hasBuild) return;
setBusy("inspect"); setBusy("inspect");
setMessage("Loading the built-message review…"); if (!silent) setMessage("Loading the built-message review…");
setError(""); setError("");
try { try {
const result = await requestBuiltMessageReview(); const result = await getCampaignJobs(settings, campaignId, version.id);
setReviewResult(result); const jobs = result.jobs.map(asRecord);
setMessage(`Loaded ${asArray(asRecord(result.build).messages).length} built message(s) for inspection.`); setBuiltReviewRows(jobs);
setJobsLoadedVersionId(version.id);
if (!silent) setMessage(`Loaded ${jobs.length} built message(s) for inspection.`);
} catch (err) { } catch (err) {
setMessage(""); if (!silent) setMessage("");
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setBusy(""); setBusy("");
} }
} }
async function requestBuiltMessageReview(): Promise<Record<string, unknown>> {
if (!version) throw new Error("No campaign version is available.");
const response = await mockSendCampaign(settings, campaignId, {
version_id: version.id,
send: false,
include_warnings: true,
include_needs_review: true,
append_sent: false,
clear_mailbox: false,
check_files: true,
});
return asRecord(response.result ?? response);
}
async function runMockSend() { async function runMockSend() {
if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return; if (!version || busy || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
setBusy("mock"); setBusy("mock");
setMessage("Running the complete mock-delivery flow…"); setMessage("Running the complete mock-delivery flow…");
setError(""); setError("");
@@ -411,6 +438,27 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
} }
async function completeInspection() {
if (!version || busy || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
setBusy("inspect");
setError("");
setMessage("Recording the completed message review…");
try {
await updateCampaignReviewState(settings, campaignId, version.id, {
inspection_complete: true,
reviewed_message_keys: [...reviewedMessageKeys],
});
setMessageReviewComplete(true);
setMessage("Message review completed and recorded for this build.");
await reload();
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function openMockMessage(id: string) { async function openMockMessage(id: string) {
if (!id || busy === "mailbox") return; if (!id || busy === "mailbox") return;
setBusy("mailbox"); setBusy("mailbox");
@@ -438,7 +486,8 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
function resetDownstreamReview() { function resetDownstreamReview() {
setMessageReviewComplete(false); setMessageReviewComplete(false);
setReviewResult(null); setBuiltReviewRows([]);
setJobsLoadedVersionId("");
setReviewedMessageKeys(new Set()); setReviewedMessageKeys(new Set());
setSelectedBuiltIndex(null); setSelectedBuiltIndex(null);
setMockResult(null); setMockResult(null);
@@ -454,7 +503,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]); const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]); const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null; const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
const canCompleteInspection = builtReviewRows.length > 0 && reviewedCount > 0; const canCompleteInspection = reviewRequiredRows.length > 0 && reviewedRequiredCount === reviewRequiredRows.length;
return ( return (
<div className="content-pad workspace-data-page review-flow-development-page"> <div className="content-pad workspace-data-page review-flow-development-page">
@@ -478,6 +527,16 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
This development layout uses the current campaign data and existing actions. The established Review &amp; Send page remains available for comparison. This development layout uses the current campaign data and existing actions. The established Review &amp; Send page remains available for comparison.
</DismissibleAlert> </DismissibleAlert>
{version && (readyForDelivery || userLockedVersion || finalVersion) && (
<LockedVersionNotice
settings={settings}
campaignId={campaignId}
version={version}
reload={reload}
message={readyForDelivery ? "Locked and validated. Unlock validation to edit campaign data, or create an editable copy." : "Send snapshot. Copy to edit."}
/>
)}
<LoadingFrame loading={loading} label="Loading workflow state…"> <LoadingFrame loading={loading} label="Loading workflow state…">
<WorkflowNavigation stages={stages} onSelect={scrollToStage} /> <WorkflowNavigation stages={stages} onSelect={scrollToStage} />
@@ -499,10 +558,8 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
{busy === "validate" {busy === "validate"
? "Validating…" ? "Validating…"
: readyForDelivery : readyForDelivery
? "Validated and locked" ? "Locked and validated"
: validationPresent : "Lock and validate"}
? "Validate again"
: "Validate campaign"}
</Button> </Button>
</div> </div>
</WorkflowStage> </WorkflowStage>
@@ -545,7 +602,8 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} /> <WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} /> <WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
<WorkflowFact label="Opened" value={reviewedCount} /> <WorkflowFact label="Need review" value={reviewRequiredRows.length} />
<WorkflowFact label="Reviewed" value={reviewedRequiredCount} />
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} /> <WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
</div> </div>
<div className="button-row compact-actions review-flow-stage-actions"> <div className="button-row compact-actions review-flow-stage-actions">
@@ -555,12 +613,15 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
<Link className="btn btn-secondary" to="../template">Open template editor</Link> <Link className="btn btn-secondary" to="../template">Open template editor</Link>
<Button <Button
variant="primary" variant="primary"
onClick={() => setMessageReviewComplete((value) => !value)} onClick={() => void completeInspection()}
disabled={!canCompleteInspection || downstreamDeliveryActivity} disabled={automaticInspectionComplete || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
> >
{messageReviewComplete ? "Inspection completed" : "Complete inspection"} {automaticInspectionComplete || messageReviewComplete ? "Inspection completed" : "Complete inspection"}
</Button> </Button>
</div> </div>
{automaticInspectionComplete && (
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> All built messages are ready. No manual inspection acknowledgement is required.</p>
)}
{builtReviewRows.length > 0 && ( {builtReviewRows.length > 0 && (
<div className="review-flow-data-section"> <div className="review-flow-data-section">
<DataGrid <DataGrid
@@ -568,10 +629,11 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
rows={builtReviewRows} rows={builtReviewRows}
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)} columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
getRowKey={builtMessageKey} getRowKey={builtMessageKey}
emptyText="No built messages are available for review." initialFilters={{ validation: MESSAGE_REVIEW_DEFAULT_FILTER }}
emptyText="No messages require review. Ready messages are hidden by the default validation filter."
className="data-table-wrap data-table compact-table" className="data-table-wrap data-table compact-table"
/> />
<p className="muted small-note">Open at least one message before completing the inspection step. The preview includes rendered content, recipients, issues and exact attachment metadata.</p> <p className="muted small-note">Ready messages are hidden initially. Use the Validation filter to include them. When review is required, open every listed issue message before completing the step.</p>
</div> </div>
)} )}
</WorkflowStage> </WorkflowStage>
@@ -584,7 +646,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} /> <WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
</div> </div>
<div className="button-row compact-actions review-flow-stage-actions"> <div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !messageReviewComplete || deliveryQueued || deliveryStarted}> <Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"} {busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
</Button> </Button>
<Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link> <Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link>
@@ -733,6 +795,7 @@ function WorkflowStage({
}) { }) {
const Icon = stage.icon; const Icon = stage.icon;
const locked = stage.state === "locked"; const locked = stage.state === "locked";
const [collapsed, setCollapsed] = useState(false);
const style = { const style = {
"--review-stage-color": stateColors[stage.state], "--review-stage-color": stateColors[stage.state],
"--review-next-stage-color": stateColors[nextState ?? stage.state], "--review-next-stage-color": stateColors[nextState ?? stage.state],
@@ -744,13 +807,14 @@ function WorkflowStage({
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div> <div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
{nextState && <div className="review-flow-stage-line" />} {nextState && <div className="review-flow-stage-line" />}
</div> </div>
<article className={`card review-flow-stage-card${locked ? " is-locked" : ""}`} aria-disabled={locked || undefined}> <article className={`card review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
<header className="card-header review-flow-stage-header"> <header className="card-header review-flow-stage-header">
<h2> <h2>
<span>{stage.title}</span> <span>{stage.title}</span>
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />} {locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />}
<InlineHelp>{stage.description}</InlineHelp> <InlineHelp>{stage.description}</InlineHelp>
</h2> </h2>
<div className="review-flow-stage-header-actions">
{!locked && ( {!locked && (
<span <span
className="review-flow-state-badge" className="review-flow-state-badge"
@@ -761,7 +825,20 @@ function WorkflowStage({
{stage.stateLabel} {stage.stateLabel}
</span> </span>
)} )}
<button
type="button"
className="review-flow-collapse-button"
aria-expanded={!collapsed}
aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`}
title={collapsed ? "Expand card" : "Collapse card"}
onClick={() => setCollapsed((value) => !value)}
>
{collapsed ? <ChevronRight size={18} aria-hidden="true" /> : <ChevronDown size={18} aria-hidden="true" />}
</button>
</div>
</header> </header>
{!collapsed && (
<>
<div className="card-body review-flow-stage-content">{children}</div> <div className="card-body review-flow-stage-content">{children}</div>
{locked && ( {locked && (
<div className="review-flow-lock-message"> <div className="review-flow-lock-message">
@@ -769,6 +846,8 @@ function WorkflowStage({
<span>{stage.lockReason}</span> <span>{stage.lockReason}</span>
</div> </div>
)} )}
</>
)}
</article> </article>
</section> </section>
); );
@@ -799,6 +878,7 @@ function BuiltMessagePreview({
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields); const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject"); const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject");
const issues = asArray(row.issues).map(asRecord); const issues = asArray(row.issues).map(asRecord);
const resolvedRecipients = asRecord(row.resolved_recipients);
return ( return (
<MessagePreviewOverlay <MessagePreviewOverlay
@@ -807,7 +887,7 @@ function BuiltMessagePreview({
bodyMode={html.trim() ? "html" : "text"} bodyMode={html.trim() ? "html" : "text"}
text={text} text={text}
html={html} html={html}
recipientLabel={formatAddressList(row.to) || `Message ${index + 1}`} recipientLabel={formatAddressList(resolvedRecipients.to) || String(row.recipient_email ?? `Message ${index + 1}`)}
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."} recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."}
metaItems={builtMessageMetaItems(row)} metaItems={builtMessageMetaItems(row)}
attachments={builtMessageAttachments(row)} attachments={builtMessageAttachments(row)}
@@ -839,19 +919,34 @@ function builtMessageColumns(
): DataGridColumn<Record<string, unknown>>[] { ): DataGridColumn<Record<string, unknown>>[] {
return [ return [
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) }, { id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || "—" }, { id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") }, {
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? asArray(row.attachments).length) }, id: "validation",
header: "Validation",
width: 145,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown"),
},
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, render: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? <Check size={17} aria-label="Reviewed" /> : <span className="muted"></span>, value: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? "yes" : "no" }, { id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, render: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? <Check size={17} aria-label="Reviewed" /> : <span className="muted"></span>, value: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? "yes" : "no" },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>Review</Button> }, { id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>Review</Button> },
]; ];
} }
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] { function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "sent", label: "Sent" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "ready", label: "Ready" },
];
return [ return [
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) }, { id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") }, { id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") },
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" }, { id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
{ id: "smtp", header: "SMTP", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") }, { id: "smtp", header: "SMTP", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
{ id: "imap", header: "IMAP", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") }, { id: "imap", header: "IMAP", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
@@ -860,8 +955,12 @@ function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
} }
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] { function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "smtp", label: "SMTP" },
{ value: "imap_append", label: "IMAP append" },
];
return [ return [
{ id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", render: (row) => <StatusBadge status={String(row.kind ?? "mock")} />, value: (row) => String(row.kind ?? "mock") }, { id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "Envelope / folder", 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: "Envelope / folder", 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: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? 0) }, { id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? 0) },
@@ -870,25 +969,44 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
} }
function builtMessageMetaItems(row: Record<string, unknown>) { function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
return [ return [
{ label: "From", value: formatSingleAddress(row.from) || "—" }, { label: "From", value: formatSingleAddress(recipients.from) || "—" },
{ label: "To", value: formatAddressList(row.to) || "—" }, { label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
{ label: "CC", value: formatAddressList(row.cc) || "—" }, { label: "CC", value: formatAddressList(recipients.cc) || "—" },
{ label: "BCC", value: formatAddressList(row.bcc) || "—" }, { label: "BCC", value: formatAddressList(recipients.bcc) || "—" },
{ label: "Validation", value: String(row.validation_status ?? "—") }, { label: "Validation", value: String(row.validation_status ?? "—") },
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" }, { label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
]; ];
} }
function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] { function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] {
return asArray(row.attachments).map((value, index) => { return asArray(row.attachments).flatMap((value, index) => {
const attachment = asRecord(value); const attachment = asRecord(value);
return { const managedMatches = asArray(attachment.managed_matches).map(asRecord);
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? `Attachment ${index + 1}`), if (managedMatches.length > 0) {
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.label ?? ""), return managedMatches.map((match, matchIndex) => ({
filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`),
detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""),
contentType: stringOrUndefined(match.content_type),
sizeBytes: numberOrUndefined(match.size_bytes),
}));
}
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
if (matches.length > 0) {
return matches.map((match) => ({
filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`),
detail: match,
contentType: stringOrUndefined(attachment.content_type), contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes), sizeBytes: numberOrUndefined(attachment.size_bytes),
}; }));
}
return [{
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`),
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""),
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
}];
}); });
} }
@@ -911,6 +1029,39 @@ function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAtta
})); }));
} }
function countResolvedAttachments(value: unknown): number {
return asArray(value).reduce<number>((count, item) => {
const attachment = asRecord(item);
const managedCount = asArray(attachment.managed_matches).length;
if (managedCount > 0) return count + managedCount;
const matchCount = asArray(attachment.matches).length;
return count + (matchCount > 0 ? matchCount : 1);
}, 0);
}
function messageRequiresReview(row: Record<string, unknown>): boolean {
const status = String(row.validation_status ?? "").toLowerCase();
return status !== "ready" || asArray(row.issues).length > 0;
}
function storedMessageReviewState(version: CampaignVersionDetail | null): {
buildToken: string;
inspectionComplete: boolean;
reviewedMessageKeys: string[];
} {
const build = asRecord(version?.build_summary);
const buildToken = String(build.build_token ?? build.built_at ?? "");
const review = asRecord(asRecord(version?.editor_state).review_send);
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
}
return {
buildToken,
inspectionComplete: review.inspection_complete === true,
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string"),
};
}
function builtMessageKey(row: Record<string, unknown>, index: number): string { function builtMessageKey(row: Record<string, unknown>, index: number): string {
return String(row.entry_id ?? row.entry_index ?? index); return String(row.entry_id ?? row.entry_index ?? index);
} }

View File

@@ -2,11 +2,12 @@ import { useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types"; import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button"; import Button from "../../../components/Button";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid"; import DataGrid, { DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import ToggleSwitch from "../../../components/ToggleSwitch"; import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor"; import { getBool, getText } from "../utils/draftEditor";
import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments"; import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser"; import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments"; export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
@@ -146,11 +147,22 @@ export function AttachmentRulesDataGrid({
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule)); onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
} }
function addRule(afterIndex = rules.length - 1) {
const nextRule = createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? "");
onChange(insertAfter(rules, afterIndex, nextRule));
}
function removeRule(index: number) { function removeRule(index: number) {
setFileChooser(null); setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index)); onChange(rules.filter((_, currentIndex) => currentIndex !== index));
} }
function moveRule(index: number, targetIndex: number) {
if (disabled || index === targetIndex) return;
setFileChooser(null);
onChange(moveArrayItem(rules, index, targetIndex));
}
function openFileChooser(ruleIndex: number) { function openFileChooser(ruleIndex: number) {
if (onOpenFileChooser) { if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex); onOpenFileChooser(ruleIndex);
@@ -182,9 +194,9 @@ export function AttachmentRulesDataGrid({
<DataGrid <DataGrid
id={id} id={id}
rows={rules} rows={rules}
columns={attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, openFileChooser, removeRule })} columns={attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)} getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={emptyText} emptyText={<div className="data-grid-empty-action"><span>{emptyText}</span><Button variant="primary" onClick={() => addRule(-1)} disabled={disabled}>Add file</Button></div>}
className="attachment-rules-table-wrap attachment-rules-table" className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/> />
@@ -208,14 +220,17 @@ export function AttachmentRulesDataGrid({
type AttachmentRuleColumnContext = { type AttachmentRuleColumnContext = {
disabled: boolean; disabled: boolean;
rules: AttachmentRule[];
basePaths: AttachmentBasePath[]; basePaths: AttachmentBasePath[];
activeChooserRuleIndex: number | null; activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void; patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
addRule: (afterIndex?: number) => void;
moveRule: (index: number, targetIndex: number) => void;
openFileChooser: (ruleIndex: number) => void; openFileChooser: (ruleIndex: number) => void;
removeRule: (index: number) => void; removeRule: (index: number) => void;
}; };
function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] { function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [ return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") }, { id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{ {
@@ -277,10 +292,22 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" }, { id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{ {
id: "actions", id: "actions",
header: "", header: "Actions",
width: 145, width: 150,
sticky: "end", sticky: "end",
render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button> render: (_rule, index) => (
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="Add attachment below"
removeLabel="Remove attachment"
moveUpLabel="Move attachment up"
moveDownLabel="Move attachment down"
/>
)
} }
]; ];
} }

View File

@@ -2,8 +2,21 @@ import { useState } from "react";
import type { ApiSettings } from "../../../types"; import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button"; import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog"; import ConfirmDialog from "../../../components/ConfirmDialog";
import { forkCampaignVersion, unlockCampaignVersionValidation, type CampaignVersionDetail, type CampaignVersionListItem } from "../../../api/campaigns"; import {
import { canUnlockValidationVersion, formatDateTime } from "../utils/campaignView"; forkCampaignVersion,
lockCampaignVersionPermanently,
unlockCampaignVersionUserLock,
unlockCampaignVersionValidation,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../../api/campaigns";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
} from "../utils/campaignView";
type LockedVersionNoticeProps = { type LockedVersionNoticeProps = {
settings: ApiSettings; settings: ApiSettings;
@@ -13,24 +26,41 @@ type LockedVersionNoticeProps = {
message?: string; message?: string;
}; };
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
export default function LockedVersionNotice({ settings, campaignId, version, reload, message }: LockedVersionNoticeProps) { export default function LockedVersionNotice({ settings, campaignId, version, reload, message }: LockedVersionNoticeProps) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useState("");
const [localMessage, setLocalMessage] = useState(""); const [localMessage, setLocalMessage] = useState("");
const [unlockConfirmOpen, setUnlockConfirmOpen] = useState(false); const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const canUnlock = canUnlockValidationVersion(version); const validationLock = canUnlockValidationVersion(version);
const noticeText = message ?? (canUnlock ? "Validated version. Unlock to edit, or create a copy." : "Final snapshot. Create a copy to edit."); const temporaryUserLock = isTemporaryUserLockedVersion(version);
const lockInfo = getCompactLockInfo(version, canUnlock); const permanentUserLock = isPermanentUserLockedVersion(version);
const finalLock = isFinalLockedVersion(version);
const presentation = lockPresentation(version, {
validationLock,
temporaryUserLock,
permanentUserLock,
finalLock,
});
async function unlockValidation() { async function runAction(action: Exclude<ConfirmAction, null>) {
if (!version || busy) return; if (!version || busy) return;
setBusy(true); setBusy(true);
setLocalError(""); setLocalError("");
setLocalMessage(""); setLocalMessage("");
try { try {
if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id); await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation invalidated; version is editable again."); setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
} else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again.");
} else {
await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
}
await reload(); await reload();
} catch (err) { } catch (err) {
setLocalError(err instanceof Error ? err.message : String(err)); setLocalError(err instanceof Error ? err.message : String(err));
@@ -47,7 +77,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
try { try {
const result = await forkCampaignVersion(settings, campaignId, version.id, { const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual", current_flow: "manual",
current_step: version.current_step ?? null current_step: version.current_step ?? null,
}); });
setLocalMessage(`Created editable version #${result.version.version_number}.`); setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload(); await reload();
@@ -59,43 +89,130 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
} }
return ( return (
<div className="alert info locked-version-notice"> <div className={`alert info locked-version-notice lock-kind-${presentation.kind}`}>
<div className="locked-version-copy"> <div className="locked-version-copy">
<strong>Locked version.</strong>{" "} <strong>{presentation.title}</strong>{" "}
<span>{noticeText}</span> <span>{presentation.description}</span>
{lockInfo && <span className="locked-version-reason"> {lockInfo}</span>} {message && <span className="locked-version-context"> {message}</span>}
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>} {localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
{localError && <span className="locked-version-error"> {localError}</span>} {localError && <span className="locked-version-error"> {localError}</span>}
</div> </div>
<div className="button-row compact-actions locked-version-actions"> <div className="button-row compact-actions locked-version-actions">
{canUnlock && ( {validationLock && (
<Button onClick={() => setUnlockConfirmOpen(true)} disabled={!version || busy}> <Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"} {busy ? "Working…" : "Unlock validation"}
</Button> </Button>
)} )}
{temporaryUserLock && (
<>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"}
</Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently
</Button>
</>
)}
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}> <Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"} {busy ? "Creating copy…" : "Create editable copy"}
</Button> </Button>
</div> </div>
<ConfirmDialog <ConfirmDialog
open={unlockConfirmOpen} open={confirmAction !== null}
title="Unlock validation?" title={confirmDialogTitle(confirmAction)}
message="This unlocks the version for editing and clears validation/build results and generated jobs for this version." message={confirmDialogMessage(confirmAction)}
confirmLabel="Unlock validation" confirmLabel={confirmDialogLabel(confirmAction)}
tone="danger" tone={confirmAction === "unlock-user" ? "default" : "danger"}
busy={busy} busy={busy}
onCancel={() => setUnlockConfirmOpen(false)} onCancel={() => setConfirmAction(null)}
onConfirm={() => { onConfirm={() => {
setUnlockConfirmOpen(false); const action = confirmAction;
void unlockValidation(); setConfirmAction(null);
if (action) void runAction(action);
}} }}
/> />
</div> </div>
); );
} }
function getCompactLockInfo(version: CampaignVersionDetail | CampaignVersionListItem | null, canUnlock: boolean): string { type LockFlags = {
if (!version?.locked_at) return ""; validationLock: boolean;
const label = canUnlock ? "Validated" : "Locked"; temporaryUserLock: boolean;
return `${label} ${formatDateTime(version.locked_at)}.`; permanentUserLock: boolean;
finalLock: boolean;
};
function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags,
): { kind: string; title: string; description: string; info: string } {
if (flags.temporaryUserLock) {
return {
kind: "temporary-user",
title: "Temporarily locked version.",
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
};
}
if (flags.permanentUserLock) {
return {
kind: "permanent-user",
title: "Permanently locked version.",
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
info: version?.user_locked_at || version?.published_at
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
: "",
};
}
if (flags.validationLock) {
return {
kind: "validation",
title: "Temporarily locked after validation.",
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
};
}
if (flags.finalLock) {
return {
kind: "delivery",
title: "Permanently locked delivery snapshot.",
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
};
}
return {
kind: "other",
title: "Locked version.",
description: "This version is read-only. Create an editable copy to continue working.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
};
}
function confirmDialogTitle(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation?";
if (action === "unlock-user") return "Unlock temporary lock?";
if (action === "permanent") return "Lock permanently?";
return "Confirm action";
}
function confirmDialogMessage(action: ConfirmAction): string {
if (action === "unlock-validation") {
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
}
if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
}
if (action === "permanent") {
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
}
return "Continue?";
}
function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation";
if (action === "unlock-user") return "Unlock";
if (action === "permanent") return "Lock permanently";
return "Confirm";
} }

View File

@@ -52,8 +52,27 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
return asArray(getCampaignJson(version).fields); return asArray(getCampaignJson(version).fields);
} }
export function userLockState(
version: CampaignVersionDetail | CampaignVersionListItem | null
): "temporary" | "permanent" | null {
if (!version) return null;
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
return version.user_lock_state;
}
// Backwards compatibility for snapshots created before explicit user locks.
return version.published_at ? "permanent" : null;
}
export function isTemporaryUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "temporary";
}
export function isPermanentUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "permanent";
}
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean { export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return Boolean(version?.published_at); return userLockState(version) !== null;
} }
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean { export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
@@ -86,16 +105,23 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean { export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false; if (!version) return false;
if (version.locked_at || version.published_at) return true; if (version.locked_at || isUserLockedVersion(version)) return true;
return isFinalLockedVersion(version); return isFinalLockedVersion(version);
} }
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string { export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "No campaign version is loaded."; if (!version) return "No campaign version is loaded.";
if (canUnlockValidationVersion(version)) return `Validation locked at ${formatDateTime(version.locked_at)}. Unlocking will invalidate validation and build state.`; if (isTemporaryUserLockedVersion(version)) {
if (isUserLockedVersion(version)) return `User locked at ${formatDateTime(version.published_at)}. Audit-safe; copy only.`; return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
if (isFinalLockedVersion(version)) return `Final state: ${humanize(version.workflow_state ?? "locked")}.`; }
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`; if (isPermanentUserLockedVersion(version)) {
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
}
if (canUnlockValidationVersion(version)) {
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
}
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
return "Editable working version."; return "Editable working version.";
} }

View File

@@ -1745,7 +1745,7 @@
width: 2px; width: 2px;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 54px; min-height: 54px;
margin: 4px 0; margin: 4px 0 -14px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(to bottom, var(--review-stage-color), var(--review-next-stage-color)); background: linear-gradient(to bottom, var(--review-stage-color), var(--review-next-stage-color));
opacity: .82; opacity: .82;
@@ -2013,3 +2013,51 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Collapsible workflow stage headers keep status and disclosure controls grouped. */
.review-flow-stage-header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
.review-flow-collapse-button {
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
padding: 0;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--muted);
background: #fff;
cursor: pointer;
}
.review-flow-collapse-button:hover,
.review-flow-collapse-button:focus-visible {
border-color: var(--line-dark);
color: var(--text-strong);
background: var(--panel-soft);
outline: none;
}
.review-flow-stage-card.is-collapsed .review-flow-stage-header {
min-height: 58px;
border-bottom: 0;
}
.locked-version-context {
color: var(--muted);
}
@media (max-width: 680px) {
.review-flow-stage-header-actions {
width: 100%;
justify-content: space-between;
margin-left: 0;
}
}

View File

@@ -1058,3 +1058,191 @@
font-weight: 700; font-weight: 700;
} }
/* List-valued DataGrid columns and checkbox filters. */
.data-grid-list-select {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--text);
font: inherit;
padding: 6px 28px 6px 8px;
}
.data-grid-list-select:focus {
outline: none;
border-color: var(--blue);
box-shadow: var(--focus-ring);
}
.data-grid-list-filter {
display: grid;
gap: 10px;
}
.data-grid-list-filter-actions {
display: flex;
align-items: center;
gap: 8px;
}
.data-grid-list-filter-actions button {
border: 0;
background: transparent;
color: var(--blue);
padding: 0;
font: inherit;
font-size: 12px;
font-weight: 750;
cursor: pointer;
}
.data-grid-list-filter-actions button:hover,
.data-grid-list-filter-actions button:focus-visible {
color: var(--text-strong);
text-decoration: underline;
outline: none;
}
.data-grid-list-filter-options {
max-height: 230px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
}
.data-grid-list-filter-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 6px 8px;
border-bottom: 1px solid var(--line);
}
.data-grid-list-filter-row:last-child {
border-bottom: 0;
}
.data-grid-list-filter-row > label {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
cursor: pointer;
}
.data-grid-list-filter-row input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
flex: 0 0 auto;
}
.data-grid-list-option-remove,
.data-grid-list-option-add button {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
width: 28px;
height: 28px;
display: inline-grid;
place-items: center;
cursor: pointer;
}
.data-grid-list-option-remove:hover,
.data-grid-list-option-remove:focus-visible,
.data-grid-list-option-add button:hover,
.data-grid-list-option-add button:focus-visible {
color: var(--text-strong);
background: var(--panel-soft);
outline: none;
}
.data-grid-list-option-add {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.data-grid-list-option-add input {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--text);
font: inherit;
padding: 8px 10px;
}
/* Consistent row-level collection actions for editable DataGrid tables. */
.data-grid-row-actions {
display: grid;
grid-template-columns: repeat(4, 28px);
align-items: center;
justify-content: end;
gap: 4px;
width: 100%;
}
.data-grid-row-action {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
cursor: pointer;
padding: 0;
}
.data-grid-row-action:hover:not(:disabled),
.data-grid-row-action:focus-visible:not(:disabled) {
border-color: var(--line-dark);
background: var(--panel-soft);
outline: none;
box-shadow: var(--focus-ring);
}
.data-grid-row-action.is-add {
border-color: color-mix(in srgb, var(--blue) 52%, var(--line));
background: color-mix(in srgb, var(--blue) 10%, #fff);
color: var(--blue);
}
.data-grid-row-action.is-remove {
border-color: rgba(171, 70, 61, .35);
background: rgba(171, 70, 61, .06);
color: #9f433b;
}
.data-grid-row-action:disabled {
cursor: default;
opacity: .42;
}
.data-grid-row-action.is-placeholder {
visibility: hidden;
pointer-events: none;
}
.data-grid-empty-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.data-grid-empty-action > span {
min-width: 0;
}

14
src/utils/arrayOrder.ts Normal file
View File

@@ -0,0 +1,14 @@
export function insertAfter<T>(items: T[], index: number, item: T): T[] {
const insertionIndex = Math.max(0, Math.min(items.length, index + 1));
return [...items.slice(0, insertionIndex), item, ...items.slice(insertionIndex)];
}
export function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length || fromIndex === toIndex) {
return items;
}
const next = [...items];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}