개발관리>E-BOM 조회 — 운영판 1:1 그리드 + 토글 + 품번 상세 + 검색 anchor 정정

(1) 정전개 트리 화면 운영판 wace structureAscendingList.jsp 1:1 정정:
   - L1..LMaxLevel 컬럼 — row.lev 와 일치하는 컬럼에만 "*" 표시 (이전엔 품번 표시)
   - 별도 품번 컬럼 1개 (모든 행 part_no)
   - 3D/2D/PDF — renderType: "folder" (wace fnc_getFolderIcon 1:1)
   - 컬럼 운영판 1:1 : 품번/품명/수량/항목수량/3D/2D/PDF/재료/열처리경도/열처리방법/표면처리/메이커/범주이름/비고
   - 제거 : 변경일/REV/규격/중량 (운영판 미사용)

(2) 토글 -/+ 버튼 추가 (wace 트리 1:1):
   - 첫 컬럼 __toggle — 자식 있는 행만 − / + 표시, 클릭 시 자식 숨김/표시
   - collapsedChildIds Set<string> 상태로 접힘 관리
   - ancestor 체인: parent_objid → 부모 행 child_objid 추적 (cycle guard)
   - 가시 행 필터: ancestor 중 하나라도 collapsed Set 에 있으면 hide → 자손 전체 숨김
   - 새 조회 시 collapsed Set 초기화 (모두 펼침)

(3) 품번 셀 클릭 → PartDetailDialog (wace partMngDetailPopUp 1:1):
   - row.part_no = part_mng.objid::varchar 이므로 그대로 detail dialog 의 objid 로 전달
   - ebom-search 페이지에 PartDetailDialog 임포트 + state

(4) 검색 필터 anchor 정정 (사용자 검증: 1행만 나오고 자식 안 풀림):
   - 이전: search_part_no/search_part_name 을 결과 단계 WHERE PM.part_no LIKE ... 로 적용
            → 매칭 행 1개만 살아남고 자식 잘림
   - 정정: anchor 단계에서 매칭된 PART 가 들어있는 bom_report_objid 전체를 startWhere 로
            → 재귀 CTE 가 자식 모두 풀어냄 (운영판 1:1)
   - search_level (1~5) 은 결과 단계 유지 (트리 깊이 제한)
   - ascending / ascendingForExcel 양쪽 동일 패턴

(5) ascending SELECT 풀 컬럼 보강:
   - 추가 : item_qty(p_qty), heat_treatment_hardness/method, surface_treatment,
            maker, part_type, part_type_title (comm_code.code_name)
   - TREE CTE 컬럼에 item_qty 추가
   - BomTreeRow 타입 동기 (lib/api/devBom.ts)

(6) 상태변경 시 확정일(DEPLOY_DATE) 처리 — 사용자 요청:
   - status = 'Y' 변경 시 DEPLOY_DATE = TO_CHAR(NOW(), 'YYYY-MM-DD') 채움 (varchar)
   - 'N' 변경 시 기존 DEPLOY_DATE 보존
   - $5 prepared statement 타입 추론 충돌 (varchar vs unknown) → $5::varchar 명시 캐스팅
   - STATUS_TITLE 매핑은 운영판 1:1 — CREATE/CHANGEDESIGN/DEPLOY 만 라벨, Y/N 등은 raw 표시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 12:11:08 +09:00
