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:
Vadym Samoilenko 2026-04-29 18:28:45 +01:00
parent c7a6f13b10
commit 460c6ce091
2 changed files with 105 additions and 25 deletions

View file

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

View file

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