215 lines
6.2 KiB
TypeScript
215 lines
6.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|