Files
ERP/pm/frontend/src/components/TaskQcSection.tsx
T
MMD 2747f9d4bb 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
2026-10-07 15:14:20 +03:30

936 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}