Files
sciagent/frontend_investigator/src/features/project-workflow/presentation/DataPage.tsx
T
Thinh Lam 688fac73e9
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped
sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:30 +07:00

539 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ClipboardCheck,
Download,
LayoutDashboard,
ListChecks,
Play,
RefreshCw,
Search,
Settings,
Sparkles,
Upload,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
Card,
CardContent,
Checkbox,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
cn,
detailFromApiError,
useAuth,
getDataset,
listDatasetStages,
listMembers,
listTasks,
generateTasks,
assignTask,
VISIBILITY_LABEL,
type DatasetTask,
} from '@ump/shared';
import { UploadDataDialog } from '../../data-import/presentation/components/UploadDataDialog';
import {
SCOPE_OPTIONS,
GROUP_OPTIONS,
matchesScope,
matchesAssignee,
searchTasks,
groupTasks,
paginate,
type TaskScope,
type GroupBy,
type AssigneeFilter,
} from '../domain/taskView';
import { ProductivitySidebar } from './ProductivitySidebar';
import { TaskRow } from './TaskRow';
const PAGE_SIZES = [10, 20, 50, 100];
/** Build a CSV of the (filtered) task list and trigger a download. */
function downloadCsv(filename: string, tasks: DatasetTask[]) {
const header = ['Datapoint', 'Giai đoạn', 'Task Id', 'Phụ trách', 'Trạng thái'];
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
const lines = tasks.map((t) =>
[t.name || t.fileLogicalPath, t.currentStageName ?? '', t.id, t.assigneeName ?? '', t.pipelineState]
.map((c) => esc(String(c)))
.join(','),
);
const csv = [header.map(esc).join(','), ...lines].join('\n');
const url = URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url;
a.download = `${filename || 'cong-viec'}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** The Data Page — a project's task list (project-workflow §12.3) in the RedBrick-style layout. */
export function DataPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { user } = useAuth();
const userId = user?.id ?? null;
const [scope, setScope] = useState<TaskScope>('all');
const [groupBy, setGroupBy] = useState<GroupBy>('none');
const [assigneeFilter, setAssigneeFilter] = useState<AssigneeFilter>('all');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [uploadOpen, setUploadOpen] = useState(false);
const dsQ = useQuery({ queryKey: ['imagehub', 'dataset', id], queryFn: () => getDataset(id), enabled: !!id });
const stagesQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'stages'],
queryFn: () => listDatasetStages(id),
enabled: !!id,
});
const membersQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'members'],
queryFn: () => listMembers(id),
enabled: !!id,
});
// The whole task array — all filtering / grouping / paging is derived on the client.
const tasksQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'tasks'],
queryFn: () => listTasks(id),
enabled: !!id,
});
const ds = dsQ.data;
const stages = useMemo(() => stagesQ.data ?? [], [stagesQ.data]);
const members = useMemo(() => membersQ.data ?? [], [membersQ.data]);
const tasks = useMemo(() => tasksQ.data ?? [], [tasksQ.data]);
const noStages = stagesQ.isSuccess && stages.length === 0;
const generateMut = useMutation({
mutationFn: () => generateTasks(id),
onSuccess: (r) => {
toast.success(r.created > 0 ? `Đã tạo ${r.created} công việc` : 'Tất cả tệp đã có công việc');
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id, 'tasks'] });
},
onError: (e: unknown) => toast.error(detailFromApiError(e, 'Không tạo được công việc')),
});
const bulkAssignMut = useMutation({
mutationFn: (assignee: string) => Promise.all([...selected].map((tid) => assignTask(id, tid, assignee))),
onSuccess: (_r, assignee) => {
const who = members.find((m) => m.userId === assignee);
toast.success(`Đã giao ${selected.size} công việc cho ${who?.fullName || who?.email || 'thành viên'}`);
setSelected(new Set());
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id, 'tasks'] });
},
onError: (e: unknown) => toast.error(detailFromApiError(e, 'Giao việc hàng loạt thất bại')),
});
// Region 2/3 — scope → assignee → search, then region 7 paginate the flat list, then group the page.
const filtered = useMemo(() => {
const scoped = tasks.filter((t) => matchesScope(t, scope, userId) && matchesAssignee(t, assigneeFilter));
return searchTasks(scoped, query);
}, [tasks, scope, assigneeFilter, query, userId]);
const pageData = useMemo(() => paginate(filtered, page, pageSize), [filtered, page, pageSize]);
const groups = useMemo(() => groupTasks(pageData.items, groupBy), [pageData.items, groupBy]);
// Reset to page 1 whenever the result set changes shape.
useEffect(() => setPage(1), [scope, assigneeFilter, query, pageSize]);
const pageIds = pageData.items.map((t) => t.id);
const allSelected = pageIds.length > 0 && pageIds.every((i) => selected.has(i));
const someSelected = pageIds.some((i) => selected.has(i)) && !allSelected;
const toggleAll = (checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
pageIds.forEach((i) => (checked ? next.add(i) : next.delete(i)));
return next;
});
const toggleOne = (taskId: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(taskId);
else next.delete(taskId);
return next;
});
const openFirst = (predicate: (t: DatasetTask) => boolean, emptyMsg: string) => {
const t = tasks.find(predicate);
if (!t) return toast.info(emptyMsg);
navigate(`/dashboard/datasets/${id}/tasks/${t.id}`);
};
const startLabeling = () =>
openFirst(
(t) => t.pipelineState === 'inLabel' && (t.assigneeUserId === userId || !t.assigneeUserId),
'Không có công việc cần gán nhãn.',
);
const startReview = () =>
openFirst(
(t) => t.pipelineState === 'inReview' && (t.assigneeUserId === userId || !t.assigneeUserId),
'Không có công việc cần rà soát.',
);
const colSpan = 8;
return (
<TooltipProvider delayDuration={200}>
<div className="mx-auto max-w-[1400px] space-y-5">
<Button variant="ghost" size="sm" onClick={() => navigate(`/dashboard/datasets/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> {ds?.name ?? 'Bộ dữ liệu'}
</Button>
{/* Header: title + tab nav (left) · toolbar (right) — region 4 */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h1 className="font-serif text-2xl font-semibold text-foreground">{ds?.name ?? 'Công việc'}</h1>
{ds && <Badge variant="outline">{VISIBILITY_LABEL[ds.visibility]}</Badge>}
</div>
<nav className="flex items-center gap-1 text-sm">
{[
{ label: 'Tổng quan', to: `/dashboard/datasets/${id}`, active: false },
{ label: 'Dữ liệu', to: `/dashboard/datasets/${id}/tasks`, active: true },
{ label: 'Cài đặt', to: `/dashboard/datasets/${id}/settings`, active: false },
].map((t) => (
<button
key={t.label}
type="button"
onClick={() => !t.active && navigate(t.to)}
className={cn(
'rounded-md px-3 py-1 font-medium transition-colors',
t.active
? 'bg-foreground text-background'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{t.label}
</button>
))}
</nav>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full border border-border px-1">
{[
{ icon: RefreshCw, label: 'Tải lại', onClick: () => tasksQ.refetch(), spin: tasksQ.isFetching },
{
icon: LayoutDashboard,
label: 'Tổng quan',
onClick: () => navigate(`/dashboard/datasets/${id}`),
},
{
icon: Settings,
label: 'Cài đặt',
onClick: () => navigate(`/dashboard/datasets/${id}/settings`),
},
].map((b) => (
<Tooltip key={b.label}>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" onClick={b.onClick}>
<b.icon className={cn('h-4 w-4', b.spin && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>{b.label}</TooltipContent>
</Tooltip>
))}
</div>
<Button variant="outline" onClick={() => setUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" /> Tải dữ liệu
</Button>
<Button variant="outline" onClick={startReview}>
<ClipboardCheck className="mr-2 h-4 w-4" /> soát
</Button>
<Button onClick={startLabeling}>
<Play className="mr-2 h-4 w-4" /> Bắt đầu gán nhãn
</Button>
</div>
</div>
{dsQ.isError && <p className="text-sm text-destructive">Không tải được bộ dữ liệu.</p>}
<div className="flex flex-col gap-5 lg:flex-row">
<ProductivitySidebar
datasetId={id}
tasks={tasks}
members={members}
currentUser={user ? { id: user.id, name: user.name || user.email, role: 'Tôi' } : null}
/>
<div className="min-w-0 flex-1 space-y-4">
{noStages ? (
<Card>
<CardContent className="space-y-3 py-12 text-center">
<ListChecks className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mx-auto max-w-md text-sm text-muted-foreground">
Bộ dữ liệu chưa giai đoạn quy trình. Hãy thêm giai đoạn gán nhãn / kiểm duyệt trong Cài đặt
trước khi tạo công việc.
</p>
<Button onClick={() => navigate(`/dashboard/datasets/${id}/settings`)}>
<Settings className="mr-2 h-4 w-4" /> Mở Cài đặt
</Button>
</CardContent>
</Card>
) : (
<>
{/* Filter bar (region 2) + search (region 3) */}
<div className="flex flex-wrap items-center gap-2">
<Select value={scope} onValueChange={(v) => setScope(v as TaskScope)}>
<SelectTrigger className="h-9 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as GroupBy)}>
<SelectTrigger className="h-9 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GROUP_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={assigneeFilter} onValueChange={(v) => setAssigneeFilter(v)}>
<SelectTrigger className="h-9 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả người dùng</SelectItem>
<SelectItem value="unassigned">Chưa giao</SelectItem>
{members.map((m) => (
<SelectItem key={m.userId} value={m.userId}>
{m.fullName || m.email}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative ml-auto">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Tìm theo Task Id hoặc tên"
className="h-9 w-64 pl-8"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
disabled={filtered.length === 0}
onClick={() => downloadCsv(ds?.name ?? 'cong-viec', filtered)}
>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Xuất CSV</TooltipContent>
</Tooltip>
<Button onClick={() => generateMut.mutate()} disabled={generateMut.isPending}>
<Sparkles className="mr-2 h-4 w-4" />
{generateMut.isPending ? 'Đang tạo…' : 'Tạo công việc'}
</Button>
</div>
{/* Bulk-selection bar (region 6) */}
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium text-foreground">Đã chọn {selected.size}</span>
<Select onValueChange={(v) => bulkAssignMut.mutate(v)} disabled={bulkAssignMut.isPending}>
<SelectTrigger className="h-8 w-48">
<SelectValue placeholder="Giao cho…" />
</SelectTrigger>
<SelectContent>
{members.map((m) => (
<SelectItem key={m.userId} value={m.userId}>
{m.fullName || m.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8" onClick={() => setSelected(new Set())}>
<X className="mr-1 h-4 w-4" /> Bỏ chọn
</Button>
</div>
)}
{tasksQ.isLoading ? (
<p className="text-sm text-muted-foreground">Đang tải</p>
) : filtered.length === 0 ? (
<Card>
<CardContent className="space-y-3 py-12 text-center">
<ListChecks className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{tasks.length === 0
? 'Chưa có công việc nào. Nhấn “Tạo công việc” để tạo từ các tệp ảnh trong bộ dữ liệu.'
: 'Không có công việc nào khớp bộ lọc.'}
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Chọn tất cả trên trang"
/>
</TableHead>
<TableHead>Datapoint</TableHead>
<TableHead>Giai đoạn</TableHead>
<TableHead className="w-16">Tệp</TableHead>
<TableHead>Task Id</TableHead>
<TableHead>Phụ trách</TableHead>
<TableHead className="w-16">Điểm</TableHead>
<TableHead className="text-right">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((g) => (
<GroupRows
key={g.key}
label={groupBy === 'none' ? null : `${g.label} (${g.tasks.length})`}
colSpan={colSpan}
>
{g.tasks.map((t) => (
<TaskRow
key={t.id}
datasetId={id}
task={t}
members={members}
stages={stages}
selected={selected.has(t.id)}
onSelectedChange={(c) => toggleOne(t.id, c)}
/>
))}
</GroupRows>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Pagination footer (region 7) */}
{filtered.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={pageData.page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span>
Trang {pageData.page} / {pageData.pageCount}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={pageData.page >= pageData.pageCount}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="ml-2">
Hiển thị
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
<SelectTrigger className="mx-2 inline-flex h-8 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
/ trang
</span>
</div>
<span>Tổng {filtered.length} mục</span>
</div>
)}
</>
)}
</div>
</div>
</div>
{uploadOpen && (
<UploadDataDialog
open
onOpenChange={setUploadOpen}
datasetId={id}
onCompleted={() => qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id] })}
/>
)}
</TooltipProvider>
);
}
/** Renders an optional group-header row followed by its task rows (region 2 grouping). */
function GroupRows({
label,
colSpan,
children,
}: {
label: string | null;
colSpan: number;
children: React.ReactNode;
}) {
return (
<>
{label && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<td colSpan={colSpan} className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</td>
</TableRow>
)}
{children}
</>
);
}
export default DataPage;