Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user