Files
ERP/frontend/src/pages/QualityPage.tsx
T
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30

957 lines
39 KiB
TypeScript

import { useRef, useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ClipboardCheck,
ExternalLink,
Layers,
Pencil,
Plus,
Search,
ShieldAlert,
Trash2,
Wrench,
X,
} from "lucide-react";
import {
createQcTemplate,
deleteQcQuestion,
deleteQcTemplate,
getDefects,
getQcTemplates,
replaceQcTemplateQuestions,
resolveDefect,
updateQcTemplate,
} from "../lib/api";
import type { Defect, QcPhase, QcQuestionInput, QcTemplate, QcTemplateQuestion } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
PanelsMenu,
btnPrimary,
btnSecondary,
inputCls,
ConfirmSheet,
apiErrorToast,
} from "../components/ui";
import { defectStatusMeta, formatDate, phaseTone, qcPhases, relativeTime, severityMeta } from "../lib/format";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
// ----------------------------------------
// شناسه یکتای پایدار برای ردیف‌های ویرایشگر
// ----------------------------------------
let keyCounter = 0;
function newRowKey(): string {
keyCounter += 1;
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `row-${keyCounter}-${Date.now()}`;
}
interface DraftQuestion {
key: string;
phase: QcPhase;
text: string;
required: boolean;
}
function draftFrom(questions: QcTemplateQuestion[]): DraftQuestion[] {
return questions.map((q) => ({
key: newRowKey(),
phase: q.phase,
text: q.text,
required: q.required,
}));
}
function toPayload(rows: DraftQuestion[]): QcQuestionInput[] {
const out: QcQuestionInput[] = [];
for (const phase of qcPhases) {
const inPhase = rows.filter((r) => r.phase === phase.value && r.text.trim());
inPhase.forEach((r, index) => {
out.push({ phase: r.phase, text: r.text.trim(), required: r.required, sort_order: index });
});
}
return out;
}
// ----------------------------------------
// ویرایشگر قالب QC
// ----------------------------------------
function QcTemplateEditor({
template,
onClose,
}: {
template: QcTemplate | null;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(() => template?.name ?? "");
const [activityType, setActivityType] = useState(() => template?.activity_type ?? "");
const [description, setDescription] = useState(() => template?.description ?? "");
const [rows, setRows] = useState<DraftQuestion[]>(() => {
const initial = template ? draftFrom(template.questions) : [];
return initial.length > 0 ? initial : [{ key: newRowKey(), phase: "pre", text: "", required: true }];
});
const [formError, setFormError] = useState<string | null>(null);
const patchRow = (key: string, patch: Partial<DraftQuestion>) => {
setRows((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row)));
};
const addRow = (phase: QcPhase = "pre") => {
setRows((prev) => [...prev, { key: newRowKey(), phase, text: "", required: true }]);
};
const removeRow = (key: string) => {
setRows((prev) => prev.filter((row) => row.key !== key));
};
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
const createMutation = useMutation({
mutationFn: (payload: { name: string; activity_type: string; description: string; questions: QcQuestionInput[] }) =>
createQcTemplate(payload),
onSuccess: () => {
invalidate();
onClose();
},
onError: (err) => apiErrorToast(err, "ایجاد قالب ناموفق بود"),
});
const updateMutation = useMutation({
mutationFn: async (payload: {
name: string;
activity_type: string;
description: string;
questions: QcQuestionInput[];
}) => {
if (!template) return;
await updateQcTemplate(template.id, {
name: payload.name,
activity_type: payload.activity_type,
description: payload.description,
});
await replaceQcTemplateQuestions(template.id, payload.questions);
},
onSuccess: () => {
invalidate();
onClose();
},
onError: (err) => apiErrorToast(err, "ذخیره قالب ناموفق بود"),
});
const pending = createMutation.isPending || updateMutation.isPending;
const submit = () => {
setFormError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setFormError("نام قالب را وارد کنید");
return;
}
const questions = toPayload(rows);
if (questions.length === 0) {
setFormError("حداقل متن یک سؤال را وارد کنید");
return;
}
const payload = {
name: trimmedName,
activity_type: activityType.trim(),
description: description.trim(),
questions,
};
if (template) updateMutation.mutate(payload);
else createMutation.mutate(payload);
};
const grouped = qcPhases.map((phase) => ({
phase: phase.value as QcPhase,
label: phase.label,
rows: rows.filter((r) => r.phase === phase.value),
}));
return (
<Modal open title={template ? `ویرایش «${template.name}»` : "قالب جدید بازرسی کیفی"} onClose={onClose}>
<label htmlFor="qc-name" className="mb-1 block text-[11px] font-medium text-slate-500">
نام قالب *
</label>
<input id="qc-name" value={name} onChange={(e) => setName(e.target.value)} className={inputCls} placeholder="مثال: کنترل کیفیت اجرای فونداسیون" />
<label htmlFor="qc-activity" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نوع فعالیت / دسته
</label>
<input
id="qc-activity"
value={activityType}
onChange={(e) => setActivityType(e.target.value)}
className={inputCls}
placeholder="مثلاً: سازه، تأسیسات، نازک‌کاری"
/>
<label htmlFor="qc-desc" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
توضیحات و دستورالعمل
</label>
<textarea
id="qc-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
placeholder="توضیحات تکمیلی یا مراجع آیین‌نامه‌ای..."
/>
<p className="mb-1.5 mt-4 text-[11px] font-medium text-slate-500">سؤالات و چک‌لیست مراحل *</p>
<div className="space-y-4 max-h-[50vh] overflow-y-auto px-1">
{grouped.map((group) => (
<div key={group.phase} className="rounded-xl border border-slate-200/80 bg-slate-50/50 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Chip tone={phaseTone(group.phase)}>{group.label}</Chip>
<button
type="button"
onClick={() => addRow(group.phase)}
className="inline-flex h-7 items-center gap-1 rounded-lg bg-white border border-slate-200 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-50 shadow-sm"
>
<Plus size={12} />
افزودن سؤال
</button>
</div>
{group.rows.length === 0 ? (
<p className="py-1 text-[11px] text-slate-400">سؤالی در این مرحله نیست</p>
) : (
<div className="space-y-2">
{group.rows.map((row, indexInPhase) => (
<div key={row.key} className="flex items-center gap-2">
<input
value={row.text}
onChange={(e) => patchRow(row.key, { text: e.target.value })}
placeholder={`متن بازرسی ${indexInPhase + 1}`}
className={`${inputCls} h-9 text-xs flex-1`}
/>
<button
type="button"
onClick={() => removeRow(row.key)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-9 w-8 shrink-0 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
)}
</div>
))}
</div>
{formError && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{formError}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending} onClick={submit} className={btnPrimary}>
{pending ? "در حال ذخیره..." : template ? "ذخیره تغییرات" : "ایجاد قالب"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// جزئیات قالب بازرسی
// ----------------------------------------
function TemplateQuestions({ template }: { template: QcTemplate }) {
const [deleteTarget, setDeleteTarget] = useState<QcTemplateQuestion | null>(null);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (questionId: string) => deleteQcQuestion(questionId),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
},
onError: (err) => apiErrorToast(err, "حذف سؤال ناموفق بود"),
});
return (
<>
<div className="space-y-3 pt-1">
{qcPhases.map((phase) => {
const questions = template.questions.filter((q) => q.phase === phase.value);
if (questions.length === 0) return null;
return (
<div key={phase.value} className="rounded-lg border border-slate-100 bg-slate-50/70 p-2.5">
<div className="mb-1 flex items-center justify-between">
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<span className="text-[10px] text-slate-400 font-medium">{questions.length} بازرسی</span>
</div>
<div className="mt-1 divide-y divide-slate-200/60">
{questions.map((q) => (
<div key={q.id} className="flex items-start justify-between gap-2 py-1.5">
<p className="min-w-0 flex-1 text-xs leading-5 text-slate-700">{q.text}</p>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => setDeleteTarget(q)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
{deleteTarget && (
<ConfirmSheet
open
title="حذف سؤال از قالب"
message={`آیا سؤال «${deleteTarget.text}» از این قالب حذف شود؟`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
// ----------------------------------------
// مودال ثبت رفع عدم انطباق (Defect Resolution Modal)
// ----------------------------------------
function DefectResolutionModal({
defect,
onClose,
onResolved,
}: {
defect: Defect;
onClose: () => void;
onResolved: () => void;
}) {
const [notes, setNotes] = useState("");
const resolveMutation = useMutation({
mutationFn: () => resolveDefect(defect.id, notes),
onSuccess: () => {
onResolved();
onClose();
},
onError: (err) => apiErrorToast(err, "ثبت رفع عیب ناموفق بود"),
});
return (
<Modal open title="ثبت رفع عدم انطباق" onClose={onClose}>
<div className="space-y-3">
<div className="rounded-xl border border-slate-200 bg-slate-50/80 p-3">
<span className="text-[11px] font-semibold text-slate-500 block mb-0.5">موضوع عدم انطباق:</span>
<p className="text-xs font-bold text-slate-800">{defect.title}</p>
{defect.task_title && (
<p className="text-[11px] text-slate-500 mt-1">وظیفه: {defect.task_title}</p>
)}
</div>
<div>
<label htmlFor="defect-resolution-notes" className="mb-1 block text-xs font-medium text-slate-600">
اقدامات انجام‌شده و توضیحات رفع عیب *
</label>
<textarea
id="defect-resolution-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className={`${inputCls} h-auto py-2`}
placeholder="شرح اقدامات اصلاحی صورت گرفته جهت رفع مشکل..."
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={resolveMutation.isPending}
onClick={() => resolveMutation.mutate()}
className={btnPrimary}
>
{resolveMutation.isPending ? "در حال ثبت..." : "تأیید و رفع عیب"}
</button>
</div>
</div>
</Modal>
);
}
// ----------------------------------------
// تابلوی جامع عدم انطباق‌ها و عیوب (Defects Board)
// ----------------------------------------
function DefectsBoard({ canExecuteQc }: { canExecuteQc: boolean }) {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all");
const [severityFilter, setSeverityFilter] = useState<"all" | "high" | "medium" | "low">("all");
const [search, setSearch] = useState("");
const [activeDefectToResolve, setActiveDefectToResolve] = useState<Defect | null>(null);
const defectsQuery = useQuery({
queryKey: ["qc-defects", statusFilter === "all" ? undefined : statusFilter],
queryFn: () => getDefects(statusFilter === "all" ? undefined : { status: statusFilter }),
});
const allDefects = defectsQuery.data ?? [];
// محاسبه آمار سریع
const stats = useMemo(() => {
const total = allDefects.length;
const open = allDefects.filter((d) => d.status !== "resolved").length;
const resolved = allDefects.filter((d) => d.status === "resolved").length;
const high = allDefects.filter((d) => d.severity === "high" && d.status !== "resolved").length;
return { total, open, resolved, high };
}, [allDefects]);
// فیلتر کلاینت برای جستجو و شدت
const filteredDefects = useMemo(() => {
return allDefects.filter((d) => {
if (severityFilter !== "all" && d.severity !== severityFilter) return false;
if (search.trim()) {
const query = search.trim().toLowerCase();
const matchesTitle = d.title.toLowerCase().includes(query);
const matchesDesc = (d.description ?? "").toLowerCase().includes(query);
const matchesAction = (d.corrective_action ?? "").toLowerCase().includes(query);
const matchesTask = (d.task_title ?? "").toLowerCase().includes(query);
if (!matchesTitle && !matchesDesc && !matchesAction && !matchesTask) return false;
}
return true;
});
}, [allDefects, severityFilter, search]);
const refreshDefects = () => {
void queryClient.invalidateQueries({ queryKey: ["qc-defects"] });
void queryClient.invalidateQueries({ queryKey: ["task-qc"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
return (
<div className="space-y-4">
{/* ردیف کارت‌های خلاصه وضعیت */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
<span className="text-[11px] text-slate-500 font-medium block">کل موارد ثبت‌شده</span>
<span className="text-lg font-bold text-slate-800 tabular-nums">{stats.total}</span>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 shadow-xs">
<span className="text-[11px] text-amber-700 font-medium block">در انتظار رفع (باز)</span>
<span className="text-lg font-bold text-amber-800 tabular-nums">{stats.open}</span>
</div>
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 shadow-xs">
<span className="text-[11px] text-emerald-700 font-medium block">رفع‌شده</span>
<span className="text-lg font-bold text-emerald-800 tabular-nums">{stats.resolved}</span>
</div>
<div className="rounded-xl border border-red-200 bg-red-50/60 p-3 shadow-xs">
<span className="text-[11px] text-red-700 font-medium block">موارد بحرانی باز</span>
<span className="text-lg font-bold text-red-800 tabular-nums">{stats.high}</span>
</div>
</div>
{/* نوار جستجو و فیلترها */}
<div className="flex flex-col sm:flex-row gap-2.5 items-stretch sm:items-center justify-between">
<div className="relative flex-1">
<Search size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجو در موضوع، شرح عیب، اقدام اصلاحی یا وظیفه..."
className={`${inputCls} pr-8.5 h-9 text-xs`}
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<X size={13} />
</button>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{/* فیلتر وضعیت */}
<div className="flex rounded-lg bg-slate-200/70 p-0.5 text-xs font-medium text-slate-600">
<button
type="button"
onClick={() => setStatusFilter("all")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "all" ? "bg-white text-slate-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
همه ({stats.total})
</button>
<button
type="button"
onClick={() => setStatusFilter("open")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "open" ? "bg-white text-amber-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
باز ({stats.open})
</button>
<button
type="button"
onClick={() => setStatusFilter("resolved")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "resolved" ? "bg-white text-emerald-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
رفع‌شده ({stats.resolved})
</button>
</div>
{/* فیلتر شدت */}
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as any)}
className="h-8.5 rounded-lg border border-slate-200 bg-white px-2 text-xs font-medium text-slate-700 shadow-xs outline-hidden"
>
<option value="all">تمام شدت‌ها</option>
<option value="high">بحرانی (زیاد)</option>
<option value="medium">متوسط</option>
<option value="low">جزئی (کم)</option>
</select>
</div>
</div>
{/* لیست عدم انطباق‌ها */}
{defectsQuery.isPending ? (
<CardSkeleton />
) : defectsQuery.isError ? (
<Card>
<ErrorState onRetry={() => void defectsQuery.refetch()} />
</Card>
) : filteredDefects.length === 0 ? (
<Card>
<EmptyState
icon={CheckCircle2}
title="هیچ عدم انطباقی یافت نشد"
description={
search || severityFilter !== "all" || statusFilter !== "all"
? "با فیلترهای فعلی موردی یافت نشد؛ فیلترها را بازنشانی کنید"
: "تمام بازرسی‌ها بدون اشکال انجام شده و عدم انطباق ثبت‌شده‌ای وجود ندارد"
}
/>
</Card>
) : (
<div className="space-y-2.5">
{filteredDefects.map((defect) => {
const isResolved = defect.status === "resolved";
const sev = severityMeta(defect.severity);
const stMeta = defectStatusMeta(defect.status);
return (
<div
key={defect.id}
className={`rounded-xl border p-3.5 transition-all shadow-xs ${
isResolved
? "border-slate-200 bg-white opacity-85"
: defect.severity === "high"
? "border-red-200 bg-red-50/20"
: "border-slate-200 bg-white"
}`}
>
{/* سرتیتر کارت */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-xs font-bold text-slate-900">{defect.title}</h3>
<Chip tone={stMeta.tone}>{stMeta.label}</Chip>
<Chip tone={sev.tone}>{sev.label}</Chip>
</div>
{/* وظیفه مرتبط */}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<Layers size={13} className="text-slate-400 shrink-0" />
<span>وظیفه:</span>
<Link
to={`/tasks/${defect.task_id || defect.task_op_wp_id}`}
className="font-semibold text-sky-700 hover:text-sky-800 hover:underline flex items-center gap-1"
>
<span>{defect.task_title || `تسک #${defect.task_op_wp_id ?? ""}`}</span>
<ExternalLink size={10} />
</Link>
</div>
</div>
<span className="text-[10px] text-slate-400 shrink-0 font-medium">
{relativeTime(defect.created_at)}
</span>
</div>
{/* شرح عیب */}
{defect.description && (
<p className="mt-2 text-xs leading-5 text-slate-600 bg-slate-50/60 rounded-lg p-2 border border-slate-100">
{defect.description}
</p>
)}
{/* اقدام اصلاحی */}
{defect.corrective_action && (
<div className="mt-2 flex items-start gap-1.5 rounded-lg border border-amber-200/80 bg-amber-50/50 p-2 text-xs text-amber-900">
<Wrench size={14} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold text-[11px] text-amber-800 block">اقدام اصلاحی لازم:</span>
<span className="text-[11px] leading-5">{defect.corrective_action}</span>
</div>
</div>
)}
{/* تصویر پیوست */}
{defect.photo_url && (
<div className="mt-2.5">
<a
href={defect.photo_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-sky-700 hover:underline font-medium"
>
<img
src={defect.photo_url}
alt="تصویر عدم انطباق"
className="h-14 w-14 rounded-lg object-cover border border-slate-200 shadow-xs"
/>
<span>مشاهده تصویر مستند</span>
</a>
</div>
)}
{/* وضعیت رفع یا دکمه اقدام */}
<div className="mt-3 pt-2.5 border-t border-slate-100 flex items-center justify-between gap-2 flex-wrap">
{isResolved ? (
<div className="flex items-center gap-1.5 text-[11px] text-emerald-700 font-medium">
<CheckCircle2 size={15} className="text-emerald-600 shrink-0" />
<span>
رفع‌شده در تاریخ {formatDate(defect.resolved_at)}
{defect.resolution_notes ? ` — ${defect.resolution_notes}` : ""}
</span>
</div>
) : (
<>
<div className="flex items-center gap-1.5 text-[11px] text-amber-700 font-medium">
<AlertTriangle size={14} className="text-amber-600 shrink-0" />
<span>این مورد باز است و مانع تأیید نهایی یا شروع کار می‌شود</span>
</div>
{canExecuteQc && (
<button
type="button"
onClick={() => setActiveDefectToResolve(defect)}
className="inline-flex h-7.5 items-center gap-1.5 rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white hover:bg-emerald-700 transition-colors shadow-xs"
>
<CheckCircle2 size={13} />
<span>ثبت رفع عیب</span>
</button>
)}
</>
)}
</div>
</div>
);
})}
</div>
)}
{activeDefectToResolve && (
<DefectResolutionModal
defect={activeDefectToResolve}
onClose={() => setActiveDefectToResolve(null)}
onResolved={refreshDefects}
/>
)}
</div>
);
}
// ----------------------------------------
// صفحه اصلی کنترل کیفیت (Quality Page)
// ----------------------------------------
export default function QualityPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
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 [activeTab, setActiveTab] = useState<"templates" | "defects">("templates");
const [templateSearch, setTemplateSearch] = useState("");
const [expandedTemplateId, setExpandedTemplateId] = useState<string | null>(null);
const [editor, setEditor] = useState<{ template: QcTemplate | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<QcTemplate | null>(null);
const editorKey = useRef(0);
// واکشی قالب‌ها
const qcQuery = useQuery({ queryKey: ["qc-templates"], queryFn: getQcTemplates });
// واکشی تعداد عیوب باز برای بج ناوبری
const openDefectsQuery = useQuery({
queryKey: ["qc-defects", "open"],
queryFn: () => getDefects({ status: "open" }),
});
const deleteMutation = useMutation({
mutationFn: (templateId: string) => deleteQcTemplate(templateId),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
},
onError: (err) => apiErrorToast(err, "حذف قالب ناموفق بود"),
});
const templates = qcQuery.data ?? [];
const openDefectsCount = openDefectsQuery.data?.length ?? 0;
const filteredTemplates = useMemo(() => {
if (!templateSearch.trim()) return templates;
const q = templateSearch.trim().toLowerCase();
return templates.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.activity_type ?? "").toLowerCase().includes(q) ||
(t.description ?? "").toLowerCase().includes(q)
);
}, [templates, templateSearch]);
const openEditor = (template: QcTemplate | null) => {
editorKey.current += 1;
setEditor({ template });
};
return (
<>
<PageHeader title="کنترل کیفیت (QC)" menu={<PanelsMenu />} />
<div className="space-y-4 px-4 pb-28 pt-4">
{/* نوار ناوبری دو تب اصلی */}
<div className="flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600 shadow-inner">
<button
type="button"
onClick={() => setActiveTab("templates")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg py-2 transition-all ${
activeTab === "templates"
? "bg-white text-slate-800 shadow-sm font-bold"
: "hover:text-slate-900"
}`}
>
<ClipboardCheck size={16} className={activeTab === "templates" ? "text-sky-600" : "text-slate-400"} />
<span>قالب‌های استاندارد بازرسی</span>
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600 font-bold tabular-nums">
{templates.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("defects")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg py-2 transition-all ${
activeTab === "defects"
? "bg-white text-slate-800 shadow-sm font-bold"
: "hover:text-slate-900"
}`}
>
<ShieldAlert size={16} className={activeTab === "defects" ? "text-amber-600" : "text-slate-400"} />
<span>تابلوی عدم انطباق‌ها و عیوب</span>
{openDefectsCount > 0 && (
<span className="rounded-full bg-amber-500 text-white px-1.5 py-0.5 text-[10px] font-bold tabular-nums shadow-xs">
{openDefectsCount}
</span>
)}
</button>
</div>
{/* محتوای تب ۱: قالب‌های بازرسی کیفی */}
{activeTab === "templates" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h2 className="text-xs font-bold text-slate-800">قالب‌های چک‌لیست QC</h2>
<p className="text-[11px] text-slate-400 mt-0.5">
قالب‌های از پیش تعریف‌شده برای استانداردسازی بازرسی وظایف عمرانی
</p>
</div>
{canDefineQc && (
<button
type="button"
onClick={() => openEditor(null)}
className="inline-flex h-8.5 items-center gap-1.5 rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white hover:bg-sky-700 transition-colors shadow-xs"
>
<Plus size={14} />
<span>قالب جدید</span>
</button>
)}
</div>
{/* جستجو در قالب‌ها */}
{templates.length > 3 && (
<div className="relative">
<Search size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={templateSearch}
onChange={(e) => setTemplateSearch(e.target.value)}
placeholder="جستجو بر اساس نام قالب یا نوع فعالیت..."
className={`${inputCls} pr-8.5 h-8.5 text-xs`}
/>
</div>
)}
{qcQuery.isPending ? (
<CardSkeleton />
) : qcQuery.isError ? (
<Card>
<ErrorState onRetry={() => void qcQuery.refetch()} />
</Card>
) : filteredTemplates.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardCheck}
title="قالبی یافت نشد"
description={
templateSearch
? "با عبارت جستجو شده قالبی پیدا نشد"
: "با دکمه «قالب جدید» اولین چک‌لیست استاندارد کیفیت را بسازید"
}
/>
</Card>
) : (
<div className="space-y-2">
{filteredTemplates.map((t) => {
const expanded = expandedTemplateId === t.id;
const preCount = t.questions.filter((q) => q.phase === "pre").length;
const duringCount = t.questions.filter((q) => q.phase === "during").length;
const afterCount = t.questions.filter((q) => q.phase === "after").length;
return (
<Card key={t.id} className="p-0 overflow-hidden border-slate-200/90 shadow-xs">
<button
type="button"
onClick={() => setExpandedTemplateId(expanded ? null : t.id)}
className="flex w-full items-center justify-between gap-3 p-3.5 text-right hover:bg-slate-50/60 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="block truncate text-xs font-bold text-slate-900">{t.name}</span>
{t.activity_type && <Chip tone="slate">{t.activity_type}</Chip>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px]">
<Chip tone="sky">{t.questions.length} سؤال</Chip>
{preCount > 0 && <Chip tone="amber">{preCount} پیش از اجرا</Chip>}
{duringCount > 0 && <Chip tone="sky">{duringCount} حین اجرا</Chip>}
{afterCount > 0 && <Chip tone="slate">{afterCount} پس از اجرا</Chip>}
</div>
</div>
{expanded ? (
<ChevronDown size={16} className="shrink-0 text-slate-400" />
) : (
<ChevronLeft size={16} className="shrink-0 text-slate-400" />
)}
</button>
{expanded && (
<div className="border-t border-slate-100 bg-slate-50/30 px-3.5 py-3">
{t.description && (
<p className="pb-2 text-[11px] leading-5 text-slate-600">{t.description}</p>
)}
{t.questions.length === 0 ? (
<p className="py-2 text-xs text-slate-400">سؤالی برای این قالب ثبت نشده است</p>
) : (
<TemplateQuestions template={t} />
)}
<div className="mt-3 pt-2.5 border-t border-slate-200/60 flex items-center justify-between gap-2 flex-wrap">
<p className="text-[10px] text-slate-400">
بارگذاری این قالب در وظیفه‌ها، یک نسخهٔ مستقل می‌سازد و ویرایش قالب روی کارهای جاری اثر ندارد.
</p>
{canDefineQc && (
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => openEditor(t)}
className="inline-flex h-7.5 items-center gap-1 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-xs"
>
<Pencil size={12} />
<span>ویرایش قالب</span>
</button>
<button
type="button"
onClick={() => setDeleteTarget(t)}
className="inline-flex h-7.5 items-center gap-1 rounded-lg border border-red-200 bg-red-50/50 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 size={12} />
<span>حذف</span>
</button>
</div>
)}
</div>
</div>
)}
</Card>
);
})}
</div>
)}
</div>
)}
{/* محتوای تب ۲: تابلوی عدم انطباق‌ها و عیوب */}
{activeTab === "defects" && <DefectsBoard canExecuteQc={canExecuteQc} />}
</div>
{editor && (
<QcTemplateEditor
key={`qc-editor-${editorKey.current}`}
template={editor.template}
onClose={() => setEditor(null)}
/>
)}
{deleteTarget && (
<ConfirmSheet
open
title="حذف قالب بازرسی کیفی"
message={`آیا قالب «${deleteTarget.name}» با ${deleteTarget.questions.length} سؤال حذف شود؟ این کار روی بازرسی‌های ساخته‌شده از آن در وظیفه‌ها اثری ندارد.`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}