Initial commit
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import {
|
||||
CalendarDays,
|
||||
ListTodo,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
Clock,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
shot?: { id: string; shotCode: string } | null;
|
||||
asset?: { id: string; assetCode: string; name: string } | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
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 TasksPageClientProps {
|
||||
tasks: Task[];
|
||||
artists: Artist[];
|
||||
currentUserId: string;
|
||||
role: string;
|
||||
counts: { today: number; overdue: number; inReview: number; total: number };
|
||||
activeStatus?: string;
|
||||
activeAssignee?: string;
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ label: "All", status: undefined, icon: ListTodo, color: "text-zinc-400" },
|
||||
{ label: "Due Today", status: "today", icon: Clock, color: "text-amber-400" },
|
||||
{ label: "Overdue", status: "overdue", icon: AlertTriangle, color: "text-red-400" },
|
||||
{ label: "In Review", status: "INTERNAL_REVIEW", icon: Eye, color: "text-purple-400" },
|
||||
];
|
||||
|
||||
export function TasksPageClient({
|
||||
tasks,
|
||||
artists,
|
||||
currentUserId,
|
||||
role,
|
||||
counts,
|
||||
activeStatus,
|
||||
activeAssignee,
|
||||
}: TasksPageClientProps) {
|
||||
const router = useRouter();
|
||||
const isArtist = role === "ARTIST";
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
// Client-side filter
|
||||
const filtered = tasks.filter((task) => {
|
||||
if (activeStatus === "today") {
|
||||
return (
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) >= todayStart &&
|
||||
new Date(task.dueDate) < new Date(todayStart.getTime() + 86400000) &&
|
||||
task.status !== "DONE"
|
||||
);
|
||||
}
|
||||
if (activeStatus === "overdue") {
|
||||
return task.dueDate && new Date(task.dueDate) < todayStart && task.status !== "DONE";
|
||||
}
|
||||
if (activeStatus === "INTERNAL_REVIEW") {
|
||||
return ["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(task.status);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const navigate = (params: { status?: string; assignee?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.status) sp.set("status", params.status);
|
||||
if (params.assignee) sp.set("assignee", params.assignee);
|
||||
router.push(`/tasks?${sp.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{isArtist ? "My Tasks" : "All Tasks"}
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-400 mt-0.5">
|
||||
{counts.total} total · {counts.overdue > 0 && (
|
||||
<span className="text-red-400">{counts.overdue} overdue · </span>
|
||||
)}
|
||||
{counts.inReview} in review
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Assignee filter (non-artists) */}
|
||||
{!isArtist && artists.length > 0 && (
|
||||
<Select
|
||||
value={activeAssignee ?? "__all__"}
|
||||
onValueChange={(v) =>
|
||||
navigate({ status: activeStatus, assignee: v === "__all__" ? undefined : v })
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 border-b border-zinc-800 pb-0">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive =
|
||||
(!activeStatus && !tab.status) || activeStatus === tab.status;
|
||||
const count =
|
||||
tab.status === "today"
|
||||
? counts.today
|
||||
: tab.status === "overdue"
|
||||
? counts.overdue
|
||||
: tab.status === "INTERNAL_REVIEW"
|
||||
? counts.inReview
|
||||
: counts.total;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() =>
|
||||
navigate({ status: tab.status, assignee: activeAssignee })
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
isActive
|
||||
? "border-amber-500 text-amber-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-amber-400" : tab.color)} />
|
||||
{tab.label}
|
||||
{count > 0 && (
|
||||
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 font-mono">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-xl">
|
||||
<ListTodo className="h-8 w-8 mx-auto mb-3 text-zinc-700" />
|
||||
<p className="text-zinc-500 text-sm">No tasks found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filtered.map((task) => {
|
||||
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||
const Icon = cfg.icon;
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) < now &&
|
||||
task.status !== "DONE";
|
||||
const latestVersion = task.versions?.[0];
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={`/tasks/${task.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-transparent hover:border-zinc-800 hover:bg-zinc-900/60 transition-all group"
|
||||
>
|
||||
{/* Priority dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Title + context */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
{contextCode && (
|
||||
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
|
||||
{contextCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-zinc-500">
|
||||
<span>{TASK_TYPE_LABELS[task.type]}</span>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span>{task.project.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{latestVersion && (
|
||||
<span className="text-xs font-mono text-zinc-500">
|
||||
v{latestVersion.versionNumber}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(task._count?.versions ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
{task._count!.versions}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.dueDate && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[11px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-5 w-5">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-[10px] border px-1.5 py-0 h-5 gap-1",
|
||||
cfg.color
|
||||
)}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user