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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user