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('all'); const [groupBy, setGroupBy] = useState('none'); const [assigneeFilter, setAssigneeFilter] = useState('all'); const [query, setQuery] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [selected, setSelected] = useState>(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 (
{/* Header: title + tab nav (left) · toolbar (right) — region 4 */}

{ds?.name ?? 'Công việc'}

{ds && {VISIBILITY_LABEL[ds.visibility]}}
{[ { 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) => ( {b.label} ))}
{dsQ.isError &&

Không tải được bộ dữ liệu.

}
{noStages ? (

Bộ dữ liệu chưa có 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.

) : ( <> {/* Filter bar (region 2) + search (region 3) */}
setQuery(e.target.value)} placeholder="Tìm theo Task Id hoặc tên" className="h-9 w-64 pl-8" />
Xuất CSV
{/* Bulk-selection bar (region 6) */} {selected.size > 0 && (
Đã chọn {selected.size}
)} {tasksQ.isLoading ? (

Đang tải…

) : filtered.length === 0 ? (

{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.'}

) : ( toggleAll(v === true)} aria-label="Chọn tất cả trên trang" /> Datapoint Giai đoạn Tệp Task Id Phụ trách Điểm Thao tác {groups.map((g) => ( {g.tasks.map((t) => ( toggleOne(t.id, c)} /> ))} ))}
)} {/* Pagination footer (region 7) */} {filtered.length > 0 && (
Trang {pageData.page} / {pageData.pageCount} Hiển thị / trang
Tổng {filtered.length} mục
)} )}
{uploadOpen && ( qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id] })} /> )}
); } /** 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 && ( {label} )} {children} ); } export default DataPage;