Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user