Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useState, useCallback } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { useDroppable } from "@dnd-kit/core";
import { useRouter } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { KanbanCard } from "./KanbanCard";
import { TASK_STATUS_CONFIG } from "./TaskCard";
import { updateTaskStatus } from "@/actions/tasks";
import { TaskStatus, TaskType } from "@prisma/client";
import { cn } from "@/lib/utils";
const COLUMN_ORDER: TaskStatus[] = [
"TODO",
"IN_PROGRESS",
"INTERNAL_REVIEW",
"CLIENT_REVIEW",
"CHANGES",
"DONE",
];
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;
}[];
}
function KanbanColumn({
status,
tasks,
}: {
status: TaskStatus;
tasks: KanbanTask[];
}) {
const { setNodeRef, isOver } = useDroppable({ id: status });
const cfg = TASK_STATUS_CONFIG[status];
const Icon = cfg.icon;
return (
<div className="flex flex-col min-w-[240px] w-[240px] shrink-0">
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<Icon className="h-3.5 w-3.5 text-zinc-500" />
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
{cfg.label}
</span>
<span className="ml-auto text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 py-0.5 font-mono">
{tasks.length}
</span>
</div>
{/* Drop zone */}
<div
ref={setNodeRef}
className={cn(
"flex-1 flex flex-col gap-2 rounded-xl p-2 min-h-[200px] transition-colors",
isOver ? "bg-zinc-800/60 ring-1 ring-amber-500/30" : "bg-zinc-900/40"
)}
>
{tasks.map((task) => (
<KanbanCard key={task.id} task={task} />
))}
{tasks.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-zinc-700">Drop here</p>
</div>
)}
</div>
</div>
);
}
interface KanbanBoardProps {
tasks: KanbanTask[];
projectId: string;
artists?: { id: string; name: string | null; email: string }[];
}
export function KanbanBoard({ tasks: initialTasks, projectId, artists = [] }: KanbanBoardProps) {
const [tasks, setTasks] = useState<KanbanTask[]>(initialTasks);
const [draggingTask, setDraggingTask] = useState<KanbanTask | null>(null);
const [filterArtist, setFilterArtist] = useState<string>("__all__");
const { toast } = useToast();
const router = useRouter();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
const filteredTasks =
filterArtist === "__all__"
? tasks
: tasks.filter((t) => t.assignedArtist?.id === filterArtist);
const columns = COLUMN_ORDER.map((status) => ({
status,
tasks: filteredTasks.filter((t) => t.status === status),
}));
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const task = tasks.find((t) => t.id === event.active.id);
if (task) setDraggingTask(task);
},
[tasks]
);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
setDraggingTask(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const taskId = active.id as string;
const newStatus = over.id as TaskStatus;
// Validate target is a column
if (!COLUMN_ORDER.includes(newStatus)) return;
const task = tasks.find((t) => t.id === taskId);
if (!task || task.status === newStatus) return;
// Optimistic update
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
);
try {
await updateTaskStatus(taskId, newStatus);
router.refresh();
} catch {
// Revert on failure
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: task.status } : t))
);
toast({ title: "Failed to update task status", variant: "destructive" });
}
},
[tasks, router, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Artist filter */}
{artists.length > 0 && (
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-zinc-500 font-medium">Filter by artist:</span>
<Select value={filterArtist} onValueChange={setFilterArtist}>
<SelectTrigger className="w-44 h-8 text-sm">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All artists</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
{filterArtist !== "__all__" && (
<button
onClick={() => setFilterArtist("__all__")}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Clear
</button>
)}
</div>
)}
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[400px]">
{columns.map(({ status, tasks: colTasks }) => (
<KanbanColumn key={status} status={status} tasks={colTasks} />
))}
</div>
<DragOverlay dropAnimation={null}>
{draggingTask && <KanbanCard task={draggingTask} isDragOverlay />}
</DragOverlay>
</DndContext>
);
}
+167
View File
@@ -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>
);
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createTask } from "@/actions/tasks";
import { useToast } from "@/components/ui/use-toast";
import { TaskType, ShotPriority } from "@prisma/client";
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",
};
// Common shot task templates
const SHOT_TEMPLATES: TaskType[] = ["TRACK", "ROTO", "KEY", "COMP", "FX", "LIGHTING"];
// Common asset task templates
const ASSET_TEMPLATES: TaskType[] = ["MODEL", "TEXTURE", "RIG", "LOOKDEV"];
const taskSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
type: z.nativeEnum(TaskType),
priority: z.nativeEnum(ShotPriority),
dueDate: z.string().optional(),
estimatedHours: z.string().optional(),
assignedArtistId: z.string().optional(),
});
type TaskFormValues = z.infer<typeof taskSchema>;
interface Artist {
id: string;
name: string | null;
email: string;
}
interface NewTaskDialogProps {
projectId: string;
shotId?: string;
assetId?: string;
artists: Artist[];
open: boolean;
onClose: () => void;
onSuccess?: () => void;
/** If provided, pre-fills the task type and title */
prefillType?: TaskType;
}
export function NewTaskDialog({
projectId,
shotId,
assetId,
artists,
open,
onClose,
onSuccess,
prefillType,
}: NewTaskDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const router = useRouter();
const templates = shotId ? SHOT_TEMPLATES : assetId ? ASSET_TEMPLATES : [];
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<TaskFormValues>({
resolver: zodResolver(taskSchema),
defaultValues: {
type: prefillType ?? "GENERAL",
priority: "NORMAL",
title: prefillType ? TASK_TYPE_LABELS[prefillType] : "",
},
});
const selectedType = watch("type");
const applyTemplate = (type: TaskType) => {
setValue("type", type);
setValue("title", TASK_TYPE_LABELS[type]);
};
const onSubmit = async (data: TaskFormValues) => {
setIsSubmitting(true);
try {
await createTask({
...data,
projectId,
shotId: shotId ?? "",
assetId: assetId ?? "",
assignedArtistId: (data.assignedArtistId === "__none__" ? undefined : data.assignedArtistId) ?? undefined,
estimatedHours: data.estimatedHours ? Number(data.estimatedHours) : undefined,
});
toast({ title: "Task created" });
reset();
router.refresh();
onSuccess?.();
onClose();
} catch (err) {
toast({
title: "Failed to create task",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Task</DialogTitle>
</DialogHeader>
{templates.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Quick add</Label>
<div className="flex flex-wrap gap-1.5">
{templates.map((t) => (
<button
key={t}
type="button"
onClick={() => applyTemplate(t)}
className={`px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
selectedType === t
? "bg-amber-500/20 text-amber-400 border-amber-500/40"
: "bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-zinc-500"
}`}
>
+ {TASK_TYPE_LABELS[t]}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="title">Title *</Label>
<Input id="title" placeholder="Task title" {...register("title")} />
{errors.title && (
<p className="text-xs text-destructive">{errors.title.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label>Type</Label>
<Select
value={selectedType}
onValueChange={(v) => setValue("type", v as TaskType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TASK_TYPE_LABELS) as TaskType[]).map((t) => (
<SelectItem key={t} value={t}>
{TASK_TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Priority</Label>
<Select
defaultValue="NORMAL"
onValueChange={(v) => setValue("priority", v as ShotPriority)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Low</SelectItem>
<SelectItem value="NORMAL">Normal</SelectItem>
<SelectItem value="HIGH">High</SelectItem>
<SelectItem value="URGENT">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" {...register("dueDate")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="estimatedHours">Est. Hours</Label>
<Input
id="estimatedHours"
type="number"
min="0"
step="0.5"
placeholder="0"
{...register("estimatedHours")}
/>
</div>
{artists.length > 0 && (
<div className="space-y-1.5 col-span-2">
<Label>Assign Artist</Label>
<Select
defaultValue=""
onValueChange={(v) => setValue("assignedArtistId", v === "__none__" ? undefined : v)}
>
<SelectTrigger>
<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>
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="What needs to be done?"
rows={2}
{...register("description")}
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating…" : "Create Task"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+189
View File
@@ -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>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TaskCard } from "./TaskCard";
import { NewTaskDialog } from "./NewTaskDialog";
import { Plus, ListTodo } from "lucide-react";
import { TaskType } from "@prisma/client";
const SHOT_QUICK_TYPES: TaskType[] = ["TRACK", "ROTO", "KEY", "COMP", "FX"];
const ASSET_QUICK_TYPES: TaskType[] = ["MODEL", "TEXTURE", "RIG", "LOOKDEV"];
interface Artist {
id: string;
name: string | null;
email: string;
}
interface Task {
id: string;
title: string;
type: TaskType;
status: any;
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 }[];
}
interface TaskListProps {
tasks: Task[];
projectId: string;
shotId?: string;
assetId?: string;
artists: Artist[];
canManage?: boolean;
onTaskCreated?: () => void;
}
export function TaskList({ tasks, projectId, shotId, assetId, artists, canManage = false, onTaskCreated }: TaskListProps) {
const [showNew, setShowNew] = useState(false);
const [prefillType, setPrefillType] = useState<TaskType | undefined>();
const quickTypes = shotId ? SHOT_QUICK_TYPES : assetId ? ASSET_QUICK_TYPES : [];
const openQuick = (type: TaskType) => {
setPrefillType(type);
setShowNew(true);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-zinc-300">Tasks</h3>
{canManage && (
<Button size="sm" variant="ghost" className="h-7 gap-1.5 text-xs" onClick={() => { setPrefillType(undefined); setShowNew(true); }}>
<Plus className="h-3.5 w-3.5" />
Add Task
</Button>
)}
</div>
{/* Quick-add templates */}
{canManage && quickTypes.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{quickTypes.map((t) => {
const label = {
TRACK: "Track",
ROTO: "Roto",
KEY: "Key",
COMP: "Comp",
FX: "FX",
ANIMATION: "Animation",
GENERAL: "General",
LIGHTING: "Lighting",
RENDER: "Render",
MODEL: "Model",
TEXTURE: "Texture",
RIG: "Rig",
LOOKDEV: "Lookdev"
}[t] ?? t;
const alreadyExists = tasks.some((task) => task.type === t);
return (
<button
key={t}
type="button"
disabled={alreadyExists}
onClick={() => openQuick(t)}
className={`px-2 py-0.5 rounded text-[11px] font-medium border transition-colors ${
alreadyExists
? "opacity-30 cursor-not-allowed bg-zinc-900 text-zinc-500 border-zinc-800"
: "bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-amber-500/50 hover:text-amber-400"
}`}
>
+ {label}
</button>
);
})}
</div>
)}
{tasks.length === 0 ? (
<div className="text-center py-6 border border-dashed border-border rounded-lg">
<ListTodo className="h-6 w-6 mx-auto mb-2 text-zinc-600" />
<p className="text-xs text-muted-foreground">No tasks yet</p>
</div>
) : (
<div className="space-y-1">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} canManage={canManage} />
))}
</div>
)}
<NewTaskDialog
projectId={projectId}
shotId={shotId}
assetId={assetId}
artists={artists}
open={showNew}
prefillType={prefillType}
onClose={() => setShowNew(false)}
onSuccess={onTaskCreated}
/>
</div>
);
}