40b99f3add
Deploy / deploy (push) Successful in 3m19s
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>
296 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|