TorbatYar/frontend/modules/workspace/features/tasks.tsx
Mortezakoohjani a483b47369 feat(workspace): complete production workspace and content operations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 11:54:17 +03:30

208 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button, Card, CardContent, Dialog, Drawer, Input, PageHeader, Select } from "@/components/ds";
import { useMe } from "@/hooks/useMe";
import { useCurrentHub } from "@/modules/workspace/hooks/useCurrentHub";
import { workspaceApi } from "@/modules/workspace/services/workspace-api";
import { KANBAN_COLUMNS, TASK_STATUS, displayPerson, formatDay } from "@/modules/workspace/labels";
import { StatusChip } from "@/modules/workspace/components/StatusChip";
import { IdentityUserSelect } from "@/modules/workspace/components/IdentityUserSelect";
import { CommentThread } from "@/modules/workspace/components/CommentThread";
import { WorkspaceEmptyState, WorkspacePageError, WorkspacePageLoader } from "@/modules/workspace/pages/shared";
import type { WorkspaceRecord } from "@/modules/workspace/types";
export const WorkspaceTasks = function WorkspaceTasksPage() {
const queryClient = useQueryClient();
const { me } = useMe();
const { tenantId, hubId, loading } = useCurrentHub();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [projectId, setProjectId] = useState("");
const [assignee, setAssignee] = useState("");
const [dueDate, setDueDate] = useState("");
const [mineOnly, setMineOnly] = useState(false);
const [selected, setSelected] = useState<WorkspaceRecord | null>(null);
const [labels, setLabels] = useState("");
const projects = useQuery({
queryKey: ["workspace", tenantId, "projects", hubId],
queryFn: () => workspaceApi.projects.list(tenantId!, { work_hub_id: hubId || undefined }),
enabled: !!tenantId && !!hubId,
});
const tasks = useQuery({
queryKey: ["workspace", tenantId, "tasks", hubId, mineOnly],
queryFn: () =>
workspaceApi.tasks.list(tenantId!, {
work_hub_id: hubId || undefined,
assignee_user_id: mineOnly && me?.user_id ? me.user_id : undefined,
}),
enabled: !!tenantId && !!hubId,
});
const create = useMutation({
mutationFn: () =>
workspaceApi.tasks.create(tenantId!, {
project_id: projectId,
title,
assignee_user_id: assignee || undefined,
due_date: dueDate || undefined,
labels: labels ? labels.split(",").map((item) => item.trim()).filter(Boolean) : [],
}),
onSuccess: () => {
toast.success("وظیفه ایجاد شد");
setOpen(false);
setTitle("");
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "tasks"] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : "ایجاد وظیفه ناموفق بود"),
});
const move = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
workspaceApi.tasks.update(tenantId!, id, { status }),
onSuccess: () => {
toast.success("وضعیت وظیفه ذخیره شد");
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "tasks"] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : "تغییر وضعیت مجاز نیست"),
});
const grouped = useMemo(() => {
const map: Record<string, WorkspaceRecord[]> = {};
for (const column of KANBAN_COLUMNS) map[column.id] = [];
for (const task of tasks.data ?? []) {
const status = String(task.status || "backlog");
if (!map[status]) map[status] = [];
map[status].push(task);
}
return map;
}, [tasks.data]);
if (!tenantId || loading || tasks.isLoading) return <WorkspacePageLoader />;
if (!hubId) return <WorkspaceEmptyState title="مرکز کار لازم است" description="ابتدا مرکز کار را انتخاب کنید." />;
if (tasks.error) return <WorkspacePageError error={tasks.error} onRetry={() => tasks.refetch()} />;
return (
<div className="space-y-6">
<PageHeader
title="وظایف"
description="ستون‌های کانبان روی سرور ذخیره می‌شوند. کارت را بکشید تا وضعیت عوض شود."
actions={
<div className="flex gap-2">
<Button variant={mineOnly ? "default" : "outline"} onClick={() => setMineOnly((value) => !value)}>
وظایف من
</Button>
<Button onClick={() => setOpen(true)}>وظیفه جدید</Button>
</div>
}
/>
{(tasks.data ?? []).length === 0 ? (
<WorkspaceEmptyState
title="وظیفه‌ای نیست"
description="یک وظیفه بسازید و آن را بین ستون‌ها جابه‌جا کنید."
action={<Button onClick={() => setOpen(true)}>ایجاد وظیفه</Button>}
/>
) : (
<div className="grid gap-3 overflow-x-auto pb-4 md:grid-cols-5">
{KANBAN_COLUMNS.map((column) => (
<div
key={column.id}
className="min-w-[220px] rounded-2xl bg-[var(--surface-muted)] p-3"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
const id = event.dataTransfer.getData("text/task-id");
if (id) move.mutate({ id, status: column.id });
}}
>
<h2 className="mb-3 text-sm font-semibold text-secondary">{column.label}</h2>
<div className="space-y-2">
{(grouped[column.id] ?? []).map((task) => (
<Card
key={String(task.id)}
draggable
onDragStart={(event) => event.dataTransfer.setData("text/task-id", String(task.id))}
className="cursor-grab"
onClick={() => setSelected(task)}
>
<CardContent className="space-y-2 p-3">
<p className="text-sm font-medium">{String(task.title || "وظیفه")}</p>
<div className="flex flex-wrap items-center gap-1">
<StatusChip value={String(task.priority || "")} kind="priority" />
<span className="text-xs text-[var(--muted)]">{displayPerson(String(task.assignee_user_id || ""))}</span>
</div>
{task.due_date ? (
<p className="text-xs text-[var(--muted)]">مهلت {formatDay(String(task.due_date))}</p>
) : null}
<Select
aria-label="تغییر وضعیت"
value={String(task.status || column.id)}
onChange={(event) => move.mutate({ id: String(task.id), status: event.target.value })}
>
{Object.entries(TASK_STATUS)
.filter(([value]) => value !== "archived")
.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)}
<Dialog open={open} onClose={() => setOpen(false)} title="وظیفه جدید">
<div className="space-y-3">
<Select value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">انتخاب پروژه</option>
{(projects.data ?? []).map((project) => (
<option key={String(project.id)} value={String(project.id)}>
{String(project.name)}
</option>
))}
</Select>
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="عنوان وظیفه" />
<IdentityUserSelect value={assignee} onChange={setAssignee} emptyLabel="مسئول وظیفه" />
<Input type="date" value={dueDate} onChange={(event) => setDueDate(event.target.value)} />
<Input value={labels} onChange={(event) => setLabels(event.target.value)} placeholder="برچسب‌ها با ویرگول" />
<Button className="w-full" disabled={!title.trim() || !projectId || create.isPending} onClick={() => create.mutate()}>
ایجاد
</Button>
</div>
</Dialog>
<Drawer open={!!selected} onClose={() => setSelected(null)} title={String(selected?.title || "وظیفه")}>
{selected && hubId ? (
<div className="space-y-4">
<IdentityUserSelect
value={String(selected.assignee_user_id || "")}
onChange={(id) => {
void workspaceApi.tasks.update(tenantId!, String(selected.id), { assignee_user_id: id }).then(() => {
toast.success("مسئول ذخیره شد");
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "tasks"] });
});
}}
emptyLabel="مسئول"
/>
<Input
type="date"
defaultValue={String(selected.due_date || "").slice(0, 10)}
onBlur={(event) => {
void workspaceApi.tasks.update(tenantId!, String(selected.id), { due_date: event.target.value }).then(() => {
toast.success("مهلت ذخیره شد");
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "tasks"] });
});
}}
/>
<CommentThread tenantId={tenantId!} workHubId={hubId} subjectType="task" subjectId={String(selected.id)} />
</div>
) : null}
</Drawer>
</div>
);
};
export default WorkspaceTasks;