Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user