Initial commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Link from "next/link";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import {
|
||||
CalendarDays,
|
||||
Layers,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface KanbanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
shot?: { shotCode: string } | null;
|
||||
asset?: { assetCode: string; name: string } | null;
|
||||
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||
_count?: { versions: number };
|
||||
versions?: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
task: KanbanTask;
|
||||
isDragOverlay?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform) }
|
||||
: undefined;
|
||||
|
||||
const latestVersion = task.versions?.[0];
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) < new Date() &&
|
||||
task.status !== "DONE";
|
||||
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group bg-zinc-900 border rounded-lg p-3 space-y-2.5 cursor-grab active:cursor-grabbing transition-all",
|
||||
isDragging && !isDragOverlay
|
||||
? "opacity-30 border-dashed border-zinc-600"
|
||||
: "border-zinc-800 hover:border-zinc-600 shadow-sm",
|
||||
isDragOverlay && "shadow-xl border-amber-500/30 rotate-1"
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Header: type badge + context code */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider">
|
||||
{TASK_TYPE_LABELS[task.type]}
|
||||
</span>
|
||||
{contextCode && (
|
||||
<span className="text-[10px] font-mono text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded">
|
||||
{contextCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="block text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors leading-tight"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
|
||||
{/* Latest version + approval */}
|
||||
{latestVersion && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500 font-mono">
|
||||
v{latestVersion.versionNumber}
|
||||
</span>
|
||||
{latestVersion.approvalStatus === "APPROVED" ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : latestVersion.approvalStatus === "CHANGES_REQUESTED" ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<Clock className="h-3.5 w-3.5 text-zinc-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: due date + priority dot + artist */}
|
||||
<div className="flex items-center justify-between gap-2 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Priority dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-1.5 rounded-full shrink-0",
|
||||
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Due date */}
|
||||
{task.dueDate && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[10px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Version count */}
|
||||
{(task._count?.versions ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-zinc-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
{task._count!.versions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignee avatar */}
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
|
||||
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user