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:
parent
2b01d0e837
commit
cc2efd1f23
7 changed files with 662 additions and 27 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
129
src/components/roadmap/gantt-client.tsx
Normal file
129
src/components/roadmap/gantt-client.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
src/components/roadmap/gantt-date-editor.tsx
Normal file
163
src/components/roadmap/gantt-date-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue