sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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" /> Rà 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 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.
|
||||
</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;
|
||||
Reference in New Issue
Block a user