593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useMemo } from "react";
|
|
import {
|
|
DndContext,
|
|
DragStartEvent,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
addDays,
|
|
startOfWeek,
|
|
startOfDay,
|
|
format,
|
|
differenceInDays,
|
|
isBefore,
|
|
parseISO,
|
|
} from "date-fns";
|
|
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
|
|
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
|
|
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
|
|
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
|
|
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { TaskStatus, TaskType } from "@prisma/client";
|
|
|
|
export const DAY_WIDTH = 216;
|
|
export const ROW_HEIGHT = 56;
|
|
export const HEADER_HEIGHT = 44;
|
|
export const LABEL_WIDTH = 208;
|
|
export const NUM_DAYS = 35;
|
|
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
|
|
|
|
export interface ScheduleTask {
|
|
id: string;
|
|
title: string;
|
|
type: TaskType;
|
|
status: TaskStatus;
|
|
priority: string;
|
|
dueDate: string | null;
|
|
estimatedHours: number | null;
|
|
scheduledStartDate: string | null;
|
|
scheduledEndDate: string | null;
|
|
scheduleNotes: string | null;
|
|
assignedArtistId: string | null;
|
|
assignedArtist: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
image: string | null;
|
|
} | null;
|
|
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
|
|
asset: { id: string; assetCode: string; name: string } | null;
|
|
project: { id: string; name: string; code: string };
|
|
}
|
|
|
|
export interface ScheduleArtist {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
image: string | null;
|
|
role: string;
|
|
}
|
|
|
|
export interface ActiveDragData {
|
|
type: "scheduled" | "backlog" | "resize";
|
|
taskId: string;
|
|
duration?: number;
|
|
estimatedHours?: number | null;
|
|
originalEndDate?: string;
|
|
}
|
|
|
|
interface SchedulePageClientProps {
|
|
artists: ScheduleArtist[];
|
|
tasks: ScheduleTask[];
|
|
backlog: ScheduleTask[];
|
|
projects: { id: string; name: string; code: string }[];
|
|
canEdit: boolean;
|
|
currentUserId: string;
|
|
activeProject?: string;
|
|
activeArtist?: string;
|
|
}
|
|
|
|
function toDate(val: string | null | undefined): Date | null {
|
|
if (!val) return null;
|
|
try {
|
|
return parseISO(val);
|
|
} catch {
|
|
return new Date(val);
|
|
}
|
|
}
|
|
|
|
function calcDuration(task: ScheduleTask): number {
|
|
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
|
|
}
|
|
|
|
export function SchedulePageClient({
|
|
artists,
|
|
tasks,
|
|
backlog,
|
|
projects,
|
|
canEdit,
|
|
currentUserId,
|
|
activeProject,
|
|
activeArtist,
|
|
}: SchedulePageClientProps) {
|
|
const { toast } = useToast();
|
|
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
|
|
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
|
|
const [viewStart, setViewStart] = useState<Date>(() =>
|
|
startOfWeek(new Date(), { weekStartsOn: 1 })
|
|
);
|
|
const [showBacklog, setShowBacklog] = useState(true);
|
|
const [filterProject, setFilterProject] = useState(activeProject ?? "");
|
|
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
|
|
const [filterStatus, setFilterStatus] = useState("");
|
|
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
|
|
const [resizePreview, setResizePreview] = useState<{
|
|
taskId: string;
|
|
endDate: Date;
|
|
estimatedHours: number;
|
|
} | null>(null);
|
|
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
|
|
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
|
|
|
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 6 },
|
|
})
|
|
);
|
|
|
|
const days = useMemo(
|
|
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
|
|
[viewStart]
|
|
);
|
|
|
|
const filteredArtists = useMemo(() => {
|
|
if (!filterArtist) return artists;
|
|
return artists.filter((a) => a.id === filterArtist);
|
|
}, [artists, filterArtist]);
|
|
|
|
const filteredScheduledTasks = useMemo(() => {
|
|
return localTasks.filter((t) => {
|
|
if (filterProject && t.project.id !== filterProject) return false;
|
|
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
|
|
if (filterStatus && t.status !== filterStatus) return false;
|
|
return true;
|
|
});
|
|
}, [localTasks, filterProject, filterArtist, filterStatus]);
|
|
|
|
const filteredBacklog = useMemo(() => {
|
|
return localBacklog.filter((t) => {
|
|
if (filterProject && t.project.id !== filterProject) return false;
|
|
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
|
|
if (filterStatus && t.status !== filterStatus) return false;
|
|
return true;
|
|
});
|
|
}, [localBacklog, filterProject, filterArtist, filterStatus]);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
const data = event.active.data.current as ActiveDragData;
|
|
setActiveDrag(data);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
setActiveDrag(null);
|
|
const { active, delta, over } = event;
|
|
if (!active.data.current) return;
|
|
|
|
const dragData = active.data.current as ActiveDragData;
|
|
|
|
// Resize handle - only deltaX matters
|
|
if (dragData.type === "resize") {
|
|
const task = localTasks.find((t) => t.id === dragData.taskId);
|
|
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
|
|
|
|
const originalEnd = toDate(dragData.originalEndDate)!;
|
|
const daysDelta = Math.round(delta.x / dayWidth);
|
|
let newEnd = addDays(originalEnd, daysDelta);
|
|
|
|
const startDate = toDate(task.scheduledStartDate)!;
|
|
if (isBefore(newEnd, startDate)) newEnd = startDate;
|
|
|
|
const prevEnd = task.scheduledEndDate;
|
|
setLocalTasks((prev) =>
|
|
prev.map((t) =>
|
|
t.id === dragData.taskId
|
|
? { ...t, scheduledEndDate: newEnd.toISOString() }
|
|
: t
|
|
)
|
|
);
|
|
|
|
scheduleTask({
|
|
taskId: dragData.taskId,
|
|
scheduledStartDate: task.scheduledStartDate,
|
|
scheduledEndDate: newEnd.toISOString(),
|
|
}).catch(() => {
|
|
setLocalTasks((prev) =>
|
|
prev.map((t) =>
|
|
t.id === dragData.taskId
|
|
? { ...t, scheduledEndDate: prevEnd }
|
|
: t
|
|
)
|
|
);
|
|
toast({ title: "Failed to resize task", variant: "destructive" });
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if dropped over the backlog panel (unschedule)
|
|
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
|
|
const task = localTasks.find((t) => t.id === dragData.taskId);
|
|
if (!task) return;
|
|
|
|
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
|
|
setLocalBacklog((prev) => [
|
|
{
|
|
...task,
|
|
scheduledStartDate: null,
|
|
scheduledEndDate: null,
|
|
},
|
|
...prev,
|
|
]);
|
|
|
|
unscheduleTask(dragData.taskId).catch(() => {
|
|
setLocalTasks((prev) => [...prev, task]);
|
|
setLocalBacklog((prev) =>
|
|
prev.filter((t) => t.id !== dragData.taskId)
|
|
);
|
|
toast({ title: "Failed to unschedule task", variant: "destructive" });
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Drop on timeline - calculate position from translated rect
|
|
const translatedRect = active.rect.current.translated;
|
|
if (!translatedRect || !timelineRef.current) return;
|
|
|
|
const timelineBounds = timelineRef.current.getBoundingClientRect();
|
|
const scrollLeft = timelineRef.current.scrollLeft;
|
|
|
|
const centerX = translatedRect.left + translatedRect.width / 2;
|
|
const centerY = translatedRect.top + translatedRect.height / 2;
|
|
|
|
// Check if drop is within timeline bounds
|
|
const inTimeline =
|
|
centerX >= timelineBounds.left &&
|
|
centerX <= timelineBounds.right &&
|
|
centerY >= timelineBounds.top + HEADER_HEIGHT &&
|
|
centerY <= timelineBounds.bottom;
|
|
|
|
if (!inTimeline && dragData.type !== "backlog") return;
|
|
if (!inTimeline && dragData.type === "backlog") return;
|
|
|
|
const dayIndex = Math.max(
|
|
0,
|
|
Math.min(
|
|
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
|
|
NUM_DAYS - 1
|
|
)
|
|
);
|
|
const artistIndex = Math.max(
|
|
0,
|
|
Math.min(
|
|
Math.floor(
|
|
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
|
|
),
|
|
filteredArtists.length - 1
|
|
)
|
|
);
|
|
|
|
const newArtist = filteredArtists[artistIndex];
|
|
if (!newArtist) return;
|
|
|
|
const taskId = dragData.taskId;
|
|
|
|
// Duration always derived from estimatedHours so width = hours
|
|
let duration = 1;
|
|
if (dragData.type === "scheduled") {
|
|
const task = localTasks.find((t) => t.id === taskId);
|
|
if (task) duration = calcDuration(task);
|
|
} else if (dragData.type === "backlog") {
|
|
const task = localBacklog.find((t) => t.id === taskId);
|
|
if (task) duration = calcDuration(task);
|
|
}
|
|
|
|
// Pack task within the dropped day using hour offsets.
|
|
// Sum hours already scheduled for this artist on that day,
|
|
// and start the new task at that hour (stored in scheduledStartDate's time component).
|
|
const existingForArtist = localTasks.filter(
|
|
(t) =>
|
|
t.assignedArtistId === newArtist.id &&
|
|
t.id !== taskId &&
|
|
t.scheduledStartDate
|
|
);
|
|
|
|
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
|
|
const hoursOnDay = existingForArtist
|
|
.filter((t) => {
|
|
const tStart = toDate(t.scheduledStartDate!);
|
|
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
|
|
})
|
|
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
|
|
|
|
const newStartDate = new Date(droppedDay);
|
|
newStartDate.setHours(hoursOnDay);
|
|
const newEndDate = addDays(newStartDate, duration - 1);
|
|
|
|
if (dragData.type === "backlog") {
|
|
const task = localBacklog.find((t) => t.id === taskId);
|
|
if (!task) return;
|
|
|
|
const scheduledTask: ScheduleTask = {
|
|
...task,
|
|
scheduledStartDate: newStartDate.toISOString(),
|
|
scheduledEndDate: newEndDate.toISOString(),
|
|
assignedArtistId: newArtist.id,
|
|
assignedArtist: {
|
|
id: newArtist.id,
|
|
name: newArtist.name,
|
|
email: newArtist.email,
|
|
image: newArtist.image,
|
|
},
|
|
};
|
|
|
|
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
|
|
setLocalTasks((prev) => [...prev, scheduledTask]);
|
|
|
|
scheduleTask({
|
|
taskId,
|
|
scheduledStartDate: newStartDate.toISOString(),
|
|
scheduledEndDate: newEndDate.toISOString(),
|
|
assignedArtistId: newArtist.id,
|
|
}).catch(() => {
|
|
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
setLocalBacklog((prev) => [task, ...prev]);
|
|
toast({ title: "Failed to schedule task", variant: "destructive" });
|
|
});
|
|
} else if (dragData.type === "scheduled") {
|
|
const prevTask = localTasks.find((t) => t.id === taskId);
|
|
|
|
setLocalTasks((prev) =>
|
|
prev.map((t) =>
|
|
t.id === taskId
|
|
? {
|
|
...t,
|
|
scheduledStartDate: newStartDate.toISOString(),
|
|
scheduledEndDate: newEndDate.toISOString(),
|
|
assignedArtistId: newArtist.id,
|
|
assignedArtist: {
|
|
id: newArtist.id,
|
|
name: newArtist.name,
|
|
email: newArtist.email,
|
|
image: newArtist.image,
|
|
},
|
|
}
|
|
: t
|
|
)
|
|
);
|
|
|
|
scheduleTask({
|
|
taskId,
|
|
scheduledStartDate: newStartDate.toISOString(),
|
|
scheduledEndDate: newEndDate.toISOString(),
|
|
assignedArtistId: newArtist.id,
|
|
}).catch(() => {
|
|
if (prevTask) {
|
|
setLocalTasks((prev) =>
|
|
prev.map((t) => (t.id === taskId ? prevTask : t))
|
|
);
|
|
}
|
|
toast({ title: "Failed to move task", variant: "destructive" });
|
|
});
|
|
}
|
|
},
|
|
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
|
|
);
|
|
|
|
const handleResizeMouseDown = useCallback(
|
|
(taskId: string, _currentEndDate: string) =>
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const prevTask = localTasks.find((t) => t.id === taskId);
|
|
if (!prevTask?.scheduledStartDate) return;
|
|
|
|
const startX = e.clientX;
|
|
const startDate = toDate(prevTask.scheduledStartDate)!;
|
|
const originalHours = prevTask.estimatedHours ?? 8;
|
|
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
|
|
let currentHours = originalHours;
|
|
|
|
const onMouseMove = (me: MouseEvent) => {
|
|
const deltaPx = me.clientX - startX;
|
|
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
|
|
currentHours = Math.max(1, originalHours + hoursDelta);
|
|
const newDays = Math.max(1, Math.ceil(currentHours / 8));
|
|
const newEndDate = addDays(startDate, newDays - 1);
|
|
setResizePreview({
|
|
taskId,
|
|
endDate: newEndDate,
|
|
estimatedHours: currentHours,
|
|
});
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
window.removeEventListener("mousemove", onMouseMove);
|
|
window.removeEventListener("mouseup", onMouseUp);
|
|
|
|
const newDays = Math.max(1, Math.ceil(currentHours / 8));
|
|
const finalEnd = addDays(startDate, newDays - 1);
|
|
|
|
setResizePreview(null);
|
|
setLocalTasks((prev) =>
|
|
prev.map((t) =>
|
|
t.id === taskId
|
|
? {
|
|
...t,
|
|
scheduledEndDate: finalEnd.toISOString(),
|
|
estimatedHours: currentHours,
|
|
}
|
|
: t
|
|
)
|
|
);
|
|
|
|
scheduleTask({
|
|
taskId,
|
|
scheduledStartDate: prevTask.scheduledStartDate,
|
|
scheduledEndDate: finalEnd.toISOString(),
|
|
estimatedHours: currentHours,
|
|
}).catch(() => {
|
|
setLocalTasks((prev) =>
|
|
prev.map((t) => (t.id === taskId ? prevTask : t))
|
|
);
|
|
toast({ title: "Failed to resize task", variant: "destructive" });
|
|
});
|
|
};
|
|
|
|
window.addEventListener("mousemove", onMouseMove);
|
|
window.addEventListener("mouseup", onMouseUp);
|
|
},
|
|
[localTasks, toast, dayWidth]
|
|
);
|
|
|
|
const activeDragTask = activeDrag
|
|
? localTasks.find((t) => t.id === activeDrag.taskId) ??
|
|
localBacklog.find((t) => t.id === activeDrag.taskId)
|
|
: null;
|
|
|
|
const handleUnschedule = useCallback(
|
|
(taskId: string) => {
|
|
const task = localTasks.find((t) => t.id === taskId);
|
|
if (!task) return;
|
|
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
setLocalBacklog((prev) => [
|
|
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
|
|
...prev,
|
|
]);
|
|
unscheduleTask(taskId).catch(() => {
|
|
setLocalTasks((prev) => [...prev, task]);
|
|
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
|
|
toast({ title: "Failed to unschedule task", variant: "destructive" });
|
|
});
|
|
},
|
|
[localTasks, toast]
|
|
);
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<CalendarDays className="h-5 w-5 text-amber-400" />
|
|
<h1 className="text-lg font-semibold text-white">Schedule</h1>
|
|
<span className="text-sm text-zinc-500">
|
|
{format(viewStart, "MMM d")} —{" "}
|
|
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Zoom controls */}
|
|
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<Columns2 className="h-3 w-3 text-zinc-500" />
|
|
<input
|
|
type="range"
|
|
min={60}
|
|
max={400}
|
|
step={8}
|
|
value={dayWidth}
|
|
onChange={(e) => setDayWidth(Number(e.target.value))}
|
|
className="w-20 accent-amber-400 cursor-pointer"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Rows className="h-3 w-3 text-zinc-500" />
|
|
<input
|
|
type="range"
|
|
min={40}
|
|
max={120}
|
|
step={4}
|
|
value={rowHeight}
|
|
onChange={(e) => setRowHeight(Number(e.target.value))}
|
|
className="w-20 accent-amber-400 cursor-pointer"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowBacklog((b) => !b)}
|
|
className="text-zinc-400 hover:text-white gap-2"
|
|
>
|
|
{showBacklog ? (
|
|
<PanelRightClose className="h-4 w-4" />
|
|
) : (
|
|
<PanelRightOpen className="h-4 w-4" />
|
|
)}
|
|
{showBacklog ? "Hide" : "Backlog"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<ScheduleFilters
|
|
projects={projects}
|
|
artists={artists}
|
|
filterProject={filterProject}
|
|
filterArtist={filterArtist}
|
|
filterStatus={filterStatus}
|
|
viewStart={viewStart}
|
|
onProjectChange={setFilterProject}
|
|
onArtistChange={setFilterArtist}
|
|
onStatusChange={setFilterStatus}
|
|
onViewStartChange={setViewStart}
|
|
/>
|
|
|
|
{/* Main content */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<ScheduleTimeline
|
|
artists={filteredArtists}
|
|
tasks={filteredScheduledTasks}
|
|
days={days}
|
|
viewStart={viewStart}
|
|
canEdit={canEdit}
|
|
timelineRef={timelineRef}
|
|
resizePreview={resizePreview}
|
|
onResizeMouseDown={handleResizeMouseDown}
|
|
activeDragId={activeDrag?.taskId ?? null}
|
|
onUnschedule={handleUnschedule}
|
|
dayWidth={dayWidth}
|
|
rowHeight={rowHeight}
|
|
/>
|
|
|
|
{showBacklog && (
|
|
<BacklogPanel
|
|
tasks={filteredBacklog}
|
|
canEdit={canEdit}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Drag overlay */}
|
|
<DragOverlay dropAnimation={null}>
|
|
{activeDragTask && (
|
|
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
|
|
{activeDragTask.shot?.shotCode ??
|
|
activeDragTask.asset?.assetCode ??
|
|
activeDragTask.title}
|
|
</div>
|
|
)}
|
|
</DragOverlay>
|
|
</div>
|
|
</DndContext>
|
|
);
|
|
}
|