feat(bom): COMPANY_7 BOM에 대체 품목 관리 기능 추가

- bom_detail_substitute 테이블 신규 (varchar PK, FK 미설정 / 코드 조인)
- 백엔드: 단일 행 substitute CRUD + BOM 단위 갯수 맵 API 5종 추가
- 프론트(COMPANY_7): 트리 행에 '대체 N' 뱃지 + 대체 품목 모달
  · 드래그앤드롭으로 우선순위 변경 + 자동 채번/재할당
  · 250ms debounce 실시간 검색, 결과 클릭 시 자동 행 추가
  · inline blur 저장, zebra 행 구분, sticky 헤더 단색 처리
- 트리뷰 액션 버튼 통합: 가상 루트 선택 시 1레벨, 일반 행 선택 시 하위로 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-05-06 17:32:10 +09:00
parent 45d3ccb004
commit 17173be350
4 changed files with 669 additions and 9 deletions

View File

@@ -224,3 +224,93 @@ export async function deleteBomVersion(req: Request, res: Response) {
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 대체품 (Substitute) ─────────────────────────────
export async function getBomDetailSubstitutes(req: Request, res: Response) {
try {
const { detailId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.getBomDetailSubstitutes(detailId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("대체품 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function getBomSubstituteCounts(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const versionId = (req.query.versionId as string) || undefined;
const rows = await bomService.getBomSubstituteCounts(bomId, companyCode, versionId);
const map: Record<string, number> = {};
for (const r of rows as any[]) {
map[r.bom_detail_id] = Number(r.count);
}
res.json({ success: true, data: map });
} catch (error: any) {
logger.error("대체품 갯수 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomDetailSubstitute(req: Request, res: Response) {
try {
const { detailId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const writer = (req as any).user?.userName || (req as any).user?.userId || "";
const { substitute_item_id, priority, ratio, remark, status } = req.body;
if (!substitute_item_id) {
res.status(400).json({ success: false, message: "substitute_item_id는 필수입니다" });
return;
}
const data = await bomService.createBomDetailSubstitute({
bom_detail_id: detailId,
substitute_item_id,
priority,
ratio,
remark,
status,
company_code: companyCode,
writer,
});
res.json({ success: true, data });
} catch (error: any) {
logger.error("대체품 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateBomDetailSubstitute(req: Request, res: Response) {
try {
const { id } = req.params;
const data = await bomService.updateBomDetailSubstitute(id, req.body);
if (!data) {
res.status(404).json({ success: false, message: "대체품을 찾을 수 없습니다" });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("대체품 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomDetailSubstitute(req: Request, res: Response) {
try {
const { id } = req.params;
const result = await bomService.deleteBomDetailSubstitute(id);
if (!result) {
res.status(404).json({ success: false, message: "대체품을 찾을 수 없습니다" });
return;
}
res.json({ success: true });
} catch (error: any) {
logger.error("대체품 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -30,4 +30,11 @@ router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
// 대체품 (Substitute)
router.get("/:bomId/substitute-counts", bomController.getBomSubstituteCounts);
router.get("/details/:detailId/substitutes", bomController.getBomDetailSubstitutes);
router.post("/details/:detailId/substitutes", bomController.createBomDetailSubstitute);
router.put("/substitutes/:id", bomController.updateBomDetailSubstitute);
router.delete("/substitutes/:id", bomController.deleteBomDetailSubstitute);
export default router;

View File

@@ -845,3 +845,117 @@ export async function deleteBomVersion(
return deleteVersion.rows.length > 0;
});
}
// ─── 대체품 (Substitute) ─────────────────────────────
export async function getBomDetailSubstitutes(detailId: string, companyCode: string) {
const sql = `
SELECT s.*,
i.item_name AS substitute_item_name,
i.item_number AS substitute_item_number,
i.unit AS substitute_unit,
i.inventory_unit AS substitute_inventory_unit,
i.size AS substitute_size,
i.material AS substitute_material,
i.division AS substitute_division
FROM bom_detail_substitute s
LEFT JOIN item_info i ON s.substitute_item_id = i.id
WHERE s.bom_detail_id = $1
${companyCode === "*" ? "" : "AND s.company_code = $2"}
ORDER BY
CASE WHEN s.priority ~ '^[0-9]+$' THEN s.priority::int ELSE 9999 END ASC,
s.created_date ASC
`;
const params = companyCode === "*" ? [detailId] : [detailId, companyCode];
return query(sql, params);
}
export async function getBomSubstituteCounts(
bomId: string,
companyCode: string,
versionId?: string,
) {
const params: any[] = [bomId];
let sql = `
SELECT s.bom_detail_id, COUNT(*)::int AS count
FROM bom_detail_substitute s
JOIN bom_detail d ON d.id = s.bom_detail_id
WHERE d.bom_id = $1
`;
if (companyCode !== "*") {
params.push(companyCode);
sql += ` AND s.company_code = $${params.length}`;
}
if (versionId) {
params.push(versionId);
sql += ` AND d.version_id = $${params.length}`;
}
sql += ` GROUP BY s.bom_detail_id`;
return query(sql, params);
}
export async function createBomDetailSubstitute(data: {
bom_detail_id: string;
substitute_item_id: string;
priority?: string;
ratio?: string;
remark?: string;
status?: string;
company_code: string;
writer?: string;
}) {
const sql = `
INSERT INTO bom_detail_substitute
(bom_detail_id, substitute_item_id, priority, ratio, remark, status, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
return queryOne(sql, [
data.bom_detail_id,
data.substitute_item_id,
data.priority ?? null,
data.ratio ?? "1",
data.remark ?? null,
data.status ?? "active",
data.company_code,
data.writer ?? null,
]);
}
export async function updateBomDetailSubstitute(
id: string,
data: {
substitute_item_id?: string;
priority?: string;
ratio?: string;
remark?: string;
status?: string;
},
) {
const fields: string[] = [];
const values: any[] = [];
let idx = 1;
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
fields.push(`${key} = $${idx++}`);
values.push(value);
}
}
if (fields.length === 0) return null;
fields.push(`updated_date = CURRENT_TIMESTAMP`);
values.push(id);
const sql = `
UPDATE bom_detail_substitute
SET ${fields.join(", ")}
WHERE id = $${idx}
RETURNING *
`;
return queryOne(sql, values);
}
export async function deleteBomDetailSubstitute(id: string) {
const sql = `DELETE FROM bom_detail_substitute WHERE id = $1 RETURNING id`;
return queryOne(sql, [id]);
}

View File

@@ -60,6 +60,7 @@ import {
Save,
Package,
Pencil,
GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -171,6 +172,26 @@ interface BomHistoryEntry {
changed_date?: string;
}
interface BomSubstitute {
id: string;
bom_detail_id: string;
substitute_item_id: string;
priority?: string;
ratio?: string;
remark?: string;
status?: string;
substitute_item_name?: string;
substitute_item_number?: string;
substitute_unit?: string;
substitute_inventory_unit?: string;
substitute_size?: string;
substitute_material?: string;
substitute_division?: string;
created_date?: string;
_isNew?: boolean; // DB에 아직 없는 신규 행
_tempId?: string; // 임시 ID
}
interface TreeNode extends BomDetail {
children: TreeNode[];
expanded?: boolean;
@@ -346,6 +367,19 @@ export default function BomManagementPage() {
const [treeItemSearchOpen, setTreeItemSearchOpen] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
// 대체품 (Substitute)
const [substituteCounts, setSubstituteCounts] = useState<Record<string, number>>({});
const [substituteModalOpen, setSubstituteModalOpen] = useState(false);
const [substituteTargetNode, setSubstituteTargetNode] = useState<TreeNode | null>(null);
const [substituteList, setSubstituteList] = useState<BomSubstitute[]>([]);
const [substituteLoading, setSubstituteLoading] = useState(false);
const [substituteSaving, setSubstituteSaving] = useState(false);
const [substituteSearchKeyword, setSubstituteSearchKeyword] = useState("");
const [substituteSearchResults, setSubstituteSearchResults] = useState<any[]>([]);
const [substituteSearchLoading, setSubstituteSearchLoading] = useState(false);
const [draggedRowId, setDraggedRowId] = useState<string | null>(null);
const [dragOverRowId, setDragOverRowId] = useState<string | null>(null);
// ─── 데이터 로드 ──────────────────────────────
const fetchBomList = useCallback(async () => {
setLoading(true);
@@ -459,6 +493,18 @@ export default function BomManagementPage() {
}).catch(() => {});
}, []);
// 대체품 갯수 맵 로드 (트리뷰 뱃지용) — fetchBomDetail보다 먼저 선언
const fetchSubstituteCounts = useCallback(async (bomId: string, versionId?: string | null) => {
try {
const res = await apiClient.get(`/bom/${bomId}/substitute-counts`, {
params: versionId ? { versionId } : undefined,
});
setSubstituteCounts(res.data?.data || {});
} catch {
setSubstituteCounts({});
}
}, []);
// ─── BOM 상세 로드 ────────────────────────────
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
@@ -550,12 +596,15 @@ export default function BomManagementPage() {
};
collectIds(tree);
setExpandedNodes(allIds);
// 대체품 갯수 맵 로드 (트리 행 뱃지용)
fetchSubstituteCounts(bomId, versionId);
} catch (err: any) {
toast.error("BOM 상세 조회에 실패했어요");
} finally {
setDetailLoading(false);
}
}, [categoryOptions]);
}, [categoryOptions, fetchSubstituteCounts]);
// 버전 목록 로드
const fetchVersions = useCallback(async (bomId: string) => {
@@ -572,6 +621,19 @@ export default function BomManagementPage() {
}
}, []);
// 단일 행의 대체품 목록 로드
const fetchSubstitutes = useCallback(async (detailId: string) => {
setSubstituteLoading(true);
try {
const res = await apiClient.get(`/bom/details/${detailId}/substitutes`);
setSubstituteList(res.data?.data || []);
} catch {
setSubstituteList([]);
} finally {
setSubstituteLoading(false);
}
}, []);
// 이력 목록 로드
const fetchHistory = useCallback(async (bomId: string) => {
setHistoryLoading(true);
@@ -1430,6 +1492,171 @@ export default function BomManagementPage() {
}
};
// ─── 대체품 (Substitute) 핸들러 ──────────────────
const openSubstituteModal = (node: TreeNode) => {
if (!node) return;
if ((node as any)._isVirtualRoot) {
toast.error("BOM 마스터 행에는 대체품을 등록할 수 없어요");
return;
}
if (!node.id || node._isNew) {
toast.error("먼저 트리를 저장한 뒤에 대체품을 등록해주세요");
return;
}
setSubstituteTargetNode(node);
setSubstituteList([]);
setSubstituteSearchKeyword("");
setSubstituteSearchResults([]);
setDraggedRowId(null);
setDragOverRowId(null);
setSubstituteModalOpen(true);
fetchSubstitutes(node.id);
};
const closeSubstituteModal = () => {
setSubstituteModalOpen(false);
setSubstituteTargetNode(null);
setSubstituteList([]);
setSubstituteSearchKeyword("");
setSubstituteSearchResults([]);
setDraggedRowId(null);
setDragOverRowId(null);
};
// 검색 결과 클릭 → 자동 행 추가 + POST + 우선순위 자동 채번
const handleSelectItem = async (item: any) => {
if (!substituteTargetNode?.id) return;
const nextPriority = String(substituteList.length + 1);
setSubstituteSaving(true);
try {
await apiClient.post(`/bom/details/${substituteTargetNode.id}/substitutes`, {
substitute_item_id: item.id,
priority: nextPriority,
ratio: "1",
remark: null,
});
toast.success(`"${item.item_name || item.item_number}" 추가됨`);
fetchSubstitutes(substituteTargetNode.id);
if (selectedBomId) fetchSubstituteCounts(selectedBomId, currentVersionId);
} catch {
toast.error("대체품 추가에 실패했어요");
} finally {
setSubstituteSaving(false);
}
};
const handleDeleteSubstitute = async (id: string) => {
if (!substituteTargetNode?.id) return;
const ok = await confirm("이 대체품을 삭제하시겠어요?", { variant: "destructive" });
if (!ok) return;
try {
await apiClient.delete(`/bom/substitutes/${id}`);
toast.success("대체품을 삭제했어요");
// 삭제 후 priority 재할당 (gap 메우기)
const remaining = substituteList.filter((s) => s.id !== id);
const reassigned = remaining.map((s, idx) => ({ ...s, priority: String(idx + 1) }));
setSubstituteList(reassigned);
// 변경된 priority를 PUT으로 동기화
await Promise.all(
reassigned.map((s) => apiClient.put(`/bom/substitutes/${s.id}`, { priority: s.priority }).catch(() => null))
);
if (substituteTargetNode?.id) fetchSubstitutes(substituteTargetNode.id);
if (selectedBomId) fetchSubstituteCounts(selectedBomId, currentVersionId);
} catch {
toast.error("대체품 삭제에 실패했어요");
}
};
const handleUpdateSubstituteField = async (id: string, field: keyof BomSubstitute, value: string) => {
setSubstituteList((prev) => prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)));
try {
await apiClient.put(`/bom/substitutes/${id}`, { [field]: value });
} catch {
toast.error("대체품 수정에 실패했어요");
if (substituteTargetNode?.id) fetchSubstitutes(substituteTargetNode.id);
}
};
// 드래그 앤 드롭으로 우선순위 변경
const handleRowDragStart = (e: React.DragEvent, rowId: string) => {
setDraggedRowId(rowId);
e.dataTransfer.effectAllowed = "move";
};
const handleRowDragOver = (e: React.DragEvent, rowId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverRowId !== rowId) setDragOverRowId(rowId);
};
const handleRowDragLeave = () => setDragOverRowId(null);
const handleRowDrop = async (e: React.DragEvent, targetId: string) => {
e.preventDefault();
const sourceId = draggedRowId;
setDraggedRowId(null);
setDragOverRowId(null);
if (!sourceId || sourceId === targetId) return;
const sourceIdx = substituteList.findIndex((s) => s.id === sourceId);
const targetIdx = substituteList.findIndex((s) => s.id === targetId);
if (sourceIdx === -1 || targetIdx === -1) return;
const newList = [...substituteList];
const [moved] = newList.splice(sourceIdx, 1);
newList.splice(targetIdx, 0, moved);
// 우선순위 재할당
const reassigned = newList.map((s, idx) => ({ ...s, priority: String(idx + 1) }));
setSubstituteList(reassigned);
// 변경된 priority 일괄 PUT
try {
await Promise.all(
reassigned
.filter((s) => !s._isNew)
.map((s) => apiClient.put(`/bom/substitutes/${s.id}`, { priority: s.priority }))
);
} catch {
toast.error("우선순위 변경에 실패했어요");
if (substituteTargetNode?.id) fetchSubstitutes(substituteTargetNode.id);
}
};
// 모달 열려있을 때 실시간 검색 — 키워드 없어도 기본 50건 fetch
useEffect(() => {
if (!substituteModalOpen) return;
const kw = substituteSearchKeyword.trim();
const handler = setTimeout(async () => {
setSubstituteSearchLoading(true);
try {
const filters: any[] = [];
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "item_number", order: "asc" },
});
let results = res.data?.data?.data || res.data?.data?.rows || [];
if (kw && results.length === 0) {
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
autoFilter: true,
});
results = res2.data?.data?.data || res2.data?.data?.rows || [];
}
setSubstituteSearchResults(results);
} catch {
setSubstituteSearchResults([]);
} finally {
setSubstituteSearchLoading(false);
}
}, kw ? 250 : 0);
return () => clearTimeout(handler);
}, [substituteSearchKeyword, substituteModalOpen]);
// ─── 상태 뱃지 렌더 ───────────────────────────
const renderStatusBadge = (status?: string) => {
const raw = status || "";
@@ -1601,18 +1828,16 @@ export default function BomManagementPage() {
{/* 상단 액션바 */}
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => handleTreeAddChild(null)}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (selectedTreeNodeId && !selectedTreeNodeId.startsWith("__root_")) {
handleTreeAddChild(selectedTreeNodeId);
}
if (!selectedTreeNodeId) return;
const isVirtualRoot = selectedTreeNodeId.startsWith("__root_");
handleTreeAddChild(isVirtualRoot ? null : selectedTreeNodeId);
}}
disabled={!selectedTreeNodeId || selectedTreeNodeId.startsWith("__root_")}
disabled={!selectedTreeNodeId}
title="선택한 행의 하위로 품목 추가 (BOM 마스터 선택 시 1레벨로 추가)"
>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
@@ -1631,6 +1856,19 @@ export default function BomManagementPage() {
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedTreeNodeId || selectedTreeNodeId.startsWith("__root_")) return;
const node = editingFlatTree.find((n) => (n.id || n._tempId) === selectedTreeNodeId);
if (node) openSubstituteModal(node);
}}
disabled={!selectedTreeNodeId || selectedTreeNodeId.startsWith("__root_")}
title="선택한 행의 대체 품목 보기/등록"
>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-5 bg-border" />
<div className="flex overflow-hidden rounded-md border">
<button onClick={() => { setTreeViewMode("forward"); }} className={cn("h-7 px-2.5 text-[11px] font-medium transition-colors", treeViewMode === "forward" ? "bg-primary text-primary-foreground" : "bg-background text-muted-foreground hover:bg-muted")}>
@@ -1797,7 +2035,22 @@ export default function BomManagementPage() {
</td>
{/* 품명 */}
<td className={cn("overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2", isVirtualRoot && "font-bold")}>
{node.item_name || "-"}
<div className="flex items-center gap-1.5">
<span className="truncate">{node.item_name || "-"}</span>
{!isVirtualRoot && node.id && substituteCounts[node.id] > 0 && (
<span
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium bg-blue-50 text-blue-700 ring-1 ring-blue-200 cursor-pointer hover:bg-blue-100"
title="대체품 보기"
onClick={(e) => {
e.stopPropagation();
openSubstituteModal(node);
}}
>
<Copy className="w-2.5 h-2.5" />
{substituteCounts[node.id]}
</span>
)}
</div>
</td>
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
@@ -2533,6 +2786,202 @@ export default function BomManagementPage() {
</DialogContent>
</Dialog>
{/* 대체품 모달 */}
<Dialog open={substituteModalOpen} onOpenChange={(v) => { if (!v) closeSubstituteModal(); }}>
<DialogContent
className="max-w-3xl max-h-[85vh] flex flex-col"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Copy className="w-4 h-4 text-primary" />
</DialogTitle>
<DialogDescription>
{substituteTargetNode && (
<span className="flex items-center gap-2 mt-1 text-foreground">
<span className="font-mono text-xs text-muted-foreground">
{substituteTargetNode.item_number || "-"}
</span>
<span className="font-semibold">
{substituteTargetNode.item_name || "-"}
</span>
<span className="text-xs text-muted-foreground">
· {substituteTargetNode.quantity || "1"} {substituteTargetNode.unit || ""}
</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col gap-3 min-h-0 overflow-hidden">
{/* 대체품 테이블 — 위쪽 (행 많아지면 자체 스크롤) */}
<div className="flex-1 min-h-[120px] flex flex-col">
<div className="shrink-0 mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> {substituteList.length}</Label>
{substituteList.length > 1 && (
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<GripVertical className="w-3 h-3" />
</span>
)}
</div>
<div className="flex-1 overflow-auto border rounded-md">
<table className="w-full text-xs">
<thead className="sticky top-0 z-10">
<tr className="bg-muted border-b">
<th className="bg-muted px-2 py-2 w-24 text-center font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-2 w-32 text-left font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-2 text-left font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-2 w-20 text-center font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-2 text-left font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-2 w-10"></th>
</tr>
</thead>
<tbody>
{substituteLoading ? (
<tr><td colSpan={6} className="px-2 py-6 text-center"><Loader2 className="w-4 h-4 animate-spin inline" /></td></tr>
) : substituteList.length === 0 ? (
<tr><td colSpan={6} className="px-2 py-6 text-center text-muted-foreground"> . </td></tr>
) : (
substituteList.map((s, idx) => {
const isDragging = draggedRowId === s.id;
const isDropTarget = dragOverRowId === s.id && draggedRowId && draggedRowId !== s.id;
const isOdd = idx % 2 === 1;
return (
<tr
key={s.id}
draggable
onDragStart={(e) => handleRowDragStart(e, s.id)}
onDragOver={(e) => handleRowDragOver(e, s.id)}
onDragLeave={handleRowDragLeave}
onDrop={(e) => handleRowDrop(e, s.id)}
className={cn(
"transition-colors",
isOdd ? "bg-muted/30 hover:bg-muted/50" : "hover:bg-muted/30",
isDragging && "opacity-40",
isDropTarget && "bg-primary/10 border-t-2 border-t-primary",
)}
>
<td className="px-2 py-1.5 text-center">
<div className="flex items-center gap-1">
<GripVertical className="w-3.5 h-3.5 text-muted-foreground/40 cursor-grab shrink-0" />
<Input
value={s.priority || ""}
onChange={(e) => setSubstituteList((prev) => prev.map((x) => x.id === s.id ? { ...x, priority: e.target.value } : x))}
onBlur={(e) => handleUpdateSubstituteField(s.id, "priority", e.target.value)}
className="h-7 text-xs text-center"
/>
</div>
</td>
<td className="px-2 py-1.5 font-mono text-[11px]">{s.substitute_item_number || "-"}</td>
<td className="px-2 py-1.5">{s.substitute_item_name || "-"}</td>
<td className="px-2 py-1.5">
<Input
value={s.ratio || ""}
onChange={(e) => setSubstituteList((prev) => prev.map((x) => x.id === s.id ? { ...x, ratio: e.target.value } : x))}
onBlur={(e) => handleUpdateSubstituteField(s.id, "ratio", e.target.value)}
className="h-7 text-xs text-center"
/>
</td>
<td className="px-2 py-1.5">
<Input
value={s.remark || ""}
onChange={(e) => setSubstituteList((prev) => prev.map((x) => x.id === s.id ? { ...x, remark: e.target.value } : x))}
onBlur={(e) => handleUpdateSubstituteField(s.id, "remark", e.target.value)}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteSubstitute(s.id)}
title="삭제"
>
<Trash2 className="w-3 h-3" />
</Button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* 품목 검색 영역 — 항상 표시, 모달 하단 고정 */}
<div className="shrink-0 rounded-md border p-3 bg-muted/30 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
{substituteSaving && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50" />
<Input
placeholder="품번 또는 품명"
value={substituteSearchKeyword}
onChange={(e) => setSubstituteSearchKeyword(e.target.value)}
className="h-8 text-xs pl-8"
/>
{substituteSearchLoading && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 animate-spin text-muted-foreground" />
)}
</div>
</div>
<div className="border rounded-md max-h-48 overflow-auto bg-background">
{substituteSearchLoading && substituteSearchResults.length === 0 ? (
<div className="text-[11px] text-muted-foreground text-center py-3">
<Loader2 className="w-3 h-3 animate-spin inline mr-1" /> ...
</div>
) : substituteSearchResults.length === 0 ? (
<div className="text-[11px] text-muted-foreground text-center py-3">
{substituteSearchKeyword.trim() ? "검색 결과가 없어요" : "품목을 불러올 수 없어요"}
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 z-10">
<tr className="bg-muted border-b">
<th className="bg-muted px-2 py-1.5 w-32 text-left font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-1.5 text-left font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-1.5 w-32 text-left font-semibold text-muted-foreground"></th>
<th className="bg-muted px-2 py-1.5 w-10"></th>
</tr>
</thead>
<tbody>
{substituteSearchResults.map((it, idx) => (
<tr
key={it.id}
className={cn(
"transition-colors cursor-pointer",
idx % 2 === 1 ? "bg-muted/30 hover:bg-primary/10" : "hover:bg-primary/5"
)}
onClick={() => handleSelectItem(it)}
>
<td className="px-2 py-1.5 font-mono text-[11px]">{it.item_number || "-"}</td>
<td className="px-2 py-1.5">{it.item_name || "-"}</td>
<td className="px-2 py-1.5 text-muted-foreground">{it.size || "-"}</td>
<td className="px-2 py-1.5 text-right"><Plus className="w-3.5 h-3.5 inline text-primary" /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeSubstituteModal}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
<TableSettingsModal