Files
ERP/pm/frontend/src/pages/TaskDetailPage.tsx
T

1389 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, useState } from "react";
import type { ReactNode } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
ChevronLeft,
Clock,
Layers,
Pencil,
Plus,
Trash2,
Link2,
Users,
} from "lucide-react";
import {
ApiError,
createTaskRelation,
deleteTaskRelation,
getProjects,
getTask,
getTasks,
updateTask,
updateTaskDates,
updateTaskProgress,
} from "../lib/api";
import type {
HumanResourceConsumption,
MaterialConsumption,
MaterialItem,
TaskProgressPayload,
} from "../lib/api";
import { TaskQcSection } from "../components/TaskQcSection";
import { ResourceSectionInputs } from "../components/ResourceSectionInputs";
import {
Card,
CardSkeleton,
Chip,
ErrorState,
Modal,
PageHeader,
ProgressBar,
btnPrimary,
btnSecondary,
inputCls,
} from "../components/ui";
import { JalaliDatePicker } from "../components/JalaliDatePicker";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission, isManager } from "../lib/roles";
import { TaskEditModal } from "../components/TaskEditModal";
import {
formatDate,
isOverdue,
priorityMeta,
qcStatusMeta,
qcSummaryStatusMeta,
relativeTime,
typeLabel,
} from "../lib/format";
import { isoToApiDate } from "../lib/jalali";
const quickValues = [0, 25, 50, 75, 100];
function parseCleanNumber(raw: string): number {
if (!raw) return 0;
const clean = raw
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString())
.replace(/[^0-9.]/g, "");
const val = parseFloat(clean);
return isNaN(val) ? 0 : Math.max(0, val);
}
function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) {
return (
<Card className="p-4">
<h2 className="mb-3 text-sm font-bold text-slate-700">{title}</h2>
{children}
</Card>
);
}
export default function TaskDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const taskIdParam = id ?? "";
const validId = Boolean(taskIdParam.trim());
const taskQuery = useQuery({
queryKey: ["task", taskIdParam],
queryFn: () => getTask(taskIdParam),
enabled: validId,
});
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
const task = taskQuery.data;
const projectTasksQuery = useQuery({
queryKey: ["tasks", task?.project_id],
queryFn: () => getTasks({ project_id: task?.project_id ? String(task.project_id) : undefined, page_size: 300 }),
enabled: Boolean(task?.project_id),
});
const [showEditModal, setShowEditModal] = useState(false);
const [progress, setProgress] = useState(0);
const [startDate, setStartDate] = useState<string | null>(null);
const [dueDate, setDueDate] = useState<string | null>(null);
const currentTaskId = task?.id || taskIdParam;
const [consumedHr, setConsumedHr] = useState<HumanResourceConsumption[]>([]);
const [consumedMaterials, setConsumedMaterials] = useState<MaterialConsumption[]>([]);
const [progressNote, setProgressNote] = useState("");
const isDateRangeInvalid = Boolean(
startDate &&
dueDate &&
isoToApiDate(startDate) &&
isoToApiDate(dueDate) &&
isoToApiDate(startDate)! > isoToApiDate(dueDate)!
);
const pastConsumptionMap = useMemo(() => {
const map = new Map<string, { total: number; unit: string }>();
if (!task?.progress_logs) return map;
for (const log of task.progress_logs) {
if (Array.isArray(log.materials_used)) {
for (const m of log.materials_used) {
if (m?.name) {
const key = m.name.trim().toLowerCase();
const curr = map.get(key) || { total: 0, unit: m.unit || "" };
map.set(key, {
total: curr.total + (Number(m.amount) || 0),
unit: m.unit || curr.unit,
});
}
}
}
}
return map;
}, [task?.progress_logs]);
useEffect(() => {
if (task) {
setProgress(task.percentage_done);
setStartDate(task.start_date);
setDueDate(task.due_date);
if (task.human_resources && task.human_resources.length > 0) {
setConsumedHr(
task.human_resources.map((h) => ({
role: h.role,
planned_count: h.count,
actual_count: h.count,
note: "",
}))
);
} else {
setConsumedHr([]);
}
const items: MaterialConsumption[] = [];
const seen = new Set<string>();
if (task.materials && task.materials.length > 0) {
for (const m of task.materials) {
const key = m.name.trim().toLowerCase();
seen.add(key);
const past = pastConsumptionMap.get(key)?.total || 0;
const alloc = Number(m.count) || 0;
const isOver = alloc > 0 && past > alloc;
items.push({
name: m.name,
unit: m.unit || "",
amount: 0,
allocated_amount: alloc,
previous_consumed: past,
total_consumed: past,
remaining_amount: Math.max(0, alloc - past),
is_over_allocated: isOver,
over_allocated_amount: isOver ? past - alloc : 0,
note: "",
});
}
}
pastConsumptionMap.forEach((val, key) => {
if (!seen.has(key)) {
items.push({
name: key,
unit: val.unit,
amount: 0,
allocated_amount: 0,
previous_consumed: val.total,
total_consumed: val.total,
remaining_amount: 0,
is_over_allocated: false,
over_allocated_amount: 0,
note: "خارج از سقف مصوب",
});
}
});
setConsumedMaterials(items);
}
}, [task, pastConsumptionMap]);
const invalidateTask = () => {
void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] });
void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const progressMutation = useMutation({
mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload),
onSuccess: () => {
toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
setProgressNote("");
invalidateTask();
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت پیشرفت ناموفق بود"),
});
const handleMaterialAmountChange = (idx: number, rawVal: string) => {
const amt = parseCleanNumber(rawVal);
setConsumedMaterials((prev) => {
const next = [...prev];
const item = { ...next[idx] };
const alloc = item.allocated_amount ?? 0;
const prevC = item.previous_consumed ?? 0;
const tot = prevC + amt;
const rem = Math.max(0, alloc - tot);
const isOver = alloc > 0 && tot > alloc;
const overAmt = isOver ? tot - alloc : 0;
item.amount = amt;
item.total_consumed = tot;
item.remaining_amount = rem;
item.is_over_allocated = isOver;
item.over_allocated_amount = overAmt;
next[idx] = item;
return next;
});
};
const handleMaterialFieldChange = (idx: number, field: "name" | "unit" | "note", val: string) => {
setConsumedMaterials((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: val };
return next;
});
};
const handleAddCustomMaterial = () => {
setConsumedMaterials((prev) => [
...prev,
{
name: "",
unit: "عدد",
amount: 0,
allocated_amount: 0,
previous_consumed: 0,
total_consumed: 0,
remaining_amount: 0,
is_over_allocated: false,
over_allocated_amount: 0,
note: "مصالح خارج از برنامه",
},
]);
};
const handleRemoveMaterialRow = (idx: number) => {
setConsumedMaterials((prev) => prev.filter((_, i) => i !== idx));
};
const datesMutation = useMutation({
mutationFn: () => {
const payload: { start_date?: string; due_date?: string } = {};
const start = isoToApiDate(startDate);
const due = isoToApiDate(dueDate);
if (start && due && start > due) {
throw new ApiError("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد", 400, "VALIDATION");
}
if (start) payload.start_date = start;
if (due) payload.due_date = due;
return updateTaskDates((task as any)?.id ?? task?.op_wp_id ?? Number(taskIdParam) ?? 0, payload);
},
onSuccess: () => {
toast.success("تاریخ‌های کار با موفقیت ثبت شد");
invalidateTask();
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخ‌ها ناموفق بود"),
});
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
useEffect(() => {
if (task?.materials) {
setEditMaterials(JSON.parse(JSON.stringify(task.materials)));
} else {
setEditMaterials([]);
}
}, [task?.materials, showEditResourcesModal]);
const updateResourcesMutation = useMutation({
mutationFn: (newMaterials: MaterialItem[]) =>
updateTask(currentTaskId, { materials: newMaterials.filter((m) => m.name.trim()) }),
onSuccess: () => {
toast.success("منابع تخصیص‌یافته با موفقیت به‌روزرسانی شد");
setShowEditResourcesModal(false);
invalidateTask();
},
onError: (err) =>
toast.error(err instanceof ApiError ? err.message : "خطا در به‌روزرسانی منابع وظیفه"),
});
const [showAddRelationModal, setShowAddRelationModal] = useState(false);
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);
const addRelationMutation = useMutation({
mutationFn: (payload: {
from_task_id: string;
to_task_id: string;
relation_type: "FS" | "SS" | "FF" | "SF";
lag_days: number;
}) => createTaskRelation(task?.project_id || "", payload),
onSuccess: () => {
toast.success("رابطه وابستگی با موفقیت ایجاد شد");
setShowAddRelationModal(false);
setRelTargetId("");
setRelLag(0);
invalidateTask();
if (task?.project_id) {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", task.project_id] });
void queryClient.invalidateQueries({ queryKey: ["tasks", task.project_id] });
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
},
});
const deleteRelationMutation = useMutation({
mutationFn: (relationId: string) =>
deleteTaskRelation(task?.project_id || "", relationId),
onSuccess: () => {
toast.success("رابطه وابستگی با موفقیت حذف شد");
invalidateTask();
if (task?.project_id) {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", task.project_id] });
void queryClient.invalidateQueries({ queryKey: ["tasks", task.project_id] });
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در حذف رابطه وابستگی");
},
});
if (!validId) {
return (
<>
<PageHeader title="وظیفه" onBack={() => navigate("/")} />
<Card className="m-4">
<ErrorState message="شناسه وظیفه نامعتبر است" onRetry={() => navigate("/")} />
</Card>
</>
);
}
if (taskQuery.isPending) {
return (
<>
<PageHeader title="وظیفه" onBack={() => navigate(-1)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
</>
);
}
if (taskQuery.isError || !task) {
return (
<>
<PageHeader title="وظیفه" onBack={() => navigate(-1)} />
<Card className="m-4">
<ErrorState message="خطا در دریافت اطلاعات وظیفه" onRetry={() => void taskQuery.refetch()} />
</Card>
</>
);
}
const qc = qcStatusMeta(task.qc_status);
const priority = priorityMeta(task.priority);
const project = (projectsQuery.data ?? []).find(
(p) => task.project_id != null && p.id != null && p.id === task.project_id,
);
const projectLabel = project?.name ?? "—";
const overdue = isOverdue(task.due_date);
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
const canEditDates = !isSupervisor && hasPermission(user, Perm.tasksDates);
const canEditTasks = !isSupervisor && (hasPermission(user, Perm.tasksUpdate) || canEditDates || isManager(user));
const readiness = task.qc ?? {
pre: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, na: 0, required_total: 0, required_unresolved: 0 },
exec: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, na: 0, required_total: 0, required_unresolved: 0 },
all: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, na: 0, required_total: 0, required_unresolved: 0 },
status: "none",
can_start: true,
can_complete: true,
block_reasons: [],
};
const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none");
const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none");
const blockedByStart = progress > 0 && !readiness.can_start;
const blockedByComplete = progress >= 100 && !readiness.can_complete;
const progressBlocked = blockedByStart || blockedByComplete;
const progressBlockReason = blockedByStart
? readiness.block_reasons?.[0] ?? "بازرسی پیش از اجرا تأیید نشده است"
: blockedByComplete
? `پیشرفت ۱۰۰٪ پس از تأیید همه بازرسی‌ها (${readiness.all?.required_unresolved ?? 0} سؤال باقی‌مانده${
(readiness.all?.failed ?? 0) > 0 ? `، ${readiness.all.failed} مورد مردود` : ""
})`
: null;
return (
<>
<PageHeader
title="وظیفه"
onBack={() => navigate(-1)}
right={
canEditTasks ? (
<button
type="button"
onClick={() => setShowEditModal(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-sky-50 px-3 text-xs font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<Pencil size={14} />
<span>ویرایش وظیفه</span>
</button>
) : undefined
}
/>
<div className="space-y-3 px-4 pb-28 pt-4">
<Card className="p-4">
<div className="flex items-start justify-between gap-2">
<p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p>
{canEditTasks && (
<button
type="button"
onClick={() => setShowEditModal(true)}
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-slate-200 px-2.5 text-xs font-medium text-slate-600 hover:bg-slate-50 transition-colors"
>
<Pencil size={13} />
<span>ویرایش</span>
</button>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Chip tone="sky">{typeLabel(task.type)}</Chip>
{priority && <Chip tone={priority.tone}>{priority.label}</Chip>}
{task.qc_status !== "none" && <Chip tone={qc.tone}>{qc.label}</Chip>}
</div>
<div className="mt-3 space-y-2 text-xs text-slate-500">
{(task.parent || task.parent_op_wp_id || task.parent_id) && (
<Link
to={`/tasks/${task.parent?.id || task.parent_id || task.parent_op_wp_id}`}
className="block text-sky-600"
>
والد: {task.parent?.title || `#${task.parent_op_wp_id || task.parent_id}`}
</Link>
)}
<p>پروژه: {projectLabel || "—"}</p>
<div className="flex gap-4">
<span>
شروع: <span className="text-slate-700">{formatDate(task.start_date)}</span>
</span>
<span className={overdue ? "font-semibold text-red-600" : ""}>
سررسید: <span className="text-slate-700">{formatDate(task.due_date)}</span>
{overdue ? " (عقب‌افتاده)" : ""}
</span>
</div>
</div>
</Card>
{/* کارت پیشرفت و مدیریت یکپارچه مصالح */}
<SectionCard
title={
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-1.5 font-bold text-slate-800">
<Layers size={16} className="text-amber-600" />
<span>پیشرفت وظیفه و مدیریت یکپارچه مصالح و منابع</span>
</span>
{canEditTasks && (
<button
type="button"
onClick={() => setShowEditResourcesModal(true)}
className="inline-flex items-center gap-1 rounded-lg bg-sky-50 px-2 py-1 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors border border-sky-200"
>
<Pencil size={12} />
<span>ویرایش سقف تخصیص مصالح</span>
</button>
)}
</div>
}
>
<div className="flex items-center justify-between">
<span className="text-2xl font-extrabold text-sky-700">{progress}٪</span>
{task.sync && task.sync.pending_pushes > 0 && (
<Chip tone="amber">{task.sync.pending_pushes} مورد در صف همگام‌سازی</Chip>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Chip tone={qc.tone}>QC: {qc.label}</Chip>
<Chip tone={preStatus.tone}>پیش از اجرا: {preStatus.label}</Chip>
<Chip tone={execStatus.tone}>اجرا: {execStatus.label}</Chip>
</div>
<input
type="range"
min={0}
max={100}
step={5}
dir="ltr"
value={progress}
onChange={(e) => setProgress(Number(e.target.value))}
className="mt-3 w-full accent-sky-600"
aria-label="درصد پیشرفت"
/>
<div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5">
{quickValues.map((v) => (
<button
key={v}
type="button"
onClick={() => setProgress(v)}
className={`h-9 rounded-lg border text-xs font-semibold transition-colors ${
progress === v
? "border-sky-500 bg-sky-50 text-sky-700 font-bold"
: "border-slate-200 bg-white text-slate-500 active:bg-slate-100"
}`}
>
{v}٪
</button>
))}
</div>
{progressBlocked && (
<div className="mt-3 rounded-xl bg-amber-50 p-2.5">
<p className="flex items-center gap-1.5 text-[11px] font-bold text-amber-800">
<AlertTriangle size={14} />
ثبت این پیشرفت تا تأیید بازرسی‌ها ممکن نیست
</p>
<p className="mt-1 text-[11px] leading-5 text-amber-700">{progressBlockReason}</p>
</div>
)}
{progress >= 100 && !readiness.can_complete && (
<p className="mt-2 rounded-lg bg-sky-50 p-2 text-[11px] leading-4 text-sky-800">
پیشرفت ۱۰۰٪ پس از تأیید همه بازرسی‌ها قابل ثبت است.
</p>
)}
{/* بخش مصالح و تجهیزات مصرفی */}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 space-y-3 shadow-2xs">
<div className="flex items-center justify-between border-b border-slate-100 pb-2 gap-2">
<span className="text-xs font-bold text-slate-900 flex items-center gap-1.5 min-w-0">
<Layers size={14} className="text-amber-600 shrink-0" />
<span className="truncate">مصالح و تجهیزات مصرفی</span>
</span>
<button
type="button"
onClick={handleAddCustomMaterial}
className="shrink-0 text-[11px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors"
>
<Plus size={13} />
<span>افزودن مصالح خارج برنامه</span>
</button>
</div>
{consumedMaterials.length === 0 ? (
<div className="text-center py-3 text-xs text-slate-400">
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. می‌توانید با دکمه بالا مصالح جدید اضافه کنید.
</div>
) : (
<div className="space-y-3">
{consumedMaterials.map((item, idx) => {
const alloc = item.allocated_amount || 0;
const prevC = item.previous_consumed || 0;
const currentC = item.amount || 0;
const totC = prevC + currentC;
const rem = Math.max(0, alloc - totC);
const isOver = alloc > 0 && totC > alloc;
const overAmt = isOver ? totC - alloc : 0;
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
const specs =
task?.materials?.find(
(m) => m.name.trim().toLowerCase() === item.name.trim().toLowerCase()
)?.specifications || [];
return (
<div
key={idx}
className={`rounded-xl border p-3 space-y-3 bg-white shadow-2xs transition-all ${
isOver ? "border-rose-300 ring-1 ring-rose-200 bg-rose-50/10" : "border-slate-200"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{alloc === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full">
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
نام مصالح یا کالا
</label>
<input
value={item.name}
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
placeholder="مثلاً: آجر فشاری، سیمان..."
className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
واحد سنجش
</label>
<input
value={item.unit}
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
placeholder="مثلاً: عدد، تن، پاکت..."
className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
) : (
<div className="flex items-baseline gap-1.5">
<span className="font-extrabold text-slate-900 text-sm">{item.name}</span>
{item.unit && <span className="text-xs text-slate-500 font-medium">({item.unit})</span>}
</div>
)}
{specs.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{specs.map((s, si) => (
<span
key={si}
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[10px] border border-slate-200"
>
{s}
</span>
))}
</div>
)}
</div>
<div className="shrink-0 pt-0.5">
{alloc === 0 ? (
<div className="flex items-center gap-1.5">
<span className="rounded-md px-2 py-0.5 text-[10px] font-bold border bg-amber-50 text-amber-700 border-amber-200">
خارج از برنامه
</span>
<button
type="button"
onClick={() => handleRemoveMaterialRow(idx)}
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
title="حذف ردیف"
>
<Trash2 size={15} />
</button>
</div>
) : (
<span
className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${
isOver
? "bg-rose-50 text-rose-700 border-rose-200"
: "bg-emerald-50 text-emerald-700 border-emerald-200"
}`}
>
{isOver ? "⚠️ مازاد بر سقف" : "مجاز"}
</span>
)}
</div>
</div>
<div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-2 text-center text-xs">
<div className="px-1">
<span className="text-slate-400 block text-[10px]">سقف مصوب</span>
<span className="font-bold text-slate-800 text-xs mt-0.5 block">
{alloc > 0 ? (
<>
<span className="tabular-nums">{alloc}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
</>
) : (
<span className="text-slate-500 font-medium">بدون سقف</span>
)}
</span>
</div>
<div className="px-1">
<span className="text-slate-400 block text-[10px]">مصرف تاکنون</span>
<span className="font-bold text-slate-700 text-xs mt-0.5 block">
<span className="tabular-nums">{prevC}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
</span>
</div>
<div className="px-1">
<span
className={`block text-[10px] font-bold ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"}
</span>
<span
className={`font-black text-xs mt-0.5 block ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? (
<span dir="ltr">+{overAmt} {item.unit}</span>
) : alloc > 0 ? (
<span>{rem} {item.unit}</span>
) : (
<span>{totC} {item.unit}</span>
)}
</span>
</div>
</div>
<div className="rounded-lg bg-amber-50/60 border border-amber-200 p-2.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-bold text-amber-950 flex items-center gap-1.5 shrink-0">
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span>مصرف این گزارش *:</span>
</label>
<div className="relative flex items-center w-36">
<input
type="text"
inputMode="decimal"
value={item.amount === 0 ? "" : item.amount}
onFocus={(e) => e.target.select()}
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
placeholder="۰"
className="h-8 w-full rounded-lg border-2 border-amber-400 bg-white px-3 pe-12 text-center text-sm font-black text-slate-900 focus:border-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-200"
/>
{item.unit && (
<span className="absolute left-2 text-[11px] font-bold text-slate-500 pointer-events-none">
{item.unit}
</span>
)}
</div>
</div>
{currentC > 0 && (
<div className="mt-2 pt-1.5 border-t border-amber-200/60 flex items-center justify-between text-[11px] text-amber-900">
<span>مجموع پس از ثبت:</span>
<b className="font-black text-slate-800">
{totC} {item.unit}
</b>
</div>
)}
</div>
{alloc > 0 && (
<div className="space-y-1 pt-0.5">
<div className="flex items-center justify-between text-[10px] text-slate-500">
<span>کل مصرف: <b className="text-slate-800">{totC}</b> از {alloc} {item.unit}</span>
<span className="font-bold">
{totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
</span>
</div>
<div className="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden flex">
<div
style={{ width: `${Math.min(pctConsumed, 100)}%` }}
className={`h-full transition-all duration-300 ${
isOver ? "bg-rose-500" : pctConsumed > 80 ? "bg-amber-500" : "bg-emerald-500"
}`}
/>
</div>
</div>
)}
{isOver && (
<div className="flex items-start gap-1.5 rounded-lg border border-rose-200 bg-rose-50 p-2 text-[11px] text-rose-700">
<AlertTriangle size={14} className="shrink-0 text-rose-600 mt-0.5" />
<span>
میزان مصرف (<strong className="tabular-nums">{totC}</strong> {item.unit}) از سقف مصوب (<strong className="tabular-nums">{alloc}</strong> {item.unit}) بیشتر است. (<span dir="ltr" className="font-bold">+{overAmt}</span> {item.unit} مازاد)
</span>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* نیروی انسانی */}
<div className="mt-3 rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-sky-950 flex items-center gap-1">
<Users size={14} className="text-sky-600" />
<span>نیروی انسانی تحویل‌شده و حاضر در کارگاه</span>
</span>
<button
type="button"
onClick={() =>
setConsumedHr([...consumedHr, { role: "", planned_count: undefined, actual_count: 1, note: "" }])
}
className="text-[10px] font-semibold text-sky-700 hover:text-sky-900 bg-white border border-sky-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-sky-50 transition-colors"
>
<Plus size={12} />
<span>افزودن ردیف نیرو</span>
</button>
</div>
{consumedHr.length === 0 ? (
<p className="text-[10px] text-slate-400 py-1">ردیفی برای نیروی انسانی ثبت نشده است</p>
) : (
consumedHr.map((item, idx) => (
<div key={idx} className="rounded-lg border border-slate-200/80 bg-white p-2 text-xs space-y-1.5 shadow-2xs">
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="text-[9px] text-slate-500 block mb-0.5">نقش / رده شغلی</label>
<input
value={item.role}
onChange={(e) => {
const next = [...consumedHr];
next[idx] = { ...next[idx], role: e.target.value };
setConsumedHr(next);
}}
placeholder="مثلاً: آرماتوربند، کارگر ساده..."
className="h-7 w-full rounded border border-slate-200 px-2 text-xs focus:border-sky-500 focus:outline-none"
/>
</div>
<div className="w-20">
<label className="text-[9px] text-slate-500 block mb-0.5">درخواستی</label>
<input
type="number"
min={0}
value={item.planned_count ?? ""}
onChange={(e) => {
const next = [...consumedHr];
next[idx] = {
...next[idx],
planned_count: e.target.value ? parseInt(e.target.value) : undefined,
};
setConsumedHr(next);
}}
placeholder="نفر"
className="h-7 w-full rounded border border-slate-200 px-2 text-xs focus:border-sky-500 focus:outline-none"
/>
</div>
<div className="w-20">
<label className="text-[9px] font-bold text-sky-800 block mb-0.5">واقعی حاضر *</label>
<input
type="number"
min={0}
value={item.actual_count}
onChange={(e) => {
const next = [...consumedHr];
next[idx] = { ...next[idx], actual_count: parseInt(e.target.value) || 0 };
setConsumedHr(next);
}}
className="h-7 w-full rounded border border-sky-400 bg-sky-50/50 px-2 text-xs font-bold text-sky-900 focus:border-sky-500 focus:outline-none"
/>
</div>
<button
type="button"
onClick={() => setConsumedHr(consumedHr.filter((_, i) => i !== idx))}
className="mt-3.5 text-slate-400 hover:text-red-500 p-1"
title="حذف ردیف"
>
<Trash2 size={13} />
</button>
</div>
{item.planned_count != null && item.planned_count !== item.actual_count && (
<p className="text-[10px] text-amber-700 font-medium">
⚠️ مغایرت تأمین نیرو: {item.planned_count} نفر درخواستی سرپرست، ولی {item.actual_count} نفر تأمین شده است.
</p>
)}
</div>
))
)}
</div>
<div className="mt-3 pt-2 border-t border-slate-200">
<label className="text-[10px] font-medium text-slate-600 block mb-1">
توضیحات و گزارش روزانه کارگاه (اختیاری)
</label>
<textarea
value={progressNote}
onChange={(e) => setProgressNote(e.target.value)}
placeholder="شرح فعالیت‌های انجام‌شده، دلایل مصرف مازاد، کسری نیرو یا مصالح و..."
className="w-full rounded-lg border border-slate-200 bg-white p-2 text-xs focus:border-sky-500 focus:outline-none h-14"
/>
</div>
<button
type="button"
disabled={
progressMutation.isPending ||
progressBlocked ||
(progress === task.percentage_done &&
!consumedMaterials.some((m) => (m.amount || 0) > 0) &&
!progressNote.trim())
}
onClick={() =>
progressMutation.mutate({
percentage: progress,
human_resources_used: consumedHr,
materials_used: consumedMaterials,
description: progressNote,
})
}
className={`${btnPrimary} mt-3 w-full`}
>
{progressMutation.isPending ? "در حال ثبت پیشرفت و گزارش مصرف..." : "ذخیره پیشرفت و ثبت مصرف منابع"}
</button>
{task.sync?.last_error && (
<p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] leading-4 text-red-600">
خطای همگام‌سازی: {task.sync.last_error}
</p>
)}
</SectionCard>
{/* کارت تاریخچه ثبت پیشرفت */}
{task.progress_logs && task.progress_logs.length > 0 && (
<SectionCard title={`تاریخچه گزارش‌های پیشرفت و مصرف منابع (${task.progress_logs.length})`}>
<div className="space-y-2.5">
{task.progress_logs.map((log) => (
<div key={log.id} className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 text-xs space-y-2">
<div className="flex items-center justify-between border-b border-slate-200/80 pb-2">
<div className="flex items-center gap-2">
<span className="rounded-md bg-sky-600 px-2 py-0.5 text-[11px] font-bold text-white shadow-2xs">
{log.progress_before}٪ ➔ {log.progress_after}٪
</span>
<span className="text-[11px] font-semibold text-slate-700">
{log.progress_after - log.progress_before > 0 ? `+${log.progress_after - log.progress_before}٪ پیشرفت` : "ثبت پیشرفت"}
</span>
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-1">
<Clock size={11} className="text-slate-400" />
<span>{relativeTime(log.created_at)}</span>
{log.logged_by && <span>توسط {log.logged_by}</span>}
</div>
</div>
{log.human_resources_used && log.human_resources_used.length > 0 && (
<div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span>
<div className="flex flex-wrap gap-1.5">
{log.human_resources_used.map((hr, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded bg-white px-2 py-1 border border-slate-200/80 text-[11px] text-slate-800 shadow-2xs">
<Users size={11} className="text-sky-600" />
<span className="font-semibold">{hr.role}:</span>
<span className="text-sky-700 font-bold">{hr.actual_count} نفر حاضر</span>
{hr.planned_count != null && hr.planned_count !== hr.actual_count && (
<span className="text-amber-600 text-[10px]">(درخواستی: {hr.planned_count})</span>
)}
</span>
))}
</div>
</div>
)}
{log.materials_used && log.materials_used.length > 0 && (
<div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرف‌شده:</span>
<div className="flex flex-wrap gap-1.5">
{log.materials_used.map((mat, i) => (
<span
key={i}
className={`inline-flex items-center gap-1.5 rounded-lg bg-white px-2 py-1 border text-[11px] shadow-2xs ${
mat.is_over_allocated
? "border-rose-300 bg-rose-50/50 text-rose-900"
: "border-slate-200/80 text-slate-800"
}`}
>
<Layers size={11} className={mat.is_over_allocated ? "text-rose-600" : "text-amber-600"} />
<span className="font-semibold">{mat.name}:</span>
<span className="font-bold">{mat.amount} {mat.unit || "واحد"}</span>
{mat.is_over_allocated && (
<span className="rounded bg-rose-100 px-1.5 py-0.2 text-[9px] font-bold text-rose-800 border border-rose-200">
مازاد (+{mat.over_allocated_amount || mat.amount})
</span>
)}
</span>
))}
</div>
</div>
)}
{log.description && (
<div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed">
{log.description}
</div>
)}
</div>
))}
</div>
</SectionCard>
)}
{/* کارت تاریخ‌های وظیفه */}
<SectionCard title="تاریخ‌های وظیفه">
<div className="space-y-3">
<div>
<p className="mb-2 text-xs font-bold text-slate-700">تاریخ‌های برنامه‌ریزی‌شده:</p>
{canEditDates ? (
<>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="mb-1 text-[11px] font-medium text-slate-500">تاریخ شروع برنامه‌ریزی‌شده</p>
<JalaliDatePicker value={startDate} onChange={setStartDate} placeholder="۱۴۰۵/۰۶/۲۳" />
</div>
<div>
<p className="mb-1 text-[11px] font-medium text-slate-500">تاریخ پایان برنامه‌ریزی‌شده</p>
<JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" />
</div>
</div>
{isDateRangeInvalid && (
<div className="mt-2.5 flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<button
type="button"
disabled={datesMutation.isPending || isDateRangeInvalid}
onClick={() => datesMutation.mutate()}
className={`${btnSecondary} mt-2.5 w-full`}
>
{datesMutation.isPending ? "در حال ثبت..." : "ذخیره تاریخ‌های برنامه‌ریزی‌شده"}
</button>
</>
) : (
<div className="grid grid-cols-2 gap-2 rounded-xl bg-slate-50 p-2.5 text-xs text-slate-600 border border-slate-100">
<div>
<span className="text-slate-400 block text-[10px]">شروع:</span>
<span className="font-semibold text-slate-700">
{task.planned_start_jalali || task.start_date_jalali || (task.start_date ? formatDate(task.start_date) : "—")}
</span>
</div>
<div>
<span className="text-slate-400 block text-[10px]">پایان:</span>
<span className="font-semibold text-slate-700">
{task.planned_end_jalali || task.due_date_jalali || (task.due_date ? formatDate(task.due_date) : "—")}
</span>
</div>
</div>
)}
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50/80 p-2.5 text-xs">
<p className="mb-1.5 font-bold text-slate-700">تاریخ‌های واقعی (محاسبه و ثبت خودکار):</p>
<div className="grid grid-cols-2 gap-2 text-slate-600">
<div className="rounded-lg bg-white p-2 border border-slate-100">
<span className="text-[10px] text-slate-400 block">شروع واقعی:</span>
<span className="font-semibold text-slate-800">
{(task as any).actual_start_jalali || ((task as any).actual_start ? formatDate((task as any).actual_start) : "شروع نشده")}
</span>
{!(task as any).actual_start && (
<span className="mt-0.5 block text-[9px] text-slate-400">با افزایش پیشرفت ثبت می‌شود</span>
)}
</div>
<div className="rounded-lg bg-white p-2 border border-slate-100">
<span className="text-[10px] text-slate-400 block">پایان واقعی:</span>
<span className="font-semibold text-slate-800">
{(task as any).actual_end_jalali || ((task as any).actual_end ? formatDate((task as any).actual_end) : "تکمیل نشده")}
</span>
{!(task as any).actual_end && (
<span className="mt-0.5 block text-[9px] text-slate-400">در پیشرفت ۱۰۰٪ ثبت می‌شود</span>
)}
</div>
</div>
</div>
</div>
</SectionCard>
{/* کارت کنترل کیفیت و بازرسی‌ها */}
<SectionCard title="کنترل کیفیت و بازرسی‌ها">
<TaskQcSection
task={task}
canExecuteQc={canExecuteQc}
canDefineQc={canDefineQc}
/>
</SectionCard>
{/* کارت ارتباطات */}
<SectionCard
title={
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-1.5">
<Link2 size={14} className="text-sky-600" />
<span>ارتباطات و روابط پیش‌نیازی (FS, SS, FF, SF)</span>
</span>
{canEditTasks && (
<button
type="button"
onClick={() => setShowAddRelationModal(true)}
className="inline-flex items-center gap-1 rounded-lg bg-sky-50 px-2 py-1 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<Plus size={13} />
<span>افزودن وابستگی</span>
</button>
)}
</div>
}
>
<p className="text-[11px] font-semibold text-slate-500 mb-1">پیش‌نیازها (مسدود شده توسط)</p>
{(task.relations?.blocked_by || []).length === 0 ? (
<p className="mt-1 text-[11px] text-slate-400">موردی نیست</p>
) : (
<div className="mt-1 space-y-1.5">
{(task.relations?.blocked_by || []).map((r) => (
<div
key={r.id || r.op_wp_id}
className="flex items-center justify-between gap-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs text-slate-700 border border-slate-100"
>
<Link
to={`/tasks/${r.task_id || r.id || r.op_wp_id}`}
className="flex items-center gap-2 min-w-0 flex-1 hover:text-sky-700 transition-colors"
>
<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 || "FS"}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{r.subject || "وظیفه پیش‌نیاز"}</span>
{Boolean(r.lag_days) && (
<span className="text-[10px] text-slate-400">
(تاخیر: {(r.lag_days ?? 0) > 0 ? `+${r.lag_days}` : r.lag_days} روز)
</span>
)}
</Link>
<div className="flex items-center gap-1">
{canEditTasks && r.id && (
<button
type="button"
onClick={() => r.id && deleteRelationMutation.mutate(r.id)}
disabled={deleteRelationMutation.isPending}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
title="حذف رابطه"
>
<Trash2 size={13} />
</button>
)}
<ChevronLeft size={14} className="shrink-0 text-slate-400" />
</div>
</div>
))}
</div>
)}
<p className="mt-3.5 text-[11px] font-semibold text-slate-500 mb-1">پس‌نیازها (مسدودکننده)</p>
{(task.relations?.blocks || []).length === 0 ? (
<p className="mt-1 text-[11px] text-slate-400">موردی نیست</p>
) : (
<div className="mt-1 space-y-1.5">
{(task.relations?.blocks || []).map((r) => (
<div
key={r.id || r.op_wp_id}
className="flex items-center justify-between gap-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs text-slate-700 border border-slate-100"
>
<Link
to={`/tasks/${r.task_id || r.id || r.op_wp_id}`}
className="flex items-center gap-2 min-w-0 flex-1 hover:text-sky-700 transition-colors"
>
<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 || "FS"}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{r.subject || "وظیفه پس‌نیاز"}</span>
{Boolean(r.lag_days) && (
<span className="text-[10px] text-slate-400">
(تاخیر: {(r.lag_days ?? 0) > 0 ? `+${r.lag_days}` : r.lag_days} روز)
</span>
)}
</Link>
<div className="flex items-center gap-1">
{canEditTasks && r.id && (
<button
type="button"
onClick={() => r.id && deleteRelationMutation.mutate(r.id)}
disabled={deleteRelationMutation.isPending}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
title="حذف رابطه"
>
<Trash2 size={13} />
</button>
)}
<ChevronLeft size={14} className="shrink-0 text-slate-400" />
</div>
</div>
))}
</div>
)}
</SectionCard>
{/* کارت زیرتسک‌ها */}
{task.children && task.children.length > 0 && (
<SectionCard title={`زیرتسک‌ها (${task.children.length})`}>
<div className="divide-y divide-slate-100">
{task.children.map((child) => (
<Link key={child.id || child.op_wp_id} to={`/tasks/${child.id || child.op_wp_id}`} className="block py-2.5">
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 flex-1 text-[13px] font-medium text-slate-800">{child.subject}</p>
<span
className={`shrink-0 text-[11px] ${isOverdue(child.due_date) ? "font-semibold text-red-600" : "text-slate-400"}`}
>
{formatDate(child.due_date)}
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<ProgressBar value={child.percentage_done} className="flex-1" />
<span className="w-8 shrink-0 text-left text-[10px] text-slate-400">{child.percentage_done}٪</span>
<ChevronLeft size={14} className="shrink-0 text-slate-400" />
</div>
</Link>
))}
</div>
</SectionCard>
)}
{task.last_synced_at && (
<p className="pt-1 text-center text-[11px] text-slate-400">
آخرین همگام‌سازی: {relativeTime(task.last_synced_at)}
</p>
)}
</div>
<TaskEditModal
open={showEditModal}
task={task}
allTasks={projectTasksQuery.data ?? []}
projectId={task?.project_id || task?.op_project_id || ""}
onClose={() => setShowEditModal(false)}
onDeleted={() => navigate(-1)}
onUpdated={() => invalidateTask()}
/>
{showEditResourcesModal && (
<Modal
open={showEditResourcesModal}
title="ویرایش مصالح و تجهیزات تخصیص‌یافته به وظیفه"
onClose={() => setShowEditResourcesModal(false)}
>
<div className="space-y-4">
<ResourceSectionInputs
materials={editMaterials}
setMaterials={setEditMaterials}
/>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => setShowEditResourcesModal(false)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={updateResourcesMutation.isPending}
onClick={() => updateResourcesMutation.mutate(editMaterials)}
className={btnPrimary}
>
{updateResourcesMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات منابع"}
</button>
</div>
</div>
</Modal>
)}
{showAddRelationModal && (
<Modal
open={showAddRelationModal}
title="افزودن رابطه وابستگی (پیش‌نیازی / پس‌نیازی)"
onClose={() => setShowAddRelationModal(false)}
>
<div className="space-y-3">
<div>
<label className="text-xs font-semibold text-slate-600 block mb-1">وظیفه مرتبط *</label>
<select
value={relTargetId}
onChange={(e) => setRelTargetId(e.target.value)}
className={inputCls}
>
<option value="">انتخاب وظیفه...</option>
{(projectTasksQuery.data || [])
.filter((t) => String(t.id || t.op_wp_id) !== String(task.id || task.op_wp_id))
.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="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-semibold text-slate-600 block mb-1">نقش رابطه</label>
<select
value={relDirection}
onChange={(e) => setRelDirection(e.target.value as any)}
className={inputCls}
>
<option value="predecessor">پیش‌نیاز این وظیفه است</option>
<option value="successor">پس‌نیاز این وظیفه است</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 block mb-1">نوع رابطه</label>
<select
value={relType}
onChange={(e) => setRelType(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>
<div>
<label className="text-xs font-semibold text-slate-600 block mb-1">تاخیر / Lag (روز)</label>
<input
type="number"
value={relLag}
onChange={(e) => setRelLag(parseInt(e.target.value) || 0)}
className={inputCls}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => setShowAddRelationModal(false)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={addRelationMutation.isPending || !relTargetId}
onClick={() => {
if (!relTargetId) {
toast.error("لطفاً یک وظیفه مرتبط انتخاب کنید");
return;
}
const from_task_id =
relDirection === "predecessor" ? relTargetId : String(task.id || task.op_wp_id);
const to_task_id =
relDirection === "predecessor" ? String(task.id || task.op_wp_id) : relTargetId;
addRelationMutation.mutate({
from_task_id,
to_task_id,
relation_type: relType,
lag_days: relLag,
});
}}
className={btnPrimary}
>
{addRelationMutation.isPending ? "در حال ثبت..." : "افزودن رابطه"}
</button>
</div>
</div>
</Modal>
)}
</>
);
}