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:
parent
c7175f4261
commit
bcc20260de
4 changed files with 137 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue