539 lines
22 KiB
TypeScript
539 lines
22 KiB
TypeScript
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;
|