Client review page updates
Deploy / deploy (push) Successful in 2m36s

This commit is contained in:
twotalesanimation
2026-06-19 11:36:42 +02:00
parent fc0e0b4716
commit 4c64842be6
+457 -212
View File
@@ -12,6 +12,8 @@ import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
Package, Package,
RotateCcw,
ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Montserrat } from 'next/font/google'; import { Montserrat } from 'next/font/google';
@@ -56,6 +58,21 @@ interface AssetTask extends ClientTask {
asset?: { id: string; assetCode: string; name: string } | null; asset?: { id: string; assetCode: string; name: string } | null;
} }
interface FlatItem {
task: ClientTask | AssetTask;
label: string;
description: string | null;
thumbnailUrl: string | null;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-AU', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
interface Project { interface Project {
id: string; id: string;
name: string; name: string;
@@ -124,6 +141,8 @@ export default function ClientPortalPage({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [requiresPassword, setRequiresPassword] = useState(false); const [requiresPassword, setRequiresPassword] = useState(false);
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set()); const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
const toggleEpisode = (ep: string) => { const toggleEpisode = (ep: string) => {
setCollapsedEpisodes((prev) => { setCollapsedEpisodes((prev) => {
@@ -133,6 +152,10 @@ export default function ClientPortalPage({
}); });
}; };
const toggleStatusFilter = (filter: string) => {
setStatusFilter((prev) => (prev === filter ? null : filter));
};
useEffect(() => { useEffect(() => {
params.then(({ token: t }) => { params.then(({ token: t }) => {
setToken(t); setToken(t);
@@ -225,6 +248,45 @@ export default function ClientPortalPage({
{}, {},
); );
const taskMatchesFilter = (t: ClientTask | AssetTask): boolean => {
if (!statusFilter) return true;
const status = getLatestVersion(t)?.approvalStatus;
if (statusFilter === 'NEEDS_CHANGES') {
return ['REJECTED', 'NEEDS_CHANGES'].includes(status ?? '');
}
if (statusFilter === 'PENDING_REVIEW') {
return !status || status === 'PENDING_REVIEW';
}
return status === statusFilter;
};
const filteredAssetTasks = assetTasks.filter(taskMatchesFilter);
const allFlatItems: FlatItem[] = [
...shots.flatMap((shot) =>
shot.tasks.map((task) => ({
task,
label: shot.shotCode,
description: shot.description,
thumbnailUrl: shot.thumbnailUrl,
})),
),
...assetTasks.map((task) => ({
task,
label: task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type] ?? task.type,
description: null,
thumbnailUrl: null,
})),
];
const filteredFlatItems = allFlatItems
.filter((item) => taskMatchesFilter(item.task))
.sort((a, b) => {
const aDate = getLatestVersion(a.task)?.createdAt ?? '';
const bDate = getLatestVersion(b.task)?.createdAt ?? '';
return bDate.localeCompare(aDate);
});
return ( return (
<div className="min-h-screen bg-zinc-950 text-white"> <div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 bg-zinc-900"> <header className="border-b border-zinc-800 bg-zinc-900">
@@ -263,208 +325,154 @@ export default function ClientPortalPage({
</p> </p>
)} )}
<div className="flex flex-wrap gap-3 mt-6"> <div className="flex flex-wrap gap-3 mt-6">
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]"> <button
onClick={() => setStatusFilter(null)}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all',
!statusFilter
? 'bg-zinc-700 ring-1 ring-zinc-500'
: 'bg-zinc-800 hover:bg-zinc-700/80',
)}
>
<p className="text-2xl font-bold text-white">{totalTasks}</p> <p className="text-2xl font-bold text-white">{totalTasks}</p>
<p className="text-xs text-zinc-400 mt-0.5">Items</p> <p className="text-xs text-zinc-400 mt-0.5">Items</p>
</div> </button>
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]"> <button
onClick={() => toggleStatusFilter('APPROVED')}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all border',
statusFilter === 'APPROVED'
? 'bg-emerald-900/50 border-emerald-700/60 ring-1 ring-emerald-600/50'
: 'bg-emerald-900/30 border-emerald-800/30 hover:bg-emerald-900/50',
)}
>
<p className="text-2xl font-bold text-emerald-400">{approved}</p> <p className="text-2xl font-bold text-emerald-400">{approved}</p>
<p className="text-xs text-zinc-400 mt-0.5">Approved</p> <p className="text-xs text-zinc-400 mt-0.5">Approved</p>
</div> </button>
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]"> <button
onClick={() => toggleStatusFilter('PENDING_REVIEW')}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all border',
statusFilter === 'PENDING_REVIEW'
? 'bg-amber-900/40 border-amber-700/50 ring-1 ring-amber-600/50'
: 'bg-amber-900/20 border-amber-800/20 hover:bg-amber-900/40',
)}
>
<p className="text-2xl font-bold text-amber-400">{pending}</p> <p className="text-2xl font-bold text-amber-400">{pending}</p>
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p> <p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
</div> </button>
{needsChanges > 0 && ( {needsChanges > 0 && (
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]"> <button
<p className="text-2xl font-bold text-red-400"> onClick={() => toggleStatusFilter('NEEDS_CHANGES')}
{needsChanges} className={cn(
</p> 'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all border',
statusFilter === 'NEEDS_CHANGES'
? 'bg-red-900/40 border-red-700/50 ring-1 ring-red-600/50'
: 'bg-red-900/20 border-red-800/20 hover:bg-red-900/40',
)}
>
<p className="text-2xl font-bold text-red-400">{needsChanges}</p>
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p> <p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
</div> </button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-4"> <main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
{Object.entries(shotsByEpisode).map(([episode, epShots]) => { {/* Controls bar */}
const isCollapsed = collapsedEpisodes.has(episode); <div className="flex items-center justify-between gap-3 flex-wrap">
const epTasks = epShots.flatMap((s) => s.tasks); <div>
const epApproved = epTasks.filter( {statusFilter && (
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED', <span className="text-sm text-zinc-400">
).length; Showing:{' '}
const epChanges = epTasks.filter((t) => <span className="text-white font-medium">
['REJECTED', 'NEEDS_CHANGES'].includes( {statusFilter === 'APPROVED'
getLatestVersion(t)?.approvalStatus ?? '', ? 'Approved'
), : statusFilter === 'PENDING_REVIEW'
).length; ? 'Awaiting Review'
const epPending = epTasks.length - epApproved - epChanges; : 'Needs Changes'}
</span>
return ( </span>
<div key={episode} className="rounded-xl border border-zinc-800 overflow-hidden"> )}
{/* Episode header — clickable */} </div>
<button <div className="flex items-center gap-2">
className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left"
onClick={() => toggleEpisode(episode)}
>
<ChevronDown
className={cn(
'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
isCollapsed && '-rotate-90',
)}
/>
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-400">
{isEpisodic ? `Episode ${episode}` : episode}
</span>
<span className="ml-3 text-xs text-zinc-600">
{epShots.length} {epShots.length === 1 ? 'shot' : 'shots'}
</span>
</div>
{/* Per-episode progress pills */}
<div className="flex items-center gap-2 shrink-0">
{epApproved > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
<CheckCircle2 className="h-3 w-3" />
{epApproved}
</span>
)}
{epChanges > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
{epChanges}
</span>
)}
{epPending > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
<Clock className="h-3 w-3" />
{epPending}
</span>
)}
</div>
</button>
{/* Collapsible shot list */}
{!isCollapsed && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40">
{epShots.map((shot) => (
<div key={shot.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-3">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<p className="font-mono text-sm font-semibold text-zinc-300">
{shot.shotCode}
{shot.description && (
<span className="font-sans font-normal text-zinc-500 ml-2">
{shot.description}
</span>
)}
</p>
</div>
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
{shot.tasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey =
ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
})}
{assetTasks.length > 0 && (
<div className="rounded-xl border border-zinc-800 overflow-hidden">
{/* Assets header */}
<button <button
className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left" onClick={() =>
onClick={() => toggleEpisode('__assets__')} setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
sortMode === 'recent'
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
> >
<ChevronDown <ArrowUpDown className="h-3 w-3" />
className={cn( {sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
collapsedEpisodes.has('__assets__') && '-rotate-90',
)}
/>
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-400 flex-1">
Assets
</span>
<span className="text-xs text-zinc-600">
{assetTasks.length} {assetTasks.length === 1 ? 'item' : 'items'}
</span>
</button> </button>
{(statusFilter !== null || sortMode !== 'episode') && (
<button
onClick={() => {
setStatusFilter(null);
setSortMode('episode');
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-zinc-700 bg-zinc-800 text-zinc-400 hover:text-zinc-300 transition-all"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
)}
</div>
</div>
{!collapsedEpisodes.has('__assets__') && ( {sortMode === 'recent' ? (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 p-4 space-y-2"> <div className="space-y-3">
{assetTasks.map((task) => { {filteredFlatItems.length === 0 ? (
const ver = getLatestVersion(task); <div className="text-center py-16 text-zinc-500">
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW'; <Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
const approval = <p>
APPROVAL_STYLES[approvalKey] ?? {statusFilter
APPROVAL_STYLES.PENDING_REVIEW; ? 'No items match the current filter.'
const ApprovalIcon = approval.Icon; : 'No items have been shared for review yet.'}
return ( </p>
</div>
) : (
filteredFlatItems.map((item) => {
const task = item.task;
const ver = getLatestVersion(task);
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ?? APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<div key={task.id} className="space-y-1">
<div className="flex items-center gap-2 px-1">
{item.thumbnailUrl && (
<div className="relative w-14 aspect-[2.39] rounded overflow-hidden border border-zinc-800 shrink-0">
<Image
src={item.thumbnailUrl}
alt={item.label}
fill
className="object-cover"
/>
</div>
)}
<p className="font-mono text-xs text-zinc-500">
{item.label}
{item.description && (
<span className="font-sans text-zinc-600 ml-2">
{item.description}
</span>
)}
</p>
{ver?.createdAt && (
<p className="ml-auto text-xs text-zinc-600">
{formatDate(ver.createdAt)}
</p>
)}
</div>
<Link <Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'} href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn( className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group', 'flex items-center gap-4 p-4 rounded-xl border transition-all group',
@@ -472,19 +480,13 @@ export default function ClientPortalPage({
!ver && 'pointer-events-none opacity-40', !ver && 'pointer-events-none opacity-40',
)} )}
> >
<Package className="h-4 w-4 text-zinc-500 shrink-0" /> <div className="flex-1 min-w-0">
<div className="min-w-[90px]"> <p className="text-sm font-medium text-white">
<p className="font-mono font-semibold text-white text-sm"> {task.title}
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
</p> </p>
<p className="text-xs text-zinc-500"> <p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type} {TASK_TYPE_LABELS[task.type] ?? task.type}
</p> </p>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">
{task.title}
</p>
{ver?.notes && ( {ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5"> <p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo; &ldquo;{ver.notes}&rdquo;
@@ -492,17 +494,15 @@ export default function ClientPortalPage({
)} )}
</div> </div>
{ver ? ( {ver ? (
<div className="flex items-center gap-3 shrink-0"> <span
<span className={cn(
className={cn( 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium shrink-0',
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium', approval.className,
approval.className, )}
)} >
> <ApprovalIcon className="h-3 w-3" />
<ApprovalIcon className="h-3 w-3" /> {approval.label}
{approval.label} </span>
</span>
</div>
) : ( ) : (
<span className="text-xs text-zinc-600 shrink-0"> <span className="text-xs text-zinc-600 shrink-0">
No versions yet No versions yet
@@ -510,18 +510,263 @@ export default function ClientPortalPage({
)} )}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" /> <ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link> </Link>
); </div>
})} );
</div> })
)} )}
</div> </div>
)} ) : (
<>
{Object.entries(shotsByEpisode).map(([episode, epShots]) => {
const filteredEpShots = epShots
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
.filter((shot) => shot.tasks.length > 0);
{totalTasks === 0 && ( if (filteredEpShots.length === 0) return null;
<div className="text-center py-16 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" /> const isCollapsed = collapsedEpisodes.has(episode);
<p>No items have been shared for review yet.</p> const epTasks = filteredEpShots.flatMap((s) => s.tasks);
</div> const epApproved = epTasks.filter(
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
).length;
const epChanges = epTasks.filter((t) =>
['REJECTED', 'NEEDS_CHANGES'].includes(
getLatestVersion(t)?.approvalStatus ?? '',
),
).length;
const epPending = epTasks.length - epApproved - epChanges;
return (
<div key={episode} className="rounded-xl border border-zinc-800 overflow-hidden">
{/* Episode header — clickable */}
<button
className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left"
onClick={() => toggleEpisode(episode)}
>
<ChevronDown
className={cn(
'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
isCollapsed && '-rotate-90',
)}
/>
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-400">
{isEpisodic ? `Episode ${episode}` : episode}
</span>
<span className="ml-3 text-xs text-zinc-600">
{filteredEpShots.length}{' '}
{filteredEpShots.length === 1 ? 'shot' : 'shots'}
</span>
</div>
{/* Per-episode progress pills */}
<div className="flex items-center gap-2 shrink-0">
{epApproved > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
<CheckCircle2 className="h-3 w-3" />
{epApproved}
</span>
)}
{epChanges > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
{epChanges}
</span>
)}
{epPending > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
<Clock className="h-3 w-3" />
{epPending}
</span>
)}
</div>
</button>
{/* Collapsible shot list */}
{!isCollapsed && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40">
{filteredEpShots.map((shot) => (
<div key={shot.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-3">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<p className="font-mono text-sm font-semibold text-zinc-300">
{shot.shotCode}
{shot.description && (
<span className="font-sans font-normal text-zinc-500 ml-2">
{shot.description}
</span>
)}
</p>
</div>
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
{shot.tasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey =
ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
})}
{filteredAssetTasks.length > 0 && (
<div className="rounded-xl border border-zinc-800 overflow-hidden">
{/* Assets header */}
<button
className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left"
onClick={() => toggleEpisode('__assets__')}
>
<ChevronDown
className={cn(
'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
collapsedEpisodes.has('__assets__') && '-rotate-90',
)}
/>
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-400 flex-1">
Assets
</span>
<span className="text-xs text-zinc-600">
{filteredAssetTasks.length}{' '}
{filteredAssetTasks.length === 1 ? 'item' : 'items'}
</span>
</button>
{!collapsedEpisodes.has('__assets__') && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 p-4 space-y-2">
{filteredAssetTasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<Package className="h-4 w-4 text-zinc-500 shrink-0" />
<div className="min-w-[90px]">
<p className="font-mono font-semibold text-white text-sm">
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">
{task.title}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
)}
</div>
)}
{totalTasks === 0 && (
<div className="text-center py-16 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No items have been shared for review yet.</p>
</div>
)}
{totalTasks > 0 && filteredFlatItems.length === 0 && (
<div className="text-center py-16 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No items match the current filter.</p>
</div>
)}
</>
)} )}
</main> </main>