chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)

The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
87 changed files with 23922 additions and 0 deletions
@@ -0,0 +1,935 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
Camera,
Check,
CheckCircle2,
ClipboardCheck,
FileDown,
Minus,
Plus,
ShieldAlert,
ShieldCheck,
Trash2,
X,
} from "lucide-react";
import {
addTaskQcQuestion,
createDefect,
createTaskQcDefinition,
deleteTaskQcQuestion,
getQcTemplates,
getTaskQc,
putTaskQcResult,
resolveDefect,
uploadFile,
} from "../lib/api";
import type { AnswerValue, Defect, QcPhase, TaskDetail, TaskQcQuestion } from "../lib/api";
import { Chip, ProgressBar, Modal, btnPrimary, btnSecondary, inputCls, Combobox } from "./ui";
import { defectStatusMeta, relativeTime, severityMeta, severityOptions } from "../lib/format";
interface TaskQcSectionProps {
task: TaskDetail;
canExecuteQc: boolean;
canDefineQc: boolean;
}
export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSectionProps) {
const taskId = String(task.id || task.op_wp_id || "");
const queryClient = useQueryClient();
const [activePhase, setActivePhase] = useState<QcPhase>("pre");
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [showAddQuestionModal, setShowAddQuestionModal] = useState(false);
const [defectTargetQuestion, setDefectTargetQuestion] = useState<TaskQcQuestion | null>(null);
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
const [resolutionNotes, setResolutionNotes] = useState("");
// Load Task QC Data
const qcQuery = useQuery({
queryKey: ["task-qc", taskId],
queryFn: () => getTaskQc(taskId),
});
const templatesQuery = useQuery({
queryKey: ["qc-templates"],
queryFn: getQcTemplates,
enabled: showTemplateModal,
});
const qcState = qcQuery.data;
const readiness = qcState?.readiness;
const definitions = qcState?.definitions ?? [];
const questions = definitions.flatMap((d) => d.questions);
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
// Mutations
const invalidateQc = () => {
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
};
const answerMutation = useMutation({
mutationFn: ({ questionId, answer, photoId }: { questionId: string; answer: AnswerValue | null; photoId?: string | null }) =>
putTaskQcResult(questionId, { answer, photo_id: photoId }),
onSuccess: (_, variables) => {
invalidateQc();
if (variables.answer === null) {
toast.success("انتخاب بازرسی لغو شد");
} else {
toast.success("نتیجه بازرسی ثبت شد");
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ثبت نتیجه بازرسی");
},
});
const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => {
if (!canExecuteQc || answerMutation.isPending) return;
if (currentAnswer === newAnswer) {
// کلیک مجدد: لغو انتخاب قبلی
answerMutation.mutate({ questionId, answer: null });
} else {
answerMutation.mutate({ questionId, answer: newAnswer });
}
};
const applyTemplateMutation = useMutation({
mutationFn: (templateId: string) =>
createTaskQcDefinition(taskId, { template_id: templateId }),
onSuccess: () => {
invalidateQc();
setShowTemplateModal(false);
setSelectedTemplateId("");
toast.success("چک‌لیست از قالب بارگذاری شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در بارگذاری قالب");
},
});
const addQuestionMutation = useMutation({
mutationFn: async ({ text, phase, required }: { text: string; phase: QcPhase; required: boolean }) => {
let definitionId = definitions[0]?.id;
if (!definitionId) {
const newDef = await createTaskQcDefinition(taskId, { name: "چک‌لیست کنترل کیفیت" });
definitionId = newDef.id;
}
return addTaskQcQuestion(definitionId, { text, phase, required });
},
onSuccess: () => {
invalidateQc();
setShowAddQuestionModal(false);
toast.success("سؤال جدید به چک‌لیست اضافه شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در افزودن سؤال");
},
});
const deleteQuestionMutation = useMutation({
mutationFn: (questionId: string) => deleteTaskQcQuestion(questionId),
onSuccess: () => {
invalidateQc();
toast.success("سؤال با موفقیت حذف شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در حذف سؤال");
},
});
const resolveDefectMutation = useMutation({
mutationFn: ({ defectId, notes }: { defectId: string; notes: string }) =>
resolveDefect(defectId, notes),
onSuccess: () => {
invalidateQc();
setDefectToResolve(null);
setResolutionNotes("");
toast.success("عیب به عنوان رفع‌شده ثبت گردید");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در رفع عیب");
},
});
// Calculate Progress
const totalQuestions = readiness?.all.total ?? questions.length;
const pendingCount = readiness?.all.pending ?? 0;
const answeredCount = totalQuestions - pendingCount;
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
// Group stats by phase
const preStats = readiness?.pre;
const execStats = readiness?.exec;
return (
<div className="space-y-3.5">
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
{readiness && (
<div>
{!readiness.can_start ? (
<div className="rounded-xl border border-amber-300 bg-amber-50/90 p-3 text-amber-900 shadow-sm">
<div className="flex items-center gap-2 font-bold text-xs">
<AlertTriangle size={16} className="text-amber-600 shrink-0" />
<span>شروع اجرای وظیفه نیازمند بازرسی پیش از اجرا است</span>
</div>
{readiness.block_reasons.length > 0 && (
<ul className="mt-1.5 list-inside list-disc text-[11px] leading-5 text-amber-800 ps-1">
{readiness.block_reasons.map((reason, idx) => (
<li key={idx}>{reason}</li>
))}
</ul>
)}
</div>
) : readiness.all.total > 0 && readiness.all.pending === 0 && readiness.all.failed === 0 ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50/80 p-3 text-emerald-900 flex items-center gap-2 text-xs font-bold">
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<span>تمام بازرسی‌های کیفی با موفقیت تأیید شدند — پیشرفت ۱۰۰٪ مجاز است</span>
</div>
) : (
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-3 text-sky-900 flex items-center justify-between text-xs font-semibold">
<div className="flex items-center gap-2">
<ShieldCheck size={16} className="text-sky-600 shrink-0" />
<span>آمادگی شروع تأیید شده است</span>
</div>
<span className="text-[11px] text-sky-700 font-bold tabular-nums">
{answeredCount} از {totalQuestions} بازرسی انجام شد
</span>
</div>
)}
{/* نوار پیشرفت کلی QC */}
{totalQuestions > 0 && (
<div className="mt-2.5">
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
<span>پیشرفت بازرسی‌های کیفی</span>
<span className="font-bold tabular-nums text-slate-700">{qcPercent}٪ ({answeredCount} از {totalQuestions})</span>
</div>
<ProgressBar value={qcPercent} />
</div>
)}
</div>
)}
{/* 2. هدر ابزارهای چک‌لیست و تب‌های ۳ مرحله */}
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap border-b border-slate-200 pb-2.5">
<div className="flex items-center gap-1.5">
<ClipboardCheck size={16} className="text-sky-600" />
<span className="text-xs font-bold text-slate-800">چک‌لیست بازرسی کیفی</span>
</div>
<div className="flex items-center gap-1.5">
{canDefineQc && (
<>
<button
type="button"
onClick={() => setShowTemplateModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-white border border-slate-200 px-2.5 text-[11px] font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 transition-colors shadow-sm"
>
<FileDown size={13} className="text-sky-600" />
<span>بارگذاری از قالب</span>
</button>
<button
type="button"
onClick={() => setShowAddQuestionModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-600 px-2.5 text-[11px] font-semibold text-white hover:bg-sky-700 transition-colors shadow-sm"
>
<Plus size={13} />
<span>افزودن سؤال</span>
</button>
</>
)}
</div>
</div>
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */}
<div className="flex rounded-lg bg-slate-200/80 p-0.5 text-xs font-semibold text-slate-600">
<button
type="button"
onClick={() => setActivePhase("pre")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
activePhase === "pre"
? "bg-white text-amber-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۱. پیش از اجرا</span>
{preStats && preStats.total > 0 && (
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "pre" ? "bg-amber-100 text-amber-800" : "bg-slate-300 text-slate-700"}`}>
{preStats.passed}/{preStats.total}
</span>
)}
</button>
<button
type="button"
onClick={() => setActivePhase("during")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
activePhase === "during"
? "bg-white text-sky-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۲. حین اجرا</span>
{execStats && execStats.total > 0 && (
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "during" ? "bg-sky-100 text-sky-800" : "bg-slate-300 text-slate-700"}`}>
{execStats.passed}/{execStats.total}
</span>
)}
</button>
<button
type="button"
onClick={() => setActivePhase("after")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
activePhase === "after"
? "bg-white text-emerald-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۳. تحویل و پس از اجرا</span>
</button>
</div>
{/* لیست سؤالات فاز انتخاب‌شده */}
{currentPhaseQuestions.length === 0 ? (
<div className="py-6 text-center text-xs text-slate-400">
<p>سؤالی در این مرحله تعریف نشده است.</p>
{canDefineQc && (
<button
type="button"
onClick={() => setShowAddQuestionModal(true)}
className="mt-2 text-sky-600 hover:underline text-xs inline-flex items-center gap-1 font-semibold"
>
<Plus size={13} />
افزودن اولین سؤال
</button>
)}
</div>
) : (
<div className="space-y-2">
{currentPhaseQuestions.map((q, idx) => {
const latestAns = q.result?.answer;
const hasFailed = latestAns === "fail";
const isSatisfied = latestAns === "pass" || latestAns === "na";
return (
<div
key={q.id}
className={`rounded-xl border bg-white p-3 shadow-xs transition-all ${
hasFailed
? "border-red-200 bg-red-50/20"
: isSatisfied
? "border-emerald-100"
: "border-slate-200"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-slate-100 text-[10px] font-bold text-slate-600">
{idx + 1}
</span>
<p className="text-xs font-bold text-slate-800 leading-5">{q.text}</p>
</div>
{q.result?.evaluated_by && (
<p className="mt-1 text-[10px] text-slate-400 ms-6">
بررسی توسط {q.result.evaluated_by} — {relativeTime(q.result.evaluated_at)}
</p>
)}
</div>
{canDefineQc && (
<button
type="button"
onClick={() => deleteQuestionMutation.mutate(q.id)}
disabled={deleteQuestionMutation.isPending}
title="حذف سؤال"
className="text-slate-300 hover:text-red-500 p-1 rounded transition-colors"
>
<Trash2 size={13} />
</button>
)}
</div>
{/* دکمه‌های پاسخ فوری ۳ گانه */}
<div className="mt-2.5 flex items-center justify-between gap-2 pt-2 border-t border-slate-100">
<span className="text-[11px] text-slate-500">وضعیت بازرسی:</span>
<div className="flex items-center gap-1">
{/* دکمه قبول / Pass */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "pass")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "pass"
? "bg-emerald-600 text-white shadow-sm ring-2 ring-emerald-200"
: "bg-slate-100 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
}`}
>
<Check size={13} strokeWidth={2.2} />
<span>قبول</span>
</button>
{/* دکمه رد / Fail */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "fail")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "fail"
? "bg-red-600 text-white shadow-sm ring-2 ring-red-200"
: "bg-slate-100 text-slate-600 hover:bg-red-50 hover:text-red-700"
}`}
>
<X size={13} strokeWidth={2.2} />
<span>مردود</span>
</button>
{/* دکمه مطرح نیست */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "na")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "na"
? "bg-slate-700 text-white shadow-sm ring-2 ring-slate-300"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<Minus size={15} strokeWidth={3} />
<span>مطرح نیست</span>
</button>
</div>
</div>
{/* در صورت قبول بودن: اکشن آپلود عکس تأییدیه */}
{latestAns === "pass" && (
<div className="mt-2 rounded-lg bg-emerald-50/70 p-2 text-xs border border-emerald-100 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-emerald-800 flex items-center gap-1">
<Check size={13} className="text-emerald-600" />
<span>این مورد تأیید شده است</span>
</span>
<label className="inline-flex items-center gap-1 rounded-md bg-emerald-600 px-2 py-1 text-[11px] font-bold text-white hover:bg-emerald-700 transition-colors shadow-xs cursor-pointer">
<Camera size={12} />
<span>{q.result?.photo_url ? "تغییر تصویر مستند" : "آپلود تصویر مستند (قبول)"}</span>
<input
type="file"
accept="image/*"
className="hidden"
disabled={!canExecuteQc || answerMutation.isPending}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await uploadFile(file);
answerMutation.mutate({ questionId: q.id, answer: "pass", photoId: res.id });
toast.success("تصویر مستند تأییدیه ذخیره شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
}
}}
/>
</label>
</div>
{q.result?.photo_url && (
<div className="flex items-center justify-between gap-2 pt-1 border-t border-emerald-200/60">
<div className="flex items-center gap-2">
<a href={q.result.photo_url} target="_blank" rel="noreferrer" className="block shrink-0">
<img src={q.result.photo_url} alt="مستند قبولی" className="h-10 w-10 rounded object-cover border border-emerald-200" />
</a>
<a href={q.result.photo_url} target="_blank" rel="noreferrer" className="text-[10px] text-emerald-700 hover:underline">
مشاهده تصویر پیوست
</a>
</div>
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => answerMutation.mutate({ questionId: q.id, answer: "pass", photoId: "" })}
className="text-[10px] text-red-500 hover:underline"
>
حذف تصویر
</button>
</div>
)}
</div>
)}
{/* در صورت مردود بودن: اکشن ثبت عیب */}
{hasFailed && (
<div className="mt-2 rounded-lg bg-red-50 p-2 flex items-center justify-between text-xs border border-red-100">
<span className="text-[11px] font-medium text-red-700">این مورد مردود شده است</span>
<button
type="button"
onClick={() => setDefectTargetQuestion(q)}
className="inline-flex items-center gap-1 rounded-md bg-red-600 px-2 py-0.5 text-[11px] font-bold text-white hover:bg-red-700 transition-colors shadow-xs"
>
<ShieldAlert size={12} />
<span>ثبت عدم انطباق (عیب)</span>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 3. لیست عیوب و عدم انطباق‌های وظیفه */}
{task.defects && task.defects.length > 0 && (
<div className="rounded-xl border border-slate-200 bg-white p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
<ShieldAlert size={14} className="text-red-600" />
<span>عدم انطباق‌ها و عیوب ثبت‌شده ({task.defects.length})</span>
</span>
{task.open_defects != null && task.open_defects > 0 && (
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-[10px] font-bold">
{task.open_defects} مورد باز
</span>
)}
</div>
<div className="space-y-2">
{task.defects.map((defect) => {
const isResolved = defect.status === "resolved";
const sev = severityMeta(defect.severity);
const dst = defectStatusMeta(defect.status);
return (
<div
key={defect.id}
className={`rounded-lg border p-2.5 text-xs transition-colors ${
isResolved ? "border-slate-100 bg-slate-50/60" : "border-red-200 bg-red-50/30"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-bold text-slate-800 text-xs block">{defect.title}</span>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Chip tone={dst.tone}>{dst.label}</Chip>
<Chip tone={sev.tone}>{sev.label}</Chip>
<span className="text-[10px] text-slate-400">{relativeTime(defect.created_at)}</span>
</div>
</div>
{!isResolved && canExecuteQc && (
<button
type="button"
onClick={() => setDefectToResolve(defect)}
className="rounded-lg bg-emerald-600 text-white px-2.5 py-1 text-[11px] font-bold hover:bg-emerald-700 transition-colors shadow-xs shrink-0"
>
رفع شد
</button>
)}
</div>
{defect.corrective_action && (
<p className="mt-1.5 text-[11px] leading-4 text-slate-600 bg-white/80 p-1.5 rounded border border-slate-100">
<span className="font-semibold text-slate-700">اقدام اصلاحی: </span>
{defect.corrective_action}
</p>
)}
{defect.resolution_notes && (
<p className="mt-1 text-[11px] text-emerald-700 font-medium">
توضیح رفع: {defect.resolution_notes}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* --- Modal: بارگذاری از قالب سراسری --- */}
{showTemplateModal && (
<Modal
open
title="بارگذاری چک‌لیست از قالب‌های سراسری"
onClose={() => {
setShowTemplateModal(false);
setSelectedTemplateId("");
}}
>
<div className="space-y-3.5">
<p className="text-xs text-slate-500 leading-5">
با انتخاب قالب، تمام سؤالات آن در این وظیفه کپی می‌شوند و می‌توانید آزادانه آن‌ها را تغییر دهید.
</p>
<div>
<label className="mb-1.5 block text-xs font-semibold text-slate-700">
انتخاب قالب چک‌لیست
</label>
{templatesQuery.isPending ? (
<p className="py-3 text-center text-xs text-slate-400">در حال دریافت قالب‌ها...</p>
) : (templatesQuery.data ?? []).length === 0 ? (
<p className="py-3 text-center text-xs text-slate-400">هنوز هیچ قالبی در سیستم تعریف نشده است.</p>
) : (
<Combobox
value={selectedTemplateId}
onChange={setSelectedTemplateId}
placeholder="یک قالب را انتخاب کنید..."
searchPlaceholder="جستجوی نام قالب یا نوع فعالیت..."
emptyMessage="قالبی با این عنوان پیدا نشد"
options={(templatesQuery.data ?? []).map((tpl) => ({
value: tpl.id,
label: tpl.name,
hint: tpl.activity_type ? `${tpl.activity_type} (${tpl.questions.length} سؤال)` : `${tpl.questions.length} سؤال`,
}))}
/>
)}
</div>
{selectedTemplate && (
<div className="rounded-xl border border-sky-200/80 bg-sky-50/60 p-3 text-xs space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-sky-950">{selectedTemplate.name}</span>
<span className="rounded bg-sky-100 px-2 py-0.5 font-semibold text-sky-800 text-[11px]">
{selectedTemplate.questions.length} سؤال
</span>
</div>
{selectedTemplate.activity_type && (
<p className="text-[11px] text-slate-600">
نوع فعالیت: <span className="font-semibold text-slate-800">{selectedTemplate.activity_type}</span>
</p>
)}
{selectedTemplate.description && (
<p className="text-[11px] text-slate-500 leading-4">{selectedTemplate.description}</p>
)}
<div className="flex flex-wrap gap-1.5 pt-1">
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
پیش از اجرا: {selectedTemplate.questions.filter((q) => q.phase === "pre").length}
</span>
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
حین اجرا: {selectedTemplate.questions.filter((q) => q.phase === "during").length}
</span>
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
پس از اجرا: {selectedTemplate.questions.filter((q) => q.phase === "after").length}
</span>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => {
setShowTemplateModal(false);
setSelectedTemplateId("");
}}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={!selectedTemplateId || applyTemplateMutation.isPending}
onClick={() => {
if (selectedTemplateId) {
applyTemplateMutation.mutate(selectedTemplateId);
}
}}
className={btnPrimary}
>
{applyTemplateMutation.isPending ? "در حال بارگذاری..." : "بارگذاری قالب"}
</button>
</div>
</div>
</Modal>
)}
{/* --- Modal: افزودن سؤال جدید --- */}
{showAddQuestionModal && (
<AddQuestionModal
open={showAddQuestionModal}
defaultPhase={activePhase}
pending={addQuestionMutation.isPending}
onClose={() => setShowAddQuestionModal(false)}
onSubmit={(data) => addQuestionMutation.mutate(data)}
/>
)}
{/* --- Modal: ثبت عدم انطباق (عیب) --- */}
{defectTargetQuestion && (
<CreateDefectModal
open={Boolean(defectTargetQuestion)}
taskId={taskId}
question={defectTargetQuestion}
onClose={() => setDefectTargetQuestion(null)}
onCreated={() => {
setDefectTargetQuestion(null);
invalidateQc();
}}
/>
)}
{/* --- Modal: رفع عیب --- */}
{defectToResolve && (
<Modal open title="ثبت رفع عدم انطباق" onClose={() => setDefectToResolve(null)}>
<div className="space-y-3">
<p className="text-xs text-slate-600">
آیا عیب «<span className="font-bold">{defectToResolve.title}</span>» برطرف شده است؟
</p>
<div>
<label className="block text-[11px] font-medium text-slate-600 mb-1">
توضیحات اقدام اصلاحی انجام‌شده (اختیاری)
</label>
<textarea
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
placeholder="توضیح دهید چه اقدامی برای رفع این عیب انجام شد..."
className={`${inputCls} h-20 py-2`}
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button
type="button"
onClick={() => setDefectToResolve(null)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={resolveDefectMutation.isPending}
onClick={() =>
resolveDefectMutation.mutate({
defectId: defectToResolve.id,
notes: resolutionNotes,
})
}
className={btnPrimary}
>
{resolveDefectMutation.isPending ? "در حال ثبت..." : "تأیید رفع عیب"}
</button>
</div>
</div>
</Modal>
)}
</div>
);
}
function AddQuestionModal({
open,
defaultPhase,
pending,
onClose,
onSubmit,
}: {
open: boolean;
defaultPhase: QcPhase;
pending: boolean;
onClose: () => void;
onSubmit: (data: { text: string; phase: QcPhase; required: boolean }) => void;
}) {
const [text, setText] = useState("");
const [phase, setPhase] = useState<QcPhase>(defaultPhase);
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) {
setError("متن سؤال الزامی است");
return;
}
onSubmit({ text: text.trim(), phase, required: true });
};
return (
<Modal open={open} title="افزودن سؤال بازرسی کیفی" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{error && <p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">{error}</p>}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">مرحله بازرسی</label>
<select
value={phase}
onChange={(e) => setPhase(e.target.value as QcPhase)}
className={inputCls}
>
<option value="pre">۱. پیش از اجرا (Pre-Execution)</option>
<option value="during">۲. حین اجرا (During-Execution)</option>
<option value="after">۳. تحویل و پس از اجرا (Post-Execution)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">متن سؤال / معیار بازرسی *</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="مثال: آیا ابعاد و فاصله میلگردها طبق نقشه اجرایی رعایت شده است؟"
className={`${inputCls} h-20 py-2`}
required
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={pending} className={btnPrimary}>
{pending ? "در حال ثبت..." : "افزودن سؤال"}
</button>
</div>
</form>
</Modal>
);
}
function CreateDefectModal({
open,
taskId,
question,
onClose,
onCreated,
}: {
open: boolean;
taskId: string;
question: TaskQcQuestion;
onClose: () => void;
onCreated: () => void;
}) {
const [title, setTitle] = useState(`عدم انطباق: ${question.text}`);
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState("medium");
const [correctiveAction, setCorrectiveAction] = useState("");
const [photoId, setPhotoId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const defectMutation = useMutation({
mutationFn: (payload: any) => createDefect(payload),
onSuccess: () => {
toast.success("عدم انطباق با موفقیت ثبت شد");
onCreated();
},
onError: (err: any) => {
setError(err?.message || "خطا در ثبت عدم انطباق");
},
});
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const res = await uploadFile(file);
setPhotoId(res.id);
toast.success("تصویر ضمیمه شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
} finally {
setUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError("عنوان عدم انطباق الزامی است");
return;
}
defectMutation.mutate({
task_ref: taskId,
question_id: question.id,
title: title.trim(),
description: description.trim(),
severity,
corrective_action: correctiveAction.trim(),
photo_id: photoId,
});
};
return (
<Modal open={open} title="ثبت عدم انطباق کیفی (Defect)" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{error && <p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">{error}</p>}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">عنوان عدم انطباق *</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputCls}
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">شرح و جزئیات مشکل</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="توضیحات بیشتر درباره مورد مشاهده‌شده..."
className={`${inputCls} h-14 py-2`}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">شدت عدم انطباق</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className={inputCls}
>
{severityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">اقدام اصلاحی لازم</label>
<textarea
value={correctiveAction}
onChange={(e) => setCorrectiveAction(e.target.value)}
placeholder="چه اصلاحی باید انجام شود تا تأیید مجدد صادر شود..."
className={`${inputCls} h-16 py-2`}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">تصویر مستند</label>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
disabled={uploading}
className="text-xs text-slate-600 file:mr-2 file:py-1 file:px-2.5 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-sky-50 file:text-sky-700 hover:file:bg-sky-100"
/>
{uploading && <span className="text-[11px] text-sky-600 block mt-1">در حال آپلود...</span>}
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={defectMutation.isPending || uploading} className={btnPrimary}>
{defectMutation.isPending ? "در حال ثبت..." : "ثبت عدم انطباق"}
</button>
</div>
</form>
</Modal>
);
}