From bcc20260de1e6ff84c7f7bd2235faa0447bcd4b7 Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 22 Feb 2026 09:43:07 -0600 Subject: [PATCH] Add sortable, filterable campaign list with "My Campaigns Only" toggle - Backend: Expose created_by field on CampaignResponse schema and all response constructors in routes.py - Frontend API layer: Add created_by to CampaignResponse interface and createdBy to the frontend campaign converter - Campaign list: Add column sorting (click headers to toggle asc/desc), per-column text filter inputs below headers, and a "My Campaigns Only" toggle that filters to campaigns created by the current user - Default sort is lastModified descending to match existing behavior Co-Authored-By: Claude Opus 4.6 --- backend/app/api/routes.py | 4 + backend/app/api/schemas.py | 1 + frontend/components/Campaigns.tsx | 146 ++++++++++++++++++++++++++---- frontend/services/apiService.ts | 2 + 4 files changed, 137 insertions(+), 16 deletions(-) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 2d9d982..6881747 100755 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -132,6 +132,7 @@ async def list_campaigns( brand_guidelines=item["campaign"].brand_guidelines, status=item["campaign"].status, agency=item["campaign"].agency.name if item["campaign"].agency else None, + created_by=item["campaign"].created_by, created_at=item["campaign"].created_at, updated_at=item["campaign"].updated_at, proofs=item["proof_count"], @@ -175,6 +176,7 @@ async def create_campaign( brand_guidelines=campaign.brand_guidelines, status=campaign.status, agency=current_user.agency.name if current_user.agency else None, + created_by=campaign.created_by, created_at=campaign.created_at, updated_at=campaign.updated_at, proofs=0, @@ -204,6 +206,7 @@ async def get_campaign( brand_guidelines=campaign.brand_guidelines, status=campaign.status, agency=campaign.agency.name if campaign.agency else None, + created_by=campaign.created_by, created_at=campaign.created_at, updated_at=campaign.updated_at, proofs=len(campaign.proofs), @@ -247,6 +250,7 @@ async def update_campaign( brand_guidelines=campaign.brand_guidelines, status=campaign.status, agency=campaign.agency.name if campaign.agency else None, + created_by=campaign.created_by, created_at=campaign.created_at, updated_at=campaign.updated_at, proofs=len(campaign.proofs) if campaign.proofs else 0, diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 379b0cd..5d52793 100755 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -33,6 +33,7 @@ class CampaignResponse(BaseModel): brand_guidelines: Optional[str] status: str agency: Optional[str] + created_by: Optional[uuid.UUID] created_at: datetime updated_at: datetime proofs: int = 0 diff --git a/frontend/components/Campaigns.tsx b/frontend/components/Campaigns.tsx index f8ee220..8121db1 100755 --- a/frontend/components/Campaigns.tsx +++ b/frontend/components/Campaigns.tsx @@ -1,5 +1,5 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import ReactDOM from 'react-dom/client'; import { PlusIcon } from './icons/PlusIcon'; import { ArrowLeftIcon } from './icons/ArrowLeftIcon'; @@ -26,6 +26,10 @@ import { ExportIcon } from './icons/ExportIcon'; import { XIcon } from './icons/XIcon'; import apiService from '../services/apiService'; import { usePdfPages } from '../hooks/usePdfPages'; +import { useUser } from '../contexts/UserContext'; + +type SortKey = 'name' | 'proofs' | 'status' | 'agencyLead' | 'agency' | 'lastModified'; +type SortDirection = 'asc' | 'desc'; const formatDate = (isoDateString: string): string => { const date = new Date(isoDateString); @@ -47,6 +51,7 @@ export const initialCampaigns = [ status: 'In Progress', lastModified: '2024-07-22', brandGuidelines: 'Barclays', + createdBy: null as string | null, }, { name: 'Barclays Q3 Roundup', @@ -58,6 +63,7 @@ export const initialCampaigns = [ status: 'Completed', lastModified: '2024-06-30', brandGuidelines: 'Barclays', + createdBy: null as string | null, }, ]; @@ -277,18 +283,90 @@ const CampaignList: React.FC<{ onDeleteCampaign: (campaign: typeof initialCampaigns[0]) => void; readOnly?: boolean; }> = ({ onSelectCampaign, campaigns, onOpenModal, onCampaignStatusChange, onDeleteCampaign, readOnly = false }) => { + const { user } = useUser(); const [showCompleted, setShowCompleted] = useState(true); + const [showMyCampaignsOnly, setShowMyCampaignsOnly] = useState(false); const [selectedCampaigns, setSelectedCampaigns] = useState>(new Set()); const [campaignToDelete, setCampaignToDelete] = useState(null); const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [sortKey, setSortKey] = useState('lastModified'); + const [sortDirection, setSortDirection] = useState('desc'); + const [columnFilters, setColumnFilters] = useState>({ + name: '', + proofs: '', + status: '', + agencyLead: '', + agency: '', + lastModified: '', + }); - const filteredCampaigns = showCompleted ? campaigns : campaigns.filter(p => p.status !== 'Completed'); + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('asc'); + } + }; + + const handleColumnFilterChange = (key: SortKey, value: string) => { + setColumnFilters(prev => ({ ...prev, [key]: value })); + }; + + const filteredAndSortedCampaigns = useMemo(() => { + let result = [...campaigns]; + + // Show completed filter + if (!showCompleted) { + result = result.filter(c => c.status !== 'Completed'); + } + + // My campaigns only filter + if (showMyCampaignsOnly && user?.id) { + result = result.filter(c => c.createdBy === user.id); + } + + // Per-column text filters + const filterEntries = Object.entries(columnFilters) as [SortKey, string][]; + for (const [key, filterValue] of filterEntries) { + if (!filterValue.trim()) continue; + const lowerFilter = filterValue.toLowerCase(); + result = result.filter(c => { + let cellValue: string; + if (key === 'proofs') { + cellValue = String(c.proofs); + } else if (key === 'lastModified') { + cellValue = formatDate(c.lastModified); + } else { + cellValue = String(c[key] || ''); + } + return cellValue.toLowerCase().includes(lowerFilter); + }); + } + + // Sort + result.sort((a, b) => { + let comparison = 0; + if (sortKey === 'proofs') { + comparison = a.proofs - b.proofs; + } else if (sortKey === 'lastModified') { + comparison = new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime(); + } else { + const aVal = String(a[sortKey] || '').toLowerCase(); + const bVal = String(b[sortKey] || '').toLowerCase(); + comparison = aVal.localeCompare(bVal); + } + return sortDirection === 'asc' ? comparison : -comparison; + }); + + return result; + }, [campaigns, showCompleted, showMyCampaignsOnly, user?.id, columnFilters, sortKey, sortDirection]); // Clear selection when campaigns list changes (e.g., after deletion) useEffect(() => { setSelectedCampaigns(prev => { - const currentNames = new Set(filteredCampaigns.map(c => c.name)); + const currentNames = new Set(filteredAndSortedCampaigns.map(c => c.name)); const newSelection = new Set(); prev.forEach(name => { if (currentNames.has(name)) { @@ -297,7 +375,7 @@ const CampaignList: React.FC<{ }); return newSelection; }); - }, [filteredCampaigns]); + }, [filteredAndSortedCampaigns]); const getStatusSelectClasses = (status: string) => { let baseClasses = 'w-full text-center px-2.5 py-0.5 text-xs font-semibold rounded-full border border-transparent focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none cursor-pointer'; @@ -316,10 +394,10 @@ const CampaignList: React.FC<{ }; const handleSelectAll = () => { - if (selectedCampaigns.size === filteredCampaigns.length) { + if (selectedCampaigns.size === filteredAndSortedCampaigns.length) { setSelectedCampaigns(new Set()); } else { - setSelectedCampaigns(new Set(filteredCampaigns.map(c => c.name))); + setSelectedCampaigns(new Set(filteredAndSortedCampaigns.map(c => c.name))); } }; @@ -352,7 +430,7 @@ const CampaignList: React.FC<{ const handleConfirmBulkDelete = async () => { setIsDeleting(true); - const campaignsToDelete = filteredCampaigns.filter(c => selectedCampaigns.has(c.name)); + const campaignsToDelete = filteredAndSortedCampaigns.filter(c => selectedCampaigns.has(c.name)); for (const campaign of campaignsToDelete) { await onDeleteCampaign(campaign); } @@ -361,8 +439,8 @@ const CampaignList: React.FC<{ setIsDeleting(false); }; - const isAllSelected = filteredCampaigns.length > 0 && selectedCampaigns.size === filteredCampaigns.length; - const isIndeterminate = selectedCampaigns.size > 0 && selectedCampaigns.size < filteredCampaigns.length; + const isAllSelected = filteredAndSortedCampaigns.length > 0 && selectedCampaigns.size === filteredAndSortedCampaigns.length; + const isIndeterminate = selectedCampaigns.size > 0 && selectedCampaigns.size < filteredAndSortedCampaigns.length; return (
@@ -397,6 +475,10 @@ const CampaignList: React.FC<{
+
+ + +
{!readOnly && (