diff --git a/app/client/[token]/page.tsx b/app/client/[token]/page.tsx index b799808..89309f5 100644 --- a/app/client/[token]/page.tsx +++ b/app/client/[token]/page.tsx @@ -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(null); const [requiresPassword, setRequiresPassword] = useState(false); const [collapsedEpisodes, setCollapsedEpisodes] = useState>(new Set()); + const [statusFilter, setStatusFilter] = useState(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 (
@@ -263,208 +325,154 @@ export default function ClientPortalPage({

)}
-
+
-
+ +
-
+ +
+ {needsChanges > 0 && ( -
-

- {needsChanges} -

+
+ )}
- {Object.entries(shotsByEpisode).map(([episode, epShots]) => { - const isCollapsed = collapsedEpisodes.has(episode); - const epTasks = epShots.flatMap((s) => s.tasks); - 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 ( -
- {/* Episode header — clickable */} - - - {/* Collapsible shot list */} - {!isCollapsed && ( -
- {epShots.map((shot) => ( -
-
- {shot.thumbnailUrl && ( -
- {shot.shotCode} -
- )} -

- {shot.shotCode} - {shot.description && ( - - {shot.description} - - )} -

-
-
- {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 ( - -
-

- {task.title} -

-

- {TASK_TYPE_LABELS[task.type] ?? task.type} -

- {ver?.notes && ( -

- “{ver.notes}” -

- )} -
- {ver ? ( -
- - - {approval.label} - -
- ) : ( - - No versions yet - - )} - - - ); - })} -
-
- ))} -
- )} -
- ); - })} - - {assetTasks.length > 0 && ( -
- {/* Assets header */} + {/* Controls bar */} +
+
+ {statusFilter && ( + + Showing:{' '} + + {statusFilter === 'APPROVED' + ? 'Approved' + : statusFilter === 'PENDING_REVIEW' + ? 'Awaiting Review' + : 'Needs Changes'} + + + )} +
+
+ {(statusFilter !== null || sortMode !== 'episode') && ( + + )} +
+
- {!collapsedEpisodes.has('__assets__') && ( -
- {assetTasks.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 ( + {sortMode === 'recent' ? ( +
+ {filteredFlatItems.length === 0 ? ( +
+ +

+ {statusFilter + ? 'No items match the current filter.' + : 'No items have been shared for review yet.'} +

+
+ ) : ( + 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 ( +
+
+ {item.thumbnailUrl && ( +
+ {item.label} +
+ )} +

+ {item.label} + {item.description && ( + + {item.description} + + )} +

+ {ver?.createdAt && ( +

+ {formatDate(ver.createdAt)} +

+ )} +
- -
-

- {task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]} +

+

+ {task.title}

{TASK_TYPE_LABELS[task.type] ?? task.type}

-
-
-

- {task.title} -

{ver?.notes && (

“{ver.notes}” @@ -492,17 +494,15 @@ export default function ClientPortalPage({ )}

{ver ? ( -
- - - {approval.label} - -
+ + + {approval.label} + ) : ( No versions yet @@ -510,18 +510,263 @@ export default function ClientPortalPage({ )} - ); - })} -
+
+ ); + }) )}
- )} + ) : ( + <> + {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 && ( -
- -

No items have been shared for review yet.

-
+ if (filteredEpShots.length === 0) return null; + + const isCollapsed = collapsedEpisodes.has(episode); + const epTasks = filteredEpShots.flatMap((s) => s.tasks); + 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 ( +
+ {/* Episode header — clickable */} + + + {/* Collapsible shot list */} + {!isCollapsed && ( +
+ {filteredEpShots.map((shot) => ( +
+
+ {shot.thumbnailUrl && ( +
+ {shot.shotCode} +
+ )} +

+ {shot.shotCode} + {shot.description && ( + + {shot.description} + + )} +

+
+
+ {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 ( + +
+

+ {task.title} +

+

+ {TASK_TYPE_LABELS[task.type] ?? task.type} +

+ {ver?.notes && ( +

+ “{ver.notes}” +

+ )} +
+ {ver ? ( +
+ + + {approval.label} + +
+ ) : ( + + No versions yet + + )} + + + ); + })} +
+
+ ))} +
+ )} +
+ ); + })} + + {filteredAssetTasks.length > 0 && ( +
+ {/* Assets header */} + + + {!collapsedEpisodes.has('__assets__') && ( +
+ {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 ( + + +
+

+ {task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]} +

+

+ {TASK_TYPE_LABELS[task.type] ?? task.type} +

+
+
+

+ {task.title} +

+ {ver?.notes && ( +

+ “{ver.notes}” +

+ )} +
+ {ver ? ( +
+ + + {approval.label} + +
+ ) : ( + + No versions yet + + )} + + + ); + })} +
+ )} +
+ )} + + {totalTasks === 0 && ( +
+ +

No items have been shared for review yet.

+
+ )} + {totalTasks > 0 && filteredFlatItems.length === 0 && ( +
+ +

No items match the current filter.

+
+ )} + )}