386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
"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<HTMLDivElement | null>;
|
|
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<string, number> {
|
|
// 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<string, number>();
|
|
|
|
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<string, number> {
|
|
const result: Record<string, number> = {};
|
|
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 (
|
|
<div
|
|
className="flex items-center gap-2.5 px-3 border-b border-zinc-800 bg-zinc-900"
|
|
style={{ height: rowHeight }}
|
|
>
|
|
<Avatar className="h-7 w-7 shrink-0">
|
|
<AvatarImage src={artist.image ?? undefined} />
|
|
<AvatarFallback className="text-[10px] bg-zinc-700 text-zinc-300">
|
|
{getInitials(artist.name ?? artist.email)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-medium text-zinc-200 truncate">
|
|
{artist.name ?? artist.email.split("@")[0]}
|
|
</span>
|
|
{isOverloaded && (
|
|
<AlertTriangle className="h-3 w-3 text-orange-400 shrink-0" />
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] text-zinc-500 capitalize">
|
|
{artist.role.toLowerCase()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineDropZone({ id }: { id: string }) {
|
|
const { setNodeRef, isOver } = useDroppable({ id });
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
className={cn(
|
|
"absolute inset-0 transition-colors pointer-events-none",
|
|
isOver && "bg-amber-500/5"
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ScheduleTimeline({
|
|
artists,
|
|
tasks,
|
|
days,
|
|
viewStart,
|
|
canEdit,
|
|
timelineRef,
|
|
resizePreview,
|
|
onResizeMouseDown,
|
|
activeDragId,
|
|
onUnschedule,
|
|
dayWidth,
|
|
rowHeight,
|
|
}: ScheduleTimelineProps) {
|
|
const totalWidth = days.length * dayWidth;
|
|
|
|
return (
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Sticky left: artist labels */}
|
|
<div
|
|
className="flex-shrink-0 bg-zinc-900 border-r border-zinc-800 z-10"
|
|
style={{ width: 208 }}
|
|
>
|
|
{/* Header spacer */}
|
|
<div
|
|
className="border-b border-zinc-800 bg-zinc-900"
|
|
style={{ height: HEADER_HEIGHT }}
|
|
/>
|
|
{artists.map((artist) => {
|
|
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
|
|
const maxLoad = Math.max(...Object.values(dailyLoad), 0);
|
|
const isOverloaded = maxLoad > 8;
|
|
return (
|
|
<ArtistLabel
|
|
key={artist.id}
|
|
artist={artist}
|
|
isOverloaded={isOverloaded}
|
|
rowHeight={rowHeight}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Scrollable timeline */}
|
|
<div
|
|
ref={timelineRef}
|
|
className="flex-1 overflow-x-auto overflow-y-hidden relative"
|
|
style={{ scrollbarColor: "#3f3f46 transparent" }}
|
|
>
|
|
{/* Date header - sticky top */}
|
|
<div
|
|
className="flex sticky top-0 z-20 bg-zinc-900 border-b border-zinc-800"
|
|
style={{ width: totalWidth, height: HEADER_HEIGHT }}
|
|
>
|
|
{days.map((day, i) => {
|
|
const isT = isToday(day);
|
|
const isWE = isWeekend(day);
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center border-r text-center shrink-0 select-none",
|
|
isWE ? "border-zinc-800" : "border-zinc-800/60",
|
|
isT ? "bg-amber-500/10" : isWE ? "bg-zinc-900/80" : ""
|
|
)}
|
|
style={{ width: dayWidth }}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"text-[9px] font-medium uppercase tracking-wider",
|
|
isT ? "text-amber-400" : "text-zinc-600"
|
|
)}
|
|
>
|
|
{format(day, "EEE")}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"text-xs font-semibold",
|
|
isT ? "text-amber-300" : isWE ? "text-zinc-600" : "text-zinc-400"
|
|
)}
|
|
>
|
|
{format(day, "d")}
|
|
</span>
|
|
{format(day, "d") === "1" || i === 0 ? (
|
|
<span className="text-[9px] text-zinc-600 absolute bottom-0.5">
|
|
{format(day, "MMM")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Artist rows */}
|
|
<div style={{ width: totalWidth }}>
|
|
{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 (
|
|
<div
|
|
key={artist.id}
|
|
className="relative border-b border-zinc-800/60"
|
|
style={{ height: rowHeight }}
|
|
>
|
|
{/* Drop zone overlay */}
|
|
<TimelineDropZone id={`timeline-row-${artist.id}`} />
|
|
|
|
{/* 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 (
|
|
<div
|
|
key={dayIndex}
|
|
className={cn(
|
|
"absolute top-0 bottom-0 border-r",
|
|
isWE ? "border-zinc-800 bg-zinc-900/30" : "border-zinc-800/40",
|
|
isT && "bg-amber-500/5",
|
|
isOverloaded && "bg-orange-500/10"
|
|
)}
|
|
style={{
|
|
left: dayIndex * dayWidth,
|
|
width: dayWidth,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<ScheduleTaskBlock
|
|
key={task.id}
|
|
task={task}
|
|
dayIndex={dayIndex}
|
|
duration={duration}
|
|
artistIndex={artistIndex}
|
|
viewStart={viewStart}
|
|
days={days}
|
|
canEdit={canEdit}
|
|
isDragging={activeDragId === task.id}
|
|
onResizeMouseDown={onResizeMouseDown}
|
|
laneTop={laneTop}
|
|
taskHeight={taskHeight}
|
|
onUnschedule={onUnschedule}
|
|
dayWidth={dayWidth}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Empty state */}
|
|
{artists.length === 0 && (
|
|
<div className="flex items-center justify-center py-16 text-zinc-600 text-sm">
|
|
No artists to display
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|