Files
ERP/pm/frontend/src/pages/ProjectDetailPage.tsx
T
MMD 2747f9d4bb chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
2026-10-07 15:14:20 +03:30

1601 lines
62 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.
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BarChart3,
Calendar,
ChevronDown,
ChevronLeft,
Folder,
Link2,
MapPin,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
UserPlus,
Users,
} from "lucide-react";
import {
addProjectMember,
createTask,
createTaskRelation,
deleteTaskRelation,
getProject,
getProjectGantt,
getTasks,
getUsers,
removeProjectMember,
rescheduleProject,
updateProject,
updateTask,
updateTaskRelation,
} from "../lib/api";
import type {
CreateRelationPayload,
CreateTaskPayload,
HumanResourceItem,
MaterialItem,
ProjectMember,
Task,
TaskRelation,
UpdateRelationPayload,
} from "../lib/api";
import { ResourceSectionInputs } from "../components/ResourceSectionInputs";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
InitialAvatar,
Modal,
PageHeader,
ProgressBar,
btnPrimary,
btnSecondary,
inputCls,
} from "../components/ui";
import { JalaliDatePicker } from "../components/JalaliDatePicker";
import { GanttChart } from "../components/GanttChart";
import { TaskEditModal } from "../components/TaskEditModal";
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
import { isoToApiDate } from "../lib/jalali";
import { reconcileDates } from "../lib/dates";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
interface TreeNode {
task: Task;
children: TreeNode[];
}
function buildTree(tasks: Task[]): TreeNode[] {
const map = new Map<string, TreeNode>();
for (const t of tasks) {
const key = String(t.id || t.op_wp_id);
map.set(key, { task: t, children: [] });
}
const roots: TreeNode[] = [];
for (const t of tasks) {
const key = String(t.id || t.op_wp_id);
const node = map.get(key);
if (!node) continue;
const parentKey = t.parent_id
? String(t.parent_id)
: t.parent_op_wp_id != null
? String(t.parent_op_wp_id)
: null;
const parent = parentKey ? map.get(parentKey) : undefined;
if (parent && parent !== node) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function filterTree(nodes: TreeNode[], needle: string): TreeNode[] {
if (!needle) return nodes;
const q = needle.trim().toLowerCase();
if (!q) return nodes;
const walk = (list: TreeNode[]): TreeNode[] => {
const result: TreeNode[] = [];
for (const node of list) {
const children = walk(node.children);
const title = node.task.title || node.task.subject || "";
if (title.toLowerCase().includes(q) || children.length > 0) {
result.push({ ...node, children });
}
}
return result;
};
return walk(nodes);
}
function TreeRow({
node,
depth,
expanded,
onToggle,
onAddSubtask,
onEditTask,
canManageTasks,
}: {
node: TreeNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
onAddSubtask: (parentTask: Task) => void;
onEditTask: (task: Task) => void;
canManageTasks?: boolean;
}) {
const t = node.task;
const taskId = String(t.id || t.op_wp_id);
const hasChildren = node.children.length > 0;
const isOpen = expanded.has(taskId);
const qc = qcStatusMeta(t.qc_status);
const overdue = isOverdue(t.due_date || t.planned_end);
const displayTitle = t.title || t.subject;
const targetUrl = `/tasks/${t.id || t.op_wp_id}`;
const progress = t.progress ?? t.percentage_done ?? 0;
// رنگ درصد: ۱۰۰٪ سبز، در حال انجام آبی، صفر خاکستری
const progressCls =
progress >= 100 ? "text-emerald-600" : progress > 0 ? "text-sky-600" : "text-slate-400";
return (
<div className="border-b border-slate-100 last:border-b-0">
<div
className="flex items-start gap-1 py-2.5 hover:bg-slate-50/70 transition-colors"
style={{ paddingInlineStart: `${Math.min(depth * 14 + 8, 56)}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => onToggle(taskId)}
aria-label={isOpen ? "بستن" : "باز کردن"}
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 active:bg-slate-100"
>
{isOpen ? <ChevronDown size={16} /> : <ChevronLeft size={16} />}
</button>
) : (
<span className="w-7 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<Link to={targetUrl} className="min-w-0 flex-1 group">
<p className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-800 group-hover:text-sky-600 transition-colors">
{displayTitle}
</span>
{/* درصد تکمیل — بلافاصله سمت چپ نام و بالای نوار پیشرفت */}
<span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}>
{progress}٪
</span>
</p>
</Link>
<div className="flex items-center gap-1.5 shrink-0">
<span className={`text-[11px] ${overdue ? "font-semibold text-red-600" : "text-slate-400"}`}>
{formatDate(t.due_date || t.planned_end)}
</span>
{canManageTasks && (
<>
<button
type="button"
onClick={() => onEditTask(t)}
title="ویرایش وظیفه"
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-600 active:bg-amber-100 transition-colors"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => onAddSubtask(t)}
title="افزودن زیرتسک"
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-sky-50 hover:text-sky-600 active:bg-sky-100 transition-colors"
>
<Plus size={14} />
</button>
</>
)}
</div>
</div>
<Link to={targetUrl} className="block mt-1">
<ProgressBar value={progress} />
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{t.qc_status && t.qc_status !== "none" && <Chip tone={qc.tone}>{qc.label}</Chip>}
{t.priority && t.priority !== "normal" && (
<Chip tone={t.priority === "urgent" || t.priority === "high" ? "red" : "slate"}>
{t.priority === "urgent" ? "فوری" : t.priority === "high" ? "بالا" : "پایین"}
</Chip>
)}
</div>
</Link>
</div>
</div>
{isOpen &&
node.children.map((child) => (
<TreeRow
key={String(child.task.id || child.task.op_wp_id)}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onAddSubtask={onAddSubtask}
onEditTask={onEditTask}
canManageTasks={canManageTasks}
/>
))}
</div>
);
}
function TaskCreateModal({
open,
projectId,
parentTask,
allTasks = [],
onClose,
}: {
open: boolean;
projectId: string;
parentTask: Task | null;
allTasks?: Task[];
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [selectedParentId, setSelectedParentId] = useState("");
const [selectedChildIds, setSelectedChildIds] = useState<string[]>([]);
const [taskType] = useState("task");
const [priority, setPriority] = useState("normal");
const [plannedStart, setPlannedStart] = useState<string | null>(null);
const [plannedEnd, setPlannedEnd] = useState<string | null>(null);
const [durationDays, setDurationDays] = useState<number | "">("");
const [humanResources, setHumanResources] = useState<HumanResourceItem[]>([]);
const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [errorMsg, setErrorMsg] = useState("");
// روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF)
interface DraftRelation {
target_task_id: string;
target_task_title: string;
direction: "predecessor" | "successor";
relation_type: "FS" | "SS" | "FF" | "SF";
lag_days: number;
}
const [draftRelations, setDraftRelations] = useState<DraftRelation[]>([]);
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [relLag, setRelLag] = useState<number>(0);
useEffect(() => {
if (open) {
setSelectedParentId(parentTask ? String(parentTask.id || parentTask.op_wp_id) : "");
setSelectedChildIds([]);
setDraftRelations([]);
setRelTargetId("");
setRelLag(0);
setErrorMsg("");
}
}, [open, parentTask]);
const createMutation = useMutation({
mutationFn: async (payload: CreateTaskPayload) => {
const created = await createTask(payload);
const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id);
// ۱. انتساب وظایف انتخاب شده به عنوان فرزندان (زیرتسک‌ها)
if (selectedChildIds.length > 0 && newTaskId) {
for (const childId of selectedChildIds) {
try {
await updateTask(childId, { parent_id: newTaskId });
} catch (e) {
console.error("خطا در انتساب والد به زیرتسک", childId, e);
}
}
}
// ۲. ایجاد روابط وابستگی تعریف شده (FS, SS, FF, SF)
if (draftRelations.length > 0 && newTaskId) {
for (const rel of draftRelations) {
try {
const from_task_id = rel.direction === "predecessor" ? rel.target_task_id : newTaskId;
const to_task_id = rel.direction === "predecessor" ? newTaskId : rel.target_task_id;
await createTaskRelation(projectId, {
from_task_id,
to_task_id,
relation_type: rel.relation_type,
lag_days: rel.lag_days,
});
} catch (e) {
console.error("خطا در ایجاد رابطه وابستگی", rel, e);
}
}
}
return created;
},
onSuccess: () => {
toast.success("وظیفه جدید با موفقیت ایجاد شد");
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-relations", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
setTitle("");
setDescription("");
setSelectedParentId("");
setSelectedChildIds([]);
setDraftRelations([]);
setPlannedStart(null);
setPlannedEnd(null);
setDurationDays("");
setHumanResources([]);
setMaterials([]);
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ثبت وظیفه");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setErrorMsg("عنوان وظیفه الزامی است");
return;
}
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
}
if (!endIso) {
setErrorMsg("وارد کردن تاریخ پایان وظیفه الزامی است");
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
return;
}
// ۳. انطباق با تاریخ‌های والد انتخابی
const activeParent = selectedParentId
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
: null;
if (activeParent) {
const pStart = activeParent.planned_start || (activeParent as any).start_date;
const pEnd = activeParent.planned_end || (activeParent as any).due_date;
if (pStart && startIso && startIso < pStart) {
setErrorMsg(
`تاریخ شروع وظیفه فرزند نمی‌تواند قبل از تاریخ شروع وظیفه والد (${(activeParent as any).planned_start_jalali || pStart}) باشد`
);
return;
}
if (pEnd && endIso && endIso > pEnd) {
setErrorMsg(
`تاریخ پایان وظیفه فرزند نمی‌تواند بعد از تاریخ پایان وظیفه والد (${(activeParent as any).planned_end_jalali || pEnd}) باشد`
);
return;
}
if (
typeof activeParent.duration === "number" &&
typeof durationDays === "number" &&
durationDays > activeParent.duration
) {
setErrorMsg(
`مدت زمان وظیفه والد (${activeParent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند (${durationDays} روز) باشد`
);
return;
}
}
createMutation.mutate({
project_id: projectId,
title: title.trim(),
description: description.trim(),
parent_id: selectedParentId.trim() ? selectedParentId.trim() : null,
task_type: taskType,
priority,
planned_start: startIso,
planned_end: endIso,
duration_days: typeof durationDays === "number" ? durationDays : undefined,
human_resources: humanResources.filter((h) => h.role.trim()),
materials: materials.filter((m) => m.name.trim()),
});
};
// وظایف کاندید برای فرزند بودن (به جز والدی که هم‌اکنون انتخاب شده)
const candidateChildren = allTasks.filter(
(t) => String(t.id || t.op_wp_id) !== selectedParentId
);
return (
<Modal
open={open}
title={parentTask ? `افزودن زیرتسک برای: ${parentTask.title || parentTask.subject}` : "تعریف وظیفه جدید"}
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-3">
{errorMsg && <div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">{errorMsg}</div>}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">عنوان وظیفه *</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="مثال: بتن‌ریزی سقف طبقه ۳"
required
className={inputCls}
/>
</div>
{/* ۱. تعیین وظیفه والد (PARENT) */}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
وظیفه والد (سطح بالاتر - Parent)
</label>
<select
value={selectedParentId}
onChange={(e) => {
const val = e.target.value;
setSelectedParentId(val);
if (val) {
setSelectedChildIds((prev) => prev.filter((id) => id !== val));
}
}}
className={inputCls}
>
<option value="">بدون والد (وظیفه سطح اصلی / ریشه)</option>
{allTasks.map((p) => {
const pKey = String(p.id || p.op_wp_id);
return (
<option key={pKey} value={pKey}>
{p.title || p.subject}
</option>
);
})}
</select>
</div>
{/* ۲. تعیین زیرتسک‌ها و فرزندان (CHILD) */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-700">
تعیین زیرتسک‌ها (فرزندان این وظیفه - Children)
</label>
{selectedChildIds.length > 0 && (
<span className="rounded-full bg-sky-100 text-sky-700 px-2 py-0.5 text-[10px] font-bold">
{selectedChildIds.length} زیرتسک انتخاب شده
</span>
)}
</div>
<p className="text-[11px] text-slate-500">
وظایفی که انتخاب کنید، پس از ثبت به عنوان فرزند (زیرتسک) این وظیفه تنظیم می‌شوند.
</p>
{candidateChildren.length === 0 ? (
<p className="text-center py-1.5 text-xs text-slate-400">وظیفه دیگری برای انتخاب وجود ندارد</p>
) : (
<div className="max-h-36 overflow-y-auto space-y-1 rounded-lg border border-slate-200 bg-white p-2">
{candidateChildren.map((t) => {
const tKey = String(t.id || t.op_wp_id);
const isChecked = selectedChildIds.includes(tKey);
return (
<label
key={tKey}
className={`flex items-center gap-2 rounded px-2 py-1 text-xs cursor-pointer transition-colors ${
isChecked ? "bg-sky-50 text-sky-900 font-medium" : "hover:bg-slate-50 text-slate-700"
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
if (e.target.checked) {
setSelectedChildIds([...selectedChildIds, tKey]);
} else {
setSelectedChildIds(selectedChildIds.filter((id) => id !== tKey));
}
}}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span className="flex-1 truncate">{t.title || t.subject}</span>
</label>
);
})}
</div>
)}
</div>
<div className="grid grid-cols-1 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">اولویت</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className={inputCls}
>
<option value="low">پایین (درجه اهمیت = 1)</option>
<option value="normal">عادی (درجه اهمیت = 2)</option>
<option value="high">بالا (درجه اهمیت = 5)</option>
<option value="urgent">فوری (درجه اهمیت = 13)</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
تاریخ شروع برنامه‌ریزی‌شده <span className="text-rose-500">*</span>
</label>
<JalaliDatePicker
value={plannedStart}
onChange={(iso) => {
const next = reconcileDates(
{ start: iso, end: plannedEnd, days: durationDays },
"start",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="انتخاب تاریخ"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
تاریخ پایان برنامه‌ریزی‌شده <span className="text-rose-500">*</span>
</label>
<JalaliDatePicker
value={plannedEnd}
onChange={(iso) => {
const next = reconcileDates(
{ start: plannedStart, end: iso, days: durationDays },
"end",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="انتخاب تاریخ"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label>
<input
type="number"
min={0}
value={durationDays}
onChange={(e) => {
const next = reconcileDates(
{
start: plannedStart,
end: plannedEnd,
days: e.target.value ? Number(e.target.value) : "",
},
"days",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="مثال: ۵"
className={inputCls}
/>
<p className="mt-1 text-[11px] leading-4 text-slate-400">
روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">توضیحات</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="مشخصات و الزامات کار..."
className="w-full rounded-xl border border-slate-300 p-2.5 text-sm text-slate-800 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<ResourceSectionInputs
humanResources={humanResources}
setHumanResources={setHumanResources}
materials={materials}
setMaterials={setMaterials}
/>
{/* ۳. تعریف روابط وابستگی (FS, SS, FF, SF + lag) در تعریف وظیفه */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<Link2 size={14} className="text-sky-600" />
<span>وابستگی‌ها و روابط پیش‌نیازی (FS, SS, FF, SF)</span>
</span>
{draftRelations.length > 0 && (
<span className="rounded-full bg-sky-100 text-sky-700 px-2 py-0.5 text-[10px] font-bold">
{draftRelations.length} رابطه
</span>
)}
</div>
{/* لیست روابط پیش‌نویس */}
{draftRelations.length > 0 && (
<div className="space-y-1.5">
{draftRelations.map((r, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white p-2 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold text-sky-800 border border-sky-200">
{r.relation_type}
</span>
<span className="text-[11px] text-slate-500">
{r.direction === "predecessor" ? "پیش‌نیاز این وظیفه:" : "پس‌نیاز این وظیفه:"}
</span>
<span className="font-semibold text-slate-800 truncate max-w-[180px]">
{r.target_task_title}
</span>
{r.lag_days !== 0 && (
<span className="text-[10px] text-slate-400">
(تاخیر: {r.lag_days > 0 ? `+${r.lag_days}` : r.lag_days} روز)
</span>
)}
</div>
<button
type="button"
onClick={() => setDraftRelations(draftRelations.filter((_, i) => i !== idx))}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
title="حذف رابطه"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
{/* فرم افزودن رابطه پیش‌نویس */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 pt-1 border-t border-slate-200/80">
<div className="sm:col-span-4">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
<select
value={relTargetId}
onChange={(e) => setRelTargetId(e.target.value)}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
>
<option value="">انتخاب وظیفه...</option>
{allTasks.map((t) => {
const tKey = String(t.id || t.op_wp_id);
return (
<option key={tKey} value={tKey}>
{t.title || t.subject}
</option>
);
})}
</select>
</div>
<div className="sm:col-span-3">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">نقش رابطه</label>
<select
value={relDirection}
onChange={(e) => setRelDirection(e.target.value as any)}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
>
<option value="predecessor">پیش‌نیاز این وظیفه است</option>
<option value="successor">پس‌نیاز این وظیفه است</option>
</select>
</div>
<div className="sm:col-span-3">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">نوع رابطه</label>
<select
value={relType}
onChange={(e) => setRelType(e.target.value as any)}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
>
<option value="FS">FS (پایان به شروع)</option>
<option value="SS">SS (شروع به شروع)</option>
<option value="FF">FF (پایان به پایان)</option>
<option value="SF">SF (شروع به پایان)</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">تاخیر (روز)</label>
<input
type="number"
value={relLag}
onChange={(e) => setRelLag(parseInt(e.target.value) || 0)}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
/>
</div>
</div>
<button
type="button"
onClick={() => {
if (!relTargetId) {
toast.error("لطفاً یک وظیفه مرتبط انتخاب کنید");
return;
}
const targetTask = allTasks.find(
(t) => String(t.id || t.op_wp_id) === relTargetId
);
setDraftRelations([
...draftRelations,
{
target_task_id: relTargetId,
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
direction: relDirection,
relation_type: relType,
lag_days: relLag,
},
]);
setRelTargetId("");
setRelLag(0);
}}
className="inline-flex h-7 items-center justify-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<Plus size={13} />
<span>افزودن رابطه به وظیفه</span>
</button>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
</button>
</div>
</form>
</Modal>
);
}
function CreateRelationModal({
open,
projectId,
tasks,
initialFromTaskId,
editRelation,
onClose,
}: {
open: boolean;
projectId: string;
tasks: Task[];
initialFromTaskId: string | null;
/** در حالت ویرایش: وابستگی موجود؛ دو سر وابستگی تغییرناپذیرند */
editRelation?: TaskRelation | null;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const isEdit = !!editRelation;
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
const [toTaskId, setToTaskId] = useState("");
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [lagDays, setLagDays] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
if (initialFromTaskId) setFromTaskId(initialFromTaskId);
}, [initialFromTaskId]);
// در حالت ویرایش، فرم با مقادیر فعلی وابستگی پر می‌شود
useEffect(() => {
if (!open) return;
if (editRelation) {
setFromTaskId(editRelation.from_task_id);
setToTaskId(editRelation.to_task_id);
setRelationType(editRelation.relation_type);
setLagDays(editRelation.lag_days || 0);
}
setErrorMsg("");
}, [open, editRelation]);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
};
const createRelMutation = useMutation({
mutationFn: (payload: CreateRelationPayload) => createTaskRelation(projectId, payload),
onSuccess: () => {
invalidate();
onClose();
setToTaskId("");
setLagDays(0);
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ثبت وابستگی");
},
});
const updateRelMutation = useMutation({
mutationFn: (payload: UpdateRelationPayload) =>
updateTaskRelation(projectId, editRelation!.id, payload),
onSuccess: () => {
invalidate();
onClose();
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ویرایش وابستگی");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit) {
updateRelMutation.mutate({ relation_type: relationType, lag_days: Number(lagDays) || 0 });
return;
}
if (!fromTaskId || !toTaskId) {
setErrorMsg("انتخاب هر دو فعالیت پیش‌نیاز و وابسته الزامی است");
return;
}
if (fromTaskId === toTaskId) {
setErrorMsg("یک فعالیت نمی‌تواند پیش‌نیاز خودش باشد");
return;
}
createRelMutation.mutate({
from_task_id: fromTaskId,
to_task_id: toTaskId,
relation_type: relationType,
lag_days: Number(lagDays) || 0,
});
};
return (
<Modal
open={open}
title={isEdit ? "ویرایش وابستگی" : "تعریف وابستگی و توالی فعالیت‌ها"}
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-3">
{errorMsg && <div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">{errorMsg}</div>}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">فعالیت پیش‌نیاز (Predecessor) *</label>
<select
value={fromTaskId}
onChange={(e) => setFromTaskId(e.target.value)}
className={inputCls}
disabled={isEdit}
required
>
<option value="">-- انتخاب فعالیت پیش‌نیاز --</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.title || t.subject}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">فعالیت وابسته / پس‌نیاز (Successor) *</label>
<select
value={toTaskId}
onChange={(e) => setToTaskId(e.target.value)}
className={inputCls}
disabled={isEdit}
required
>
<option value="">-- انتخاب فعالیت وابسته --</option>
{tasks
.filter((t) => t.id !== fromTaskId)
.map((t) => (
<option key={t.id} value={t.id}>
{t.title || t.subject}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">نوع وابستگی</label>
<select
value={relationType}
onChange={(e) => setRelationType(e.target.value as any)}
className={inputCls}
>
<option value="FS">پایان به شروع (FS - رایج)</option>
<option value="SS">شروع به شروع (SS)</option>
<option value="FF">پایان به پایان (FF)</option>
<option value="SF">شروع به پایان (SF)</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">فاصله زمانی / وقفه (روز)</label>
<input
type="number"
value={lagDays}
onChange={(e) => setLagDays(Number(e.target.value))}
placeholder="مثلاً ۲ (گیرش بتن)"
className={inputCls}
/>
</div>
</div>
<p className="text-[11px] text-slate-400 leading-4">
{isEdit
? "با ذخیره، نوع و وقفهٔ این وابستگی به‌روزرسانی و زمان‌بندی پروژه دوباره محاسبه می‌شود. برای تغییر دو سر وابستگی، آن را حذف و دوباره تعریف کنید."
: "با ثبت وابستگی، موتور زمان‌بندی به طور خودکار تاریخ‌های فعالیت وابسته را جلو برده و از عدم ایجاد چرخه در برنامه زمانی اطمینان حاصل می‌کند."}
</p>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="submit"
disabled={createRelMutation.isPending || updateRelMutation.isPending}
className={btnPrimary}
>
{createRelMutation.isPending || updateRelMutation.isPending
? "در حال محاسبه..."
: isEdit
? "ذخیره تغییرات"
: "ثبت وابستگی"}
</button>
</div>
</form>
</Modal>
);
}
function AddMemberModal({
open,
projectId,
onClose,
}: {
open: boolean;
projectId: string;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedUserId, setSelectedUserId] = useState("");
const [memberRole, setMemberRole] = useState("supervisor");
const [notes, setNotes] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const usersQuery = useQuery({ queryKey: ["users"], queryFn: getUsers });
const addMutation = useMutation({
mutationFn: () =>
addProjectMember(projectId, {
user_id: selectedUserId,
role: memberRole,
notes: notes.trim() || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
setSelectedUserId("");
setNotes("");
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در افزودن عضو به پروژه");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUserId) {
setErrorMsg("لطفاً یک کاربر را انتخاب کنید");
return;
}
addMutation.mutate();
};
return (
<Modal open={open} title="افزودن عضو جدید به تیم پروژه" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{errorMsg && <div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">{errorMsg}</div>}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">انتخاب کاربر *</label>
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className={inputCls}
required
>
<option value="">-- انتخاب کاربر --</option>
{(usersQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.display_name} ({u.username}) — {u.role_label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">نقش در پروژه</label>
<select
value={memberRole}
onChange={(e) => setMemberRole(e.target.value)}
className={inputCls}
>
<option value="supervisor">ناظر اجرایی</option>
<option value="manager">مدیر پروژه</option>
<option value="qc_inspector">بازرس کیفیت (QC)</option>
<option value="subcontractor">پیمانکار جزء</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">یادداشت / حدود اختیارات</label>
<input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="یادداشت اختیاری"
className={inputCls}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={addMutation.isPending} className={btnPrimary}>
{addMutation.isPending ? "در حال ثبت..." : "افزودن به تیم"}
</button>
</div>
</form>
</Modal>
);
}
export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canManageTasks = !isSupervisor && hasPermission(user, Perm.tasksCreate);
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
const projectId = id ?? "";
const [activeTab, setActiveTab] = useState<"tasks" | "gantt" | "members">("tasks");
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showTaskModal, setShowTaskModal] = useState(false);
const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRelationModal, setShowRelationModal] = useState(false);
const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null);
/** وابستگی در حال ویرایش (null = حالت تعریف جدید) */
const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null);
const initializedFor = useRef<string | null>(null);
const projectQuery = useQuery({
queryKey: ["project", projectId],
queryFn: () => getProject(projectId),
enabled: Boolean(projectId),
});
const tasksQuery = useQuery({
queryKey: ["tasks", projectId],
queryFn: () => getTasks({ project_id: projectId, page_size: 300 }),
enabled: Boolean(projectId),
});
const ganttQuery = useQuery({
queryKey: ["project-gantt", projectId],
queryFn: () => getProjectGantt(projectId),
enabled: Boolean(projectId) && activeTab === "gantt",
});
const project = projectQuery.data;
const tasks = tasksQuery.data;
const tree = useMemo(() => buildTree(tasks ?? []), [tasks]);
const filteredTree = useMemo(() => filterTree(tree, search), [tree, search]);
useEffect(() => {
if (tasks && initializedFor.current !== projectId) {
initializedFor.current = projectId;
setExpanded(new Set(tree.map((n) => String(n.task.id || n.task.op_wp_id))));
}
}, [tasks, projectId, tree]);
const toggle = (taskId: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(taskId)) next.delete(taskId);
else next.add(taskId);
return next;
});
};
const handleAddSubtask = (parent: Task) => {
setParentTaskForModal(parent);
setShowTaskModal(true);
};
const handleEditTask = (task: Task) => {
setTaskToEdit(task);
setShowEditModal(true);
};
const handleOpenRootTaskModal = () => {
setParentTaskForModal(null);
setShowTaskModal(true);
};
const handleOpenRelationModal = (fromTaskId?: string) => {
setRelationToEdit(null);
setRelationFromTaskId(fromTaskId || null);
setShowRelationModal(true);
};
/** ویرایش یک وابستگی موجود از داخل کشوی تسک */
const handleEditRelation = (relation: TaskRelation) => {
setRelationFromTaskId(null);
setRelationToEdit(relation);
setShowRelationModal(true);
};
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => removeProjectMember(projectId, memberId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
const deleteRelationMutation = useMutation({
mutationFn: (relId: string) => deleteTaskRelation(projectId, relId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
},
});
const rescheduleMutation = useMutation({
mutationFn: () => rescheduleProject(projectId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
},
});
const updateStatusMutation = useMutation({
mutationFn: (newStatus: string) => updateProject(projectId, { status: newStatus }),
onSuccess: () => {
toast.success("وضعیت پروژه با موفقیت تغییر کرد");
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در تغییر وضعیت پروژه");
},
});
const refresh = () => {
void projectQuery.refetch();
void tasksQuery.refetch();
if (activeTab === "gantt") void ganttQuery.refetch();
};
return (
<>
<PageHeader
title={project?.name ?? "جزئیات پروژه"}
onBack={() => navigate("/projects")}
right={
<div className="flex items-center gap-1">
{canManageTasks && (
<button
type="button"
onClick={handleOpenRootTaskModal}
className="inline-flex h-9 items-center gap-1 rounded-lg bg-sky-600 px-2.5 text-xs font-semibold text-white transition-colors active:bg-sky-700"
>
<Plus size={15} />
<span>تسک جدید</span>
</button>
)}
<button
type="button"
onClick={refresh}
aria-label="به‌روزرسانی"
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
>
<RefreshCw
size={20}
className={projectQuery.isFetching || tasksQuery.isFetching ? "animate-spin" : ""}
/>
</button>
</div>
}
/>
<div className="px-4 pb-28 pt-3">
{project && (
<Card className="p-3.5 mb-3 bg-gradient-to-br from-white to-slate-50/50">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<p className="text-xs font-mono font-semibold text-sky-600">{project.identifier}</p>
<h2 className="text-base font-bold text-slate-800 mt-0.5">{project.name}</h2>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500 font-medium">وضعیت:</span>
{canManageProjects ? (
<select
value={project.status?.toUpperCase() || "ACTIVE"}
onChange={(e) => updateStatusMutation.mutate(e.target.value)}
disabled={updateStatusMutation.isPending}
aria-label="تغییر وضعیت پروژه"
className={`rounded-lg px-2.5 py-1 text-xs font-semibold border transition-all cursor-pointer shadow-sm ${
(project.status || "").toUpperCase() === "ACTIVE"
? "bg-emerald-50 text-emerald-700 border-emerald-300 focus:ring-emerald-200"
: (project.status || "").toUpperCase() === "COMPLETED"
? "bg-sky-50 text-sky-700 border-sky-300 focus:ring-sky-200"
: (project.status || "").toUpperCase() === "ON_HOLD"
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
: (project.status || "").toUpperCase() === "PLANNED" || (project.status || "").toUpperCase() === "PLANNING"
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
: "bg-slate-50 text-slate-700 border-slate-300 focus:ring-slate-200"
}`}
>
<option value="ACTIVE">در حال اجرا (ACTIVE)</option>
<option value="PLANNED">برنامه‌ریزی (PLANNED)</option>
<option value="ON_HOLD">متوقف (ON_HOLD)</option>
<option value="COMPLETED">تکمیل‌شده (COMPLETED)</option>
<option value="CANCELLED">لغو شده (CANCELLED)</option>
</select>
) : (
<Chip
tone={
project.status === "ACTIVE"
? "green"
: project.status === "COMPLETED"
? "sky"
: project.status === "ON_HOLD" || project.status === "PLANNED"
? "amber"
: "slate"
}
>
{project.status === "ACTIVE"
? "در حال اجرا"
: project.status === "PLANNED" || project.status === "PLANNING"
? "برنامه‌ریزی"
: project.status === "ON_HOLD"
? "متوقف"
: project.status === "COMPLETED"
? "تکمیل‌شده"
: project.status === "CANCELLED"
? "لغو شده"
: project.status}
</Chip>
)}
</div>
</div>
{project.location && (
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<MapPin size={13} className="text-slate-400" />
{project.location}
</span>
</div>
)}
{(project.planned_start_jalali || project.planned_end_jalali || project.actual_start_jalali) && (
<div className="mt-2.5 space-y-1.5 rounded-xl bg-slate-50 p-2.5 text-xs border border-slate-100">
<div className="flex items-center gap-2 text-slate-600">
<Calendar size={13} className="text-sky-600" />
<span className="font-semibold text-slate-700">برنامه‌ریزی‌شده:</span>
<span>{project.planned_start_jalali || "—"} تا {project.planned_end_jalali || "—"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 ms-1" />
<span className="font-semibold text-slate-700">واقعی:</span>
<span>
شروع: {project.actual_start_jalali || "شروع نشده"}
{" | "}
پایان: {project.actual_end_jalali || (project.progress === 100 ? "تکمیل شده" : "تکمیل نشده")}
</span>
</div>
</div>
)}
{project.progress !== undefined && (
<div className="mt-2.5 rounded-xl bg-slate-50 p-2.5 border border-slate-100">
<div className="flex items-center justify-between text-xs text-slate-600 mb-1">
<span className="font-semibold">پیشرفت کل پروژه</span>
<span className="font-extrabold tabular-nums text-sky-700 text-sm">{Math.round(project.progress)}٪</span>
</div>
<ProgressBar value={project.progress} />
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-1.5 pt-2 border-t border-slate-100">
<Chip tone="sky">کل: {project.stats.total}</Chip>
<Chip tone="amber">انتظار QC: {project.stats.awaiting_qc}</Chip>
{project.stats.failed_qc > 0 && <Chip tone="red">مردود: {project.stats.failed_qc}</Chip>}
{project.stats.overdue > 0 && <Chip tone="red">عقب‌افتاده: {project.stats.overdue}</Chip>}
</div>
</Card>
)}
{/* ۳ تب: ساختار شکست کار / گانت و زمان‌بندی / اعضای تیم */}
<div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
<button
type="button"
onClick={() => setActiveTab("tasks")}
className={`flex-1 rounded-lg py-1.5 text-center transition-all ${
activeTab === "tasks" ? "bg-white text-sky-700 shadow-sm" : "hover:text-slate-800"
}`}
>
ساختار شکست کار (WBS)
</button>
<button
type="button"
onClick={() => setActiveTab("gantt")}
className={`flex-1 rounded-lg py-1.5 text-center transition-all ${
activeTab === "gantt" ? "bg-white text-sky-700 shadow-sm" : "hover:text-slate-800"
}`}
>
نمودار گانت و زمان‌بندی
</button>
<button
type="button"
onClick={() => setActiveTab("members")}
className={`flex-1 rounded-lg py-1.5 text-center transition-all ${
activeTab === "members" ? "bg-white text-sky-700 shadow-sm" : "hover:text-slate-800"
}`}
>
اعضای تیم ({project?.members?.length ?? project?.member_count ?? 0})
</button>
</div>
{activeTab === "tasks" && (
<>
<div className="relative mb-3">
<Search size={16} className="absolute start-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی وظیفه..."
className={`${inputCls} ps-9`}
/>
</div>
<div>
{tasksQuery.isPending ? (
<div className="space-y-3">
<CardSkeleton />
<CardSkeleton />
</div>
) : tasksQuery.isError ? (
<Card>
<ErrorState onRetry={() => void tasksQuery.refetch()} />
</Card>
) : filteredTree.length === 0 ? (
<Card>
<EmptyState
icon={Folder}
title={search ? "نتیجه‌ای یافت نشد" : "هنوز وظیفه‌ای ثبت نشده"}
description={
canManageTasks
? "برای تعریف نخستین وظیفه روی دکمه «تسک جدید» در بالا کلیک کنید."
: "در این پروژه تاکنون فعالیتی ایجاد نشده است."
}
/>
</Card>
) : (
<Card className="p-0 overflow-hidden">
<div>
{filteredTree.map((node) => (
<TreeRow
key={String(node.task.id || node.task.op_wp_id)}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
onAddSubtask={handleAddSubtask}
onEditTask={handleEditTask}
canManageTasks={canManageTasks}
/>
))}
</div>
</Card>
)}
</div>
</>
)}
{activeTab === "gantt" && (
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-700">زمان‌بندی و توالی فعالیت‌ها</span>
</div>
<div className="flex items-center gap-1.5">
{canManageTasks && (
<button
type="button"
onClick={() => handleOpenRelationModal()}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-50 px-2.5 text-xs font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<Link2 size={14} />
<span>افزودن وابستگی</span>
</button>
)}
</div>
</div>
{ganttQuery.isPending ? (
<div className="space-y-3">
<CardSkeleton />
<CardSkeleton />
</div>
) : ganttQuery.isError ? (
<Card>
<ErrorState onRetry={() => void ganttQuery.refetch()} />
</Card>
) : !ganttQuery.data ? (
<Card>
<EmptyState
icon={BarChart3}
title="اطلاعات گانت موجود نیست"
description="پس از افزودن فعالیت‌ها، نمودار گانت نمایش داده می‌شود."
/>
</Card>
) : (
<GanttChart
data={ganttQuery.data}
projectId={projectId}
allTasks={tasks ?? []}
onEditTask={handleEditTask}
onAddRelation={canManageTasks ? handleOpenRelationModal : undefined}
onEditRelation={canManageTasks ? handleEditRelation : undefined}
onDeleteRelation={canManageTasks ? (relId) => deleteRelationMutation.mutate(relId) : undefined}
onReschedule={canManageProjects ? () => rescheduleMutation.mutate() : undefined}
isRescheduling={rescheduleMutation.isPending}
canManageTasks={canManageTasks}
canManageProjects={canManageProjects}
/>
)}
</div>
)}
{activeTab === "members" && (
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700">اعضای اختصاص‌یافته به این پروژه</h3>
{canManageProjects && (
<button
type="button"
onClick={() => setShowMemberModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-50 px-2.5 text-xs font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<UserPlus size={14} />
<span>افزودن عضو</span>
</button>
)}
</div>
{(!project?.members || project.members.length === 0) ? (
<Card>
<EmptyState
icon={Users}
title="عضوی ثبت نشده است"
description="برای تخصیص ناظران یا کارشناسان به این پروژه، دکمه «افزودن عضو» را بزنید."
/>
</Card>
) : (
<div className="space-y-2">
{project.members.map((m: ProjectMember) => (
<Card key={m.id} className="p-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<InitialAvatar name={m.display_name || m.username} className="h-8 w-8" />
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 truncate">
{m.display_name || m.username}
</p>
<p className="text-[11px] text-slate-400">
{m.role === "manager"
? "مدیر پروژه"
: m.role === "supervisor"
? "ناظر اجرایی"
: m.role === "qc_inspector"
? "بازرس QC"
: m.role}
</p>
</div>
</div>
{canManageProjects && (
<button
type="button"
onClick={() => removeMemberMutation.mutate(m.id)}
disabled={removeMemberMutation.isPending}
title="حذف عضو"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors"
>
<Trash2 size={15} />
</button>
)}
</Card>
))}
</div>
)}
</div>
)}
</div>
<TaskCreateModal
open={showTaskModal}
projectId={projectId}
parentTask={parentTaskForModal}
allTasks={tasks ?? []}
onClose={() => {
setShowTaskModal(false);
setParentTaskForModal(null);
}}
/>
<TaskEditModal
open={showEditModal}
task={taskToEdit}
allTasks={tasks ?? []}
projectId={projectId}
onClose={() => {
setShowEditModal(false);
setTaskToEdit(null);
}}
/>
<CreateRelationModal
open={showRelationModal}
projectId={projectId}
tasks={tasks ?? []}
initialFromTaskId={relationFromTaskId}
editRelation={relationToEdit}
onClose={() => {
setShowRelationModal(false);
setRelationFromTaskId(null);
setRelationToEdit(null);
}}
/>
<AddMemberModal
open={showMemberModal}
projectId={projectId}
onClose={() => setShowMemberModal(false)}
/>
</>
);
}