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:
kjs
2026-04-14 16:58:53 +09:00
parent 030ffb581b
commit ecb6321cb6
24 changed files with 3381 additions and 3183 deletions
@@ -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
@@ -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}
@@ -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">
@@ -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(
@@ -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"
@@ -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>
);
@@ -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)
}
@@ -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;
}