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 (

{title}

{children}
); } 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(null); const [dueDate, setDueDate] = useState(null); const currentTaskId = task?.id || taskIdParam; const [consumedHr, setConsumedHr] = useState([]); const [consumedMaterials, setConsumedMaterials] = useState([]); const [progressNote, setProgressNote] = useState(""); const isDateRangeInvalid = Boolean( startDate && dueDate && isoToApiDate(startDate) && isoToApiDate(dueDate) && isoToApiDate(startDate)! > isoToApiDate(dueDate)! ); const pastConsumptionMap = useMemo(() => { const map = new Map(); 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(); 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([]); 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(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 ( <> navigate("/")} /> navigate("/")} /> ); } if (taskQuery.isPending) { return ( <> navigate(-1)} />
); } if (taskQuery.isError || !task) { return ( <> navigate(-1)} /> void taskQuery.refetch()} /> ); } 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 ( <> navigate(-1)} right={ canEditTasks ? ( ) : undefined } />

{task.subject}

{canEditTasks && ( )}
{typeLabel(task.type)} {priority && {priority.label}} {task.qc_status !== "none" && {qc.label}}
{(task.parent || task.parent_op_wp_id || task.parent_id) && ( والد: {task.parent?.title || `#${task.parent_op_wp_id || task.parent_id}`} )}

پروژه: {projectLabel || "—"}

شروع: {formatDate(task.start_date)} سررسید: {formatDate(task.due_date)} {overdue ? " (عقب‌افتاده)" : ""}
{/* کارت پیشرفت و مدیریت یکپارچه مصالح */} پیشرفت وظیفه و مدیریت یکپارچه مصالح و منابع {canEditTasks && ( )}
} >
{progress}٪ {task.sync && task.sync.pending_pushes > 0 && ( {task.sync.pending_pushes} مورد در صف همگام‌سازی )}
QC: {qc.label} پیش از اجرا: {preStatus.label} اجرا: {execStatus.label}
setProgress(Number(e.target.value))} className="mt-3 w-full accent-sky-600" aria-label="درصد پیشرفت" />
{quickValues.map((v) => ( ))}
{progressBlocked && (

ثبت این پیشرفت تا تأیید بازرسی‌ها ممکن نیست

{progressBlockReason}

)} {progress >= 100 && !readiness.can_complete && (

پیشرفت ۱۰۰٪ پس از تأیید همه بازرسی‌ها قابل ثبت است.

)} {/* بخش مصالح و تجهیزات مصرفی */}
مصالح و تجهیزات مصرفی
{consumedMaterials.length === 0 ? (
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. می‌توانید با دکمه بالا مصالح جدید اضافه کنید.
) : (
{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 (
{alloc === 0 ? (
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" />
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" />
) : (
{item.name} {item.unit && ({item.unit})}
)} {specs.length > 0 && (
{specs.map((s, si) => ( {s} ))}
)}
{alloc === 0 ? (
خارج از برنامه
) : ( {isOver ? "⚠️ مازاد بر سقف" : "مجاز"} )}
سقف مصوب {alloc > 0 ? ( <> {alloc}{" "} {item.unit} ) : ( بدون سقف )}
مصرف تاکنون {prevC}{" "} {item.unit}
0 ? "text-emerald-700" : "text-sky-700" }`} > {isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"} 0 ? "text-emerald-700" : "text-sky-700" }`} > {isOver ? ( +{overAmt} {item.unit} ) : alloc > 0 ? ( {rem} {item.unit} ) : ( {totC} {item.unit} )}
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 && ( {item.unit} )}
{currentC > 0 && (
مجموع پس از ثبت: {totC} {item.unit}
)}
{alloc > 0 && (
کل مصرف: {totC} از {alloc} {item.unit} {totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
80 ? "bg-amber-500" : "bg-emerald-500" }`} />
)} {isOver && (
میزان مصرف ({totC} {item.unit}) از سقف مصوب ({alloc} {item.unit}) بیشتر است. (+{overAmt} {item.unit} مازاد)
)}
); })}
)}
{/* نیروی انسانی */}
نیروی انسانی تحویل‌شده و حاضر در کارگاه
{consumedHr.length === 0 ? (

ردیفی برای نیروی انسانی ثبت نشده است

) : ( consumedHr.map((item, idx) => (
{ 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" />
{ 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" />
{ 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" />
{item.planned_count != null && item.planned_count !== item.actual_count && (

⚠️ مغایرت تأمین نیرو: {item.planned_count} نفر درخواستی سرپرست، ولی {item.actual_count} نفر تأمین شده است.

)}
)) )}