Files
twotalesanimation 0fbe856dce Initial commit
2026-05-19 22:20:29 +02:00

486 lines
18 KiB
TypeScript

"use client";
import { useState, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate, formatFileSize } from "@/lib/utils";
import { updateTask, updateTaskStatus } from "@/actions/tasks";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
import { formatDistanceToNow, format } from "date-fns";
import {
ArrowLeft,
CalendarDays,
Clock,
Film,
Layers,
Play,
Upload,
ChevronDown,
CheckCircle2,
Eye,
RefreshCw,
Loader2,
AlertCircle,
User,
ExternalLink,
} from "lucide-react";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { VersionUpload } from "@/components/versions/VersionUpload";
import type { CommentWithReplies } from "@/types";
import { CommentPanel } from "@/components/comments/CommentPanel";
const PRIORITY_COLORS: Record<string, string> = {
LOW: "text-zinc-400", NORMAL: "text-blue-400", HIGH: "text-amber-400", URGENT: "text-red-400",
};
const APPROVAL_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
interface Artist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
interface Version {
id: string;
versionNumber: number;
fileName: string;
fileSize: bigint | null;
fps: number;
approvalStatus: string;
isLatest: boolean;
isClientVisible: boolean;
createdAt: Date;
notes: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
_count: { comments: number };
approvals: { id: string; user: { id: string; name: string | null; role: string } }[];
comments: CommentWithReplies[];
}
interface Task {
id: string;
title: string;
description: string | null;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
estimatedHours: number | null;
projectId: string;
assignedArtistId: string | null;
assignedArtist: Artist | null;
createdBy: { id: string; name: string | null; email: string };
project: { id: string; name: string; code: string };
shot: { id: string; shotCode: string; projectId: string } | null;
asset: { id: string; assetCode: string; name: string; projectId: string } | null;
versions: Version[];
}
interface TaskDetailClientProps {
task: Task;
artists: Artist[];
currentUserId: string;
canManage: boolean;
canUpload: boolean;
}
export function TaskDetailClient({
task,
artists,
currentUserId,
canManage,
canUpload,
}: TaskDetailClientProps) {
const router = useRouter();
const { toast } = useToast();
const [showUpload, setShowUpload] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
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 parentLink = task.shot
? { label: task.shot.shotCode, href: `/projects/${task.project.id}` }
: task.asset
? { label: `${task.asset.assetCode}${task.asset.name}`, href: `/projects/${task.project.id}` }
: null;
const handleStatusChange = async (newStatus: TaskStatus) => {
setUpdatingStatus(true);
try {
await updateTaskStatus(task.id, newStatus);
router.refresh();
} catch {
toast({ title: "Failed to update status", variant: "destructive" });
} finally {
setUpdatingStatus(false);
}
};
const handleAssigneeChange = async (artistId: string) => {
try {
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
router.refresh();
toast({ title: "Assignment updated" });
} catch {
toast({ title: "Failed to update assignment", variant: "destructive" });
}
};
return (
<div className="min-h-screen bg-background">
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">Projects</Link>
<span>/</span>
<Link href={`/projects/${task.project.id}`} className="hover:text-white transition-colors">
{task.project.name}
</Link>
{parentLink && (
<>
<span>/</span>
<Link href={parentLink.href} className="hover:text-white transition-colors">
{parentLink.label}
</Link>
</>
)}
<span>/</span>
<span className="text-zinc-300">{task.title}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main column */}
<div className="lg:col-span-2 space-y-5">
{/* Task header */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold text-white">{task.title}</h1>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="font-mono">{TASK_TYPE_LABELS[task.type]}</span>
{parentLink && (
<>
<span>·</span>
<Link href={parentLink.href} className="hover:text-zinc-300">
{parentLink.label}
</Link>
</>
)}
</div>
</div>
{canUpload && (
<Button onClick={() => setShowUpload(true)} className="gap-2 shrink-0">
<Upload className="h-4 w-4" />
Upload Version
</Button>
)}
</div>
{task.description && (
<p className="text-zinc-400 text-sm leading-relaxed">{task.description}</p>
)}
</div>
{/* Version history */}
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<Layers className="h-4 w-4" />
Versions
{task.versions.length > 0 && (
<span className="text-xs text-zinc-500 font-normal">({task.versions.length})</span>
)}
</h2>
{task.versions.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-lg">
<Film className="h-8 w-8 mx-auto mb-2 text-zinc-600" />
<p className="text-sm text-muted-foreground">No versions uploaded yet</p>
{canUpload && (
<Button
variant="ghost"
size="sm"
className="mt-3 gap-2"
onClick={() => setShowUpload(true)}
>
<Upload className="h-3.5 w-3.5" />
Upload first version
</Button>
)}
</div>
) : (
<div className="space-y-2">
{task.versions.map((v) => (
<div
key={v.id}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors",
v.isLatest
? "border-amber-500/30 bg-amber-500/5"
: "border-border bg-card"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-medium text-sm text-white">
v{String(v.versionNumber).padStart(3, "0")}
</span>
{v.isLatest && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-amber-500/15 text-amber-400 border-amber-500/30">
Latest
</Badge>
)}
<Badge className={cn("text-xs border px-1.5 py-0 h-5", APPROVAL_STYLES[v.approvalStatus])}>
{v.approvalStatus.replace(/_/g, " ")}
</Badge>
{v.isClientVisible && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-blue-500/15 text-blue-400 border-blue-500/30">
Client Visible
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-zinc-500">
<span>{v.fileName}</span>
{v.fileSize != null && (
<span>{formatFileSize(Number(v.fileSize))}</span>
)}
<span>{formatRelativeDate(new Date(v.createdAt))}</span>
{v._count.comments > 0 && (
<span className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{v._count.comments} comments
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{v.artist && (
<Avatar className="h-6 w-6">
<AvatarImage src={v.artist.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{getInitials(v.artist.name ?? v.artist.email)}
</AvatarFallback>
</Avatar>
)}
<Link href={`/review/${v.id}`}>
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
<Play className="h-3 w-3" />
Review
</Button>
</Link>
</div>
</div>
))}
</div>
)}
</div>
{/* Comments on latest version */}
{latestVersion && (
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Comments
</h2>
<CommentPanel
versionId={latestVersion.id}
fps={latestVersion.fps}
comments={latestVersion.comments}
/>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Status */}
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<h3 className="text-sm font-medium text-zinc-400">Status</h3>
{canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={updatingStatus}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
statusCfg.color
)}
>
<span className="flex items-center gap-2">
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[]).map((s) => {
const cfg = TASK_STATUS_CONFIG[s];
const Icon = cfg.icon;
return (
<DropdownMenuItem
key={s}
onClick={() => handleStatusChange(s)}
className={cn(s === task.status && "font-medium")}
>
<Icon className="h-3.5 w-3.5 mr-2" />
{cfg.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-md border text-sm font-medium", statusCfg.color)}>
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</div>
)}
{/* Meta fields */}
<div className="space-y-3 pt-2 border-t border-border">
<div>
<p className="text-xs text-zinc-500 mb-1">Type</p>
<p className="text-sm font-medium">{TASK_TYPE_LABELS[task.type]}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Priority</p>
<p className={cn("text-sm font-medium capitalize", PRIORITY_COLORS[task.priority])}>
{task.priority}
</p>
</div>
{task.dueDate && (
<div>
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
<CalendarDays className="h-3.5 w-3.5" />
{format(new Date(task.dueDate), "MMM d, yyyy")}
{isOverdue && <span className="text-xs">(Overdue)</span>}
</p>
</div>
)}
{task.estimatedHours && (
<div>
<p className="text-xs text-zinc-500 mb-1">Estimated</p>
<p className="text-sm flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-zinc-400" />
{task.estimatedHours}h
</p>
</div>
)}
</div>
</div>
{/* Assignee */}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<h3 className="text-sm font-medium text-zinc-400">Assigned Artist</h3>
{canManage ? (
<Select
value={task.assignedArtistId ?? "__none__"}
onValueChange={handleAssigneeChange}
>
<SelectTrigger className="text-sm">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : task.assignedArtist ? (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-xs">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.assignedArtist.name ?? task.assignedArtist.email}</p>
<p className="text-xs text-zinc-500">{task.assignedArtist.role}</p>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">Unassigned</p>
)}
</div>
{/* Project context */}
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
<h3 className="text-sm font-medium text-zinc-400">Project</h3>
<Link
href={`/projects/${task.project.id}`}
className="flex items-center gap-1.5 text-sm text-white hover:text-amber-400 transition-colors"
>
<span className="font-mono text-zinc-500">{task.project.code}</span>
{task.project.name}
<ExternalLink className="h-3 w-3 text-zinc-500" />
</Link>
{task.shot && (
<p className="text-xs text-zinc-500">Shot: {task.shot.shotCode}</p>
)}
{task.asset && (
<p className="text-xs text-zinc-500">Asset: {task.asset.assetCode} {task.asset.name}</p>
)}
</div>
</div>
</div>
</div>
{/* Version upload dialog */}
{showUpload && (
<VersionUpload
taskId={task.id}
projectId={task.projectId}
currentVersionNumber={latestVersion?.versionNumber ?? 0}
open={showUpload}
onClose={() => setShowUpload(false)}
onSuccess={() => {
setShowUpload(false);
router.refresh();
}}
/>
)}
</div>
);
}