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
936 lines
39 KiB
TypeScript
936 lines
39 KiB
TypeScript
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>
|
||
);
|
||
}
|