208 lines
9.9 KiB
TypeScript
208 lines
9.9 KiB
TypeScript
"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;
|