feat: Enhance inspection management and item inspection pages with selection options
- Updated the inspection management page to include handling for selection options when the judgment criteria is of type "선택형". - Implemented logic to validate that at least one option is provided when the selection criteria is selected, improving user feedback with appropriate error messages. - Enhanced the item inspection page to support judgment criteria and selection options, allowing for more detailed inspection configurations. - Added functionality to dynamically load and display category options for judgment criteria and units, streamlining the user experience in setting up inspections. - These changes aim to improve the usability and functionality of the inspection management process across multiple company implementations.
This commit is contained in:
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_10`);
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_16`);
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_29`);
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_30`);
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_8`);
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_9`);
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
@@ -330,6 +330,11 @@ export default function InspectionManagementPage() {
|
||||
toast.error("판단기준은 필수예요");
|
||||
return;
|
||||
}
|
||||
const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label;
|
||||
if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) {
|
||||
toast.error("선택형은 옵션을 1개 이상 추가해주세요");
|
||||
return;
|
||||
}
|
||||
setInspSaving(true);
|
||||
try {
|
||||
let finalCode = inspForm.inspection_code || "";
|
||||
@@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */}
|
||||
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold">선택 옵션 설정</Label>
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-primary/20 p-0.5"
|
||||
onClick={() => {
|
||||
const opts = inspForm.selection_options.split(",").filter(Boolean);
|
||||
opts.splice(idx, 1);
|
||||
setInspForm((p: any) => ({ ...p, selection_options: opts.join(",") }));
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px]">✕</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="옵션명 입력 후 Enter"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!v) return;
|
||||
const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : [];
|
||||
if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; }
|
||||
setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") }));
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 단위 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">단위</Label>
|
||||
|
||||
@@ -49,6 +49,9 @@ type InspectionRow = {
|
||||
apply_process: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
selection_options?: string; // 선택형일 때 옵션 (콤마 구분)
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
@@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 품목 카테고리 코드→라벨 (type, inventory_unit)
|
||||
const [itemCatMap, setItemCatMap] = useState<Record<string, Record<string, string>>>({});
|
||||
const itemCatMapRef = React.useRef(itemCatMap);
|
||||
itemCatMapRef.current = itemCatMap;
|
||||
|
||||
// 검사유형별 검사항목 rows (모달용)
|
||||
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
|
||||
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 기본 라우팅 공정 목록 (적용공정 Select용)
|
||||
const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 품목 선택 모달
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
@@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() {
|
||||
label: r.inspection_criteria || r.inspection_standard || r.id,
|
||||
detail: r.inspection_item || r.inspection_criteria || "",
|
||||
method: r.inspection_method || "",
|
||||
judgment_criteria: r.judgment_criteria || "",
|
||||
selection_options: r.selection_options || "",
|
||||
unit: r.unit || "",
|
||||
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
|
||||
})));
|
||||
|
||||
// 품목 카테고리 (type, unit)
|
||||
const catMap: Record<string, Record<string, string>> = {};
|
||||
for (const col of ["type", "unit", "inventory_unit"]) {
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (catRes.data?.success && catRes.data.data?.length) {
|
||||
catMap[col] = {};
|
||||
const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } };
|
||||
fl(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setItemCatMap(catMap);
|
||||
|
||||
// 검사유형 카테고리
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
|
||||
@@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() {
|
||||
setInspMethodCatOptions(flatMethods);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 판단기준 카테고리
|
||||
try {
|
||||
const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`);
|
||||
const flatJc: { code: string; label: string }[] = [];
|
||||
const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } };
|
||||
if (jcRes.data?.data?.length) flattenJc(jcRes.data.data);
|
||||
setJudgmentCatOptions(flatJc);
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 검사 단위 카테고리
|
||||
try {
|
||||
const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`);
|
||||
const flatUnit: { code: string; label: string }[] = [];
|
||||
const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } };
|
||||
if (unitRes.data?.data?.length) flattenU(unitRes.data.data);
|
||||
setInspUnitCatOptions(flatUnit);
|
||||
} catch { /* skip */ }
|
||||
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
setUserOptions(users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
@@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
// 품목별 기본 라우팅 공정 로드
|
||||
const loadProcessOptions = async (itemCode: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0];
|
||||
const procs = (defaultVer.processes || []).map((p: any) => ({
|
||||
code: p.process_code,
|
||||
name: p.process_name || p.process_code,
|
||||
}));
|
||||
setProcessOptions(procs);
|
||||
} else {
|
||||
setProcessOptions([]);
|
||||
}
|
||||
} catch { setProcessOptions([]); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
|
||||
const searchItemServer = async (page?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
@@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() {
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" })));
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); };
|
||||
const handleItemSearch = () => { setItemPage(1); searchItemServer(1); };
|
||||
const selectItem = (item: typeof itemOptions[0]) => {
|
||||
if (groupedData.some(g => g.item_code === item.code)) {
|
||||
toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`);
|
||||
return;
|
||||
}
|
||||
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
|
||||
setItemModalOpen(false);
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
@@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
|
||||
const group = groupedData.find(g => g.item_code === code);
|
||||
if (!group) return;
|
||||
loadProcessOptions(code);
|
||||
const row = group.rows[0];
|
||||
setForm({ ...row });
|
||||
setEditMode(true);
|
||||
@@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() {
|
||||
if (!rowMap[typeKey]) rowMap[typeKey] = [];
|
||||
const mCode = r.inspection_method || "";
|
||||
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
|
||||
// 판단기준/선택옵션/단위 resolve
|
||||
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
|
||||
const jcCode = inspOpt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
const unitCode = inspOpt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
rowMap[typeKey].push({
|
||||
id: r.id,
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
@@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() {
|
||||
apply_process: "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: inspOpt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
});
|
||||
}
|
||||
setInspectionRows(rowMap);
|
||||
@@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() {
|
||||
const opt = inspOptions.find(o => o.code === value);
|
||||
const methodCode = opt?.method || "";
|
||||
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
|
||||
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
|
||||
// 판단기준 라벨 resolve
|
||||
const jcCode = opt?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
// 단위 라벨 resolve
|
||||
const unitCode = opt?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return {
|
||||
...r,
|
||||
inspection_standard_id: value,
|
||||
inspection_detail: opt?.detail || "",
|
||||
inspection_method: methodLabel,
|
||||
judgment_criteria: jcLabel,
|
||||
selection_options: opt?.selection_options || "",
|
||||
unit: unitLabel,
|
||||
acceptance_criteria: "", // 판단기준 변경 시 초기화
|
||||
};
|
||||
}
|
||||
return { ...r, [field]: value };
|
||||
}),
|
||||
@@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{row.judgment_criteria ? (
|
||||
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
|
||||
) : "-"}
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const jcCode = insp?.judgment_criteria || "";
|
||||
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
|
||||
return jcLabel ? <Badge variant="outline" className="text-[10px]">{jcLabel}</Badge> : "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2 text-center">
|
||||
@@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-4xl")}>
|
||||
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden", itemModalOpen ? "sm:max-w-3xl" : "sm:max-w-[1400px]")}>
|
||||
{itemModalOpen ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사항목</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1"><Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
{processOptions.length > 0 ? (
|
||||
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((p) => (
|
||||
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
|
||||
{row.judgment_criteria === "선택형" && row.selection_options ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{row.selection_options.split(",").filter(Boolean).map((opt) => (
|
||||
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "O/X" ? (
|
||||
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="O" className="text-xs">O (합격)</SelectItem>
|
||||
<SelectItem value="X" className="text-xs">X (불합격)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : row.judgment_criteria === "수치(범위)" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="h-8 text-xs w-16" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[0] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="기준값" disabled={!row.inspection_standard_id} />
|
||||
<span className="text-[10px] text-muted-foreground">±</span>
|
||||
<Input className="h-8 text-xs w-12" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
|
||||
const parts = (row.acceptance_criteria || "||").split("|");
|
||||
parts[1] = e.target.value;
|
||||
updateInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
|
||||
}} placeholder="오차" disabled={!row.inspection_standard_id} />
|
||||
{row.unit && <span className="text-[10px] text-muted-foreground shrink-0">{row.unit}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
@@ -209,6 +209,7 @@ export function ProcessWorkStandardComponent({
|
||||
detailTypes={config.detailTypes}
|
||||
readonly={config.readonly}
|
||||
selectedItemCode={selection.itemCode || undefined}
|
||||
selectedProcessCode={selection.processCode || undefined}
|
||||
onSelectWorkItem={handleSelectWorkItem}
|
||||
onAddWorkItem={handleAddWorkItem}
|
||||
onEditWorkItem={handleEditWorkItem}
|
||||
|
||||
+154
-15
@@ -24,6 +24,7 @@ import {
|
||||
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
||||
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
||||
import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface DetailFormModalProps {
|
||||
open: boolean;
|
||||
@@ -33,6 +34,7 @@ interface DetailFormModalProps {
|
||||
editData?: WorkItemDetail | null;
|
||||
mode: "add" | "edit";
|
||||
selectedItemCode?: string;
|
||||
selectedProcessCode?: string;
|
||||
}
|
||||
|
||||
const INPUT_TYPES = [
|
||||
@@ -87,6 +89,7 @@ export function DetailFormModal({
|
||||
editData,
|
||||
mode,
|
||||
selectedItemCode,
|
||||
selectedProcessCode,
|
||||
}: DetailFormModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
||||
@@ -95,6 +98,17 @@ export function DetailFormModal({
|
||||
const [bomLoading, setBomLoading] = useState(false);
|
||||
const [bomChecked, setBomChecked] = useState<Set<string>>(new Set());
|
||||
|
||||
// 품목검사정보 연동
|
||||
const [itemInspections, setItemInspections] = useState<any[]>([]);
|
||||
const [itemInspLoading, setItemInspLoading] = useState(false);
|
||||
|
||||
// 품목 카테고리 (type 코드→라벨)
|
||||
const [itemTypeCatMap, setItemTypeCatMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 설비 점검항목 연동
|
||||
const [equipInspItems, setEquipInspItems] = useState<any[]>([]);
|
||||
const [equipInspLoading, setEquipInspLoading] = useState(false);
|
||||
|
||||
const loadBomMaterials = useCallback(async () => {
|
||||
if (!selectedItemCode) {
|
||||
setBomMaterials([]);
|
||||
@@ -160,9 +174,68 @@ export function DetailFormModal({
|
||||
useEffect(() => {
|
||||
if (open && formData.detail_type === "material_input") {
|
||||
loadBomMaterials();
|
||||
// 품목구분 카테고리 로드 (type 코드→라벨)
|
||||
if (Object.keys(itemTypeCatMap).length === 0) {
|
||||
apiClient.get("/table-categories/item_info/type/values").then(res => {
|
||||
if (res.data?.success && res.data.data?.length) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } };
|
||||
flatten(res.data.data);
|
||||
setItemTypeCatMap(map);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [open, formData.detail_type, loadBomMaterials]);
|
||||
|
||||
// 검사항목 적용 시 품목검사정보 로드
|
||||
useEffect(() => {
|
||||
if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) {
|
||||
setItemInspections([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
setItemInspLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/item_inspection_info/data", {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemInspections(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setItemInspections([]); } finally { setItemInspLoading(false); }
|
||||
})();
|
||||
}, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]);
|
||||
|
||||
// 설비점검 적용 시 해당 공정의 설비 점검항목 로드
|
||||
useEffect(() => {
|
||||
if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) {
|
||||
setEquipInspItems([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
setEquipInspLoading(true);
|
||||
try {
|
||||
// 1. 해당 공정의 설비 목록 조회
|
||||
const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean);
|
||||
if (equipCodes.length === 0) { setEquipInspItems([]); return; }
|
||||
|
||||
// 2. 각 설비의 점검항목 조회
|
||||
const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []);
|
||||
} catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); }
|
||||
})();
|
||||
}, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]);
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
@@ -211,8 +284,28 @@ export function DetailFormModal({
|
||||
submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량";
|
||||
}
|
||||
if (type === "material_input") {
|
||||
// 선택된 BOM 자재를 각각 개별 상세 항목으로 등록
|
||||
const checkedMats = bomMaterials.filter(m => bomChecked.has(m.child_item_id));
|
||||
if (checkedMats.length > 0) {
|
||||
const resolveType = (code: string) => itemTypeCatMap[code] || code || "";
|
||||
for (const mat of checkedMats) {
|
||||
const typeLabel = resolveType(mat.child_item_type || "");
|
||||
const unitLabel = mat.detail_unit || mat.item_unit || "";
|
||||
onSubmit({
|
||||
...submitData,
|
||||
detail_type: "material_input",
|
||||
content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(),
|
||||
is_required: submitData.is_required || "Y",
|
||||
bom_item_id: mat.child_item_id,
|
||||
bom_item_name: mat.child_item_name || "",
|
||||
bom_qty: String(mat.quantity || 1),
|
||||
bom_unit: unitLabel,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)";
|
||||
// 체크된 자재 ID 목록 저장
|
||||
submitData.selected_bom_items = Array.from(bomChecked);
|
||||
}
|
||||
|
||||
@@ -311,15 +404,36 @@ export function DetailFormModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 시: 품목별 검사정보 자동 연동 안내 */}
|
||||
{/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */}
|
||||
{formData.process_inspection_apply === "apply" && (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-sky-800">
|
||||
품목별 검사정보 (자동 연동)
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
품목에 등록된 검사기준이 자동으로 적용됩니다.
|
||||
</p>
|
||||
{itemInspLoading ? (
|
||||
<div className="flex items-center gap-2 py-2"><Loader2 className="h-3.5 w-3.5 animate-spin" /><span className="text-[11px] text-muted-foreground">조회 중...</span></div>
|
||||
) : itemInspections.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.</p>
|
||||
) : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 text-left font-medium text-sky-700">검사항목</th>
|
||||
<th className="py-1 text-left font-medium text-sky-700">검사유형</th>
|
||||
<th className="py-1 text-left font-medium text-sky-700">합격기준</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{itemInspections.map((insp, idx) => (
|
||||
<tr key={insp.id || idx} className="border-b border-sky-100">
|
||||
<td className="py-1">{insp.inspection_item_name || insp.inspection_standard || "-"}</td>
|
||||
<td className="py-1">{insp.inspection_type || "-"}</td>
|
||||
<td className="py-1 font-mono">{insp.pass_criteria || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -544,15 +658,38 @@ export function DetailFormModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 시: 설비 점검항목 자동 연동 */}
|
||||
{/* 적용 시: 설비 점검항목 표시 */}
|
||||
{formData.equip_inspection_apply === "apply" && (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
|
||||
<p className="text-xs font-semibold text-sky-800">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-amber-800">
|
||||
설비 점검항목 (설비정보 연동)
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
공정에 지정된 설비의 점검항목이 자동으로 적용됩니다.
|
||||
</p>
|
||||
{equipInspLoading ? (
|
||||
<div className="flex items-center gap-2 py-2"><Loader2 className="h-3.5 w-3.5 animate-spin" /><span className="text-[11px] text-muted-foreground">조회 중...</span></div>
|
||||
) : equipInspItems.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">해당 공정에 지정된 설비의 점검항목이 없습니다.</p>
|
||||
) : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 text-left font-medium text-amber-700">설비코드</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">점검항목</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">점검방법</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">기준범위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{equipInspItems.map((item, idx) => (
|
||||
<tr key={item.id || idx} className="border-b border-amber-100">
|
||||
<td className="py-1 font-mono">{item.equipment_code || "-"}</td>
|
||||
<td className="py-1">{item.inspection_item || "-"}</td>
|
||||
<td className="py-1">{item.inspection_method || "-"}</td>
|
||||
<td className="py-1 font-mono">{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -796,9 +933,11 @@ export function DetailFormModal({
|
||||
</div>
|
||||
) : (
|
||||
bomMaterials.map((mat) => {
|
||||
const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || "";
|
||||
const typeColor =
|
||||
mat.child_item_type === "원자재" ? "#16a34a"
|
||||
: mat.child_item_type === "반제품" ? "#2563eb"
|
||||
resolvedType.includes("원자재") ? "#16a34a"
|
||||
: resolvedType.includes("반제품") ? "#2563eb"
|
||||
: resolvedType.includes("제품") ? "#9333ea"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<div
|
||||
@@ -823,7 +962,7 @@ export function DetailFormModal({
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: typeColor }}
|
||||
>
|
||||
{mat.child_item_type}
|
||||
{resolvedType}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
|
||||
+4
-2
@@ -17,7 +17,8 @@ interface ItemProcessSelectorProps {
|
||||
routingDetailId: string,
|
||||
processName: string,
|
||||
routingVersionId: string,
|
||||
routingVersionName: string
|
||||
routingVersionName: string,
|
||||
processCode?: string
|
||||
) => void;
|
||||
onInit: () => void;
|
||||
}
|
||||
@@ -136,7 +137,8 @@ export function ItemProcessSelector({
|
||||
proc.routing_detail_id,
|
||||
proc.process_name,
|
||||
routing.id,
|
||||
routing.version_name || "기본 라우팅"
|
||||
routing.version_name || "기본 라우팅",
|
||||
proc.process_code
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
|
||||
+2
-3
@@ -141,13 +141,13 @@ export function WorkItemAddModal({
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
if (!v && !detailFormOpen) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]" onPointerDownOutside={(e) => { if (detailFormOpen) e.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
작업 항목 {editItem ? "수정" : "추가"}
|
||||
@@ -365,7 +365,6 @@ export function WorkItemAddModal({
|
||||
sort_order: prev.length + 1,
|
||||
},
|
||||
]);
|
||||
setDetailFormOpen(false);
|
||||
}}
|
||||
detailTypes={detailTypes}
|
||||
mode="add"
|
||||
|
||||
+4
-1
@@ -14,6 +14,7 @@ interface WorkItemDetailListProps {
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
readonly?: boolean;
|
||||
selectedItemCode?: string;
|
||||
selectedProcessCode?: string;
|
||||
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
|
||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||
onDeleteDetail: (id: string) => void;
|
||||
@@ -25,6 +26,7 @@ export function WorkItemDetailList({
|
||||
detailTypes,
|
||||
readonly,
|
||||
selectedItemCode,
|
||||
selectedProcessCode,
|
||||
onCreateDetail,
|
||||
onUpdateDetail,
|
||||
onDeleteDetail,
|
||||
@@ -103,7 +105,7 @@ export function WorkItemDetailList({
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (type === "production_result") return "작업수량 / 불량수량 / 양품수량";
|
||||
if (type === "material_input") return "BOM 구성 자재 (자동 연동)";
|
||||
if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)";
|
||||
return detail.content || "-";
|
||||
};
|
||||
|
||||
@@ -222,6 +224,7 @@ export function WorkItemDetailList({
|
||||
editData={editTarget}
|
||||
mode={modalMode}
|
||||
selectedItemCode={selectedItemCode}
|
||||
selectedProcessCode={selectedProcessCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
@@ -21,6 +21,7 @@ interface WorkPhaseSectionProps {
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
readonly?: boolean;
|
||||
selectedItemCode?: string;
|
||||
selectedProcessCode?: string;
|
||||
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
|
||||
onAddWorkItem: (phase: string) => void;
|
||||
onEditWorkItem: (item: WorkItem) => void;
|
||||
@@ -38,6 +39,7 @@ export function WorkPhaseSection({
|
||||
detailTypes,
|
||||
readonly,
|
||||
selectedItemCode,
|
||||
selectedProcessCode,
|
||||
onSelectWorkItem,
|
||||
onAddWorkItem,
|
||||
onEditWorkItem,
|
||||
@@ -110,6 +112,7 @@ export function WorkPhaseSection({
|
||||
detailTypes={detailTypes}
|
||||
readonly={readonly}
|
||||
selectedItemCode={selectedItemCode}
|
||||
selectedProcessCode={selectedProcessCode}
|
||||
onCreateDetail={(data) =>
|
||||
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
|
||||
}
|
||||
|
||||
+4
-1
@@ -30,6 +30,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
routingVersionName: null,
|
||||
routingDetailId: null,
|
||||
processName: null,
|
||||
processCode: null,
|
||||
});
|
||||
|
||||
const isRegisteredMode = config.itemListMode === "registered";
|
||||
@@ -192,7 +193,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
routingDetailId: string,
|
||||
processName: string,
|
||||
routingVersionId: string,
|
||||
routingVersionName: string
|
||||
routingVersionName: string,
|
||||
processCode?: string
|
||||
) => {
|
||||
setSelection((prev) => ({
|
||||
...prev,
|
||||
@@ -200,6 +202,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
routingVersionName,
|
||||
routingDetailId,
|
||||
processName,
|
||||
processCode: processCode || null,
|
||||
}));
|
||||
setSelectedDetailsByPhase({});
|
||||
setSelectedWorkItemIdByPhase({});
|
||||
|
||||
@@ -171,4 +171,5 @@ export interface SelectionState {
|
||||
routingVersionName: string | null;
|
||||
routingDetailId: string | null;
|
||||
processName: string | null;
|
||||
processCode: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user