"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(tasks); const [localBacklog, setLocalBacklog] = useState(backlog); const [viewStart, setViewStart] = useState(() => 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(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(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 (
{/* Header */}

Schedule

{format(viewStart, "MMM d")} —{" "} {format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
{/* Zoom controls */}
setDayWidth(Number(e.target.value))} className="w-20 accent-amber-400 cursor-pointer" />
setRowHeight(Number(e.target.value))} className="w-20 accent-amber-400 cursor-pointer" />
{/* Filters */} {/* Main content */}
{showBacklog && ( )}
{/* Drag overlay */} {activeDragTask && (
{activeDragTask.shot?.shotCode ?? activeDragTask.asset?.assetCode ?? activeDragTask.title}
)}
); }