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 <noreply@anthropic.com>
This commit is contained in:
michael 2026-02-22 09:43:07 -06:00
parent c7175f4261
commit bcc20260de
4 changed files with 137 additions and 16 deletions

View file

@ -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,

View file

@ -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

View file

@ -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<Set<string>>(new Set());
const [campaignToDelete, setCampaignToDelete] = useState<typeof initialCampaigns[0] | null>(null);
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>('lastModified');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [columnFilters, setColumnFilters] = useState<Record<SortKey, string>>({
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<string>();
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 (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-white">
@ -397,6 +475,10 @@ const CampaignList: React.FC<{
<label htmlFor="show-completed" className="text-sm font-medium text-black-title whitespace-nowrap">Show Completed</label>
<ToggleSwitch enabled={showCompleted} onChange={setShowCompleted} />
</div>
<div className="flex items-center gap-2">
<label htmlFor="my-campaigns" className="text-sm font-medium text-black-title whitespace-nowrap">My Campaigns Only</label>
<ToggleSwitch enabled={showMyCampaignsOnly} onChange={setShowMyCampaignsOnly} />
</div>
{!readOnly && (
<button
onClick={onOpenModal}
@ -455,17 +537,49 @@ const CampaignList: React.FC<{
/>
</th>
)}
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Campaign Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Proofs</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Status</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Created By</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Owning Agency</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Last Modified</th>
{([
['name', 'Campaign Name'],
['proofs', 'Proofs'],
['status', 'Status'],
['agencyLead', 'Created By'],
['agency', 'Owning Agency'],
['lastModified', 'Last Modified'],
] as [SortKey, string][]).map(([key, label]) => (
<th
key={key}
scope="col"
className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider cursor-pointer select-none hover:text-active-blue"
onClick={() => handleSort(key)}
>
<span className="inline-flex items-center gap-1">
{label}
<span className={`text-[10px] leading-none ${sortKey === key ? 'text-active-blue' : 'text-grey-700'}`}>
{sortKey === key ? (sortDirection === 'asc' ? '\u25B2' : '\u25BC') : '\u25B4\u25BE'}
</span>
</span>
</th>
))}
{!readOnly && <th scope="col" className="relative px-4 py-3"><span className="sr-only">Actions</span></th>}
</tr>
<tr className="bg-grey-100">
{!readOnly && <td className="px-4 py-1"></td>}
{(['name', 'proofs', 'status', 'agencyLead', 'agency', 'lastModified'] as SortKey[]).map((key) => (
<td key={key} className="px-6 py-1">
<input
type="text"
value={columnFilters[key]}
onChange={(e) => handleColumnFilterChange(key, e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Filter..."
className="w-full text-xs py-1 px-2 border border-grey-300 rounded bg-white focus:outline-none focus:border-active-blue focus:ring-1 focus:ring-active-blue"
/>
</td>
))}
{!readOnly && <td className="px-4 py-1"></td>}
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
{filteredCampaigns.map((campaign, index) => {
{filteredAndSortedCampaigns.map((campaign, index) => {
const isSelected = selectedCampaigns.has(campaign.name);
return (
<tr

View file

@ -18,6 +18,7 @@ export interface CampaignResponse {
brand_guidelines: string | null;
status: string;
agency: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
proofs: number;
@ -280,6 +281,7 @@ class ApiService {
status: campaign.status as 'In Progress' | 'Completed',
lastModified: campaign.updated_at,
brandGuidelines: campaign.brand_guidelines || 'Barclays',
createdBy: campaign.created_by || null,
_id: campaign.id,
};
}