177 lines
8.7 KiB
TypeScript
177 lines
8.7 KiB
TypeScript
"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;
|