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