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