From e97af1cf9123e5d94873df4972d2eccf833c7f60 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 13 Jun 2026 22:06:32 +0200 Subject: [PATCH] Locking refinement; Review/Send improvement --- src/components/table/DataGrid.tsx | 348 +++++++++++++----- .../campaigns/AttachmentsDataPage.tsx | 71 +++- src/features/campaigns/CampaignFieldsPage.tsx | 11 +- .../campaigns/CampaignOverviewPage.tsx | 38 +- src/features/campaigns/GlobalSettingsPage.tsx | 4 +- src/features/campaigns/MailSettingsPage.tsx | 4 +- src/features/campaigns/RecipientDataPage.tsx | 11 +- .../campaigns/RecipientDetailsPage.tsx | 4 +- .../campaigns/ReviewSendDevelopmentPage.tsx | 229 +++++------- src/features/campaigns/SendDataPage.tsx | 4 +- src/features/campaigns/TemplateDataPage.tsx | 4 +- .../components/AttachmentRulesOverlay.tsx | 43 ++- .../components/LockedVersionNotice.tsx | 28 +- src/features/campaigns/utils/attachments.ts | 27 +- src/features/campaigns/utils/campaignView.ts | 21 +- .../campaigns/wizard/CreateWizard.tsx | 5 +- src/styles/campaign-workspace.css | 30 +- src/styles/components.css | 58 +-- 18 files changed, 569 insertions(+), 371 deletions(-) diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx index 14d4f56..a0362d9 100644 --- a/src/components/table/DataGrid.tsx +++ b/src/components/table/DataGrid.tsx @@ -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 = { columns: DataGridColumn[]; 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({ id, @@ -94,6 +108,9 @@ export default function DataGrid({ 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({ startX: number; startWidth: number; baseWidths: Record; - immediateRightColumnId?: string; - immediateRightStartWidth?: number; + rightResizeTargets: ResizeTarget[]; shrinkRoomWithoutScroll: number; + actualLastColumnMaxShrink: number; + isActualLastColumn: boolean; } | null>(null); const [openFilterColumnId, setOpenFilterColumnId] = useState(null); const [filterPosition, setFilterPosition] = useState(null); @@ -193,39 +211,49 @@ export default function DataGrid({ 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({ 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) { if (!column.sortable) return; @@ -418,13 +447,25 @@ export default function DataGrid({ 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({ startX: event.clientX, startWidth: currentWidth, baseWidths, - immediateRightColumnId: immediateRightColumn?.id, - immediateRightStartWidth, - shrinkRoomWithoutScroll + rightResizeTargets, + shrinkRoomWithoutScroll, + actualLastColumnMaxShrink, + isActualLastColumn }); }} > @@ -450,11 +492,36 @@ export default function DataGrid({ ); })} - {visibleRows.length === 0 ? ( -
-
{emptyText}
-
- ) : visibleRows.map((row, visibleIndex) => { + {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 ( + <> +
+ {emptyText} +
+
+ {emptyAction} +
+ + ); + } + return ( +
+
{filteredEmpty ? filteredEmptyText : emptyText}
+
+ ); + })() : 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 (
- - + - + - + +
+ ); +} + +export function DataGridEmptyAction({ + disabled = false, + onAdd, + label = "Add first row" +}: { + disabled?: boolean; + onAdd: () => void; + label?: string; +}) { + return ( +
+
); } @@ -841,33 +929,65 @@ function humanizeListValue(value: string): string { } function sanitizePersistedColumnState(columns: DataGridColumn[], 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(column: DataGridColumn, 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( @@ -913,11 +1033,62 @@ function chooseLayoutFillColumn(columns: DataGridColumn[]): string | undef return (resizable[resizable.length - 1] ?? nonSticky[nonSticky.length - 1])?.id; } -function chooseImmediateResizePartner(columns: DataGridColumn[], activeColumnId: string): DataGridColumn | 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(column: DataGridColumn): 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 } { + const amounts: Record = 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, + targets: ResizeTarget[], + amounts: Record +): void { + for (const target of targets) { + widths[target.columnId] = target.startWidth + (amounts[target.columnId] ?? 0); + } } function measuredColumnWidths( @@ -936,9 +1107,11 @@ function measuredColumnWidths( } function fixedWidthFloor(column: DataGridColumn): 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(column: DataGridColumn, savedWidth?: number): boolean { @@ -954,12 +1127,13 @@ function isFlexibleWidth(width?: string | number): boolean { } function columnPixelWidth(column: DataGridColumn, 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 { diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index 590c1c5..b644f25 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -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(null); const [fileSpaces, setFileSpaces] = useState([]); + const [individualDisable, setIndividualDisable] = useState(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 && {error}} {localError && {localError}} - {locked && } + {locked && } <> @@ -105,9 +142,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings basePath.id} - emptyText={
No attachment sources configured.
} + emptyText="No attachment sources configured." + emptyAction={ addBasePath(-1)} disabled={locked} label="Add first attachment source" />} className="attachment-sources-table-wrap attachment-sources-table" /> @@ -164,6 +202,20 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings }} /> )} + + + setIndividualDisable(null)} + /> ); } @@ -173,13 +225,14 @@ type AttachmentSourceColumnContext = { basePaths: AttachmentBasePath[]; fileSpaces: FileSpace[]; patchBasePath: (index: number, patch: Partial) => 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[] { +function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn[] { return [ { id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => 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) => 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) => 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) => 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) => ( ([]); 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 && {error}} {localError && {localError}} {fieldNameWarning && {fieldNameWarning}} - {locked && } + {locked && } <> @@ -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={
No campaign fields configured yet.
} + emptyText="No campaign fields configured yet." + emptyAction={ addField(-1)} disabled={locked} label="Add first field" />} className="field-editor-table-wrap field-editor-table" /> @@ -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) => ( 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[] { +function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn[] { 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) => , 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) => ( -
- - {isTemporaryUserLockedVersion(version) ? ( - <> - - - - ) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? ( - - ) : null} -
- ), + render: (version) => { + const isCurrent = version.id === currentVersionId; + return ( +
+ + {isCurrent && (isTemporaryUserLockedVersion(version) ? ( + <> + + + + ) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? ( + + ) : null)} +
+ ); + }, }, ]; } -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"; diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index 412f473..1e71ba8 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -23,7 +23,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: const [editorState, setEditorState] = useState({}); 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 && {error}} {localError && {localError}} - {locked && } + {locked && } <> diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx index 294cea3..4db0bf2 100644 --- a/src/features/campaigns/MailSettingsPage.tsx +++ b/src/features/campaigns/MailSettingsPage.tsx @@ -32,7 +32,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const mockSandboxSnapshot = useRef | 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 && {error}} {localError && {localError}} - {locked && } + {locked && } <> diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx index d3491a6..82b035d 100644 --- a/src/features/campaigns/RecipientDataPage.tsx +++ b/src/features/campaigns/RecipientDataPage.tsx @@ -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 && {error}} {localError && {localError}} - {locked && } + {locked && } <> @@ -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={
No recipient profiles are stored in the current version yet.
} + emptyText="No recipient profiles are stored in the current version yet." + emptyAction={ 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) => ( {error}} {localError && {localError}} - {locked && } + {locked && } <> diff --git a/src/features/campaigns/ReviewSendDevelopmentPage.tsx b/src/features/campaigns/ReviewSendDevelopmentPage.tsx index e080404..d89019e 100644 --- a/src/features/campaigns/ReviewSendDevelopmentPage.tsx +++ b/src/features/campaigns/ReviewSendDevelopmentPage.tsx @@ -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,50 +186,32 @@ 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 - ? "complete" - : "active"; + : hasBuild && buildBlocked > 0 + ? "danger" + : hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied) + ? "warning" + : hasBuild && inspectionSatisfied + ? "complete" + : "active"; const mockState: FlowState = !inspectionSatisfied ? "locked" @@ -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. - {version && (readyForDelivery || userLockedVersion || finalVersion) && ( + {version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) && ( )} @@ -544,16 +505,22 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
- + - +
{validationStale &&

The stored validation result is no longer an active delivery lock. Run validation again.

} -
+ {validationPresent && validationErrors === 0 && ( +

+ )} + {validationErrors > 0 && ( +

Resolve the blocking entries, then validate again.

+ )} +
+ Open issue table + Review attachment rules
-
- - - - -
- {validationPresent && validationErrors === 0 && ( -

- )} - {validationErrors > 0 && ( -

Resolve the blocking entries in the current issue table, then validate again.

- )} -
- Open current issue table - Review attachment rules -
-
- -
- - -
-

Building resolves recipient values and attachment patterns into the exact message queue used by the later stages.

-
- -
-
- - -
- - - - + + +
+

Building freezes the current recipients, rendered content and exact managed-file versions into the queue reviewed below.

+ Open template editor - + {reviewRequiredRows.length > 0 && ( + + )}
{automaticInspectionComplete && ( -

+

)} {builtReviewRows.length > 0 && (
@@ -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" /> -

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.

+

Ready messages are hidden initially. Use the Validation filter to include them. When review is required, open every issue message before completing the step.

)}
- +
@@ -646,7 +589,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
- Review server settings @@ -679,7 +622,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se )} - +
Recipients{jobsTotal || "—"}
Messages to send{builtCount || jobsTotal || "—"}
@@ -690,13 +633,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
IMAP append{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}
Version{version ? `v${version.version_number}` : "—"}
-

