Files
vfxreview/app/(dashboard)/tasks/TasksPageClient.tsx
T
twotalesanimation 40b99f3add
Deploy / deploy (push) Successful in 3m19s
feat(ui): usable on phones without changing the desktop layout
At phone width the sidebar stayed 240px wide, leaving pages a ~135px strip.
Below md it is now hidden behind a menu button in the header and opens as a
drawer (closes on navigation, backdrop, X or Escape); from md up the sidebar
is unchanged. Page wrappers use p-4 on phones and keep p-6/p-8 from md.

Shot detail: on phones the thumbnail takes the full width with the details
below it instead of pushing them off screen, the action buttons wrap, and
the tab bar scrolls sideways. Desktop sizing is kept as it was.

Every change is behind a md: breakpoint, so the desktop layout is the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:56:38 +02:00

296 lines
10 KiB
TypeScript

"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-4 md: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>
);
}