parent 20a429eecb
commit 68d2dcb32e
3 changed files with 177 additions and 51 deletions
@@ -15,6 +15,7 @@ import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom";
import { DevPartSelect } from "@/components/development/DevPartSelect";
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
type Direction = "ascending" | "descending";
@@ -29,6 +30,11 @@ export default function EbomSearchPage() {
const [maxLevel, setMaxLevel] = useState(0);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
// 토글 접힘 상태: 접힌 부모 행의 child_objid 집합
const [collapsedChildIds, setCollapsedChildIds] = useState<Set<string>>(new Set());
// PART 상세 다이얼로그
const [partDetailOpen, setPartDetailOpen] = useState(false);
const [partDetailObjid, setPartDetailObjid] = useState<string | null>(null);
const runQuery = useCallback(async (dir: Direction) => {
setLoading(true);
@@ -38,6 +44,7 @@ export default function EbomSearchPage() {
setRows(res.rows ?? []);
setMaxLevel(Number(res.max_level) || 0);
setDirection(dir);
setCollapsedChildIds(new Set()); // 새 조회 시 모두 펼침
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
@@ -64,44 +71,110 @@ export default function EbomSearchPage() {
}
}, [filter]);
// 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시
// 자식 보유 행 식별 (다른 행이 parent_objid 로 참조하는 child_objid 집합)
const hasChildSet = useMemo(() => {
const s = new Set<string>();
for (const r of rows) if (r.parent_objid) s.add(String(r.parent_objid));
return s;
}, [rows]);
// 각 행의 ancestor child_objid 체인 (collapsed 검사용)
const ancestorsByChildId = useMemo(() => {
const byChild = new Map<string, BomTreeRow>();
for (const r of rows) if (r.child_objid) byChild.set(String(r.child_objid), r);
const result = new Map<string, string[]>();
for (const r of rows) {
if (!r.child_objid) continue;
const list: string[] = [];
let cur: string | null = r.parent_objid ? String(r.parent_objid) : null;
const guard = new Set<string>();
while (cur && !guard.has(cur)) {
list.push(cur);
guard.add(cur);
const p = byChild.get(cur);
cur = p?.parent_objid ? String(p.parent_objid) : null;
}
result.set(String(r.child_objid), list);
}
return result;
}, [rows]);
// 토글 클릭 핸들러 — 부모 행의 child_objid 를 collapsed Set 에 toggle
const toggleCollapse = useCallback((row: any) => {
const childId = row.child_objid ? String(row.child_objid) : "";
if (!childId || !hasChildSet.has(childId)) return;
setCollapsedChildIds((prev) => {
const next = new Set(prev);
if (next.has(childId)) next.delete(childId);
else next.add(childId);
return next;
});
}, [hasChildSet]);
// 운영판 structureAscendingList.jsp 1:1
// - 첫 컬럼: -/+ 토글 (자식 있는 행만)
// - L1..LMaxLevel 컬럼은 해당 레벨에만 "*" 표시 (품번 표시 X)
// - 별도 품번 컬럼에 모든 행 part_no
// - 3D/2D/PDF 폴더 아이콘 (renderType: "folder")
const columns: DataGridColumn[] = useMemo(() => {
const levelCols: DataGridColumn[] = [];
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
levelCols.push({
key: `__lev_${i}`,
label: `L${i}`,
width: "w-[140px]",
label: String(i),
width: "w-[36px]",
align: "center",
});
}
return [
{ key: "__toggle", label: "", width: "w-[36px]", align: "center",
onClick: (row: any) => toggleCollapse(row) },
...levelCols,
{ key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false },
{ key: "pm_part_name", label: "품", minWidth: "min-w-[200px]" },
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" },
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
{ key: "spec", label: "규격", width: "w-[120px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "weight", label: "량", width: "w-[80px]", align: "right" },
{ key: "remark", label: "비고", minWidth: "min-w-[140px]" },
// 품번 셀 클릭 → PART 상세 (wace partMngDetailPopUp 1:1). row.part_no = part_mng.objid 임.
{ key: "pm_part_no", label: "품", width: "w-[160px]",
onClick: (row: any) => {
if (row.part_no) {
setPartDetailObjid(String(row.part_no));
setPartDetailOpen(true);
}
} },
{ key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true },
{ key: "p_qty", label: "항목수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "center", renderType: "folder" },
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "center", renderType: "folder" },
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "center", renderType: "folder" },
{ key: "material", label: "재료", width: "w-[100px]" },
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
{ key: "surface_treatment", label: "표면처리", width: "w-[100px]" },
{ key: "maker", label: "메이커", width: "w-[110px]" },
{ key: "part_type_title", label: "범주 이름", width: "w-[100px]", align: "center" },
{ key: "remark", label: "비고", minWidth: "min-w-[140px]" },
];
}, [maxLevel]);
}, [maxLevel, toggleCollapse]);
// 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움
const gridData = useMemo(
() => rows.map((r) => {
const expanded: any = { ...r };
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : "";
}
return expanded;
}),
[rows, maxLevel],
);
// 가시 행: collapsed 부모를 ancestor 로 가진 행은 hide
// 행 데이터: __toggle 셀 + __lev_{i} "*" 표시
const gridData = useMemo(() => {
return rows
.filter((r) => {
if (!r.child_objid) return true;
const ancestors = ancestorsByChildId.get(String(r.child_objid)) ?? [];
return !ancestors.some((a) => collapsedChildIds.has(a));
})
.map((r) => {
const expanded: any = { ...r };
const lev = Number(r.lev ?? 0);
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
expanded[`__lev_${i}`] = lev === i ? "*" : "";
}
const childId = r.child_objid ? String(r.child_objid) : "";
const hasChild = childId && hasChildSet.has(childId);
expanded.__toggle = hasChild ? (collapsedChildIds.has(childId) ? "+" : "") : "";
return expanded;
});
}, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]);
return (
<div className="flex h-full flex-col">
@@ -194,6 +267,12 @@ export default function EbomSearchPage() {
gridId={`development-ebom-search-${direction}`}
/>
</div>
<PartDetailDialog
open={partDetailOpen}
onOpenChange={setPartDetailOpen}
objid={partDetailObjid}
/>
</div>
);
}