TorbatYar/frontend/modules/workspace/features/projectDetail.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

177 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button, Card, CardContent, ConfirmDialog, Dialog, Input, PageHeader, Select, StatCard, Textarea } from "@/components/ds";
import { useTenantId } from "@/hooks/useTenantId";
import { workspaceApi } from "@/modules/workspace/services/workspace-api";
import { PRIORITY, PROJECT_STATUS, displayPerson, labelOf } from "@/modules/workspace/labels";
import { StatusChip } from "@/modules/workspace/components/StatusChip";
import { CommentThread } from "@/modules/workspace/components/CommentThread";
import { IdentityUserSelect } from "@/modules/workspace/components/IdentityUserSelect";
import { WorkspacePageError, WorkspacePageLoader } from "@/modules/workspace/pages/shared";
export const WorkspaceProjectDetail = function WorkspaceProjectDetailPage() {
const params = useParams<{ id: string }>();
const { tenantId } = useTenantId();
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [form, setForm] = useState({
name: "",
description: "",
status: "planning",
priority: "medium",
team_id: "",
owner_user_id: "",
start_date: "",
due_date: "",
});
const project = useQuery({
queryKey: ["workspace", tenantId, "project", params.id],
queryFn: () => workspaceApi.projects.get(tenantId!, params.id),
enabled: !!tenantId && !!params.id,
});
const teams = useQuery({
queryKey: ["workspace", tenantId, "teams", project.data?.work_hub_id],
queryFn: () => workspaceApi.teams.list(tenantId!, String(project.data?.work_hub_id || "")),
enabled: !!tenantId && !!project.data?.work_hub_id,
});
const update = useMutation({
mutationFn: (body: Record<string, unknown>) => workspaceApi.projects.update(tenantId!, params.id, body),
onSuccess: () => {
toast.success("پروژه به‌روز شد");
setEditOpen(false);
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "project", params.id] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : "به‌روزرسانی ناموفق بود"),
});
if (!tenantId || project.isLoading) return <WorkspacePageLoader />;
if (project.error) return <WorkspacePageError error={project.error} onRetry={() => project.refetch()} />;
const row = project.data!;
const progress = row.progress || { total: 0, done: 0, percent: 0 };
return (
<div className="space-y-6">
<PageHeader
title={String(row.name || "پروژه")}
description={String(row.description || "جزئیات، پیشرفت و گفتگو")}
actions={
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setForm({
name: String(row.name || ""),
description: String(row.description || ""),
status: String(row.status || "planning"),
priority: String(row.priority || "medium"),
team_id: String(row.team_id || ""),
owner_user_id: String(row.owner_user_id || ""),
start_date: String(row.start_date || "").slice(0, 10),
due_date: String(row.due_date || "").slice(0, 10),
});
setEditOpen(true);
}}
>
ویرایش
</Button>
<Button variant="outline" onClick={() => setArchiveOpen(true)}>
بایگانی
</Button>
</div>
}
/>
<div className="grid gap-4 md:grid-cols-3">
<StatCard label="پیشرفت وظایف" value={`${progress.percent}٪`} hint={`${progress.done} از ${progress.total}`} />
<StatCard label="مسئول" value={displayPerson(String(row.owner_user_id || ""))} />
<StatCard label="اولویت" value={labelOf(PRIORITY, String(row.priority || ""))} />
</div>
<div className="flex items-center gap-2">
<StatusChip value={String(row.status || "")} kind="project" />
<StatusChip value={String(row.priority || "")} kind="priority" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardContent className="space-y-2 p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-secondary">وظایف</h2>
<Link href="/workspace/tasks" className="text-sm text-primary">کانبان</Link>
</div>
{(row.tasks ?? []).length === 0 ? (
<p className="text-sm text-[var(--muted)]">وظیفه‌ای ثبت نشده است.</p>
) : (
(row.tasks ?? []).map((task) => (
<div key={String(task.id)} className="flex items-center justify-between text-sm">
<span>{String(task.title || "وظیفه")}</span>
<StatusChip value={String(task.status || "")} kind="task" />
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-secondary">محتوا</h2>
<Link href="/workspace/content" className="text-sm text-primary">استودیو</Link>
</div>
{(row.content ?? []).length === 0 ? (
<p className="text-sm text-[var(--muted)]">محتوایی به این پروژه وصل نیست.</p>
) : (
(row.content ?? []).map((item) => (
<div key={String(item.id)} className="flex items-center justify-between text-sm">
<span>{String(item.title || "محتوا")}</span>
<StatusChip value={String(item.status || "")} />
</div>
))
)}
</CardContent>
</Card>
</div>
<CommentThread tenantId={tenantId} workHubId={String(row.work_hub_id || "")} subjectType="project" subjectId={params.id} />
<Dialog open={editOpen} onClose={() => setEditOpen(false)} title="ویرایش پروژه" size="lg">
<div className="space-y-3">
<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="نام پروژه" />
<Textarea value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="توضیح" />
<Select value={form.team_id} onChange={(event) => setForm({ ...form, team_id: event.target.value })}>
<option value="">بدون تیم</option>
{(teams.data ?? []).map((team) => (
<option key={String(team.id)} value={String(team.id)}>{String(team.name)}</option>
))}
</Select>
<IdentityUserSelect value={form.owner_user_id} onChange={(id) => setForm({ ...form, owner_user_id: id })} emptyLabel="مسئول پروژه" />
<div className="grid gap-2 md:grid-cols-2">
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
{Object.entries(PROJECT_STATUS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Select>
<Select value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })}>
{Object.entries(PRIORITY).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Select>
<Input type="date" value={form.start_date} onChange={(event) => setForm({ ...form, start_date: event.target.value })} />
<Input type="date" value={form.due_date} onChange={(event) => setForm({ ...form, due_date: event.target.value })} />
</div>
<Button className="w-full" disabled={!form.name.trim() || update.isPending} onClick={() => update.mutate(form)}>
ذخیره
</Button>
</div>
</Dialog>
<ConfirmDialog
open={archiveOpen}
onClose={() => setArchiveOpen(false)}
onConfirm={() => update.mutate({ status: "archived" })}
title="بایگانی پروژه"
confirmLabel="بایگانی"
danger
loading={update.isPending}
/>
</div>
);
};
export default WorkspaceProjectDetail;