Initial commit
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
CalendarDays,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { updateTaskStatus, deleteTask } from "@/actions/tasks";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export const TASK_STATUS_CONFIG: Record<
|
||||
TaskStatus,
|
||||
{ label: string; color: string; icon: React.ElementType }
|
||||
> = {
|
||||
TODO: { label: "To Do", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
|
||||
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Loader2 },
|
||||
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: Eye },
|
||||
CLIENT_REVIEW: { label: "Client Review", color: "bg-amber-500/10 text-amber-400 border-amber-500/20", icon: AlertCircle },
|
||||
CHANGES: { label: "Changes", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: RefreshCw },
|
||||
DONE: { label: "Done", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
export const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||
TRACK: "Track", ROTO: "Roto", KEY: "Key", COMP: "Comp", FX: "FX",
|
||||
LIGHTING: "Lighting", RENDER: "Render", ANIMATION: "Animation",
|
||||
MODEL: "Model", TEXTURE: "Texture", RIG: "Rig", LOOKDEV: "Lookdev", GENERAL: "General",
|
||||
};
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500", NORMAL: "bg-blue-500", HIGH: "bg-amber-500", URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | 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 }[];
|
||||
};
|
||||
projectId: string;
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, projectId, canManage = false }: TaskCardProps) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const statusCfg = TASK_STATUS_CONFIG[task.status];
|
||||
const StatusIcon = statusCfg.icon;
|
||||
const latestVersion = task.versions?.[0];
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "DONE";
|
||||
|
||||
const handleStatusChange = async (newStatus: TaskStatus) => {
|
||||
try {
|
||||
await updateTaskStatus(task.id, newStatus);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({ title: "Failed to update status", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Delete this task? All versions and comments will be lost.")) return;
|
||||
try {
|
||||
await deleteTask(task.id);
|
||||
router.refresh();
|
||||
toast({ title: "Task deleted" });
|
||||
} catch {
|
||||
toast({ title: "Failed to delete task", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3 px-3 py-3 rounded-lg border border-border bg-card hover:border-border/80 transition-colors">
|
||||
{/* Priority dot */}
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full mt-2 shrink-0", PRIORITY_DOT[task.priority] ?? "bg-zinc-500")} />
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="font-medium text-sm text-white hover:text-amber-400 transition-colors truncate"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
<span className="text-xs text-zinc-500 shrink-0">{TASK_TYPE_LABELS[task.type]}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Badge className={cn("text-xs border px-1.5 py-0 h-5 gap-1", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
|
||||
{task._count && task._count.versions > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-zinc-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
v{latestVersion?.versionNumber ?? task._count.versions}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.dueDate && (
|
||||
<span className={cn("flex items-center gap-1 text-xs", isOverdue ? "text-red-400" : "text-zinc-500")}>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{isOverdue ? "Overdue · " : ""}
|
||||
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-zinc-800"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-zinc-400" />
|
||||
</Link>
|
||||
|
||||
{canManage && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-zinc-800">
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-zinc-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/tasks/${task.id}`}>Open task</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[])
|
||||
.filter((s) => s !== task.status)
|
||||
.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => handleStatusChange(s)}>
|
||||
Move to {TASK_STATUS_CONFIG[s].label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user