1601 lines
62 KiB
TypeScript
1601 lines
62 KiB
TypeScript
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)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|