feat(tpo): added bulk edit of status
This commit is contained in:
168
apps/admin/app/(main)/jobs/[jobId]/ApplicationsTable.tsx
Normal file
168
apps/admin/app/(main)/jobs/[jobId]/ApplicationsTable.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from 'react';
|
||||||
|
import { Table, TableBody, TableHead, TableHeader, TableRow, TableCell } from '@workspace/ui/components/table';
|
||||||
|
import { Input } from '@workspace/ui/components/input';
|
||||||
|
import { Button } from '@workspace/ui/components/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectValue,
|
||||||
|
} from '@workspace/ui/components/select';
|
||||||
|
import StatusSelect from './StatusSelect';
|
||||||
|
|
||||||
|
type Applicant = {
|
||||||
|
applicationId: number;
|
||||||
|
status: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
studentId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
'in review',
|
||||||
|
'Online Assessment',
|
||||||
|
'Interview round',
|
||||||
|
'offer given',
|
||||||
|
'accepted',
|
||||||
|
'rejected',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ApplicationsTable({ applicants }: { applicants: Applicant[] }) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selected, setSelected] = useState<number[]>([]);
|
||||||
|
const [bulkStatus, setBulkStatus] = useState<string>('');
|
||||||
|
const [rows, setRows] = useState<Applicant[]>(applicants);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query.trim()) return rows;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return rows.filter((a) => {
|
||||||
|
const name = `${a.firstName ?? ''} ${a.lastName ?? ''}`.toLowerCase();
|
||||||
|
const email = (a.email ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || email.includes(q);
|
||||||
|
});
|
||||||
|
}, [rows, query]);
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
setSelected(checked ? filtered.map((a) => a.applicationId) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOne = (id: number, checked: boolean) => {
|
||||||
|
setSelected((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((x) => x !== id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBulkUpdate = () => {
|
||||||
|
if (!bulkStatus || selected.length === 0) return;
|
||||||
|
const targets = new Set(selected);
|
||||||
|
startTransition(async () => {
|
||||||
|
const updates = rows.filter((r) => targets.has(r.applicationId));
|
||||||
|
for (const r of updates) {
|
||||||
|
await fetch(`/api/applications/${r.applicationId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: bulkStatus, studentId: r.studentId ?? 0 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (targets.has(r.applicationId) ? { ...r, status: bulkStatus } : r)),
|
||||||
|
);
|
||||||
|
setSelected([]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = filtered.length > 0 && selected.length === filtered.length;
|
||||||
|
const someSelected = selected.length > 0 && selected.length < filtered.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or email"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="w-72"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">{filtered.length} results</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
||||||
|
<SelectTrigger className="min-w-[200px]">
|
||||||
|
<SelectValue placeholder="Bulk update status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={onBulkUpdate} disabled={!bulkStatus || selected.length === 0 || isPending}>
|
||||||
|
Update {selected.length || ''}
|
||||||
|
</Button>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<Button variant="outline" onClick={() => setSelected([])} disabled={isPending}>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-gray-50">
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Select all"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = someSelected;
|
||||||
|
}}
|
||||||
|
onChange={(e) => toggleAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="font-medium text-gray-700">Name</TableHead>
|
||||||
|
<TableHead className="font-medium text-gray-700">Email</TableHead>
|
||||||
|
<TableHead className="font-medium text-gray-700">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((applicant) => (
|
||||||
|
<TableRow key={applicant.applicationId} className="hover:bg-gray-50">
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select ${applicant.email ?? ''}`}
|
||||||
|
checked={selected.includes(applicant.applicationId)}
|
||||||
|
onChange={(e) => toggleOne(applicant.applicationId, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{`${applicant.firstName ?? ''} ${applicant.lastName ?? ''}`.trim() || 'Unknown'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-600">{applicant.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusSelect
|
||||||
|
applicationId={applicant.applicationId}
|
||||||
|
initialStatus={applicant.status}
|
||||||
|
studentId={applicant.studentId ?? 0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
@@ -32,6 +32,11 @@ export default function StatusSelect({
|
|||||||
const [status, setStatus] = useState(initialStatus);
|
const [status, setStatus] = useState(initialStatus);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Sync local state when parent updates the initialStatus (e.g., after bulk update)
|
||||||
|
useEffect(() => {
|
||||||
|
setStatus(initialStatus);
|
||||||
|
}, [initialStatus]);
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
setStatus(value); // Optimistic update
|
setStatus(value); // Optimistic update
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import StatusSelect from './StatusSelect';
|
import StatusSelect from './StatusSelect';
|
||||||
|
import ApplicationsTable from './ApplicationsTable';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -321,35 +322,7 @@ export default async function JobDetailPage({ params }: { params: Promise<{ jobI
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-gray-200 overflow-hidden">
|
<ApplicationsTable applicants={applicants} />
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-gray-50">
|
|
||||||
<TableHead className="font-medium text-gray-700">Name</TableHead>
|
|
||||||
<TableHead className="font-medium text-gray-700">Email</TableHead>
|
|
||||||
<TableHead className="font-medium text-gray-700">Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{applicants.map((applicant) => (
|
|
||||||
<TableRow key={applicant.applicationId} className="hover:bg-gray-50">
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{`${applicant.firstName ?? ''} ${applicant.lastName ?? ''}`.trim() ||
|
|
||||||
'Unknown'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-gray-600">{applicant.email}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusSelect
|
|
||||||
applicationId={applicant.applicationId}
|
|
||||||
initialStatus={applicant.status}
|
|
||||||
studentId={applicant.studentId ?? 0}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user