The development page intentionally delegates the real-send action to the established page while this layout is evaluated.

+

The workflow preview delegates the real-send action to the established page while this layout is evaluated.

Open final send controls
- +
@@ -807,14 +750,11 @@ function WorkflowStage({
{nextState &&
}
-
+

{stage.title} {locked && } - {stage.description} -

-
{!locked && ( )} + {stage.description} + +
diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index 7b747bb..2cd30c2 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -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 && {error}} {sendMessage && {sendMessage}} {mockMessage && {mockMessage}} - {locked && } + {locked && }
diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index 1fd85e8..c2bc6b0 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -35,7 +35,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const htmlRef = useRef(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 && {error}} {localError && {localError}} - {locked && } + {locked && } <> diff --git a/src/features/campaigns/components/AttachmentRulesOverlay.tsx b/src/features/campaigns/components/AttachmentRulesOverlay.tsx index df6e345..4de461e 100644 --- a/src/features/campaigns/components/AttachmentRulesOverlay.tsx +++ b/src/features/campaigns/components/AttachmentRulesOverlay.tsx @@ -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({ />
- +
@@ -121,7 +121,7 @@ export function AttachmentRulesTable({ {showAddButton && (
- +
)}
@@ -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={
{emptyText}
} + emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText} + emptyAction={ 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 ( - ) : ( - ); }, value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "") @@ -272,7 +283,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, activeChooserRuleIn - +
), 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) => ( Promise; 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(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 )} - + {canCreateEditableCopy && ( + + )} 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 { + 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 }; } diff --git a/src/features/campaigns/utils/campaignView.ts b/src/features/campaigns/utils/campaignView.ts index 3b6e95b..f942514 100644 --- a/src/features/campaigns/utils/campaignView.ts +++ b/src/features/campaigns/utils/campaignView.ts @@ -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.`; } diff --git a/src/features/campaigns/wizard/CreateWizard.tsx b/src/features/campaigns/wizard/CreateWizard.tsx index a614abc..0e2182e 100644 --- a/src/features/campaigns/wizard/CreateWizard.tsx +++ b/src/features/campaigns/wizard/CreateWizard.tsx @@ -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." />
diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css index 6eb544f..5af48b9 100644 --- a/src/styles/campaign-workspace.css +++ b/src/styles/campaign-workspace.css @@ -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; diff --git a/src/styles/components.css b/src/styles/components.css index a9f2f23..b314d11 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; }