Locking refinement; Review/Send improvement
This commit is contained in:
@@ -2,6 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
||||
import StatusBadge from "../StatusBadge";
|
||||
import Button from "../Button";
|
||||
|
||||
export type DataGridSortDirection = "asc" | "desc";
|
||||
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
|
||||
@@ -58,6 +59,9 @@ type DataGridProps<T> = {
|
||||
columns: DataGridColumn<T>[];
|
||||
getRowKey: (row: T, index: number) => string;
|
||||
emptyText?: ReactNode;
|
||||
filteredEmptyText?: ReactNode;
|
||||
emptyAction?: ReactNode;
|
||||
emptyActionColumnId?: string;
|
||||
fit?: "content" | "container";
|
||||
className?: string;
|
||||
rowClassName?: (row: T, index: number) => string | undefined;
|
||||
@@ -84,9 +88,19 @@ type FilterPosition = {
|
||||
width: number;
|
||||
};
|
||||
|
||||
type ResizeTarget = {
|
||||
columnId: string;
|
||||
startWidth: number;
|
||||
maxWidth: number;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "multimailer.datagrid.";
|
||||
const FILTER_POPOVER_WIDTH = 320;
|
||||
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>({
|
||||
id,
|
||||
@@ -94,6 +108,9 @@ export default function DataGrid<T>({
|
||||
columns,
|
||||
getRowKey,
|
||||
emptyText = "No rows found.",
|
||||
filteredEmptyText = "Filter yields an empty result.",
|
||||
emptyAction,
|
||||
emptyActionColumnId = "actions",
|
||||
fit = "container",
|
||||
className = "",
|
||||
rowClassName,
|
||||
@@ -109,9 +126,10 @@ export default function DataGrid<T>({
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
baseWidths: Record<string, number>;
|
||||
immediateRightColumnId?: string;
|
||||
immediateRightStartWidth?: number;
|
||||
rightResizeTargets: ResizeTarget[];
|
||||
shrinkRoomWithoutScroll: number;
|
||||
actualLastColumnMaxShrink: number;
|
||||
isActualLastColumn: boolean;
|
||||
} | null>(null);
|
||||
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | 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);
|
||||
if (!column) return;
|
||||
|
||||
const minWidth = column.minWidth ?? 80;
|
||||
const maxWidth = column.maxWidth ?? 2000;
|
||||
const minWidth = effectiveColumnMinWidth(column);
|
||||
const maxWidth = Math.max(minWidth, column.maxWidth ?? 2000);
|
||||
const rawDelta = event.clientX - activeResize.startX;
|
||||
const activeDelta = Math.min(
|
||||
maxWidth - activeResize.startWidth,
|
||||
Math.max(minWidth - activeResize.startWidth, rawDelta)
|
||||
);
|
||||
const nextWidths = { ...activeResize.baseWidths };
|
||||
let fillColumnId: string | null | undefined;
|
||||
let fillColumnId: string | null | undefined = null;
|
||||
|
||||
if (activeDelta > 0 && activeResize.immediateRightColumnId) {
|
||||
// A growing column may only shrink its immediate neighbour. Once that
|
||||
// neighbour reaches its minimum, the grid grows and can scroll rather
|
||||
// than pulling space from a more distant column.
|
||||
const neighbour = columns.find((item) => item.id === activeResize.immediateRightColumnId);
|
||||
const neighbourStartWidth = activeResize.immediateRightStartWidth ?? 0;
|
||||
const neighbourMinWidth = neighbour?.minWidth ?? 80;
|
||||
const absorbedDelta = Math.min(activeDelta, Math.max(0, neighbourStartWidth - neighbourMinWidth));
|
||||
if (activeResize.isActualLastColumn) {
|
||||
// The resizable actual final column has no columns to its right to
|
||||
// compensate. It may grow freely, and it may shrink by the amount of
|
||||
// horizontal overflow measured at drag start, but never far enough to
|
||||
// leave unused space on the right.
|
||||
const minimumDelta = -activeResize.actualLastColumnMaxShrink;
|
||||
const clampedLastColumnDelta = Math.max(minimumDelta, activeDelta);
|
||||
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.immediateRightColumnId] = neighbourStartWidth - absorbedDelta;
|
||||
fillColumnId = activeResize.immediateRightColumnId;
|
||||
} else {
|
||||
// Shrinking never resizes another column. It consumes only horizontal
|
||||
// overflow that is still available to the right of the viewport. Once
|
||||
// that room is exhausted, resizing stops instead of growing a buffer
|
||||
// column, reclaiming width from the left, or shifting the scroll view.
|
||||
const boundedDelta = activeDelta < 0
|
||||
? Math.max(activeDelta, -activeResize.shrinkRoomWithoutScroll)
|
||||
: activeDelta;
|
||||
nextWidths[activeResize.columnId] = activeResize.startWidth + boundedDelta;
|
||||
// Exact pixel tracks remain fixed during the drag, so no implicit
|
||||
// flexible filler can change a different column behind the pointer.
|
||||
fillColumnId = null;
|
||||
const requestedShrink = -activeDelta;
|
||||
const freeShrink = Math.min(requestedShrink, activeResize.shrinkRoomWithoutScroll);
|
||||
const compensationNeeded = requestedShrink - freeShrink;
|
||||
|
||||
// Shrinking first consumes only overflow that is still to the right of
|
||||
// the current viewport. Once that room is exhausted, all eligible
|
||||
// resizable, non-sticky columns to the right grow evenly so the grid
|
||||
// continues to fill its container. Targets at maxWidth drop out and the
|
||||
// remainder is redistributed. Recomputing from drag-start widths makes
|
||||
// the operation exactly reversible when the pointer changes direction.
|
||||
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) => ({
|
||||
@@ -328,6 +356,7 @@ export default function DataGrid<T>({
|
||||
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 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>) {
|
||||
if (!column.sortable) return;
|
||||
@@ -418,13 +447,25 @@ export default function DataGrid<T>({
|
||||
event.stopPropagation();
|
||||
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
|
||||
const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]);
|
||||
const immediateRightColumn = chooseImmediateResizePartner(columns, column.id);
|
||||
const immediateRightStartWidth = immediateRightColumn
|
||||
? baseWidths[immediateRightColumn.id] ?? columnPixelWidth(immediateRightColumn, state.widths?.[immediateRightColumn.id], measuredWidths[immediateRightColumn.id])
|
||||
: undefined;
|
||||
const activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
|
||||
const isActualLastColumn = activeColumnIndex === columns.length - 1;
|
||||
const rightResizeTargets = columns
|
||||
.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 totalHorizontalOverflow = gridElement
|
||||
? Math.max(0, gridElement.scrollWidth - gridElement.clientWidth)
|
||||
: 0;
|
||||
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;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
@@ -438,9 +479,10 @@ export default function DataGrid<T>({
|
||||
startX: event.clientX,
|
||||
startWidth: currentWidth,
|
||||
baseWidths,
|
||||
immediateRightColumnId: immediateRightColumn?.id,
|
||||
immediateRightStartWidth,
|
||||
shrinkRoomWithoutScroll
|
||||
rightResizeTargets,
|
||||
shrinkRoomWithoutScroll,
|
||||
actualLastColumnMaxShrink,
|
||||
isActualLastColumn
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -450,11 +492,36 @@ export default function DataGrid<T>({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleRows.length === 0 ? (
|
||||
<div className="data-grid-empty" role="row">
|
||||
<div role="cell">{emptyText}</div>
|
||||
{visibleRows.length === 0 ? (() => {
|
||||
const filteredEmpty = rows.length > 0 && hasActiveFilters;
|
||||
const actionColumnIndex = columns.findIndex((column) => column.id === emptyActionColumnId);
|
||||
const actionColumn = actionColumnIndex >= 0 ? columns[actionColumnIndex] : undefined;
|
||||
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>
|
||||
) : visibleRows.map((row, visibleIndex) => {
|
||||
<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 rowClass = rowClassName?.(row, originalIndex);
|
||||
const parityClass = visibleIndex % 2 === 0 ? "data-grid-row-even" : "data-grid-row-odd";
|
||||
@@ -506,50 +573,71 @@ export function DataGridRowActions({
|
||||
}: DataGridRowActionsProps) {
|
||||
return (
|
||||
<div className="data-grid-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="primary"
|
||||
className="data-grid-row-action is-add"
|
||||
aria-label={addLabel}
|
||||
title={addLabel}
|
||||
disabled={disabled}
|
||||
onClick={onAddBelow}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`data-grid-row-action is-reorder${onMoveUp ? "" : " is-placeholder"}`}
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="data-grid-row-action is-reorder"
|
||||
aria-label={moveUpLabel}
|
||||
title={moveUpLabel}
|
||||
aria-hidden={!onMoveUp}
|
||||
tabIndex={onMoveUp ? 0 : -1}
|
||||
disabled={disabled || !onMoveUp}
|
||||
onClick={onMoveUp}
|
||||
onClick={() => onMoveUp?.()}
|
||||
>
|
||||
<ArrowUp size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`data-grid-row-action is-reorder${onMoveDown ? "" : " is-placeholder"}`}
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="data-grid-row-action is-reorder"
|
||||
aria-label={moveDownLabel}
|
||||
title={moveDownLabel}
|
||||
aria-hidden={!onMoveDown}
|
||||
tabIndex={onMoveDown ? 0 : -1}
|
||||
disabled={disabled || !onMoveDown}
|
||||
onClick={onMoveDown}
|
||||
onClick={() => onMoveDown?.()}
|
||||
>
|
||||
<ArrowDown size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="data-grid-row-action is-remove"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
disabled={disabled || removeDisabled}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -841,33 +929,65 @@ function humanizeListValue(value: string): string {
|
||||
}
|
||||
|
||||
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(
|
||||
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)
|
||||
: undefined;
|
||||
const nextFillColumnId = fillColumn?.resizable && !fillColumn.sticky ? fillColumn.id : undefined;
|
||||
const widthsChanged = Object.keys(nextWidths).length !== Object.keys(state.widths ?? {}).length;
|
||||
const shouldPreserveExactWidths = hasSavedWidths && (state.fillColumnId === null || state.fillColumnId === undefined);
|
||||
const nextFillColumnId = shouldPreserveExactWidths
|
||||
? null
|
||||
: fillColumn?.resizable && !fillColumn.sticky
|
||||
? fillColumn.id
|
||||
: undefined;
|
||||
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, nextWidths);
|
||||
const fillChanged = nextFillColumnId !== state.fillColumnId;
|
||||
if (!widthsChanged && !fillChanged) return state;
|
||||
return {
|
||||
...state,
|
||||
widths: Object.keys(nextWidths).length > 0 ? nextWidths : undefined,
|
||||
widths: hasSavedWidths ? nextWidths : undefined,
|
||||
fillColumnId: nextFillColumnId
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const baseWidth = savedWidth ?? fixedWidthFloor(column);
|
||||
const baseWidth = Math.min(maximum, Math.max(minimum, savedWidth ?? fixedWidthFloor(column)));
|
||||
return `minmax(${baseWidth}px, 1fr)`;
|
||||
}
|
||||
if (savedWidth) return `${savedWidth}px`;
|
||||
if (typeof column.width === "number") return `${column.width}px`;
|
||||
if (column.width) return column.width;
|
||||
return `minmax(${column.minWidth ?? 140}px, 1fr)`;
|
||||
if (savedWidth) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
|
||||
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
||||
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
||||
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>(
|
||||
@@ -913,11 +1033,62 @@ function chooseLayoutFillColumn<T>(columns: DataGridColumn<T>[]): string | undef
|
||||
return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id;
|
||||
}
|
||||
|
||||
function chooseImmediateResizePartner<T>(columns: DataGridColumn<T>[], activeColumnId: string): DataGridColumn<T> | undefined {
|
||||
const activeIndex = columns.findIndex((column) => column.id === activeColumnId);
|
||||
if (activeIndex < 0) return undefined;
|
||||
const neighbour = columns[activeIndex + 1];
|
||||
return neighbour?.resizable && !neighbour.sticky ? neighbour : undefined;
|
||||
function effectiveColumnMinWidth<T>(column: DataGridColumn<T>): number {
|
||||
const affordanceWidth = MIN_HEADER_LABEL_WIDTH
|
||||
+ (column.sortable ? SORT_CONTROL_RESERVE : 0)
|
||||
+ (column.filterable ? FILTER_CONTROL_RESERVE : 0)
|
||||
+ (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>(
|
||||
@@ -936,9 +1107,11 @@ function measuredColumnWidths<T>(
|
||||
}
|
||||
|
||||
function fixedWidthFloor<T>(column: DataGridColumn<T>): number {
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||
const parsed = parsePixelWidth(column.width);
|
||||
if (typeof column.width === "number") return column.width;
|
||||
return parsed ?? column.minWidth ?? 140;
|
||||
const configured = typeof column.width === "number" ? column.width : parsed ?? minimum;
|
||||
return Math.min(maximum, Math.max(minimum, configured));
|
||||
}
|
||||
|
||||
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 {
|
||||
if (measuredWidth) return measuredWidth;
|
||||
if (savedWidth) return savedWidth;
|
||||
if (typeof column.width === "number") return column.width;
|
||||
const parsed = parsePixelWidth(column.width);
|
||||
if (parsed) return parsed;
|
||||
return column.minWidth ?? 160;
|
||||
const minimum = effectiveColumnMinWidth(column);
|
||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
||||
const configured = measuredWidth
|
||||
?? savedWidth
|
||||
?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))
|
||||
?? minimum;
|
||||
return Math.min(maximum, Math.max(minimum, configured));
|
||||
}
|
||||
|
||||
function parsePixelWidth(width?: string | number): number | null {
|
||||
|
||||
@@ -9,24 +9,27 @@ import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
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 { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||
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";
|
||||
|
||||
type PathChooserState = { index: number };
|
||||
type IndividualDisableState = { index: number; usageCount: number };
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
||||
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||
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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -79,6 +82,40 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
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 (
|
||||
@@ -97,7 +134,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
<>
|
||||
@@ -105,9 +142,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-attachment-sources`}
|
||||
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}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -173,13 +225,14 @@ type AttachmentSourceColumnContext = {
|
||||
basePaths: AttachmentBasePath[];
|
||||
fileSpaces: FileSpace[];
|
||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||
addBasePath: (afterIndex?: number) => void;
|
||||
moveBasePath: (index: number, targetIndex: number) => void;
|
||||
removeBasePath: (index: number) => 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 [
|
||||
{ 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)
|
||||
},
|
||||
{ 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: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_basePath, index) => (
|
||||
<DataGridRowActions
|
||||
|
||||
@@ -13,7 +13,7 @@ import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
|
||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
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 { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
const fieldValueKeys = useRef<string[]>([]);
|
||||
|
||||
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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -167,7 +167,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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…">
|
||||
<>
|
||||
@@ -177,7 +177,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
rows={fields}
|
||||
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
||||
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"
|
||||
/>
|
||||
</Card>
|
||||
@@ -215,7 +216,7 @@ function fieldColumns({ locked, fields, globalValues, renameField, setField, set
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_field, index) => (
|
||||
<DataGridRowActions
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
rows={versions}
|
||||
columns={versionColumns(setPendingLockAction)}
|
||||
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
||||
getRowKey={(version) => version.id}
|
||||
emptyText="No versions found."
|
||||
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 [
|
||||
{ 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: "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: "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 ?? "" },
|
||||
@@ -200,24 +200,28 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
header: "Actions",
|
||||
width: 310,
|
||||
sticky: "end",
|
||||
render: (version) => (
|
||||
render: (version) => {
|
||||
const isCurrent = version.id === currentVersionId;
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Link to={`send?version=${version.id}`}><Button variant="primary">Open</Button></Link>
|
||||
{isTemporaryUserLockedVersion(version) ? (
|
||||
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
|
||||
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
|
||||
<>
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
||||
</>
|
||||
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
|
||||
) : null}
|
||||
) : 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 (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
||||
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
const [editorState, setEditorState] = useState<EditorState>({});
|
||||
|
||||
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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -70,7 +70,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
<>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -263,7 +263,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
<>
|
||||
|
||||
@@ -10,7 +10,7 @@ import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
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 { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
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 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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -144,7 +144,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
<>
|
||||
@@ -239,7 +239,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
rows={inlineEntries.slice(0, 100)}
|
||||
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry })}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
@@ -293,7 +294,7 @@ function recipientProfileColumns({ locked, entries, entryAddressColumns, address
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_entry, index) => (
|
||||
<DataGridRowActions
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
|
||||
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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -83,7 +83,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
LockKeyhole,
|
||||
MailSearch,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
Send,
|
||||
@@ -44,6 +41,7 @@ import {
|
||||
getDeliverySection,
|
||||
humanize,
|
||||
isFinalLockedVersion,
|
||||
isHistoricalCampaignVersion,
|
||||
isUserLockedVersion,
|
||||
isVersionReadyForDelivery,
|
||||
} from "./utils/campaignView";
|
||||
@@ -165,8 +163,10 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
|
||||
const deliveryQueued = currentWorkflowState === "queued";
|
||||
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 userLockedVersion = isUserLockedVersion(version);
|
||||
const readOnlyVersion = historicalVersion || userLockedVersion || finalVersion;
|
||||
|
||||
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
|
||||
const reviewRequiredRows = builtReviewRows.filter(messageRequiresReview);
|
||||
@@ -186,48 +186,30 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
const mockMailbox = asRecord(mockResult?.mailbox);
|
||||
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
|
||||
|
||||
const validationState: FlowState = busy === "validate"
|
||||
const validationReviewState: FlowState = busy === "validate"
|
||||
? "running"
|
||||
: validationStale
|
||||
? "stale"
|
||||
: validationPresent && !validationOk
|
||||
? "danger"
|
||||
: readyForDelivery && validationWarnings > 0
|
||||
: readyForDelivery && (validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0)
|
||||
? "warning"
|
||||
: readyForDelivery
|
||||
? "complete"
|
||||
: "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 inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
|
||||
|
||||
const inspectState: FlowState = !hasBuild
|
||||
const buildReviewState: FlowState = !readyForDelivery
|
||||
? "locked"
|
||||
: busy === "inspect"
|
||||
: busy === "build" || busy === "inspect"
|
||||
? "running"
|
||||
: inspectionSatisfied
|
||||
: hasBuild && buildBlocked > 0
|
||||
? "danger"
|
||||
: hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied)
|
||||
? "warning"
|
||||
: hasBuild && inspectionSatisfied
|
||||
? "complete"
|
||||
: "active";
|
||||
|
||||
@@ -268,61 +250,41 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
|
||||
const stages: FlowStageDefinition[] = useMemo(() => [
|
||||
{
|
||||
id: "workflow-validate",
|
||||
title: "Validate campaign",
|
||||
id: "workflow-validate-review",
|
||||
title: "Validate and inspect",
|
||||
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,
|
||||
state: validationState,
|
||||
stateLabel: stateLabel(validationState),
|
||||
state: validationReviewState,
|
||||
stateLabel: stateLabel(validationReviewState),
|
||||
},
|
||||
{
|
||||
id: "workflow-exceptions",
|
||||
title: "Review exceptions",
|
||||
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",
|
||||
id: "workflow-build-review",
|
||||
title: "Build and review",
|
||||
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,
|
||||
state: buildState,
|
||||
stateLabel: stateLabel(buildState),
|
||||
state: buildReviewState,
|
||||
stateLabel: stateLabel(buildReviewState),
|
||||
lockReason: validationErrors > 0
|
||||
? `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",
|
||||
title: "Inspect built messages",
|
||||
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",
|
||||
id: "workflow-mock-verify",
|
||||
title: "Mock send and verify",
|
||||
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,
|
||||
state: mockState,
|
||||
stateLabel: stateLabel(mockState),
|
||||
lockReason: "Complete the message inspection step first.",
|
||||
lockReason: "Build and complete the required message review first.",
|
||||
},
|
||||
{
|
||||
id: "workflow-send",
|
||||
title: "Confirm and send",
|
||||
shortTitle: "Real send",
|
||||
description: "Review the final execution summary before opening the current real-send controls.",
|
||||
shortTitle: "Send",
|
||||
description: "Review the final execution summary before opening the established real-send controls.",
|
||||
icon: Send,
|
||||
state: sendState,
|
||||
stateLabel: stateLabel(sendState),
|
||||
@@ -332,17 +294,15 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
id: "workflow-results",
|
||||
title: "Delivery 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,
|
||||
state: resultState,
|
||||
stateLabel: stateLabel(resultState),
|
||||
lockReason: "Delivery results become available after the real send starts.",
|
||||
},
|
||||
], [
|
||||
validationState,
|
||||
exceptionState,
|
||||
buildState,
|
||||
inspectState,
|
||||
validationReviewState,
|
||||
buildReviewState,
|
||||
mockState,
|
||||
sendState,
|
||||
resultState,
|
||||
@@ -350,7 +310,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
]);
|
||||
|
||||
async function runValidation() {
|
||||
if (!version || busy || userLockedVersion || finalVersion || readyForDelivery) return;
|
||||
if (!version || busy || readOnlyVersion || readyForDelivery) return;
|
||||
setBusy("validate");
|
||||
setMessage("Validating the campaign, including managed attachment matches…");
|
||||
setError("");
|
||||
@@ -368,7 +328,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
}
|
||||
|
||||
async function runBuild() {
|
||||
if (!version || busy || !readyForDelivery || deliveryQueued || deliveryStarted) return;
|
||||
if (!version || busy || readOnlyVersion || !readyForDelivery || deliveryQueued || deliveryStarted) return;
|
||||
setBusy("build");
|
||||
setMessage("Building exact messages and resolving managed attachment versions…");
|
||||
setError("");
|
||||
@@ -411,7 +371,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
}
|
||||
|
||||
async function runMockSend() {
|
||||
if (!version || busy || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
||||
if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
||||
setBusy("mock");
|
||||
setMessage("Running the complete mock-delivery flow…");
|
||||
setError("");
|
||||
@@ -439,7 +399,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
}
|
||||
|
||||
async function completeInspection() {
|
||||
if (!version || busy || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
||||
if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
||||
setBusy("inspect");
|
||||
setError("");
|
||||
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 & Send page remains available for comparison.
|
||||
</DismissibleAlert>
|
||||
|
||||
{version && (readyForDelivery || userLockedVersion || finalVersion) && (
|
||||
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) && (
|
||||
<LockedVersionNotice
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
version={version}
|
||||
currentVersionId={data.campaign?.current_version_id}
|
||||
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}>
|
||||
<div className="review-flow-fact-grid">
|
||||
<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="Last change" value={formatDateTime(version?.updated_at)} />
|
||||
<WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
|
||||
</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>}
|
||||
<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
|
||||
variant="primary"
|
||||
onClick={() => void runValidation()}
|
||||
disabled={!version || Boolean(busy) || userLockedVersion || finalVersion || readyForDelivery}
|
||||
disabled={!version || Boolean(busy) || readOnlyVersion || readyForDelivery}
|
||||
>
|
||||
{busy === "validate"
|
||||
? "Validating…"
|
||||
@@ -561,66 +528,41 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
? "Locked and validated"
|
||||
: "Lock and validate"}
|
||||
</Button>
|
||||
<Link className="btn btn-secondary" to="../send">Open issue table</Link>
|
||||
<Link className="btn btn-secondary" to="../files">Review attachment rules</Link>
|
||||
</div>
|
||||
</WorkflowStage>
|
||||
|
||||
<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">
|
||||
<WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} />
|
||||
<WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} />
|
||||
<WorkflowFact label="Needs review" value={hasBuild ? buildNeedsReview : "—"} />
|
||||
<WorkflowFact label="Warnings" value={hasBuild ? buildWarnings : "—"} />
|
||||
</div>
|
||||
<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="Need review" value={hasBuild ? buildNeedsReview : "—"} />
|
||||
<WorkflowFact label="Reviewed" value={hasBuild ? reviewedRequiredCount : "—"} />
|
||||
<WorkflowFact label="Loaded messages" value={builtReviewRows.length || "—"} />
|
||||
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
|
||||
</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">
|
||||
<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)}>
|
||||
{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>
|
||||
<Link className="btn btn-secondary" to="../template">Open template editor</Link>
|
||||
{reviewRequiredRows.length > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void completeInspection()}
|
||||
disabled={automaticInspectionComplete || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
|
||||
disabled={readOnlyVersion || messageReviewComplete || !canCompleteInspection || downstreamDeliveryActivity || Boolean(busy)}
|
||||
>
|
||||
{automaticInspectionComplete || messageReviewComplete ? "Inspection completed" : "Complete inspection"}
|
||||
{messageReviewComplete ? "Review completed" : "Complete review"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{automaticInspectionComplete && (
|
||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> All built messages are ready. No manual inspection acknowledgement is required.</p>
|
||||
<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 && (
|
||||
<div className="review-flow-data-section">
|
||||
@@ -630,15 +572,16 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
||||
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
|
||||
getRowKey={builtMessageKey}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</WorkflowStage>
|
||||
|
||||
<WorkflowStage stage={stages[4]} nextState={stages[5].state}>
|
||||
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
|
||||
<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 : "—"} />
|
||||
</div>
|
||||
<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"}
|
||||
</Button>
|
||||
<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 stage={stages[5]} nextState={stages[6].state}>
|
||||
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
|
||||
<div className="review-flow-execution-summary">
|
||||
<div><span>Recipients</span><strong>{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>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></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">
|
||||
<Link className="btn btn-primary" to="../send">Open final send controls</Link>
|
||||
</div>
|
||||
</WorkflowStage>
|
||||
|
||||
<WorkflowStage stage={stages[6]}>
|
||||
<WorkflowStage stage={stages[4]}>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact label="SMTP accepted" value={sentCount} />
|
||||
<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>
|
||||
{nextState && <div className="review-flow-stage-line" />}
|
||||
</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">
|
||||
<h2>
|
||||
<span>{stage.title}</span>
|
||||
{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 && (
|
||||
<span
|
||||
className="review-flow-state-badge"
|
||||
@@ -825,15 +765,18 @@ function WorkflowStage({
|
||||
{stage.stateLabel}
|
||||
</span>
|
||||
)}
|
||||
<InlineHelp>{stage.description}</InlineHelp>
|
||||
</h2>
|
||||
<div className="review-flow-stage-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-collapse-button"
|
||||
className="card-collapse-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
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)}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} aria-hidden="true" /> : <ChevronDown size={18} aria-hidden="true" />}
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVe
|
||||
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = getDeliverySection(version);
|
||||
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>}
|
||||
{sendMessage && <DismissibleAlert tone="info" resetKey={sendMessage} floating>{sendMessage}</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…"}>
|
||||
<div className="metric-grid">
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
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({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -184,7 +184,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
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 { getBool, getText } from "../utils/draftEditor";
|
||||
import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
|
||||
@@ -85,7 +85,7 @@ export default function AttachmentRulesOverlay({
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@ export function AttachmentRulesTable({
|
||||
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
||||
{showAddButton && (
|
||||
<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>
|
||||
@@ -148,7 +148,8 @@ export function AttachmentRulesDataGrid({
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -169,9 +170,14 @@ export function AttachmentRulesDataGrid({
|
||||
return;
|
||||
}
|
||||
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 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 });
|
||||
}
|
||||
|
||||
@@ -196,7 +202,8 @@ export function AttachmentRulesDataGrid({
|
||||
rows={rules}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
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"
|
||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||
/>
|
||||
@@ -240,22 +247,26 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
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 selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ?? basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? basePaths[0];
|
||||
return basePaths.length > 0 ? (
|
||||
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
|
||||
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
|
||||
?? (!explicitlyReferenced ? basePaths[0] : undefined);
|
||||
return (
|
||||
<select
|
||||
value={selectedBasePath?.id ?? ""}
|
||||
disabled={disabled}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
onChange={(event) => {
|
||||
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
||||
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>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={currentBasePathValue} disabled={disabled} readOnly placeholder="optional/folder" />
|
||||
);
|
||||
},
|
||||
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
||||
@@ -272,7 +283,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
value={getText(rule, "file_filter")}
|
||||
disabled={disabled}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
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>
|
||||
),
|
||||
value: (rule) => getText(rule, "file_filter")
|
||||
@@ -293,7 +304,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_rule, index) => (
|
||||
<DataGridRowActions
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
canUnlockValidationVersion,
|
||||
formatDateTime,
|
||||
isFinalLockedVersion,
|
||||
isHistoricalCampaignVersion,
|
||||
isPermanentUserLockedVersion,
|
||||
isTemporaryUserLockedVersion,
|
||||
} from "../utils/campaignView";
|
||||
@@ -22,23 +23,27 @@ type LockedVersionNoticeProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null;
|
||||
currentVersionId?: string | null;
|
||||
reload: () => Promise<void>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
|
||||
|
||||
export default function LockedVersionNotice({ settings, campaignId, version, reload, message }: LockedVersionNoticeProps) {
|
||||
export default function LockedVersionNotice({ settings, campaignId, version, currentVersionId, reload, message }: LockedVersionNoticeProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [localMessage, setLocalMessage] = useState("");
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
|
||||
|
||||
const validationLock = canUnlockValidationVersion(version);
|
||||
const temporaryUserLock = isTemporaryUserLockedVersion(version);
|
||||
const historicalVersion = isHistoricalCampaignVersion(version, currentVersionId);
|
||||
const validationLock = !historicalVersion && canUnlockValidationVersion(version);
|
||||
const temporaryUserLock = !historicalVersion && isTemporaryUserLockedVersion(version);
|
||||
const permanentUserLock = isPermanentUserLockedVersion(version);
|
||||
const finalLock = isFinalLockedVersion(version);
|
||||
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
|
||||
const presentation = lockPresentation(version, {
|
||||
historicalVersion,
|
||||
validationLock,
|
||||
temporaryUserLock,
|
||||
permanentUserLock,
|
||||
@@ -114,9 +119,11 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canCreateEditableCopy && (
|
||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||
{busy ? "Creating copy…" : "Create editable copy"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -138,6 +145,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, rel
|
||||
}
|
||||
|
||||
type LockFlags = {
|
||||
historicalVersion: boolean;
|
||||
validationLock: boolean;
|
||||
temporaryUserLock: boolean;
|
||||
permanentUserLock: boolean;
|
||||
@@ -148,6 +156,14 @@ function lockPresentation(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
flags: LockFlags,
|
||||
): { 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) {
|
||||
return {
|
||||
kind: "temporary-user",
|
||||
|
||||
@@ -99,8 +99,31 @@ export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): Attachme
|
||||
}
|
||||
|
||||
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
||||
const enabled = paths.filter((basePath) => basePath.allow_individual);
|
||||
return enabled.length > 0 ? enabled : paths;
|
||||
return paths.filter((basePath) => basePath.allow_individual);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -103,14 +103,31 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
|
||||
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 (isHistoricalCampaignVersion(version, currentVersionId)) return true;
|
||||
if (version.locked_at || isUserLockedVersion(version)) return true;
|
||||
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 (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)) {
|
||||
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
const index = steps.findIndex((s) => s.id === activeStep);
|
||||
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
@@ -93,8 +93,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
version={data.currentVersion}
|
||||
currentVersionId={data.campaign?.current_version_id}
|
||||
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">
|
||||
<Link to="../.."><Button>Back to overview</Button></Link>
|
||||
|
||||
@@ -1785,7 +1785,7 @@
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
min-height: 28px;
|
||||
margin-left: auto;
|
||||
margin-left: 2px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
|
||||
border-radius: var(--radius-pill);
|
||||
@@ -1810,7 +1810,11 @@
|
||||
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 {
|
||||
opacity: .38;
|
||||
filter: grayscale(.35);
|
||||
@@ -2023,28 +2027,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.review-flow-collapse-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.review-flow-collapse-button:hover,
|
||||
.review-flow-collapse-button:focus-visible {
|
||||
border-color: var(--line-dark);
|
||||
color: var(--text-strong);
|
||||
background: var(--panel-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.review-flow-stage-card.is-collapsed .review-flow-stage-header {
|
||||
min-height: 58px;
|
||||
border-bottom: 0;
|
||||
|
||||
@@ -1185,64 +1185,36 @@
|
||||
/* Consistent row-level collection actions for editable DataGrid tables. */
|
||||
.data-grid-row-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 28px);
|
||||
grid-template-columns: repeat(4, 36px);
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-grid-row-action {
|
||||
.data-grid-row-action.btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.data-grid-row-action:hover:not(:disabled),
|
||||
.data-grid-row-action:focus-visible:not(:disabled) {
|
||||
border-color: var(--line-dark);
|
||||
background: var(--panel-soft);
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
.data-grid-row-action.btn:disabled {
|
||||
opacity: .45;
|
||||
}
|
||||
|
||||
.data-grid-row-action.is-add {
|
||||
border-color: color-mix(in srgb, var(--blue) 52%, var(--line));
|
||||
background: color-mix(in srgb, var(--blue) 10%, #fff);
|
||||
color: var(--blue);
|
||||
.data-grid-empty-message {
|
||||
color: var(--muted);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.data-grid-row-action.is-remove {
|
||||
border-color: rgba(171, 70, 61, .35);
|
||||
background: rgba(171, 70, 61, .06);
|
||||
color: #9f433b;
|
||||
.data-grid-empty-action-cell {
|
||||
justify-content: flex-end;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.data-grid-row-action:disabled {
|
||||
cursor: default;
|
||||
opacity: .42;
|
||||
}
|
||||
|
||||
.data-grid-row-action.is-placeholder {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.data-grid-empty-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-grid-empty-action > span {
|
||||
min-width: 0;
|
||||
.data-grid-empty-row-actions {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user