224 lines
10 KiB
TypeScript
224 lines
10 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, Textarea } from "@/components/ds";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { workspaceApi } from "@/modules/workspace/services/workspace-api";
|
||
import { ACTIVITY_ACTION, TEAM_ROLE, displayPerson, formatWhen, labelOf } from "@/modules/workspace/labels";
|
||
import { IdentityUserSelect } from "@/modules/workspace/components/IdentityUserSelect";
|
||
import { CommentThread } from "@/modules/workspace/components/CommentThread";
|
||
import { StatusChip } from "@/modules/workspace/components/StatusChip";
|
||
import { WorkspacePageError, WorkspacePageLoader } from "@/modules/workspace/pages/shared";
|
||
import { useTenantMembers } from "@/modules/workspace/hooks/useTenantMembers";
|
||
|
||
export const WorkspaceTeamDetail = function WorkspaceTeamDetailPage() {
|
||
const params = useParams<{ id: string }>();
|
||
const { tenantId } = useTenantId();
|
||
const queryClient = useQueryClient();
|
||
const directory = useTenantMembers();
|
||
const names = Object.fromEntries((directory.data ?? []).map((person) => [person.id, person.label]));
|
||
const [userId, setUserId] = useState("");
|
||
const [role, setRole] = useState("member");
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const team = useQuery({
|
||
queryKey: ["workspace", tenantId, "team", params.id],
|
||
queryFn: () => workspaceApi.teams.get(tenantId!, params.id),
|
||
enabled: !!tenantId && !!params.id,
|
||
});
|
||
const hubId = team.data?.work_hub_id ? String(team.data.work_hub_id) : "";
|
||
const projects = useQuery({
|
||
queryKey: ["workspace", tenantId, "projects", hubId, params.id],
|
||
queryFn: () => workspaceApi.projects.list(tenantId!, { work_hub_id: hubId, team_id: params.id }),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const tasks = useQuery({
|
||
queryKey: ["workspace", tenantId, "tasks", hubId],
|
||
queryFn: () => workspaceApi.tasks.list(tenantId!, { work_hub_id: hubId }),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const content = useQuery({
|
||
queryKey: ["workspace", tenantId, "content", hubId],
|
||
queryFn: () => workspaceApi.content.list(tenantId!, { work_hub_id: hubId }),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const activity = useQuery({
|
||
queryKey: ["workspace", tenantId, "activity", hubId],
|
||
queryFn: () => workspaceApi.activity.list(tenantId!, hubId),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
|
||
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "team", params.id] });
|
||
const addMember = useMutation({
|
||
mutationFn: () => workspaceApi.members.add(tenantId!, { team_id: params.id, user_id: userId, role }),
|
||
onSuccess: () => {
|
||
toast.success("عضو اضافه شد");
|
||
setUserId("");
|
||
invalidate();
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "افزودن عضو ناموفق بود"),
|
||
});
|
||
const updateMember = useMutation({
|
||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||
workspaceApi.members.update(tenantId!, id, body),
|
||
onSuccess: () => {
|
||
toast.success("عضو بهروز شد");
|
||
invalidate();
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "بهروزرسانی عضو ناموفق بود"),
|
||
});
|
||
const saveTeam = useMutation({
|
||
mutationFn: () => workspaceApi.teams.update(tenantId!, params.id, { name, description }),
|
||
onSuccess: () => {
|
||
toast.success("تیم بهروز شد");
|
||
setEditOpen(false);
|
||
invalidate();
|
||
},
|
||
});
|
||
const archive = useMutation({
|
||
mutationFn: () => workspaceApi.teams.update(tenantId!, params.id, { status: "archived" }),
|
||
onSuccess: () => {
|
||
toast.success("تیم بایگانی شد");
|
||
setArchiveOpen(false);
|
||
invalidate();
|
||
},
|
||
});
|
||
|
||
if (!tenantId || team.isLoading) return <WorkspacePageLoader />;
|
||
if (team.error) return <WorkspacePageError error={team.error} onRetry={() => team.refetch()} />;
|
||
const members = (team.data?.members as Record<string, unknown>[]) || [];
|
||
const teamTasks = (tasks.data ?? []).filter((task) => String(task.team_id || "") === params.id);
|
||
const teamContent = (content.data ?? []).filter((item) => String(item.team_id || "") === params.id);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title={String(team.data?.name || "تیم")}
|
||
description={String(team.data?.description || "اعضای تیم، پروژهها و فعالیتها")}
|
||
actions={
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setName(String(team.data?.name || ""));
|
||
setDescription(String(team.data?.description || ""));
|
||
setEditOpen(true);
|
||
}}
|
||
>
|
||
ویرایش
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setArchiveOpen(true)}>
|
||
بایگانی تیم
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
<Card>
|
||
<CardContent className="space-y-4 p-5">
|
||
<h2 className="font-semibold text-secondary">اعضای تیم</h2>
|
||
<div className="grid gap-2 md:grid-cols-[1fr_160px_auto]">
|
||
<IdentityUserSelect value={userId} onChange={setUserId} emptyLabel="انتخاب عضو سازمان" />
|
||
<Select value={role} onChange={(event) => setRole(event.target.value)}>
|
||
{Object.entries(TEAM_ROLE).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Button disabled={!userId.trim() || addMember.isPending} onClick={() => addMember.mutate()}>
|
||
افزودن عضو
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{members.map((member) => (
|
||
<div key={String(member.id)} className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-[var(--border)] px-3 py-2">
|
||
<div>
|
||
<p className="text-sm font-medium">{names[String(member.user_id || "")] || displayPerson(String(member.user_id || ""))}</p>
|
||
<p className="text-xs text-[var(--muted)]">{labelOf(TEAM_ROLE, String(member.role || ""))}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Select
|
||
value={String(member.role || "member")}
|
||
onChange={(event) => updateMember.mutate({ id: String(member.id), body: { role: event.target.value } })}
|
||
>
|
||
{Object.entries(TEAM_ROLE).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Button variant="outline" size="sm" onClick={() => updateMember.mutate({ id: String(member.id), body: { status: "removed" } })}>
|
||
حذف
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Card>
|
||
<CardContent className="space-y-2 p-5">
|
||
<h2 className="font-semibold text-secondary">پروژههای تیم</h2>
|
||
{(projects.data ?? []).length === 0 ? (
|
||
<p className="text-sm text-[var(--muted)]">پروژهای به این تیم وصل نیست.</p>
|
||
) : (
|
||
(projects.data ?? []).map((project) => (
|
||
<Link key={String(project.id)} href={`/workspace/projects/${project.id}`} className="flex justify-between text-sm">
|
||
<span>{String(project.name)}</span>
|
||
<StatusChip value={String(project.status || "")} kind="project" />
|
||
</Link>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-2 p-5">
|
||
<h2 className="font-semibold text-secondary">وظایف و محتوا</h2>
|
||
<p className="text-sm text-[var(--muted)]">{teamTasks.length} وظیفه · {teamContent.length} محتوا</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<Card>
|
||
<CardContent className="space-y-2 p-5">
|
||
<h2 className="font-semibold text-secondary">فعالیت تیم</h2>
|
||
{(activity.data ?? []).slice(0, 12).map((item) => (
|
||
<p key={String(item.id)} className="text-sm text-[var(--muted)]">
|
||
{labelOf(ACTIVITY_ACTION, String(item.action || ""), "رویداد")} · {names[String(item.actor_user_id || "")] || displayPerson(String(item.actor_user_id || ""))} · {formatWhen(String(item.created_at || ""))}
|
||
</p>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
{hubId ? (
|
||
<CommentThread tenantId={tenantId} workHubId={hubId} subjectType="team" subjectId={params.id} />
|
||
) : null}
|
||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} title="ویرایش تیم">
|
||
<div className="space-y-3">
|
||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="نام تیم" />
|
||
<Textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="توضیح" />
|
||
<Button className="w-full" disabled={!name.trim() || saveTeam.isPending} onClick={() => saveTeam.mutate()}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
<ConfirmDialog
|
||
open={archiveOpen}
|
||
onClose={() => setArchiveOpen(false)}
|
||
onConfirm={() => archive.mutate()}
|
||
title="بایگانی تیم"
|
||
confirmLabel="بایگانی"
|
||
danger
|
||
loading={archive.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default WorkspaceTeamDetail;
|