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
+262 -17
View File
@@ -12,6 +12,8 @@ import {
ChevronRight,
ChevronDown,
Package,
RotateCcw,
ArrowUpDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Montserrat } from 'next/font/google';
@@ -56,6 +58,21 @@ interface AssetTask extends ClientTask {
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 {
id: string;
name: string;
@@ -124,6 +141,8 @@ export default function ClientPortalPage({
const [error, setError] = useState<string | null>(null);
const [requiresPassword, setRequiresPassword] = useState(false);
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) => {
setCollapsedEpisodes((prev) => {
@@ -133,6 +152,10 @@ export default function ClientPortalPage({
});
};
const toggleStatusFilter = (filter: string) => {
setStatusFilter((prev) => (prev === filter ? null : filter));
};
useEffect(() => {
params.then(({ token: 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 (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 bg-zinc-900">
@@ -263,34 +325,207 @@ export default function ClientPortalPage({
</p>
)}
<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-xs text-zinc-400 mt-0.5">Items</p>
</div>
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
</button>
<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-xs text-zinc-400 mt-0.5">Approved</p>
</div>
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
</button>
<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-xs text-zinc-400 mt-0.5">Awaiting Review</p>
</div>
</button>
{needsChanges > 0 && (
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-red-400">
{needsChanges}
</p>
<button
onClick={() => toggleStatusFilter('NEEDS_CHANGES')}
className={cn(
'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>
</div>
</button>
)}
</div>
</div>
</div>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
{/* Controls bar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
{statusFilter && (
<span className="text-sm text-zinc-400">
Showing:{' '}
<span className="text-white font-medium">
{statusFilter === 'APPROVED'
? 'Approved'
: statusFilter === 'PENDING_REVIEW'
? 'Awaiting Review'
: 'Needs Changes'}
</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
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',
)}
>
<ArrowUpDown className="h-3 w-3" />
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
</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>
{sortMode === 'recent' ? (
<div className="space-y-3">
{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>
{statusFilter
? 'No items match the current filter.'
: 'No items have been shared for review yet.'}
</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
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 ? (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium shrink-0',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
) : (
<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>
) : (
<>
{Object.entries(shotsByEpisode).map(([episode, epShots]) => {
const filteredEpShots = epShots
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
.filter((shot) => shot.tasks.length > 0);
if (filteredEpShots.length === 0) return null;
const isCollapsed = collapsedEpisodes.has(episode);
const epTasks = epShots.flatMap((s) => s.tasks);
const epTasks = filteredEpShots.flatMap((s) => s.tasks);
const epApproved = epTasks.filter(
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
).length;
@@ -319,7 +554,8 @@ export default function ClientPortalPage({
{isEpisodic ? `Episode ${episode}` : episode}
</span>
<span className="ml-3 text-xs text-zinc-600">
{epShots.length} {epShots.length === 1 ? 'shot' : 'shots'}
{filteredEpShots.length}{' '}
{filteredEpShots.length === 1 ? 'shot' : 'shots'}
</span>
</div>
{/* Per-episode progress pills */}
@@ -348,7 +584,7 @@ export default function ClientPortalPage({
{/* Collapsible shot list */}
{!isCollapsed && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40">
{epShots.map((shot) => (
{filteredEpShots.map((shot) => (
<div key={shot.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-3">
{shot.thumbnailUrl && (
@@ -432,7 +668,7 @@ export default function ClientPortalPage({
);
})}
{assetTasks.length > 0 && (
{filteredAssetTasks.length > 0 && (
<div className="rounded-xl border border-zinc-800 overflow-hidden">
{/* Assets header */}
<button
@@ -449,13 +685,14 @@ export default function ClientPortalPage({
Assets
</span>
<span className="text-xs text-zinc-600">
{assetTasks.length} {assetTasks.length === 1 ? 'item' : 'items'}
{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">
{assetTasks.map((task) => {
{filteredAssetTasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
@@ -523,6 +760,14 @@ export default function ClientPortalPage({
<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>
<footer className="border-t border-zinc-800 py-6 text-center text-xs text-zinc-600">