feat(plan): drag + double-click editor on the Gantt bars

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.
This commit is contained in:
Dave 2026-09-22 05:19:19 +02:00
parent 2b01d0e837
commit cc2efd1f23
7 changed files with 662 additions and 27 deletions

View file

@ -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

View file

@ -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() {
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-6xl space-y-6 p-6">
<GanttPlanView sections={sections} />
<GanttClient sections={sections} />
</div>
</div>
</AppShell>

View file

@ -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<HTMLElement | null>(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 (
<a
@ -58,8 +200,15 @@ export function GanttBar({
role="button"
tabIndex={0}
title={title}
onDoubleClick={onDoubleClick}
onPointerDown={canEdit ? beginMove : undefined}
onPointerMove={canEdit ? updateMove : undefined}
onPointerUp={canEdit ? endMove : undefined}
onPointerCancel={canEdit ? cancelMove : undefined}
className={`absolute flex items-center overflow-hidden rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] ${
strike ? "line-through" : ""
} ${moving || resizing ? "opacity-70" : ""} ${
canEdit ? "cursor-grab active:cursor-grabbing" : ""
}`}
style={{
left: `calc(${(left * 100).toFixed(3)}% + ${insetLeft}px)`,
@ -70,6 +219,24 @@ export function GanttBar({
}}
>
<span className="sr-only">{title}</span>
{canEdit && onResize && (
<span
aria-hidden
onPointerDown={beginResize}
onPointerMove={updateResize}
onPointerUp={endResize}
onPointerCancel={cancelResize}
className="absolute right-0 top-0 h-full cursor-ew-resize"
style={{
width: RESIZE_HANDLE_PX,
background:
"linear-gradient(to left, rgba(255,255,255,0.45), rgba(255,255,255,0))",
backgroundPosition: "right center",
backgroundSize: "2px 100%",
backgroundRepeat: "no-repeat",
}}
/>
)}
</a>
);
}

View file

@ -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 `<GanttPlanView>` 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 (
<GanttPlanView
sections={sections}
canEdit
onCardMove={onCardMove}
onCardResize={onCardResize}
onSubtaskMove={onSubtaskMove}
onSubtaskResize={onSubtaskResize}
onSaveDates={onSaveDates}
/>
);
}

View file

@ -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<HTMLDivElement>(null);
const startRef = useRef<HTMLInputElement>(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 (
<div
role="dialog"
aria-modal="true"
aria-label={`Edit dates for ${title}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
onClick={(event) => {
if (event.target === event.currentTarget && !pending) onCancel();
}}
>
<div
ref={dialogRef}
className="w-full max-w-sm rounded-[var(--radius-tile)] border border-[var(--border-subtle)] bg-[var(--surface)] p-4 shadow-[var(--shadow-tile)]"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
Edit dates
</h2>
<p className="mt-1 truncate text-xs text-[var(--text-muted)]">
{title}
</p>
<div className="mt-4 space-y-3">
<label className="block text-xs font-medium text-[var(--text-secondary)]">
Start
<input
ref={startRef}
type="date"
value={start}
onChange={(event) => setStart(event.target.value)}
onKeyDown={onKeyDown}
className="mt-1 block w-full rounded border border-[var(--border-subtle)] bg-[var(--surface)] px-2 py-1 text-sm text-[var(--text-primary)] focus:border-[var(--brand)] focus:outline-none"
/>
</label>
<label className="block text-xs font-medium text-[var(--text-secondary)]">
End
<input
type="date"
value={end}
onChange={(event) => setEnd(event.target.value)}
onKeyDown={onKeyDown}
className="mt-1 block w-full rounded border border-[var(--border-subtle)] bg-[var(--surface)] px-2 py-1 text-sm text-[var(--text-primary)] focus:border-[var(--brand)] focus:outline-none"
/>
</label>
{!orderValid && (
<p className="text-xs text-accent">
Start must be on or before the end date.
</p>
)}
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="rounded px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-raised)] disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={!canSave || pending}
className="rounded bg-[var(--brand)] px-3 py-1.5 text-xs font-medium text-[var(--brand-fg)] disabled:opacity-50"
>
{pending ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}

View file

@ -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 `<GanttDateEditor>` 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<RescheduleRequest | null>(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 (
<div className="rounded-[var(--radius-tile)] border-2 border-dashed border-[var(--border-subtle)] px-4 py-12 text-center text-[var(--text-muted)]">
@ -43,14 +111,33 @@ export function GanttPlanView({ sections }: { sections: PlanSection[] }) {
}
return (
<div className="space-y-4">
{sections.map((section) => (
<ProjectSection
key={section.project.id}
section={section}
range={range}
<>
<div className="space-y-4">
{sections.map((section) => (
<ProjectSection
key={section.project.id}
section={section}
range={range}
canEdit={canEdit}
onCardMove={onCardMove}
onCardResize={onCardResize}
onSubtaskMove={onSubtaskMove}
onSubtaskResize={onSubtaskResize}
onEditRequest={canEdit && onSaveDates ? openEditor : undefined}
/>
))}
</div>
{editing && onSaveDates && (
<GanttDateEditor
targetId={editing.id}
title={editing.title}
initialStartDate={editing.startDate}
initialTargetDate={editing.targetDate}
onSave={saveEditor}
onCancel={() => !pending && setEditing(null)}
pending={pending}
/>
))}
</div>
)}
</>
);
}

View file

@ -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) {
</div>
{/* Timeline track */}
<div className="relative flex-1" style={{ height: trackHeight }}>
<div
data-gantt-track
className="relative flex-1"
style={{ height: trackHeight }}
>
{/* Month + week header */}
<div
className="absolute left-0 right-0 top-0 border-b border-[var(--border-subtle)]"
@ -197,6 +236,31 @@ export function ProjectSection({ section, range }: ProjectSectionProps) {
height={BAR_HEIGHT_SUBTASK}
title={`${subtask.title} — ${subtask.startDate || "?"} → ${subtask.targetDate || "?"}`}
strike={subtask.done}
canEdit={canEdit}
onMove={
canEdit && onSubtaskMove
? (nextStart, nextTarget) => 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
}
/>
</div>
);
@ -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
}
/>
</div>
);