"use client"; import { useRef, RefObject } from "react"; import { useDroppable } from "@dnd-kit/core"; import { format, differenceInDays, startOfDay, isToday, isWeekend, isBefore, parseISO, addDays, } from "date-fns"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getInitials } from "@/lib/utils"; import { ScheduleTaskBlock } from "./ScheduleTaskBlock"; import { ScheduleTask, ScheduleArtist, DAY_WIDTH, ROW_HEIGHT, HEADER_HEIGHT, } from "@/app/(dashboard)/schedule/SchedulePageClient"; import { AlertTriangle } from "lucide-react"; interface ResizePreview { taskId: string; endDate: Date; estimatedHours: number; } interface ScheduleTimelineProps { artists: ScheduleArtist[]; tasks: ScheduleTask[]; days: Date[]; viewStart: Date; canEdit: boolean; timelineRef: RefObject; resizePreview: ResizePreview | null; onResizeMouseDown: ( taskId: string, currentEndDate: string ) => (e: React.MouseEvent) => void; activeDragId: string | null; onUnschedule: (taskId: string) => void; dayWidth: number; rowHeight: number; } function toDate(val: string | null | undefined): Date | null { if (!val) return null; try { return parseISO(val); } catch { return new Date(val); } } function getDayIndex(date: Date | null, viewStart: Date): number { if (!date) return -1; return differenceInDays(date, viewStart); } /** Greedy lane assignment based on hour-ranges so task width = hours. * Two tasks for the same artist conflict when their [startHour, endHour] * ranges overlap. Each task occupies exactly one lane (fixed height). */ function computeLanes( tasks: ScheduleTask[], viewStart: Date ): Map { // Build hour ranges: startHour = dayIndex * 8, endHour = startHour + estimatedHours const ranges = tasks .filter((t) => t.scheduledStartDate) .map((t) => { const dayIdx = Math.max( 0, differenceInDays(toDate(t.scheduledStartDate)!, viewStart) ); const startHour = dayIdx * 8; const endHour = startHour + (t.estimatedHours ?? 8); return { id: t.id, startHour, endHour }; }) .sort((a, b) => a.startHour - b.startHour); const laneEndHours: number[] = []; const result = new Map(); for (const task of ranges) { let lane = laneEndHours.findIndex((end) => task.startHour >= end); if (lane === -1) lane = laneEndHours.length; laneEndHours[lane] = task.endHour; result.set(task.id, lane); } return result; } function getTaskDuration(task: ScheduleTask): number { return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8)); } // Calculate daily load (hours) for each artist function calcDailyLoad( tasks: ScheduleTask[], artistId: string, days: Date[] ): Record { const result: Record = {}; const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId); for (const day of days) { const dayStr = format(day, "yyyy-MM-dd"); let totalHours = 0; for (const task of artistTasks) { const start = toDate(task.scheduledStartDate); const end = toDate(task.scheduledEndDate) ?? start; if (!start || !end) continue; const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999); if (start <= dayEnd && end >= dayStart) { const dur = Math.max(1, differenceInDays(end, start) + 1); const hoursPerDay = (task.estimatedHours ?? 8) / dur; totalHours += hoursPerDay; } } result[dayStr] = totalHours; } return result; } function ArtistLabel({ artist, isOverloaded, rowHeight, }: { artist: ScheduleArtist; isOverloaded: boolean; rowHeight: number; }) { return (
{getInitials(artist.name ?? artist.email)}
{artist.name ?? artist.email.split("@")[0]} {isOverloaded && ( )}
{artist.role.toLowerCase()}
); } function TimelineDropZone({ id }: { id: string }) { const { setNodeRef, isOver } = useDroppable({ id }); return (
); } export function ScheduleTimeline({ artists, tasks, days, viewStart, canEdit, timelineRef, resizePreview, onResizeMouseDown, activeDragId, onUnschedule, dayWidth, rowHeight, }: ScheduleTimelineProps) { const totalWidth = days.length * dayWidth; return (
{/* Sticky left: artist labels */}
{/* Header spacer */}
{artists.map((artist) => { const dailyLoad = calcDailyLoad(tasks, artist.id, days); const maxLoad = Math.max(...Object.values(dailyLoad), 0); const isOverloaded = maxLoad > 8; return ( ); })}
{/* Scrollable timeline */}
{/* Date header - sticky top */}
{days.map((day, i) => { const isT = isToday(day); const isWE = isWeekend(day); return (
{format(day, "EEE")} {format(day, "d")} {format(day, "d") === "1" || i === 0 ? ( {format(day, "MMM")} ) : null}
); })}
{/* Artist rows */}
{artists.map((artist, artistIndex) => { const artistTasks = tasks.filter( (t) => t.assignedArtistId === artist.id ); const dailyLoad = calcDailyLoad(tasks, artist.id, days); // Apply resize preview so width animates live const tasksWithPreview = artistTasks.map((t) => resizePreview?.taskId === t.id ? { ...t, scheduledEndDate: resizePreview.endDate.toISOString(), estimatedHours: resizePreview.estimatedHours, } : t ); return (
{/* Drop zone overlay */} {/* Day grid lines + overload indicators */} {days.map((day, dayIndex) => { const dayStr = format(day, "yyyy-MM-dd"); const load = dailyLoad[dayStr] ?? 0; const isT = isToday(day); const isWE = isWeekend(day); const isOverloaded = load > 8; return (
); })} {/* Task blocks */} {tasksWithPreview.map((task) => { const startDate = toDate(task.scheduledStartDate); if (!startDate) return null; // dayIndex may be fractional: integer part = day column, // fractional part = intra-day hour offset stored in the time component. const dayIdx = getDayIndex(startOfDay(startDate), viewStart); const hourOffset = startDate.getHours(); // hours into the 8h workday const dayIndex = dayIdx + hourOffset / 8; const duration = getTaskDuration(task); // Skip tasks outside view if (dayIndex + duration < 0 || dayIndex >= days.length) return null; const taskHeight = rowHeight - 8; const laneTop = 4; return ( ); })}
); })} {/* Empty state */} {artists.length === 0 && (
No artists to display
)}
); }