feat(pr3): PM productivity — server pagination, quick filters, PM access
- JobsList: switch from size:10000 to server-side pagination (PAGE_SIZE=50) with page state and numbered pagination controls - JobsList: move status filter server-side; search/user/date remain client-side - JobsList: add PM quick-filter presets (Final Review / In QC / Failed) shown for project_manager and admin roles - JobsList: extend canManageJobs, New Job button, and Final Review action link to include project_manager role - NewJob (W-5): autofill job languages from project.default_languages when selecting an existing project from the dropdown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c7a6f13b10
commit
460c6ce091
2 changed files with 105 additions and 25 deletions
|
|
@ -96,6 +96,8 @@ export function JobsList() {
|
|||
const [userFilter, setUserFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateRangeFilter, setDateRangeFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Sort state
|
||||
const [sortColumn, setSortColumn] = useState('created_at');
|
||||
|
|
@ -116,14 +118,16 @@ export function JobsList() {
|
|||
const status = searchParams.get('status');
|
||||
if (status) {
|
||||
setStatusFilter(status);
|
||||
setPage(1);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch ALL jobs for full client-side filtering
|
||||
// Fetch jobs with server-side status filter and pagination
|
||||
const { data: jobsData, isLoading, error } = useJobs({
|
||||
mine: user?.role === 'client',
|
||||
page: 1,
|
||||
size: 10000, // Fetch all jobs
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
const bulkDeleteMutation = useBulkDeleteJobs();
|
||||
|
|
@ -147,15 +151,8 @@ export function JobsList() {
|
|||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [jobsData?.jobs]);
|
||||
|
||||
// Extract unique statuses from current jobs for filter dropdown
|
||||
const uniqueStatuses = useMemo(() => {
|
||||
if (!jobsData?.jobs) return [];
|
||||
const statuses = new Set<string>();
|
||||
jobsData.jobs.forEach((job: Job) => {
|
||||
if (job.status) statuses.add(job.status);
|
||||
});
|
||||
return Array.from(statuses).sort();
|
||||
}, [jobsData?.jobs]);
|
||||
// All possible statuses for the dropdown (static, server-side filtered)
|
||||
const uniqueStatuses = Object.keys(STATUS_LABELS);
|
||||
|
||||
// Handle column sort
|
||||
const handleSort = (column: string) => {
|
||||
|
|
@ -188,11 +185,6 @@ export function JobsList() {
|
|||
jobs = jobs.filter((job: Job) => job.client_id === userFilter);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter) {
|
||||
jobs = jobs.filter((job: Job) => job.status === statusFilter);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (dateRangeFilter) {
|
||||
const now = new Date();
|
||||
|
|
@ -230,7 +222,7 @@ export function JobsList() {
|
|||
});
|
||||
|
||||
return jobs;
|
||||
}, [jobsData?.jobs, searchTerm, userFilter, statusFilter, dateRangeFilter, sortColumn, sortDirection]);
|
||||
}, [jobsData?.jobs, searchTerm, userFilter, dateRangeFilter, sortColumn, sortDirection]);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
|
|
@ -238,6 +230,20 @@ export function JobsList() {
|
|||
setUserFilter('');
|
||||
setStatusFilter('');
|
||||
setDateRangeFilter('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// Quick preset filters for PM productivity (PM-9)
|
||||
const PM_PRESETS = [
|
||||
{ label: 'Pending Final Review', status: 'pending_final_review' },
|
||||
{ label: 'In QC', status: 'pending_qc' },
|
||||
{ label: 'TTS Failed', status: 'tts_failed' },
|
||||
{ label: 'Render Failed', status: 'render_failed' },
|
||||
];
|
||||
|
||||
const applyPreset = (status: string) => {
|
||||
setStatusFilter(prev => prev === status ? '' : status);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchTerm || userFilter || statusFilter || dateRangeFilter;
|
||||
|
|
@ -427,12 +433,13 @@ export function JobsList() {
|
|||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const isProduction = user?.role === 'production';
|
||||
const canManageJobs = isAdmin || isProduction;
|
||||
const isPM = user?.role === 'project_manager';
|
||||
const canManageJobs = isAdmin || isProduction || isPM;
|
||||
const canBulkDelete = canManageJobs && selectedJobs.size > 0;
|
||||
const canBulkReprocess = canManageJobs && selectedJobs.size > 0;
|
||||
|
||||
const getActionButton = (job: Job) => {
|
||||
const isReviewer = ['reviewer', 'production', 'admin'].includes(user?.role || '');
|
||||
const isReviewer = ['reviewer', 'production', 'admin', 'project_manager'].includes(user?.role || '');
|
||||
|
||||
switch (job.status) {
|
||||
case 'pending_qc':
|
||||
|
|
@ -505,7 +512,7 @@ export function JobsList() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{['client', 'production', 'admin'].includes(user?.role || '') && (
|
||||
{['client', 'production', 'admin', 'project_manager'].includes(user?.role || '') && (
|
||||
<Link
|
||||
to="/jobs/new"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors inline-flex items-center"
|
||||
|
|
@ -518,6 +525,25 @@ export function JobsList() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick filter presets — shown for PM and admin */}
|
||||
{(isPM || isAdmin) && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{PM_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.status}
|
||||
onClick={() => applyPreset(preset.status)}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
statusFilter === preset.status
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
|
|
@ -694,7 +720,9 @@ export function JobsList() {
|
|||
{/* Results Summary */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {filteredAndSortedJobs.length} of {jobsData?.total || 0} jobs
|
||||
{jobsData?.total
|
||||
? `Showing ${(page - 1) * PAGE_SIZE + 1}–${Math.min(page * PAGE_SIZE, jobsData.total)} of ${jobsData.total} jobs`
|
||||
: 'No jobs found'}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
|
|
@ -735,7 +763,7 @@ export function JobsList() {
|
|||
) : (
|
||||
<p>
|
||||
No jobs found.{' '}
|
||||
{['client', 'production', 'admin'].includes(user?.role || '') && (
|
||||
{['client', 'production', 'admin', 'project_manager'].includes(user?.role || '') && (
|
||||
<Link to="/jobs/new" className="text-blue-600 hover:text-blue-700">
|
||||
Upload your first video to get started!
|
||||
</Link>
|
||||
|
|
@ -836,6 +864,50 @@ export function JobsList() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{jobsData && jobsData.total > PAGE_SIZE && (
|
||||
<div className="flex justify-center items-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.ceil(jobsData.total / PAGE_SIZE) }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === Math.ceil(jobsData.total / PAGE_SIZE) || Math.abs(p - page) <= 2)
|
||||
.reduce<(number | 'ellipsis')[]>((acc, p, idx, arr) => {
|
||||
if (idx > 0 && (arr[idx - 1] as number) + 1 < p) acc.push('ellipsis');
|
||||
acc.push(p);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item, idx) =>
|
||||
item === 'ellipsis' ? (
|
||||
<span key={`e-${idx}`} className="px-2 text-gray-400">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
onClick={() => setPage(item as number)}
|
||||
className={`px-3 py-2 text-sm border rounded-md ${
|
||||
page === item
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(Math.ceil(jobsData.total / PAGE_SIZE), p + 1))}
|
||||
disabled={page >= Math.ceil(jobsData.total / PAGE_SIZE)}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
|
|
|
|||
|
|
@ -791,12 +791,20 @@ export function NewJob() {
|
|||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__create__') {
|
||||
const value = e.target.value;
|
||||
if (value === '__create__') {
|
||||
setSelectedProjectId('');
|
||||
setShowCreateProject(true);
|
||||
} else {
|
||||
setSelectedProjectId(e.target.value);
|
||||
setSelectedProjectId(value);
|
||||
setShowCreateProject(false);
|
||||
// W-5: autofill job languages from project defaults
|
||||
if (value) {
|
||||
const proj = projects.find(p => p.id === value);
|
||||
if (proj?.default_languages?.length) {
|
||||
setValue('languages', proj.default_languages);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue