+457
-212
@@ -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">
|
|
||||||
“{ver.notes}”
|
|
||||||
</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">
|
||||||
“{ver.notes}”
|
“{ver.notes}”
|
||||||
@@ -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">
|
||||||
|
“{ver.notes}”
|
||||||
|
</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">
|
||||||
|
“{ver.notes}”
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user