From cc2efd1f23cf36bfd98c2e89650f7371c75f2457 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 05:19:19 +0200 Subject: [PATCH] feat(plan): drag + double-click editor on the Gantt bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any signed-in user — owner or client_manager — can now edit dates directly on the Gantt. Three interaction shapes: - Drag the bar body to move it (both start and target shift by the same number of days, snapped to whole UTC days). - Drag the right edge to resize (target only). - Double-click any card or subtask bar to open a modal with Start + End date inputs, ESC to close, Enter to save. Both card and subtask rows ship the same affordances. Cards go through the existing rescheduleCardAction so the activity-log row keeps recording who moved the date; subtasks go through a new setSubtaskDatesAction (no audit row — matches the existing subtask-toggle pattern). Components touched: - GanttBar gains canEdit / onMove / onResize / onEditRequest. - GanttDateEditor is the inline two-input modal. - ProjectSection forwards four callbacks (card move/resize, subtask move/resize) plus onEditRequest. - GanttPlanView holds the editor's open state and computes one shared timeline range across sections. - GanttClient wraps the view and bridges to the actions. - /plan/page.tsx renders GanttClient. Spec §6 amended to move drag from out-of-scope into the delivered surface. 482/482 unit tests pass (was 471, +11 from bar-math). Integration plan-gantt tests still pass. Build clean. --- ...-21-phase-10-gantt-plan-redesign-design.md | 4 +- src/app/plan/page.tsx | 4 +- src/components/roadmap/gantt-bar.tsx | 187 +++++++++++++++++- src/components/roadmap/gantt-client.tsx | 129 ++++++++++++ src/components/roadmap/gantt-date-editor.tsx | 163 +++++++++++++++ src/components/roadmap/gantt-plan-view.tsx | 109 ++++++++-- src/components/roadmap/project-section.tsx | 93 ++++++++- 7 files changed, 662 insertions(+), 27 deletions(-) create mode 100644 src/components/roadmap/gantt-client.tsx create mode 100644 src/components/roadmap/gantt-date-editor.tsx diff --git a/docs/superpowers/specs/2026-09-21-phase-10-gantt-plan-redesign-design.md b/docs/superpowers/specs/2026-09-21-phase-10-gantt-plan-redesign-design.md index 6fb749f..c1736e8 100644 --- a/docs/superpowers/specs/2026-09-21-phase-10-gantt-plan-redesign-design.md +++ b/docs/superpowers/specs/2026-09-21-phase-10-gantt-plan-redesign-design.md @@ -158,12 +158,12 @@ The existing `RoadmapCard` type and `src/lib/roadmap/band.ts` are dropped from t ## 6. Out of scope -- **Drag-to-reschedule.** The Phase 5 audit-log shape (owner moving a client-owned `target_date`) applies here too; adding drag to a denser widget without the activity log in place would be a regression. A future phase can wire drag with the same audit-log shape as Phase 5 §5. -- **Subtask editing on the Gantt.** Dates are edited on the card page or subtask panel, as today. +- **Drag-to-reschedule — moved into scope after first build.** Any signed-in user (owner or client_manager) can drag a bar to move it or drag its right edge to resize it. Writes go through the existing `rescheduleCardAction` (cards) or a new `setSubtaskDatesAction` (subtasks). Cards still get the `activity` audit row; subtasks do not, matching the existing `done`-toggle pattern. - **Filtering by client, priority, or status.** Not in the request. - **Connector lines** between parent and child bars (the reference Gantt has them; the indented alignment reads cleanly without them, and the dark palette makes 1px rules tricky to get right). - **Hover-only tooltips that pop out of the bar** (we use a native `title` attribute for now). - **Removing the legacy `RoadmapTabs` / `TimelineView` / `PuzzleView` / `band-view` files.** If the dashboard still uses them, leave them. If not, the plan may delete them. This keeps the diff small and reversible. +- **Inline date editor when the page is read-only.** Owners AND client managers get the editor (it's the Gantt's whole purpose for them). If a future role needs read-only access, pass `canEdit={false}` to `GanttPlanView`. ## 7. Migration diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 6909373..bfd5165 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { AppShell } from "@/components/shell/app-shell"; import { TopBar } from "@/components/shell/top-bar"; -import { GanttPlanView } from "@/components/roadmap/gantt-plan-view"; +import { GanttClient } from "@/components/roadmap/gantt-client"; import { getSession } from "@/lib/auth/session"; import { loadPlan } from "@/lib/roadmap/load"; @@ -25,7 +25,7 @@ export default async function PlanPage() {
- +
diff --git a/src/components/roadmap/gantt-bar.tsx b/src/components/roadmap/gantt-bar.tsx index 68f18b9..39925be 100644 --- a/src/components/roadmap/gantt-bar.tsx +++ b/src/components/roadmap/gantt-bar.tsx @@ -1,9 +1,14 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { datePosition } from "@/lib/roadmap/timeline"; import type { TimelineRange } from "@/lib/roadmap/timeline"; +import { + fractionToDate, + shiftDatesByFraction, + resizeEndToFraction, +} from "@/lib/roadmap/bar-math"; interface GanttBarProps { href: string; @@ -20,15 +25,23 @@ interface GanttBarProps { title: string; /** Optional strikethrough for subtasks whose `done` is true. */ strike?: boolean; + /** When true, the bar accepts pointer-drag (move + resize) and + * double-click → opens the date editor. Server-write happens on + * pointerup; the visual preview updates live during drag. */ + canEdit?: boolean; + /** Called with new dates after a move drag completes. Either may + * be null when the bar had no start/target before the drag. */ + onMove?: (newStart: string | null, newTarget: string | null) => void; + /** Called with the new target when a right-edge resize completes. */ + onResize?: (newTarget: string) => void; + /** Called when the user double-clicks the bar body. */ + onEditRequest?: () => void; } -/** - * A single horizontal bar in the Gantt track. Width is derived from - * the date range against the same `TimelineRange` the header uses, so - * a card and the month ticks above it can never disagree. Falls back to - * a small minimum width so a card with `start_date == target_date` - * still gets a clickable target. - */ +const MIN_WIDTH = 0.012; + +const RESIZE_HANDLE_PX = 12; + export function GanttBar({ href, startDate, @@ -39,10 +52,29 @@ export function GanttBar({ height, title, strike = false, + canEdit = false, + onMove, + onResize, + onEditRequest, }: GanttBarProps) { - const MIN_WIDTH = 0.012; + /** + * `trackRef` is the ancestor that owns the time axis — every + * pointer coordinate is measured against its bounding rect, so + * a bar moves and resizes in lockstep with the header above it. + */ + const trackRef = useRef(null); + + /** Card id currently being pointer-dragged to move. */ + const [moving, setMoving] = useState(false); + /** Card id currently being pointer-dragged to resize. */ + const [resizing, setResizing] = useState(false); + /** Live preview fractions during drag. `null` outside a drag. */ + const [preview, setPreview] = useState<{ start: number; end: number } | null>( + null + ); const { left, width } = useMemo(() => { + if (preview) return { left: preview.start, width: preview.end - preview.start }; const anchor = startDate || targetDate; const startFraction = datePosition(anchor, range); const endFraction = Math.max( @@ -50,7 +82,117 @@ export function GanttBar({ datePosition(targetDate || anchor, range) ); return { left: startFraction, width: endFraction - startFraction }; - }, [startDate, targetDate, range]); + }, [startDate, targetDate, range, preview]); + + /** + * Live pointer-fraction within the track — pure DOM dependency + * (the track's bounding rect), no state involved. + */ + function fractionAt(clientX: number): number { + const element = trackRef.current; + if (!element) return 0; + const rect = element.getBoundingClientRect(); + return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + } + + /** Begin a move drag from the bar body. */ + function beginMove(event: React.PointerEvent) { + if (!canEdit || !onMove) return; + // Only the primary button starts a move — anything else is a system gesture. + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + trackRef.current = event.currentTarget as HTMLElement; + setMoving(true); + } + + function updateMove(event: React.PointerEvent) { + if (!moving) return; + const currentFraction = fractionAt(event.clientX); + const anchorFraction = datePosition(startDate || targetDate, range); + const delta = currentFraction - anchorFraction; + const next = shiftDatesByFraction(startDate, targetDate, delta, range); + if (!next.targetDate) return; + setPreview({ + start: next.startDate ? datePosition(next.startDate, range) : currentFraction - MIN_WIDTH, + end: datePosition(next.targetDate, range), + }); + } + + function endMove(event: React.PointerEvent) { + if (!moving) return; + setMoving(false); + const currentFraction = fractionAt(event.clientX); + const anchorFraction = datePosition(startDate || targetDate, range); + const delta = currentFraction - anchorFraction; + const next = shiftDatesByFraction(startDate, targetDate, delta, range); + setPreview(null); + if ( + onMove && + (next.startDate !== startDate || next.targetDate !== targetDate) + ) { + onMove(next.startDate, next.targetDate); + } + } + + function cancelMove() { + setMoving(false); + setPreview(null); + } + + /** Begin a right-edge resize. The handle stops propagation so the + * bar body's move-drag handler never fires on top of it. */ + function beginResize(event: React.PointerEvent) { + if (!canEdit || !onResize) return; + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + trackRef.current = (event.currentTarget as HTMLElement).closest( + "[data-gantt-track]" + ) as HTMLElement | null; + setResizing(true); + } + + function updateResize(event: React.PointerEvent) { + if (!resizing) return; + const newFraction = fractionAt(event.clientX); + const anchorFraction = datePosition(startDate || targetDate, range); + const start = Math.min(anchorFraction, newFraction); + const end = Math.max(anchorFraction + MIN_WIDTH, newFraction); + setPreview({ start, end }); + } + + function endResize(event: React.PointerEvent) { + if (!resizing) return; + const newFraction = fractionAt(event.clientX); + const next = resizeEndToFraction(targetDate || startDate, newFraction, range); + setResizing(false); + setPreview(null); + if (onResize && next !== targetDate) { + onResize(next); + } + } + + function cancelResize() { + setResizing(false); + setPreview(null); + } + + /** Double-click opens the inline date editor (if `canEdit`). */ + function onDoubleClick(event: React.MouseEvent) { + if (!canEdit || !onEditRequest) return; + event.preventDefault(); + onEditRequest(); + } + + // Drop the preview state if the user navigates away mid-drag. + useEffect(() => { + return () => { + setPreview(null); + }; + }, []); return ( {title} + {canEdit && onResize && ( + + )} ); } diff --git a/src/components/roadmap/gantt-client.tsx b/src/components/roadmap/gantt-client.tsx new file mode 100644 index 0000000..96f0b41 --- /dev/null +++ b/src/components/roadmap/gantt-client.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; + +import { GanttPlanView } from "@/components/roadmap/gantt-plan-view"; +import type { DraftDates } from "@/components/roadmap/gantt-date-editor"; +import { useToast } from "@/components/ui/toast"; +import { rescheduleCardAction } from "@/app/actions/reschedule"; +import { setSubtaskDatesAction } from "@/app/actions/subtask-dates"; +import type { PlanSection } from "@/lib/roadmap/load"; + +interface GanttClientProps { + sections: PlanSection[]; +} + +/** + * Client wrapper around `` that wires every drag and + * double-click gesture to the existing Server Actions. Re-fetches + * `/plan` after each write so the new dates reach the loader on the + * next render — the audit log + activity row are the responsibility of + * the actions themselves, not the view. + */ +export function GanttClient({ sections }: GanttClientProps) { + const [, startTransition] = useTransition(); + const router = useRouter(); + const { showError } = useToast(); + + function onCardMove( + cardId: string, + nextStart: string | null, + nextTarget: string | null + ) { + startTransition(async () => { + const result = await rescheduleCardAction({ + cardId, + date: nextTarget, + startDate: nextStart ?? undefined, + }); + if (!result.ok) { + showError(result.message ?? "Couldn't move the card"); + } + router.refresh(); + }); + } + + function onCardResize(cardId: string, newTarget: string) { + startTransition(async () => { + const result = await rescheduleCardAction({ + cardId, + date: newTarget, + }); + if (!result.ok) { + showError(result.message ?? "Couldn't resize the card"); + } + router.refresh(); + }); + } + + function onSubtaskMove( + subtaskId: string, + nextStart: string | null, + nextTarget: string | null + ) { + startTransition(async () => { + const result = await setSubtaskDatesAction({ + subtaskId, + startDate: nextStart, + targetDate: nextTarget, + }); + if (!result.ok) { + showError(result.message ?? "Couldn't move the subtask"); + } + router.refresh(); + }); + } + + function onSubtaskResize(subtaskId: string, newTarget: string) { + startTransition(async () => { + const result = await setSubtaskDatesAction({ + subtaskId, + startDate: null, + targetDate: newTarget, + }); + if (!result.ok) { + showError(result.message ?? "Couldn't resize the subtask"); + } + router.refresh(); + }); + } + + async function onSaveDates( + target: { kind: "card" | "subtask"; id: string }, + dates: DraftDates + ): Promise<{ ok: boolean; message?: string }> { + let result: { ok: boolean; message?: string }; + if (target.kind === "card") { + result = await rescheduleCardAction({ + cardId: target.id, + date: dates.targetDate || null, + startDate: dates.startDate || undefined, + }); + } else { + result = await setSubtaskDatesAction({ + subtaskId: target.id, + startDate: dates.startDate || null, + targetDate: dates.targetDate || null, + }); + } + if (!result.ok) { + showError(result.message ?? "Couldn't save the dates"); + } else { + router.refresh(); + } + return result; + } + + return ( + + ); +} diff --git a/src/components/roadmap/gantt-date-editor.tsx b/src/components/roadmap/gantt-date-editor.tsx new file mode 100644 index 0000000..37d7b16 --- /dev/null +++ b/src/components/roadmap/gantt-date-editor.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export interface DraftDates { + startDate: string; + targetDate: string; +} + +interface GanttDateEditorProps { + /** Stable id for the editing target — used to keep Escape / Save + * tied to the same row that opened the modal. */ + targetId: string; + /** Title shown at the top of the modal ("Edit dates for ..."). */ + title: string; + /** Initial draft values. The modal re-initialises when `targetId` + * changes so double-clicking a different bar while the modal is + * open resets the form. */ + initialStartDate: string; + initialTargetDate: string; + /** Called on Save with the user's final values. */ + onSave: (next: DraftDates) => void; + /** Called on Cancel and on Escape / click-outside. */ + onCancel: () => void; + /** True while the parent is awaiting the server action — disables + * Save / Cancel so the modal can't be closed mid-write. */ + pending?: boolean; +} + +const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +/** + * Inline modal with two date inputs and Save / Cancel. Trapped focus + * keeps Tab inside the modal until the user dismisses it; Escape closes. + * Rendered into a portal-free fixed-position overlay so its z-index + * sits above the Gantt without affecting the layout beneath. + */ +export function GanttDateEditor({ + targetId, + title, + initialStartDate, + initialTargetDate, + onSave, + onCancel, + pending = false, +}: GanttDateEditorProps) { + const [start, setStart] = useState(initialStartDate); + const [end, setEnd] = useState(initialTargetDate); + const dialogRef = useRef(null); + const startRef = useRef(null); + + // Re-initialise when the user opens the modal against a different row + // without closing the modal first. + useEffect(() => { + setStart(initialStartDate); + setEnd(initialTargetDate); + }, [targetId, initialStartDate, initialTargetDate]); + + // Focus the first input the first time it opens. + useEffect(() => { + startRef.current?.focus(); + }, [targetId]); + + // ESC closes the modal (unless a write is in flight). + useEffect(() => { + function onKey(event: KeyboardEvent) { + if (event.key === "Escape" && !pending) onCancel(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onCancel, pending]); + + // Trap Enter to submit when the start input is focused — Save is the + // obvious next action and the user said "next, next, next". + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter" && !pending) { + event.preventDefault(); + submit(); + } + } + + const startValid = !start || ISO_DATE.test(start); + const endValid = !end || ISO_DATE.test(end); + const orderValid = + !start || !end || start <= end; + const canSave = startValid && endValid && orderValid; + + function submit() { + if (!canSave) return; + onSave({ startDate: start, targetDate: end }); + } + + return ( +
{ + if (event.target === event.currentTarget && !pending) onCancel(); + }} + > +
event.stopPropagation()} + > +

+ Edit dates +

+

+ {title} +

+
+ + + {!orderValid && ( +

+ Start must be on or before the end date. +

+ )} +
+
+ + +
+
+
+ ); +} diff --git a/src/components/roadmap/gantt-plan-view.tsx b/src/components/roadmap/gantt-plan-view.tsx index 5724546..176e1ec 100644 --- a/src/components/roadmap/gantt-plan-view.tsx +++ b/src/components/roadmap/gantt-plan-view.tsx @@ -1,18 +1,63 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; -import { ProjectSection } from "@/components/roadmap/project-section"; +import { + ProjectSection, + type RescheduleRequest, +} from "@/components/roadmap/project-section"; +import { GanttDateEditor, type DraftDates } from "@/components/roadmap/gantt-date-editor"; import { timelineRange } from "@/lib/roadmap/timeline"; import type { PlanSection } from "@/lib/roadmap/load"; +interface GanttPlanViewProps { + sections: PlanSection[]; + /** True when the signed-in user may drag and double-click rows. */ + canEdit?: boolean; + /** Card drag callbacks. */ + onCardMove?: (cardId: string, nextStart: string | null, nextTarget: string | null) => void; + onCardResize?: (cardId: string, nextTarget: string) => void; + /** Subtask drag callbacks. */ + onSubtaskMove?: (subtaskId: string, nextStart: string | null, nextTarget: string | null) => void; + onSubtaskResize?: (subtaskId: string, nextTarget: string) => void; + /** + * Save handler for the inline date editor modal. Receives the target + * id (card or subtask depending on `kind`) plus the user's edited dates. + */ + onSaveDates?: ( + target: { kind: "card" | "subtask"; id: string }, + dates: DraftDates + ) => Promise<{ ok: boolean; message?: string }>; +} + /** * Top-level Gantt view. Computes one shared timeline range across every * project section so a card in one project and a card in another share * the same month/week axis. When every card and every subtask has no * dates, shows the "Nothing scheduled yet" empty state. + * + * Drag + double-click flows: + * - Drag the body of a bar to move. The bar previews during drag; the + * server write happens on pointerup via `onCardMove` / `onSubtaskMove`. + * - Drag the right edge of a bar to resize. + * - Double-click a bar to open `` with two date inputs. */ -export function GanttPlanView({ sections }: { sections: PlanSection[] }) { +export function GanttPlanView({ + sections, + canEdit = false, + onCardMove, + onCardResize, + onSubtaskMove, + onSubtaskResize, + onSaveDates, +}: GanttPlanViewProps) { + const [editing, setEditing] = useState(null); + const [pending, setPending] = useState(false); + + /** + * Memoised because every section below depends on the same range; + * computing it here once is cheaper than per-section. + */ const range = useMemo(() => { let n = 0; const items: { id: string; startDate: string; targetDate: string }[] = []; @@ -29,6 +74,29 @@ export function GanttPlanView({ sections }: { sections: PlanSection[] }) { return timelineRange(items); }, [sections]); + function openEditor(req: RescheduleRequest) { + setEditing(req); + } + + async function saveEditor(next: DraftDates) { + if (!editing || !onSaveDates) return; + setPending(true); + try { + const result = await onSaveDates( + { kind: editing.kind, id: editing.id }, + next + ); + if (result.ok) { + setEditing(null); + } else { + // Leave the modal open with an error toast (rendered by the parent + // via the page's toast surface). The page wires the error. + } + } finally { + setPending(false); + } + } + if (sections.length === 0) { return (
@@ -43,14 +111,33 @@ export function GanttPlanView({ sections }: { sections: PlanSection[] }) { } return ( -
- {sections.map((section) => ( - +
+ {sections.map((section) => ( + + ))} +
+ {editing && onSaveDates && ( + !pending && setEditing(null)} + pending={pending} /> - ))} -
+ )} + ); } diff --git a/src/components/roadmap/project-section.tsx b/src/components/roadmap/project-section.tsx index 6249eb0..3db1b52 100644 --- a/src/components/roadmap/project-section.tsx +++ b/src/components/roadmap/project-section.tsx @@ -25,12 +25,47 @@ type Row = | { kind: "subtask"; planCard: PlanCard; subtaskId: string } | { kind: "undated"; planCard: PlanCard }; +export interface RescheduleRequest { + kind: "card" | "subtask"; + /** Card or subtask id, depending on `kind`. */ + id: string; + /** Title to show in the date-editor modal. */ + title: string; + /** href the user came from (used by the modal to navigate on save-error). */ + href: string; + /** Card id — needed for subtask edits so the editor can highlight context. */ + cardId: string; + /** Existing values to prefill the modal. */ + startDate: string; + targetDate: string; +} + interface ProjectSectionProps { section: PlanSection; range: TimelineRange; + /** True when the signed-in user is allowed to drag + double-click. */ + canEdit?: boolean; + /** Card drag callbacks. */ + onCardMove?: (cardId: string, nextStart: string | null, nextTarget: string | null) => void; + onCardResize?: (cardId: string, nextTarget: string) => void; + /** Subtask drag callbacks. */ + onSubtaskMove?: (subtaskId: string, nextStart: string | null, nextTarget: string | null) => void; + onSubtaskResize?: (subtaskId: string, nextTarget: string) => void; + /** Open the inline date editor. Receives everything the modal needs + * to identify and call the right action. */ + onEditRequest?: (req: RescheduleRequest) => void; } -export function ProjectSection({ section, range }: ProjectSectionProps) { +export function ProjectSection({ + section, + range, + canEdit = false, + onCardMove, + onCardResize, + onSubtaskMove, + onSubtaskResize, + onEditRequest, +}: ProjectSectionProps) { const { project, client, cards } = section; /** One flat array drives both sides of the chart so a label and its @@ -110,7 +145,11 @@ export function ProjectSection({ section, range }: ProjectSectionProps) {
{/* Timeline track */} -
+
{/* Month + week header */}
onSubtaskMove(subtask.id, nextStart, nextTarget) + : undefined + } + onResize={ + canEdit && onSubtaskResize + ? (newTarget) => onSubtaskResize(subtask.id, newTarget) + : undefined + } + onEditRequest={ + canEdit && onEditRequest + ? () => + onEditRequest({ + kind: "subtask", + id: subtask.id, + title: `${row.planCard.title} → ${subtask.title}`, + href: row.planCard.href, + cardId: row.planCard.id, + startDate: subtask.startDate, + targetDate: subtask.targetDate, + }) + : undefined + } />
); @@ -216,6 +280,31 @@ export function ProjectSection({ section, range }: ProjectSectionProps) { insetLeft={0} height={BAR_HEIGHT_CARD} title={`${row.planCard.title} — ${row.planCard.startDate || "?"} → ${row.planCard.targetDate || "?"}`} + canEdit={canEdit} + onMove={ + canEdit && onCardMove + ? (nextStart, nextTarget) => onCardMove(row.planCard.id, nextStart, nextTarget) + : undefined + } + onResize={ + canEdit && onCardResize + ? (newTarget) => onCardResize(row.planCard.id, newTarget) + : undefined + } + onEditRequest={ + canEdit && onEditRequest + ? () => + onEditRequest({ + kind: "card", + id: row.planCard.id, + title: row.planCard.title, + href: row.planCard.href, + cardId: row.planCard.id, + startDate: row.planCard.startDate, + targetDate: row.planCard.targetDate, + }) + : undefined + } />
);