sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
@@ -0,0 +1,538 @@
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;