Locking refinement; Review/Send improvement

This commit is contained in:
2026-06-13 22:06:32 +02:00
parent c72df498e7
commit e97af1cf91
18 changed files with 569 additions and 371 deletions

View File

@@ -2,6 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react"; import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge"; import StatusBadge from "../StatusBadge";
import Button from "../Button";
export type DataGridSortDirection = "asc" | "desc"; export type DataGridSortDirection = "asc" | "desc";
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list"; export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
@@ -58,6 +59,9 @@ type DataGridProps<T> = {
columns: DataGridColumn<T>[]; columns: DataGridColumn<T>[];
getRowKey: (row: T, index: number) => string; getRowKey: (row: T, index: number) => string;
emptyText?: ReactNode; emptyText?: ReactNode;
filteredEmptyText?: ReactNode;
emptyAction?: ReactNode;
emptyActionColumnId?: string;
fit?: "content" | "container"; fit?: "content" | "container";
className?: string; className?: string;
rowClassName?: (row: T, index: number) => string | undefined; rowClassName?: (row: T, index: number) => string | undefined;
@@ -84,9 +88,19 @@ type FilterPosition = {
width: number; width: number;
}; };
type ResizeTarget = {
columnId: string;
startWidth: number;
maxWidth: number;
};
const STORAGE_PREFIX = "multimailer.datagrid."; const STORAGE_PREFIX = "multimailer.datagrid.";
const FILTER_POPOVER_WIDTH = 320; const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12; const FILTER_POPOVER_MARGIN = 12;
const MIN_HEADER_LABEL_WIDTH = 72;
const SORT_CONTROL_RESERVE = 24;
const FILTER_CONTROL_RESERVE = 32;
const RESIZE_CONTROL_RESERVE = 20;
export default function DataGrid<T>({ export default function DataGrid<T>({
id, id,
@@ -94,6 +108,9 @@ export default function DataGrid<T>({
columns, columns,
getRowKey, getRowKey,
emptyText = "No rows found.", emptyText = "No rows found.",
filteredEmptyText = "Filter yields an empty result.",
emptyAction,
emptyActionColumnId = "actions",
fit = "container", fit = "container",
className = "", className = "",
rowClassName, rowClassName,
@@ -109,9 +126,10 @@ export default function DataGrid<T>({
startX: number; startX: number;
startWidth: number; startWidth: number;
baseWidths: Record<string, number>; baseWidths: Record<string, number>;
immediateRightColumnId?: string; rightResizeTargets: ResizeTarget[];
immediateRightStartWidth?: number;
shrinkRoomWithoutScroll: number; shrinkRoomWithoutScroll: number;
actualLastColumnMaxShrink: number;
isActualLastColumn: boolean;
} | null>(null); } | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null); const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null); const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
@@ -193,39 +211,49 @@ export default function DataGrid<T>({
const column = columns.find((item) => item.id === activeResize.columnId); const column = columns.find((item) => item.id === activeResize.columnId);
if (!column) return; if (!column) return;
const minWidth = column.minWidth ?? 80; const minWidth = effectiveColumnMinWidth(column);
const maxWidth = column.maxWidth ?? 2000; const maxWidth = Math.max(minWidth, column.maxWidth ?? 2000);
const rawDelta = event.clientX - activeResize.startX; const rawDelta = event.clientX - activeResize.startX;
const activeDelta = Math.min( const activeDelta = Math.min(
maxWidth - activeResize.startWidth, maxWidth - activeResize.startWidth,
Math.max(minWidth - activeResize.startWidth, rawDelta) Math.max(minWidth - activeResize.startWidth, rawDelta)
); );
const nextWidths = { ...activeResize.baseWidths }; const nextWidths = { ...activeResize.baseWidths };
let fillColumnId: string | null | undefined; let fillColumnId: string | null | undefined = null;
if (activeDelta > 0 && activeResize.immediateRightColumnId) { if (activeResize.isActualLastColumn) {
// A growing column may only shrink its immediate neighbour. Once that // The resizable actual final column has no columns to its right to
// neighbour reaches its minimum, the grid grows and can scroll rather // compensate. It may grow freely, and it may shrink by the amount of
// than pulling space from a more distant column. // horizontal overflow measured at drag start, but never far enough to
const neighbour = columns.find((item) => item.id === activeResize.immediateRightColumnId); // leave unused space on the right.
const neighbourStartWidth = activeResize.immediateRightStartWidth ?? 0; const minimumDelta = -activeResize.actualLastColumnMaxShrink;
const neighbourMinWidth = neighbour?.minWidth ?? 80; const clampedLastColumnDelta = Math.max(minimumDelta, activeDelta);
const absorbedDelta = Math.min(activeDelta, Math.max(0, neighbourStartWidth - neighbourMinWidth)); nextWidths[activeResize.columnId] = activeResize.startWidth + clampedLastColumnDelta;
} else if (activeDelta >= 0) {
// Growing a non-final column changes only the dragged column. Any
// compensation introduced while the pointer was left of its drag-start
// position disappears automatically because each move is recalculated
// from the drag-start widths. Untouched columns are never shrunk merely
// to make room for growth.
nextWidths[activeResize.columnId] = activeResize.startWidth + activeDelta; nextWidths[activeResize.columnId] = activeResize.startWidth + activeDelta;
nextWidths[activeResize.immediateRightColumnId] = neighbourStartWidth - absorbedDelta;
fillColumnId = activeResize.immediateRightColumnId;
} else { } else {
// Shrinking never resizes another column. It consumes only horizontal const requestedShrink = -activeDelta;
// overflow that is still available to the right of the viewport. Once const freeShrink = Math.min(requestedShrink, activeResize.shrinkRoomWithoutScroll);
// that room is exhausted, resizing stops instead of growing a buffer const compensationNeeded = requestedShrink - freeShrink;
// column, reclaiming width from the left, or shifting the scroll view.
const boundedDelta = activeDelta < 0 // Shrinking first consumes only overflow that is still to the right of
? Math.max(activeDelta, -activeResize.shrinkRoomWithoutScroll) // the current viewport. Once that room is exhausted, all eligible
: activeDelta; // resizable, non-sticky columns to the right grow evenly so the grid
nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta; // continues to fill its container. Targets at maxWidth drop out and the
// Exact pixel tracks remain fixed during the drag, so no implicit // remainder is redistributed. Recomputing from drag-start widths makes
// flexible filler can change a different column behind the pointer. // the operation exactly reversible when the pointer changes direction.
fillColumnId = null; const distribution = distributeGrowthAmount(
compensationNeeded,
activeResize.rightResizeTargets
);
const appliedShrink = freeShrink + distribution.applied;
nextWidths[activeResize.columnId] = activeResize.startWidth - appliedShrink;
applyGrowthDistribution(nextWidths, activeResize.rightResizeTargets, distribution.amounts);
} }
setState((current) => ({ setState((current) => ({
@@ -328,6 +356,7 @@ export default function DataGrid<T>({
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]); const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
const gridClassName = `data-grid ${hasFlexibleColumns ? "data-grid-has-flex" : "data-grid-fixed-only"}`; const gridClassName = `data-grid ${hasFlexibleColumns ? "data-grid-has-flex" : "data-grid-fixed-only"}`;
const activeFilterColumn = openFilterColumnId ? columns.find((column) => column.id === openFilterColumnId) : undefined; const activeFilterColumn = openFilterColumnId ? columns.find((column) => column.id === openFilterColumnId) : undefined;
const hasActiveFilters = columns.some((column) => Boolean((state.filters?.[column.id] ?? "").trim()));
function toggleSort(column: DataGridColumn<T>) { function toggleSort(column: DataGridColumn<T>) {
if (!column.sortable) return; if (!column.sortable) return;
@@ -418,13 +447,25 @@ export default function DataGrid<T>({
event.stopPropagation(); event.stopPropagation();
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths); const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]); const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
const immediateRightColumn = chooseImmediateResizePartner(columns, column.id); const activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
const immediateRightStartWidth = immediateRightColumn const isActualLastColumn = activeColumnIndex === columns.length - 1;
? baseWidths[immediateRightColumn.id] ?? columnPixelWidth(immediateRightColumn, state.widths?.[immediateRightColumn.id], measuredWidths[immediateRightColumn.id]) const rightResizeTargets = columns
: undefined; .slice(activeColumnIndex + 1)
.filter((candidate) => candidate.resizable && !candidate.sticky)
.map((candidate) => ({
columnId: candidate.id,
startWidth: baseWidths[candidate.id] ?? columnPixelWidth(candidate, state.widths?.[candidate.id], measuredWidths[candidate.id]),
maxWidth: Math.max(effectiveColumnMinWidth(candidate), candidate.maxWidth ?? 2000)
}));
const gridElement = gridRef.current; const gridElement = gridRef.current;
const totalHorizontalOverflow = gridElement
? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth)
: 0;
const shrinkRoomWithoutScroll = gridElement const shrinkRoomWithoutScroll = gridElement
? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth - gridElement.scrollLeft) ? Math.max(0, totalHorizontalOverflow - gridElement.scrollLeft)
: 0;
const actualLastColumnMaxShrink = isActualLastColumn
? Math.min(totalHorizontalOverflow, Math.max(0, currentWidth - effectiveColumnMinWidth(column)))
: 0; : 0;
setState((current) => ({ setState((current) => ({
...current, ...current,
@@ -438,9 +479,10 @@ export default function DataGrid<T>({
startX: event.clientX, startX: event.clientX,
startWidth: currentWidth, startWidth: currentWidth,
baseWidths, baseWidths,
immediateRightColumnId: immediateRightColumn?.id, rightResizeTargets,
immediateRightStartWidth, shrinkRoomWithoutScroll,
shrinkRoomWithoutScroll actualLastColumnMaxShrink,
isActualLastColumn
}); });
}} }}
> >
@@ -450,11 +492,36 @@ export default function DataGrid<T>({
</div> </div>
); );
})} })}
{visibleRows.length === 0 ? ( {visibleRows.length === 0 ? (() => {
<div className="data-grid-empty" role="row"> const filteredEmpty = rows.length > 0 && hasActiveFilters;
<div role="cell">{emptyText}</div> const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
</div> const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
) : visibleRows.map((row, visibleIndex) => { if (!filteredEmpty && emptyAction && actionColumn && actionColumnIndex > 0) {
return (
<>
<div
className="data-grid-cell data-grid-body-cell data-grid-empty-message data-grid-row-even is-last-row"
role="cell"
style={{ gridColumn: `1 / ${actionColumnIndex + 1}` }}
>
{emptyText}
</div>
<div
className={`data-grid-cell data-grid-body-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
role="cell"
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}
>
{emptyAction}
</div>
</>
);
}
return (
<div className="data-grid-empty" role="row">
<div role="cell">{filteredEmpty ? filteredEmptyText : emptyText}</div>
</div>
);
})() : visibleRows.map((row, visibleIndex) => {
const originalIndex = rows.indexOf(row); const originalIndex = rows.indexOf(row);
const rowClass = rowClassName?.(row, originalIndex); const rowClass = rowClassName?.(row, originalIndex);
const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd"; const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd";
@@ -506,50 +573,71 @@ export function DataGridRowActions({
}: DataGridRowActionsProps) { }: DataGridRowActionsProps) {
return ( return (
<div className="data-grid-row-actions"> <div className="data-grid-row-actions">
<button <Button
type="button" variant="primary"
className="data-grid-row-action is-add" className="data-grid-row-action is-add"
aria-label={addLabel} aria-label={addLabel}
title={addLabel} title={addLabel}
disabled={disabled} disabled={disabled}
onClick={onAddBelow} onClick={onAddBelow}
> >
<Plus size={15} aria-hidden="true" /> <Plus size={16} aria-hidden="true" />
</button> </Button>
<button <Button
type="button" variant="secondary"
className={`data-grid-row-action is-reorder${onMoveUp ? "" : " is-placeholder"}`} className="data-grid-row-action is-reorder"
aria-label={moveUpLabel} aria-label={moveUpLabel}
title={moveUpLabel} title={moveUpLabel}
aria-hidden={!onMoveUp}
tabIndex={onMoveUp ? 0 : -1}
disabled={disabled || !onMoveUp} disabled={disabled || !onMoveUp}
onClick={onMoveUp} onClick={() => onMoveUp?.()}
> >
<ArrowUp size={15} aria-hidden="true" /> <ArrowUp size={16} aria-hidden="true" />
</button> </Button>
<button <Button
type="button" variant="secondary"
className={`data-grid-row-action is-reorder${onMoveDown ? "" : " is-placeholder"}`} className="data-grid-row-action is-reorder"
aria-label={moveDownLabel} aria-label={moveDownLabel}
title={moveDownLabel} title={moveDownLabel}
aria-hidden={!onMoveDown}
tabIndex={onMoveDown ? 0 : -1}
disabled={disabled || !onMoveDown} disabled={disabled || !onMoveDown}
onClick={onMoveDown} onClick={() => onMoveDown?.()}
> >
<ArrowDown size={15} aria-hidden="true" /> <ArrowDown size={16} aria-hidden="true" />
</button> </Button>
<button <Button
type="button" variant="danger"
className="data-grid-row-action is-remove" className="data-grid-row-action is-remove"
aria-label={removeLabel} aria-label={removeLabel}
title={removeLabel} title={removeLabel}
disabled={disabled || removeDisabled} disabled={disabled || removeDisabled}
onClick={onRemove} onClick={onRemove}
> >
<Trash2 size={15} aria-hidden="true" /> <Trash2 size={16} aria-hidden="true" />
</button> </Button>
</div>
);
}
export function DataGridEmptyAction({
disabled = false,
onAdd,
label = "Add first row"
}: {
disabled?: boolean;
onAdd: () => void;
label?: string;
}) {
return (
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
variant="primary"
className="data-grid-row-action is-add"
aria-label={label}
title={label}
disabled={disabled}
onClick={onAdd}
>
<Plus size={16} aria-hidden="true" />
</Button>
</div> </div>
); );
} }
@@ -841,33 +929,65 @@ function humanizeListValue(value: string): string {
} }
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 resizableColumns = new Map(columns.filter((column) => column.resizable).map((column) => [column.id, column]));
const nextWidths = Object.fromEntries( const nextWidths = Object.fromEntries(
Object.entries(state.widths ?? {}).filter(([columnId]) => resizableColumnIds.has(columnId)) Object.entries(state.widths ?? {}).flatMap(([columnId, width]) => {
const column = resizableColumns.get(columnId);
if (!column || !Number.isFinite(width)) return [];
const minimum = effectiveColumnMinWidth(column);
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
})
); );
const fillColumn = state.fillColumnId const hasSavedWidths = Object.keys(nextWidths).length > 0;
const fillColumn = typeof state.fillColumnId === "string"
? columns.find((column) => column.id === state.fillColumnId) ? columns.find((column) => column.id === state.fillColumnId)
: undefined; : undefined;
const nextFillColumnId = fillColumn?.resizable && !fillColumn.sticky ? fillColumn.id : undefined; const shouldPreserveExactWidths = hasSavedWidths && (state.fillColumnId === null || state.fillColumnId === undefined);
const widthsChanged = Object.keys(nextWidths).length !== Object.keys(state.widths ?? {}).length; const nextFillColumnId = shouldPreserveExactWidths
? null
: fillColumn?.resizable && !fillColumn.sticky
? fillColumn.id
: undefined;
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, nextWidths);
const fillChanged = nextFillColumnId !== state.fillColumnId; const fillChanged = nextFillColumnId !== state.fillColumnId;
if (!widthsChanged && !fillChanged) return state; if (!widthsChanged && !fillChanged) return state;
return { return {
...state, ...state,
widths: Object.keys(nextWidths).length > 0 ? nextWidths : undefined, widths: hasSavedWidths ? nextWidths : undefined,
fillColumnId: nextFillColumnId fillColumnId: nextFillColumnId
}; };
} }
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number, stretch = false): string { function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number, stretch = false): string {
const minimum = effectiveColumnMinWidth(column);
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
if (stretch) { if (stretch) {
const baseWidth = savedWidth ?? fixedWidthFloor(column); const baseWidth = Math.min(maximum, Math.max(minimum, savedWidth ?? fixedWidthFloor(column)));
return `minmax(${baseWidth}px, 1fr)`; return `minmax(${baseWidth}px, 1fr)`;
} }
if (savedWidth) return `${savedWidth}px`; if (savedWidth) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
if (typeof column.width === "number") return `${column.width}px`; if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
if (column.width) return column.width; if (column.width) return columnTrackWithMinimum(column.width, minimum);
return `minmax(${column.minWidth ?? 140}px, 1fr)`; return `minmax(${minimum}px, 1fr)`;
}
function columnTrackWithMinimum(width: string, minimum: number): string {
const normalized = width.trim();
const minmaxMatch = normalized.match(/^minmax\(\s*([^,]+),\s*(.+)\)$/i);
if (minmaxMatch) {
const parsedMinimum = parsePixelWidth(minmaxMatch[1]);
const minimumTrack = parsedMinimum === null ? `${minimum}px` : `${Math.max(minimum, parsedMinimum)}px`;
return `minmax(${minimumTrack}, ${minmaxMatch[2]})`;
}
const parsed = parsePixelWidth(normalized);
if (parsed !== null && /^\s*\d+(?:\.\d+)?px\s*$/i.test(normalized)) {
return `${Math.max(minimum, parsed)}px`;
}
if (normalized === "auto" || normalized.includes("fr")) {
return `minmax(${minimum}px, ${normalized})`;
}
return normalized;
} }
function chooseStretchedColumns<T>( function chooseStretchedColumns<T>(
@@ -913,11 +1033,62 @@ function chooseLayoutFillColumn<T>(columns: DataGridColumn<T>[]): string | undef
return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id; return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id;
} }
function chooseImmediateResizePartner<T>(columns: DataGridColumn<T>[], activeColumnId: string): DataGridColumn<T> | undefined { function effectiveColumnMinWidth<T>(column: DataGridColumn<T>): number {
const activeIndex = columns.findIndex((column) => column.id === activeColumnId); const affordanceWidth = MIN_HEADER_LABEL_WIDTH
if (activeIndex < 0) return undefined; + (column.sortable ? SORT_CONTROL_RESERVE : 0)
const neighbour = columns[activeIndex + 1]; + (column.filterable ? FILTER_CONTROL_RESERVE : 0)
return neighbour?.resizable && !neighbour.sticky ? neighbour : undefined; + (column.resizable ? RESIZE_CONTROL_RESERVE : 0);
return Math.max(column.minWidth ?? 0, affordanceWidth);
}
function distributeGrowthAmount(
requestedAmount: number,
targets: ResizeTarget[]
): { applied: number; amounts: Record<string, number> } {
const amounts: Record<string, number> = Object.fromEntries(targets.map((target) => [target.columnId, 0]));
let remaining = Math.max(0, requestedAmount);
let activeTargets = targets.filter((target) => growthCapacity(target) > 0.01);
// Water-fill the available capacity: every eligible target receives the same
// share until it reaches maxWidth, then the remainder is redistributed among
// the targets that still have room.
while (remaining > 0.01 && activeTargets.length > 0) {
const share = remaining / activeTargets.length;
let appliedThisRound = 0;
const nextTargets: ResizeTarget[] = [];
for (const target of activeTargets) {
const used = amounts[target.columnId] ?? 0;
const available = Math.max(0, growthCapacity(target) - used);
const applied = Math.min(share, available);
amounts[target.columnId] = used + applied;
appliedThisRound += applied;
if (available - applied > 0.01) nextTargets.push(target);
}
if (appliedThisRound <= 0.01) break;
remaining -= appliedThisRound;
activeTargets = nextTargets;
}
return {
applied: Math.max(0, requestedAmount - remaining),
amounts
};
}
function growthCapacity(target: ResizeTarget): number {
return Math.max(0, target.maxWidth - target.startWidth);
}
function applyGrowthDistribution(
widths: Record<string, number>,
targets: ResizeTarget[],
amounts: Record<string, number>
): void {
for (const target of targets) {
widths[target.columnId] = target.startWidth + (amounts[target.columnId] ?? 0);
}
} }
function measuredColumnWidths<T>( function measuredColumnWidths<T>(
@@ -936,9 +1107,11 @@ function measuredColumnWidths<T>(
} }
function fixedWidthFloor<T>(column: DataGridColumn<T>): number { function fixedWidthFloor<T>(column: DataGridColumn<T>): number {
const minimum = effectiveColumnMinWidth(column);
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
const parsed = parsePixelWidth(column.width); const parsed = parsePixelWidth(column.width);
if (typeof column.width === "number") return column.width; const configured = typeof column.width === "number" ? column.width : parsed ?? minimum;
return parsed ?? column.minWidth ?? 140; return Math.min(maximum, Math.max(minimum, configured));
} }
function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean { function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean {
@@ -954,12 +1127,13 @@ function isFlexibleWidth(width?: string | number): boolean {
} }
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number { function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
if (measuredWidth) return measuredWidth; const minimum = effectiveColumnMinWidth(column);
if (savedWidth) return savedWidth; const maximum = Math.max(minimum, column.maxWidth ?? 2000);
if (typeof column.width === "number") return column.width; const configured = measuredWidth
const parsed = parsePixelWidth(column.width); ?? savedWidth
if (parsed) return parsed; ?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))
return column.minWidth ?? 160; ?? minimum;
return Math.min(maximum, Math.max(minimum, configured));
} }
function parsePixelWidth(width?: string | number): number | null { function parsePixelWidth(width?: string | number): number | null {

View File

@@ -9,24 +9,27 @@ 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, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
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, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder"; import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
type PathChooserState = { index: number }; type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null); const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]); const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -79,6 +82,40 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
patchBasePaths(moveArrayItem(basePaths, index, targetIndex)); patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
} }
function setIndividualEligibility(index: number, checked: boolean) {
if (locked) return;
if (checked) {
patchBasePath(index, { allow_individual: true });
return;
}
const basePath = basePaths[index];
if (!basePath) return;
const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath);
if (usageCount > 0) {
setIndividualDisable({ index, usageCount });
return;
}
patchBasePath(index, { allow_individual: false });
}
function confirmIndividualDisable() {
if (!individualDisable) return;
const basePath = basePaths[individualDisable.index];
if (!basePath) {
setIndividualDisable(null);
return;
}
const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item);
setDraft((current) => {
const source = current ?? {};
const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths);
const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || ".");
return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath));
});
markDirty();
setIndividualDisable(null);
}
return ( return (
@@ -97,7 +134,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing attachments." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>
@@ -105,9 +142,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<DataGrid <DataGrid
id={`campaign-${campaignId}-attachment-sources`} id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths} rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, addBasePath, moveBasePath, removeBasePath, setPathChooser })} columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id} getRowKey={(basePath) => basePath.id}
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>} emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table" className="attachment-sources-table-wrap attachment-sources-table"
/> />
</Card> </Card>
@@ -164,6 +202,20 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
}} }}
/> />
)} )}
<ConfirmDialog
open={Boolean(individualDisable)}
title="Disable individual attachments for this source?"
message={individualDisable
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
: ""}
confirmLabel="Remove individual attachments"
cancelLabel="Cancel"
tone="danger"
onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)}
/>
</div> </div>
); );
} }
@@ -173,13 +225,14 @@ type AttachmentSourceColumnContext = {
basePaths: AttachmentBasePath[]; basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[]; fileSpaces: FileSpace[];
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void; patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void; addBasePath: (afterIndex?: number) => void;
moveBasePath: (index: number, targetIndex: 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, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] { function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, 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 },
{ {
@@ -211,12 +264,12 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
), ),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
}, },
{ 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) => setIndividualEligibility(index, 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", id: "actions",
header: "Actions", header: "Actions",
width: 150, width: 180,
sticky: "end", sticky: "end",
render: (_basePath, index) => ( render: (_basePath, index) => (
<DataGridRowActions <DataGridRowActions

View File

@@ -13,7 +13,7 @@ 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, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { DataGridEmptyAction, 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"; import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
@@ -23,7 +23,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
const fieldValueKeys = useRef<string[]>([]); const fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({ const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -167,7 +167,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>} {fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing fields." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>
@@ -177,7 +177,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
rows={fields} rows={fields}
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, 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={<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>} emptyText="No campaign fields configured yet."
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
className="field-editor-table-wrap field-editor-table" className="field-editor-table-wrap field-editor-table"
/> />
</Card> </Card>
@@ -215,7 +216,7 @@ function fieldColumns({ locked, fields, globalValues, renameField, setField, set
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
width: 150, width: 180,
sticky: "end", sticky: "end",
render: (_field, index) => ( render: (_field, index) => (
<DataGridRowActions <DataGridRowActions

View File

@@ -155,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(setPendingLockAction)} columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id} getRowKey={(version) => version.id}
emptyText="No versions found." emptyText="No versions found."
className="version-history-table" className="version-history-table"
@@ -187,11 +187,11 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
); );
} }
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void): DataGridColumn<CampaignVersionListItem>[] { function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): 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: 190, sortable: true, filterable: true, render: versionLockLabel, value: versionLockLabel }, { id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
{ 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 ?? "" },
@@ -200,24 +200,28 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
header: "Actions", header: "Actions",
width: 310, width: 310,
sticky: "end", sticky: "end",
render: (version) => ( render: (version) => {
<div className="button-row compact-actions"> const isCurrent = version.id === currentVersionId;
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link> return (
{isTemporaryUserLockedVersion(version) ? ( <div className="button-row compact-actions">
<> <Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button> {isCurrent && (isTemporaryUserLockedVersion(version) ? (
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button> <>
</> <Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? ( <Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button> </>
) : null} ) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
</div> <Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
), ) : null)}
</div>
);
},
}, },
]; ];
} }
function versionLockLabel(version: CampaignVersionListItem): string { function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock"; if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
if (isPermanentUserLockedVersion(version)) return "Permanent user lock"; if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
if (isFinalLockedVersion(version)) return "Permanent delivery lock"; if (isFinalLockedVersion(version)) return "Permanent delivery lock";

View File

@@ -23,7 +23,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
const [editorState, setEditorState] = useState<EditorState>({}); const [editorState, setEditorState] = useState<EditorState>({});
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -70,7 +70,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing global settings." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>

View File

@@ -32,7 +32,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null); const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({ const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -263,7 +263,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing server settings." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>

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, { DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -42,7 +42,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -144,7 +144,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing sender or recipient profiles." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>
@@ -239,7 +239,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
rows={inlineEntries.slice(0, 100)} rows={inlineEntries.slice(0, 100)}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, 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={<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>} emptyText="No recipient profiles are stored in the current version yet."
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
className="recipient-table-wrap recipient-address-table" className="recipient-table-wrap recipient-address-table"
/> />
)} )}
@@ -293,7 +294,7 @@ function recipientProfileColumns({ locked, entries, entryAddressColumns, address
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
width: 150, width: 180,
sticky: "end", sticky: "end",
render: (_entry, index) => ( render: (_entry, index) => (
<DataGridRowActions <DataGridRowActions

View File

@@ -23,7 +23,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -83,7 +83,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing recipient data." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>

View File

@@ -1,14 +1,11 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
AlertTriangle,
BarChart3, BarChart3,
Check, Check,
ChevronDown, ChevronDown,
ChevronRight,
FlaskConical, FlaskConical,
LockKeyhole, LockKeyhole,
MailSearch,
PackageCheck, PackageCheck,
RefreshCw, RefreshCw,
Send, Send,
@@ -44,6 +41,7 @@ import {
getDeliverySection, getDeliverySection,
humanize, humanize,
isFinalLockedVersion, isFinalLockedVersion,
isHistoricalCampaignVersion,
isUserLockedVersion, isUserLockedVersion,
isVersionReadyForDelivery, isVersionReadyForDelivery,
} from "./utils/campaignView"; } from "./utils/campaignView";
@@ -165,8 +163,10 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase(); const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
const deliveryQueued = currentWorkflowState === "queued"; const deliveryQueued = currentWorkflowState === "queued";
const deliveryStarted = ["sending", "sent", "completed", "partially_sent", "failed_partial"].includes(currentWorkflowState); const deliveryStarted = ["sending", "sent", "completed", "partially_sent", "failed_partial"].includes(currentWorkflowState);
const historicalVersion = isHistoricalCampaignVersion(version, data.campaign?.current_version_id);
const finalVersion = isFinalLockedVersion(version); const finalVersion = isFinalLockedVersion(version);
const userLockedVersion = isUserLockedVersion(version); const userLockedVersion = isUserLockedVersion(version);
const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion;
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null; const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview); const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview);
@@ -186,50 +186,32 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const mockMailbox = asRecord(mockResult?.mailbox); const mockMailbox = asRecord(mockResult?.mailbox);
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord); const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
const validationState: FlowState = busy === "validate" const validationReviewState: FlowState = busy === "validate"
? "running" ? "running"
: validationStale : validationStale
? "stale" ? "stale"
: validationPresent && !validationOk : validationPresent && !validationOk
? "danger" ? "danger"
: readyForDelivery && validationWarnings > 0 : readyForDelivery && (validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0)
? "warning" ? "warning"
: readyForDelivery : readyForDelivery
? "complete" ? "complete"
: "active"; : "active";
const exceptionState: FlowState = !validationPresent
? "locked"
: validationErrors > 0
? "danger"
: validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0
? "warning"
: validationOk
? "complete"
: "active";
const buildState: FlowState = !readyForDelivery
? "locked"
: busy === "build"
? "running"
: hasBuild && buildBlocked > 0
? "danger"
: hasBuild && (buildNeedsReview > 0 || buildWarnings > 0)
? "warning"
: hasBuild
? "complete"
: "active";
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity; const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
const inspectState: FlowState = !hasBuild const buildReviewState: FlowState = !readyForDelivery
? "locked" ? "locked"
: busy === "inspect" : busy === "build" || busy === "inspect"
? "running" ? "running"
: inspectionSatisfied : hasBuild && buildBlocked > 0
? "complete" ? "danger"
: "active"; : hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied)
? "warning"
: hasBuild && inspectionSatisfied
? "complete"
: "active";
const mockState: FlowState = !inspectionSatisfied const mockState: FlowState = !inspectionSatisfied
? "locked" ? "locked"
@@ -268,61 +250,41 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const stages: FlowStageDefinition[] = useMemo(() => [ const stages: FlowStageDefinition[] = useMemo(() => [
{ {
id: "workflow-validate", id: "workflow-validate-review",
title: "Validate campaign", title: "Validate and inspect",
shortTitle: "Validate", shortTitle: "Validate",
description: "Check campaign structure, recipients, templates and managed attachment matches.", description: "Lock and validate the campaign, then inspect blocking errors, warnings and attachment exceptions in the same step.",
icon: ShieldCheck, icon: ShieldCheck,
state: validationState, state: validationReviewState,
stateLabel: stateLabel(validationState), stateLabel: stateLabel(validationReviewState),
}, },
{ {
id: "workflow-exceptions", id: "workflow-build-review",
title: "Review exceptions", title: "Build and review",
shortTitle: "Exceptions",
description: "Resolve blocking errors and make warnings visible before building messages.",
icon: AlertTriangle,
state: exceptionState,
stateLabel: stateLabel(exceptionState),
lockReason: "Run validation first to discover campaign exceptions.",
},
{
id: "workflow-build",
title: "Build exact messages",
shortTitle: "Build", shortTitle: "Build",
description: "Freeze the current recipients, rendered content and resolved attachment files.", description: "Build the exact queue, resolve recipient values and managed files, then review only messages that need attention.",
icon: PackageCheck, icon: PackageCheck,
state: buildState, state: buildReviewState,
stateLabel: stateLabel(buildState), stateLabel: stateLabel(buildReviewState),
lockReason: validationErrors > 0 lockReason: validationErrors > 0
? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.` ? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.`
: "Complete validation and lock the version first.", : "Lock and validate the current working version first.",
}, },
{ {
id: "workflow-inspect", id: "workflow-mock-verify",
title: "Inspect built messages", title: "Mock send and verify",
shortTitle: "Inspect",
description: "Review rendered content, recipients, validation state and the exact managed files attached to individual messages.",
icon: MailSearch,
state: inspectState,
stateLabel: stateLabel(inspectState),
lockReason: "Build the exact messages before inspecting them.",
},
{
id: "workflow-mock",
title: "Run mock delivery",
shortTitle: "Mock send", shortTitle: "Mock send",
description: "Exercise the delivery path, inspect recipient outcomes and open the captured MIME messages without contacting the real SMTP or IMAP server.", description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers.",
icon: FlaskConical, icon: FlaskConical,
state: mockState, state: mockState,
stateLabel: stateLabel(mockState), stateLabel: stateLabel(mockState),
lockReason: "Complete the message inspection step first.", lockReason: "Build and complete the required message review first.",
}, },
{ {
id: "workflow-send", id: "workflow-send",
title: "Confirm and send", title: "Confirm and send",
shortTitle: "Real send", shortTitle: "Send",
description: "Review the final execution summary before opening the current real-send controls.", description: "Review the final execution summary before opening the established real-send controls.",
icon: Send, icon: Send,
state: sendState, state: sendState,
stateLabel: stateLabel(sendState), stateLabel: stateLabel(sendState),
@@ -332,17 +294,15 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
id: "workflow-results", id: "workflow-results",
title: "Delivery results", title: "Delivery results",
shortTitle: "Results", shortTitle: "Results",
description: "Separate SMTP outcomes, IMAP append results and partial failures.", description: "Review SMTP outcomes, IMAP append results, partial delivery and failures.",
icon: BarChart3, icon: BarChart3,
state: resultState, state: resultState,
stateLabel: stateLabel(resultState), stateLabel: stateLabel(resultState),
lockReason: "Delivery results become available after the real send starts.", lockReason: "Delivery results become available after the real send starts.",
}, },
], [ ], [
validationState, validationReviewState,
exceptionState, buildReviewState,
buildState,
inspectState,
mockState, mockState,
sendState, sendState,
resultState, resultState,
@@ -350,7 +310,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
]); ]);
async function runValidation() { async function runValidation() {
if (!version || busy || userLockedVersion || finalVersion || readyForDelivery) return; if (!version || busy || readOnlyVersion || readyForDelivery) return;
setBusy("validate"); setBusy("validate");
setMessage("Validating the campaign, including managed attachment matches…"); setMessage("Validating the campaign, including managed attachment matches…");
setError(""); setError("");
@@ -368,7 +328,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
async function runBuild() { async function runBuild() {
if (!version || busy || !readyForDelivery || deliveryQueued || deliveryStarted) return; if (!version || busy || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted) return;
setBusy("build"); setBusy("build");
setMessage("Building exact messages and resolving managed attachment versions…"); setMessage("Building exact messages and resolving managed attachment versions…");
setError(""); setError("");
@@ -411,7 +371,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
async function runMockSend() { async function runMockSend() {
if (!version || busy || !inspectionSatisfied || deliveryQueued || deliveryStarted) return; if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
setBusy("mock"); setBusy("mock");
setMessage("Running the complete mock-delivery flow…"); setMessage("Running the complete mock-delivery flow…");
setError(""); setError("");
@@ -439,7 +399,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
async function completeInspection() { async function completeInspection() {
if (!version || busy || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return; if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
setBusy("inspect"); setBusy("inspect");
setError(""); setError("");
setMessage("Recording the completed message review…"); setMessage("Recording the completed message review…");
@@ -527,13 +487,14 @@ 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) && ( {version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) && (
<LockedVersionNotice <LockedVersionNotice
settings={settings} settings={settings}
campaignId={campaignId} campaignId={campaignId}
version={version} version={version}
currentVersionId={data.campaign?.current_version_id}
reload={reload} reload={reload}
message={readyForDelivery ? "Locked and validated. Unlock validation to edit campaign data, or create an editable copy." : "Send snapshot. Copy to edit."} message="This workflow is read-only for the selected version."
/> />
)} )}
@@ -544,16 +505,22 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
<WorkflowStage stage={stages[0]} nextState={stages[1].state}> <WorkflowStage stage={stages[0]} nextState={stages[1].state}>
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="Status" value={validationPresent ? (validationOk ? "Passed" : "Needs attention") : "Not run"} /> <WorkflowFact label="Status" value={validationPresent ? (validationOk ? "Passed" : "Needs attention") : "Not run"} />
<WorkflowFact label="Errors" value={validationPresent ? validationErrors : "—"} /> <WorkflowFact label="Blocking" value={validationPresent ? validationErrors : "—"} />
<WorkflowFact label="Warnings" value={validationPresent ? validationWarnings : "—"} /> <WorkflowFact label="Warnings" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="Last change" value={formatDateTime(version?.updated_at)} /> <WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
</div> </div>
{validationStale && <p className="review-flow-inline-note is-stale">The stored validation result is no longer an active delivery lock. Run validation again.</p>} {validationStale && <p className="review-flow-inline-note is-stale">The stored validation result is no longer an active delivery lock. Run validation again.</p>}
<div className="button-row compact-actions"> {validationPresent && validationErrors === 0 && (
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> No blocking validation exceptions remain.</p>
)}
{validationErrors > 0 && (
<p className="review-flow-inline-note is-danger">Resolve the blocking entries, then validate again.</p>
)}
<div className="button-row compact-actions review-flow-stage-actions">
<Button <Button
variant="primary" variant="primary"
onClick={() => void runValidation()} onClick={() => void runValidation()}
disabled={!version || Boolean(busy) || userLockedVersion || finalVersion || readyForDelivery} disabled={!version || Boolean(busy) || readOnlyVersion || readyForDelivery}
> >
{busy === "validate" {busy === "validate"
? "Validating…" ? "Validating…"
@@ -561,66 +528,41 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
? "Locked and validated" ? "Locked and validated"
: "Lock and validate"} : "Lock and validate"}
</Button> </Button>
<Link className="btn btn-secondary" to="../send">Open issue table</Link>
<Link className="btn btn-secondary" to="../files">Review attachment rules</Link>
</div> </div>
</WorkflowStage> </WorkflowStage>
<WorkflowStage stage={stages[1]} nextState={stages[2].state}> <WorkflowStage stage={stages[1]} nextState={stages[2].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Blocking" value={validationErrors} />
<WorkflowFact label="Warnings" value={validationWarnings} />
<WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
<WorkflowFact label="Delivery readiness" value={readyForDelivery ? "Ready" : "Not ready"} />
</div>
{validationPresent && validationErrors === 0 && (
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> No blocking validation exceptions remain.</p>
)}
{validationErrors > 0 && (
<p className="review-flow-inline-note is-danger">Resolve the blocking entries in the current issue table, then validate again.</p>
)}
<div className="button-row compact-actions">
<Link className="btn btn-secondary" to="../send">Open current issue table</Link>
<Link className="btn btn-secondary" to="../files">Review attachment rules</Link>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} /> <WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} /> <WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="Needs review" value={hasBuild ? buildNeedsReview : "—"} /> <WorkflowFact label="Need review" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="Warnings" value={hasBuild ? buildWarnings : "—"} /> <WorkflowFact label="Reviewed" value={hasBuild ? reviewedRequiredCount : "—"} />
</div> <WorkflowFact label="Loaded messages" value={builtReviewRows.length || "—"} />
<p className="muted">Building resolves recipient values and attachment patterns into the exact message queue used by the later stages.</p>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || !readyForDelivery || deliveryQueued || deliveryStarted}>
{busy === "build" ? "Building…" : hasBuild ? "Build again" : "Build exact messages"}
</Button>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
<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>
<p className="muted">Building freezes the current recipients, rendered content and exact managed-file versions into the queue reviewed below.</p>
<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 runBuild()} disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted}>
{busy === "build" ? "Building…" : hasBuild ? "Build again" : "Build exact messages"}
</Button>
<Button onClick={() => void loadBuiltMessages()} disabled={!hasBuild || Boolean(busy)}> <Button onClick={() => void loadBuiltMessages()} disabled={!hasBuild || Boolean(busy)}>
{busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload message review" : "Load message review"} {busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload review" : "Load review"}
</Button> </Button>
<Link className="btn btn-secondary" to="../template">Open template editor</Link> <Link className="btn btn-secondary" to="../template">Open template editor</Link>
<Button {reviewRequiredRows.length > 0 && (
variant="primary" <Button
onClick={() => void completeInspection()} variant="primary"
disabled={automaticInspectionComplete || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)} onClick={() => void completeInspection()}
> disabled={readOnlyVersion || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
{automaticInspectionComplete || messageReviewComplete ? "Inspection completed" : "Complete inspection"} >
</Button> {messageReviewComplete ? "Review completed" : "Complete review"}
</Button>
)}
</div> </div>
{automaticInspectionComplete && ( {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> <p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> All built messages are ready. No manual review acknowledgement is required.</p>
)} )}
{builtReviewRows.length > 0 && ( {builtReviewRows.length > 0 && (
<div className="review-flow-data-section"> <div className="review-flow-data-section">
@@ -630,15 +572,16 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)} columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
getRowKey={builtMessageKey} getRowKey={builtMessageKey}
initialFilters={{ validation: MESSAGE_REVIEW_DEFAULT_FILTER }} initialFilters={{ validation: MESSAGE_REVIEW_DEFAULT_FILTER }}
emptyText="No messages require review. Ready messages are hidden by the default validation filter." emptyText="No built messages are available."
filteredEmptyText="No messages match the active filters."
className="data-table-wrap data-table compact-table" className="data-table-wrap data-table compact-table"
/> />
<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> <p className="muted small-note">Ready messages are hidden initially. Use the Validation filter to include them. When review is required, open every issue message before completing the step.</p>
</div> </div>
)} )}
</WorkflowStage> </WorkflowStage>
<WorkflowStage stage={stages[4]} nextState={stages[5].state}> <WorkflowStage stage={stages[2]} nextState={stages[3].state}>
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} /> <WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} /> <WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
@@ -646,7 +589,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) || !inspectionSatisfied || deliveryQueued || deliveryStarted}> <Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || readOnlyVersion || !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>
@@ -679,7 +622,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
)} )}
</WorkflowStage> </WorkflowStage>
<WorkflowStage stage={stages[5]} nextState={stages[6].state}> <WorkflowStage stage={stages[3]} nextState={stages[4].state}>
<div className="review-flow-execution-summary"> <div className="review-flow-execution-summary">
<div><span>Recipients</span><strong>{jobsTotal || "—"}</strong></div> <div><span>Recipients</span><strong>{jobsTotal || "—"}</strong></div>
<div><span>Messages to send</span><strong>{builtCount || jobsTotal || "—"}</strong></div> <div><span>Messages to send</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
@@ -690,13 +633,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
<div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div> <div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div>
<div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div> <div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div> </div>
<p className="review-flow-inline-note is-warning">The development page intentionally delegates the real-send action to the established page while this layout is evaluated.</p> <p className="review-flow-inline-note is-warning">The workflow preview delegates the real-send action to the established page while this layout is evaluated.</p>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Link className="btn btn-primary" to="../send">Open final send controls</Link> <Link className="btn btn-primary" to="../send">Open final send controls</Link>
</div> </div>
</WorkflowStage> </WorkflowStage>
<WorkflowStage stage={stages[6]}> <WorkflowStage stage={stages[4]}>
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="SMTP accepted" value={sentCount} /> <WorkflowFact label="SMTP accepted" value={sentCount} />
<WorkflowFact label="SMTP failed" value={failedCount} /> <WorkflowFact label="SMTP failed" value={failedCount} />
@@ -807,14 +750,11 @@ 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" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}> <article className={`card card-collapsible 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>
</h2>
<div className="review-flow-stage-header-actions">
{!locked && ( {!locked && (
<span <span
className="review-flow-state-badge" className="review-flow-state-badge"
@@ -825,15 +765,18 @@ function WorkflowStage({
{stage.stateLabel} {stage.stateLabel}
</span> </span>
)} )}
<InlineHelp>{stage.description}</InlineHelp>
</h2>
<div className="review-flow-stage-header-actions">
<button <button
type="button" type="button"
className="review-flow-collapse-button" className="card-collapse-toggle"
aria-expanded={!collapsed} aria-expanded={!collapsed}
aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`} aria-label={collapsed ? `Expand ${stage.title}` : `Collapse ${stage.title}`}
title={collapsed ? "Expand card" : "Collapse card"} title={collapsed ? "Show content" : "Show header only"}
onClick={() => setCollapsed((value) => !value)} onClick={() => setCollapsed((value) => !value)}
> >
{collapsed ? <ChevronRight size={18} aria-hidden="true" /> : <ChevronDown size={18} aria-hidden="true" />} <ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button> </button>
</div> </div>
</header> </header>

View File

@@ -23,7 +23,7 @@ import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVe
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function SendDataPage({ 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;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const cards = data.summary?.cards; const cards = data.summary?.cards;
const delivery = getDeliverySection(version); const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit); const rateLimit = asRecord(delivery.rate_limit);
@@ -151,7 +151,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</DismissibleAlert>} {sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</DismissibleAlert>}
{mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage} floating>{mockMessage}</DismissibleAlert>} {mockMessage && <DismissibleAlert tone="info" resetKey={mockMessage} floating>{mockMessage}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}> <LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
<div className="metric-grid"> <div className="metric-grid">

View File

@@ -35,7 +35,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const htmlRef = useRef<HTMLTextAreaElement | null>(null); const htmlRef = useRef<HTMLTextAreaElement | null>(null);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -184,7 +184,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…"> <LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<> <>

View File

@@ -2,7 +2,7 @@ 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, { DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid"; import DataGrid, { DataGridEmptyAction, 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";
@@ -85,7 +85,7 @@ export default function AttachmentRulesOverlay({
/> />
</div> </div>
<footer className="modal-footer"> <footer className="modal-footer">
<Button variant="primary" onClick={addOverlayRule} disabled={disabled}>Add file</Button> <Button variant="primary" onClick={addOverlayRule} disabled={disabled || basePaths.length === 0}>Add file</Button>
<Button onClick={closeOverlay}>Close</Button> <Button onClick={closeOverlay}>Close</Button>
</footer> </footer>
</div> </div>
@@ -121,7 +121,7 @@ export function AttachmentRulesTable({
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} /> <AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && ( {showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions"> <div className="button-row compact-actions attachment-rules-footer-actions">
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled}>Add file</Button> <Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
</div> </div>
)} )}
</div> </div>
@@ -148,7 +148,8 @@ export function AttachmentRulesDataGrid({
} }
function addRule(afterIndex = rules.length - 1) { function addRule(afterIndex = rules.length - 1) {
const nextRule = createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? ""); if (disabled || basePaths.length === 0) return;
const nextRule = createAttachmentRule(basePaths[0].path, nextAttachmentLabel(rules), basePaths[0].id);
onChange(insertAfter(rules, afterIndex, nextRule)); onChange(insertAfter(rules, afterIndex, nextRule));
} }
@@ -169,9 +170,14 @@ export function AttachmentRulesDataGrid({
return; return;
} }
const rule = rules[ruleIndex] ?? {}; const rule = rules[ruleIndex] ?? {};
const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id"); const currentBasePathId = getText(rule, "base_path_id");
const basePath = basePaths.find((item) => item.id === currentBasePathId) ?? basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null; const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId)
?? basePaths.find((item) => item.path === currentPath)
?? (!explicitlyReferenced ? basePaths[0] : undefined)
?? null;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath }); setFileChooser({ ruleIndex, basePath });
} }
@@ -196,7 +202,8 @@ export function AttachmentRulesDataGrid({
rows={rules} rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, 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={<div className="data-grid-empty-action"><span>{emptyText}</span><Button variant="primary" onClick={() => addRule(-1)} disabled={disabled}>Add file</Button></div>} emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
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}
/> />
@@ -240,22 +247,26 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
sortable: true, sortable: true,
filterable: true, filterable: true,
render: (rule, index) => { render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ""); const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id"); const currentBasePathId = getText(rule, "base_path_id");
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ?? basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? basePaths[0]; const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
return basePaths.length > 0 ? ( const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
?? (!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select <select
value={selectedBasePath?.id ?? ""} value={selectedBasePath?.id ?? ""}
disabled={disabled} disabled={disabled || basePaths.length === 0}
onChange={(event) => { onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value); const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path }); if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}} }}
> >
{!selectedBasePath && (
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
)}
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)} {basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select> </select>
) : (
<input value={currentBasePathValue} disabled={disabled} readOnly placeholder="optional/folder" />
); );
}, },
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "") value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
@@ -272,7 +283,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
<input <input
className="chooser-display-input" className="chooser-display-input"
value={getText(rule, "file_filter")} value={getText(rule, "file_filter")}
disabled={disabled} disabled={disabled || basePaths.length === 0}
readOnly readOnly
tabIndex={-1} tabIndex={-1}
placeholder="Choose a managed file or pattern" placeholder="Choose a managed file or pattern"
@@ -284,7 +295,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
} }
}} }}
/> />
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button> <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
</div> </div>
), ),
value: (rule) => getText(rule, "file_filter") value: (rule) => getText(rule, "file_filter")
@@ -293,7 +304,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
width: 150, width: 180,
sticky: "end", sticky: "end",
render: (_rule, index) => ( render: (_rule, index) => (
<DataGridRowActions <DataGridRowActions

View File

@@ -14,6 +14,7 @@ import {
canUnlockValidationVersion, canUnlockValidationVersion,
formatDateTime, formatDateTime,
isFinalLockedVersion, isFinalLockedVersion,
isHistoricalCampaignVersion,
isPermanentUserLockedVersion, isPermanentUserLockedVersion,
isTemporaryUserLockedVersion, isTemporaryUserLockedVersion,
} from "../utils/campaignView"; } from "../utils/campaignView";
@@ -22,23 +23,27 @@ type LockedVersionNoticeProps = {
settings: ApiSettings; settings: ApiSettings;
campaignId: string; campaignId: string;
version: CampaignVersionDetail | CampaignVersionListItem | null; version: CampaignVersionDetail | CampaignVersionListItem | null;
currentVersionId?: string | null;
reload: () => Promise<void>; reload: () => Promise<void>;
message?: string; message?: string;
}; };
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null; 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, currentVersionId, 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 [confirmAction, setConfirmAction] = useState<ConfirmAction>(null); const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const validationLock = canUnlockValidationVersion(version); const historicalVersion = isHistoricalCampaignVersion(version, currentVersionId);
const temporaryUserLock = isTemporaryUserLockedVersion(version); const validationLock = !historicalVersion && canUnlockValidationVersion(version);
const temporaryUserLock = !historicalVersion && isTemporaryUserLockedVersion(version);
const permanentUserLock = isPermanentUserLockedVersion(version); const permanentUserLock = isPermanentUserLockedVersion(version);
const finalLock = isFinalLockedVersion(version); const finalLock = isFinalLockedVersion(version);
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
const presentation = lockPresentation(version, { const presentation = lockPresentation(version, {
historicalVersion,
validationLock, validationLock,
temporaryUserLock, temporaryUserLock,
permanentUserLock, permanentUserLock,
@@ -114,9 +119,11 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
</Button> </Button>
</> </>
)} )}
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}> {canCreateEditableCopy && (
{busy ? "Creating copy…" : "Create editable copy"} <Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
</Button> {busy ? "Creating copy…" : "Create editable copy"}
</Button>
)}
</div> </div>
<ConfirmDialog <ConfirmDialog
@@ -138,6 +145,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
} }
type LockFlags = { type LockFlags = {
historicalVersion: boolean;
validationLock: boolean; validationLock: boolean;
temporaryUserLock: boolean; temporaryUserLock: boolean;
permanentUserLock: boolean; permanentUserLock: boolean;
@@ -148,6 +156,14 @@ function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null, version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags, flags: LockFlags,
): { kind: string; title: string; description: string; info: string } { ): { kind: string; title: string; description: string; info: string } {
if (flags.historicalVersion) {
return {
kind: "historical",
title: "Historical version.",
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
info: `Version #${version?.version_number ?? "—"}.`,
};
}
if (flags.temporaryUserLock) { if (flags.temporaryUserLock) {
return { return {
kind: "temporary-user", kind: "temporary-user",

View File

@@ -99,8 +99,31 @@ export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): Attachme
} }
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] { export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
const enabled = paths.filter((basePath) => basePath.allow_individual); return paths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : paths; }
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
const basePathId = getText(rule, "base_path_id");
if (basePathId) return basePathId === basePath.id;
return getText(rule, "base_dir") === basePath.path;
}
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
.length;
}
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
const entries = asRecord(entriesValue);
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
...entry,
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
}));
return { ...entries, inline };
} }

View File

@@ -103,14 +103,31 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
return ["approved", "built"].includes(version.workflow_state ?? ""); return ["approved", "built"].includes(version.workflow_state ?? "");
} }
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean { export function isHistoricalCampaignVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
return Boolean(version && currentVersionId && version.id !== currentVersionId);
}
export function isAuditLockedVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
if (!version) return false; if (!version) return false;
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
if (version.locked_at || isUserLockedVersion(version)) 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,
currentVersionId?: string | null,
): string {
if (!version) return "No campaign version is loaded."; if (!version) return "No campaign version is loaded.";
if (isHistoricalCampaignVersion(version, currentVersionId)) {
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
}
if (isTemporaryUserLockedVersion(version)) { if (isTemporaryUserLockedVersion(version)) {
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`; return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
} }

View File

@@ -31,7 +31,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
const index = steps.findIndex((s) => s.id === activeStep); const index = steps.findIndex((s) => s.id === activeStep);
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({ const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
@@ -93,8 +93,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
settings={settings} settings={settings}
campaignId={campaignId} campaignId={campaignId}
version={data.currentVersion} version={data.currentVersion}
currentVersionId={data.campaign?.current_version_id}
reload={reload} reload={reload}
message="Create an editable copy before continuing the creation wizard." message="This wizard is read-only for the selected version."
/> />
<div className="button-row"> <div className="button-row">
<Link to="../.."><Button>Back to overview</Button></Link> <Link to="../.."><Button>Back to overview</Button></Link>

View File

@@ -1785,7 +1785,7 @@
justify-content: center; justify-content: center;
min-width: 32px; min-width: 32px;
min-height: 28px; min-height: 28px;
margin-left: auto; margin-left: 2px;
padding: 4px 9px; padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line)); border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
@@ -1810,7 +1810,11 @@
min-height: 150px; min-height: 150px;
} }
.review-flow-stage-card.is-locked .review-flow-stage-header, .review-flow-stage-card.is-locked .review-flow-stage-header {
opacity: .58;
filter: grayscale(.25);
}
.review-flow-stage-card.is-locked .review-flow-stage-content { .review-flow-stage-card.is-locked .review-flow-stage-content {
opacity: .38; opacity: .38;
filter: grayscale(.35); filter: grayscale(.35);
@@ -2023,28 +2027,6 @@
margin-left: auto; 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 { .review-flow-stage-card.is-collapsed .review-flow-stage-header {
min-height: 58px; min-height: 58px;
border-bottom: 0; border-bottom: 0;

View File

@@ -1185,64 +1185,36 @@
/* Consistent row-level collection actions for editable DataGrid tables. */ /* Consistent row-level collection actions for editable DataGrid tables. */
.data-grid-row-actions { .data-grid-row-actions {
display: grid; display: grid;
grid-template-columns: repeat(4, 28px); grid-template-columns: repeat(4, 36px);
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 4px; gap: 4px;
width: 100%; width: 100%;
} }
.data-grid-row-action { .data-grid-row-action.btn {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 28px; width: 36px;
height: 28px; height: 36px;
border: 1px solid var(--line); min-width: 36px;
border-radius: 6px;
background: #fff;
color: var(--text);
cursor: pointer;
padding: 0; padding: 0;
} }
.data-grid-row-action:hover:not(:disabled), .data-grid-row-action.btn:disabled {
.data-grid-row-action:focus-visible:not(:disabled) { opacity: .45;
border-color: var(--line-dark);
background: var(--panel-soft);
outline: none;
box-shadow: var(--focus-ring);
} }
.data-grid-row-action.is-add { .data-grid-empty-message {
border-color: color-mix(in srgb, var(--blue) 52%, var(--line)); color: var(--muted);
background: color-mix(in srgb, var(--blue) 10%, #fff); min-height: 64px;
color: var(--blue);
} }
.data-grid-row-action.is-remove { .data-grid-empty-action-cell {
border-color: rgba(171, 70, 61, .35); justify-content: flex-end;
background: rgba(171, 70, 61, .06); min-height: 64px;
color: #9f433b;
} }
.data-grid-row-action:disabled { .data-grid-empty-row-actions {
cursor: default; justify-content: end;
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;
} }