Initial commit
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useDroppable, useDraggable } from "@dnd-kit/core";
|
||||
import { format, parseISO, formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CalendarDays,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import {
|
||||
ScheduleTask,
|
||||
ActiveDragData,
|
||||
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
function toDate(val: string | null | undefined): Date | null {
|
||||
if (!val) return null;
|
||||
try {
|
||||
return parseISO(val);
|
||||
} catch {
|
||||
return new Date(val);
|
||||
}
|
||||
}
|
||||
|
||||
function BacklogItem({
|
||||
task,
|
||||
canEdit,
|
||||
}: {
|
||||
task: ScheduleTask;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: task.id,
|
||||
disabled: !canEdit,
|
||||
data: {
|
||||
type: "backlog",
|
||||
taskId: task.id,
|
||||
estimatedHours: task.estimatedHours,
|
||||
} as ActiveDragData,
|
||||
});
|
||||
|
||||
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||
const StatusIcon = cfg.icon;
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const dueDate = toDate(task.dueDate);
|
||||
const isOverdue =
|
||||
dueDate && dueDate < new Date() && task.status !== "DONE";
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 50,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group flex items-start gap-2 px-3 py-2.5 rounded-lg border transition-colors",
|
||||
"border-zinc-800 bg-zinc-900 hover:border-zinc-700 hover:bg-zinc-800/80",
|
||||
isDragging && "opacity-30 border-amber-500/40",
|
||||
canEdit && "cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Grip */}
|
||||
{canEdit && (
|
||||
<GripVertical className="h-3.5 w-3.5 text-zinc-600 shrink-0 mt-0.5 group-hover:text-zinc-400" />
|
||||
)}
|
||||
|
||||
{/* Priority dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full shrink-0 mt-1.5",
|
||||
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{contextCode && (
|
||||
<span className="text-[10px] font-mono bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded shrink-0">
|
||||
{contextCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-medium text-zinc-200 truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type as TaskType]} · {task.project.code}
|
||||
</span>
|
||||
{task.estimatedHours && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-zinc-600">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{task.estimatedHours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-[9px] border px-1 py-0 h-3.5 gap-0.5 shrink-0",
|
||||
cfg.color
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-2 w-2" />
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
{dueDate && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-[10px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-600"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-2.5 w-2.5" />
|
||||
{formatDistanceToNow(dueDate, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BacklogPanelProps {
|
||||
tasks: ScheduleTask[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function BacklogPanel({ tasks, canEdit }: BacklogPanelProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "backlog-drop-zone",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border-l border-zinc-800 bg-zinc-900 transition-colors",
|
||||
"w-72 shrink-0"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-zinc-800 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-200">Backlog</span>
|
||||
<span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded-full">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
Drag onto timeline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone + list */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex-1 transition-colors",
|
||||
isOver && "bg-amber-500/5 ring-1 ring-inset ring-amber-500/20"
|
||||
)}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-zinc-600">
|
||||
<Layers className="h-8 w-8 mb-2 opacity-30" />
|
||||
<p className="text-xs">All tasks scheduled</p>
|
||||
{canEdit && (
|
||||
<p className="text-[10px] mt-1 text-zinc-700">
|
||||
Drop here to unschedule
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{canEdit && isOver && (
|
||||
<div className="flex items-center justify-center py-3 rounded-lg border border-dashed border-amber-500/40 text-amber-400 text-xs">
|
||||
Drop to unschedule
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((task) => (
|
||||
<BacklogItem key={task.id} task={task} canEdit={canEdit} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { addWeeks, subWeeks, addDays, format } from "date-fns";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react";
|
||||
import { ScheduleArtist } from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "TODO", label: "To Do" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "INTERNAL_REVIEW", label: "Internal Review" },
|
||||
{ value: "CLIENT_REVIEW", label: "Client Review" },
|
||||
{ value: "CHANGES", label: "Changes" },
|
||||
];
|
||||
|
||||
interface ScheduleFiltersProps {
|
||||
projects: { id: string; name: string; code: string }[];
|
||||
artists: ScheduleArtist[];
|
||||
filterProject: string;
|
||||
filterArtist: string;
|
||||
filterStatus: string;
|
||||
viewStart: Date;
|
||||
onProjectChange: (v: string) => void;
|
||||
onArtistChange: (v: string) => void;
|
||||
onStatusChange: (v: string) => void;
|
||||
onViewStartChange: (d: Date) => void;
|
||||
}
|
||||
|
||||
export function ScheduleFilters({
|
||||
projects,
|
||||
artists,
|
||||
filterProject,
|
||||
filterArtist,
|
||||
filterStatus,
|
||||
viewStart,
|
||||
onProjectChange,
|
||||
onArtistChange,
|
||||
onStatusChange,
|
||||
onViewStartChange,
|
||||
}: ScheduleFiltersProps) {
|
||||
const goPrev = () => onViewStartChange(subWeeks(viewStart, 1));
|
||||
const goNext = () => onViewStartChange(addWeeks(viewStart, 1));
|
||||
const goToday = () => {
|
||||
const today = new Date();
|
||||
const monday = addDays(today, -((today.getDay() + 6) % 7));
|
||||
onViewStartChange(monday);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-950 shrink-0 flex-wrap">
|
||||
{/* Week navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-zinc-400 hover:text-white"
|
||||
onClick={goPrev}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-zinc-400 hover:text-white gap-1.5"
|
||||
onClick={goToday}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-zinc-400 hover:text-white"
|
||||
onClick={goNext}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-zinc-500 ml-1">
|
||||
{format(viewStart, "MMM d")} – {format(addDays(viewStart, 34), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-zinc-800" />
|
||||
|
||||
{/* Project filter */}
|
||||
<Select
|
||||
value={filterProject || "__all__"}
|
||||
onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
All projects
|
||||
</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id} className="text-xs">
|
||||
[{p.code}] {p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Artist filter */}
|
||||
<Select
|
||||
value={filterArtist || "__all__"}
|
||||
onValueChange={(v) => onArtistChange(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||
<SelectValue placeholder="All artists" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
All artists
|
||||
</SelectItem>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id} className="text-xs">
|
||||
{a.name ?? a.email.split("@")[0]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status filter */}
|
||||
<Select
|
||||
value={filterStatus || "__all__"}
|
||||
onValueChange={(v) => onStatusChange(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value} className="text-xs">
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(filterProject || filterArtist || filterStatus) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-zinc-500 hover:text-white"
|
||||
onClick={() => {
|
||||
onProjectChange("");
|
||||
onArtistChange("");
|
||||
onStatusChange("");
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import {
|
||||
format,
|
||||
isAfter,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarDays, Clock, ExternalLink, CalendarOff } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import {
|
||||
ScheduleTask,
|
||||
ActiveDragData,
|
||||
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
TaskStatus,
|
||||
{ bg: string; border: string; text: string; dot: string }
|
||||
> = {
|
||||
TODO: {
|
||||
bg: "bg-zinc-800/80",
|
||||
border: "border-zinc-600/60",
|
||||
text: "text-zinc-300",
|
||||
dot: "bg-zinc-500",
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
bg: "bg-blue-900/50",
|
||||
border: "border-blue-600/60",
|
||||
text: "text-blue-200",
|
||||
dot: "bg-blue-400",
|
||||
},
|
||||
INTERNAL_REVIEW: {
|
||||
bg: "bg-purple-900/50",
|
||||
border: "border-purple-600/60",
|
||||
text: "text-purple-200",
|
||||
dot: "bg-purple-400",
|
||||
},
|
||||
CLIENT_REVIEW: {
|
||||
bg: "bg-amber-900/50",
|
||||
border: "border-amber-600/60",
|
||||
text: "text-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
CHANGES: {
|
||||
bg: "bg-orange-900/50",
|
||||
border: "border-orange-600/60",
|
||||
text: "text-orange-200",
|
||||
dot: "bg-orange-400",
|
||||
},
|
||||
DONE: {
|
||||
bg: "bg-emerald-900/50",
|
||||
border: "border-emerald-600/60",
|
||||
text: "text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
};
|
||||
|
||||
function toDate(val: string | null | undefined): Date | null {
|
||||
if (!val) return null;
|
||||
try {
|
||||
return parseISO(val);
|
||||
} catch {
|
||||
return new Date(val);
|
||||
}
|
||||
}
|
||||
|
||||
interface ScheduleTaskBlockProps {
|
||||
task: ScheduleTask;
|
||||
dayIndex: number;
|
||||
duration: number;
|
||||
artistIndex: number;
|
||||
viewStart: Date;
|
||||
days: Date[];
|
||||
canEdit: boolean;
|
||||
isDragging: boolean;
|
||||
laneTop: number;
|
||||
taskHeight: number;
|
||||
onResizeMouseDown: (
|
||||
taskId: string,
|
||||
currentEndDate: string
|
||||
) => (e: React.MouseEvent) => void;
|
||||
onUnschedule: (taskId: string) => void;
|
||||
dayWidth: number;
|
||||
}
|
||||
|
||||
export function ScheduleTaskBlock({
|
||||
task,
|
||||
dayIndex,
|
||||
duration,
|
||||
canEdit,
|
||||
isDragging,
|
||||
laneTop,
|
||||
taskHeight,
|
||||
onResizeMouseDown,
|
||||
onUnschedule,
|
||||
dayWidth,
|
||||
}: ScheduleTaskBlockProps) {
|
||||
const router = useRouter();
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: task.id,
|
||||
disabled: !canEdit,
|
||||
data: {
|
||||
type: "scheduled",
|
||||
taskId: task.id,
|
||||
duration,
|
||||
} as ActiveDragData,
|
||||
});
|
||||
|
||||
const taskWidth = Math.max(20, (task.estimatedHours ?? 8) / 8 * dayWidth - 4);
|
||||
|
||||
const style = {
|
||||
position: "absolute" as const,
|
||||
left: Math.max(0, dayIndex) * dayWidth + 2,
|
||||
width: taskWidth,
|
||||
top: laneTop,
|
||||
height: taskHeight,
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
const statusStyle = STATUS_STYLES[task.status];
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
|
||||
const thumbnail = task.shot?.thumbnailUrl ?? null;
|
||||
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
task.scheduledEndDate &&
|
||||
isAfter(
|
||||
toDate(task.scheduledEndDate)!,
|
||||
toDate(task.dueDate)!
|
||||
);
|
||||
|
||||
const dueDate = toDate(task.dueDate);
|
||||
const tooNarrow = (task.estimatedHours ?? 8) < 3; // < 3h = too narrow for labels
|
||||
const tooShort = taskHeight < 16;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"absolute rounded-md border flex items-center overflow-hidden select-none group",
|
||||
"transition-opacity duration-100",
|
||||
statusStyle.bg,
|
||||
statusStyle.border,
|
||||
isDragging && "opacity-30",
|
||||
canEdit && "cursor-grab active:cursor-grabbing",
|
||||
isOverdue && "ring-1 ring-red-500/50"
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 w-1 self-stretch rounded-l-md",
|
||||
statusStyle.dot
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Thumbnail */}
|
||||
{thumbnail && !tooNarrow && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
className="shrink-0 h-full object-cover"
|
||||
style={{ width: taskHeight * 2.39, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden px-1">
|
||||
{!tooShort && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono font-semibold truncate shrink-0 max-w-[50%]",
|
||||
statusStyle.text
|
||||
)}
|
||||
>
|
||||
{contextName}
|
||||
</span>
|
||||
)}
|
||||
{!tooNarrow && !tooShort && (
|
||||
<span className="text-[9px] text-zinc-400 truncate">
|
||||
{TASK_TYPE_LABELS[task.type as TaskType]}
|
||||
</span>
|
||||
)}
|
||||
{!tooNarrow && !tooShort && task.estimatedHours && (
|
||||
<span className="text-[9px] text-zinc-500 shrink-0 ml-auto">
|
||||
{task.estimatedHours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overdue indicator */}
|
||||
{isOverdue && (
|
||||
<div className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-400 mr-1" />
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
{canEdit && task.scheduledEndDate && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={onResizeMouseDown(task.id, task.scheduledEndDate)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-3/4 rounded-full bg-current opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-zinc-900 border-zinc-700 text-zinc-100 max-w-[240px]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-xs">
|
||||
{contextCode && (
|
||||
<span className="text-amber-400 mr-1">{contextCode}</span>
|
||||
)}
|
||||
{task.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
{TASK_TYPE_LABELS[task.type as TaskType]} ·{" "}
|
||||
{task.project.code}
|
||||
</div>
|
||||
{task.scheduledStartDate && task.scheduledEndDate && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{format(toDate(task.scheduledStartDate)!, "MMM d")} →{" "}
|
||||
{format(toDate(task.scheduledEndDate)!, "MMM d")}
|
||||
</div>
|
||||
)}
|
||||
{task.estimatedHours && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
{task.estimatedHours}h estimated
|
||||
</div>
|
||||
)}
|
||||
{dueDate && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[11px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-400"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
Due {format(dueDate, "MMM d")}
|
||||
{isOverdue && " — Late!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="bg-zinc-900 border-zinc-700 text-zinc-100 w-48">
|
||||
<ContextMenuItem
|
||||
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-white"
|
||||
onSelect={() => router.push(`/tasks/${task.id}`)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-zinc-400" />
|
||||
View Task
|
||||
</ContextMenuItem>
|
||||
{canEdit && (
|
||||
<>
|
||||
<ContextMenuSeparator className="bg-zinc-800" />
|
||||
<ContextMenuItem
|
||||
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-red-400 text-red-400"
|
||||
onSelect={() => onUnschedule(task.id)}
|
||||
>
|
||||
<CalendarOff className="h-3.5 w-3.5" />
|
||||
Unschedule
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user