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(); 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; 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 (
{hasChildren ? ( ) : ( )}

{displayTitle} {/* درصد تکمیل — بلافاصله سمت چپ نام و بالای نوار پیشرفت */} {progress}٪

{formatDate(t.due_date || t.planned_end)} {canManageTasks && ( <> )}
{t.qc_status && t.qc_status !== "none" && {qc.label}} {t.priority && t.priority !== "normal" && ( {t.priority === "urgent" ? "فوری" : t.priority === "high" ? "بالا" : "پایین"} )}
{isOpen && node.children.map((child) => ( ))}
); } 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([]); const [taskType] = useState("task"); const [priority, setPriority] = useState("normal"); const [plannedStart, setPlannedStart] = useState(null); const [plannedEnd, setPlannedEnd] = useState(null); const [durationDays, setDurationDays] = useState(""); const [humanResources, setHumanResources] = useState([]); const [materials, setMaterials] = useState([]); 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([]); const [relTargetId, setRelTargetId] = useState(""); const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor"); const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS"); const [relLag, setRelLag] = useState(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 (
{errorMsg &&
{errorMsg}
}
setTitle(e.target.value)} placeholder="مثال: بتن‌ریزی سقف طبقه ۳" required className={inputCls} />
{/* ۱. تعیین وظیفه والد (PARENT) */}
{/* ۲. تعیین زیرتسک‌ها و فرزندان (CHILD) */}
{selectedChildIds.length > 0 && ( {selectedChildIds.length} زیرتسک انتخاب شده )}

وظایفی که انتخاب کنید، پس از ثبت به عنوان فرزند (زیرتسک) این وظیفه تنظیم می‌شوند.

{candidateChildren.length === 0 ? (

وظیفه دیگری برای انتخاب وجود ندارد

) : (
{candidateChildren.map((t) => { const tKey = String(t.id || t.op_wp_id); const isChecked = selectedChildIds.includes(tKey); return ( ); })}
)}
{ const next = reconcileDates( { start: iso, end: plannedEnd, days: durationDays }, "start", ); setPlannedStart(next.start); setPlannedEnd(next.end); setDurationDays(next.days); }} placeholder="انتخاب تاریخ" />
{ const next = reconcileDates( { start: plannedStart, end: iso, days: durationDays }, "end", ); setPlannedStart(next.start); setPlannedEnd(next.end); setDurationDays(next.days); }} placeholder="انتخاب تاریخ" />
{ 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} />

روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.