Files
wace_rps/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx
T
hjjeong c955fe0dac 개발관리 — 5메뉴(PART/E-BOM/EO이력)에 logicstudio 스타일 DataGrid 적용
영업관리/프로젝트관리 패턴(fc959d88·6a181371)을 개발관리 5메뉴 전체로 확장.

공통 변경:
- 부모 wrapper 영업관리 통일: flex h-full flex-col overflow-hidden p-2 gap-2
  + DataGrid 를 직접 자식으로 (불필요한 min-h-0 flex-1 wrapper 제거)
- DataGrid props 확장:
  - showColumnSettings · paginationStyle="range" · pageSizeOptions=[10,15,20,50,100]
  - onRefresh = fetchList(또는 runQuery) · onDownload = exportToExcel(GRID_COLUMNS 라벨 매핑)
  - showChart
- 컬럼 폭: ⋮⋮ 드래그 핸들 추가로 좁아진 4글자 한국어/영문 라벨을 95~125px 로 보정

메뉴별 summaryStats:
- PART 등록(M1): 전체·페이지 건수 / 환산수량·개당수량·BOM 수량 합계
- PART 조회(M2): 전체·페이지 건수 / BOM 수량·환산수량·개당수량 합계
- E-BOM 등록(M3): 전체·페이지 건수 + 상태별(status_title) 분포 4종
- E-BOM 조회(M4): 모드(정/역전개) + 표시·원본 행 + MAX_LEVEL + 수량·항목수량 합계
- 설계변경 리스트(M5): 전체·페이지 건수 / 수량·변경수량 합계

systemColumnKeys 분리 (컬럼 설정의 데이터/시스템 그룹 구분):
- PART 등록: revision, status
- PART 조회: revision, eo_no
- E-BOM 등록: dept_user_name, reg_date, deploy_date, revision, status_title
- 설계변경: writer_name, his_reg_date_title

id 매핑 누락 보강 (350ddcd3 함정 재발 방지):
- ebom-search: gridData 에 child_objid 또는 인덱스 fallback id 부여
- change-list: rows 에 objid 또는 인덱스 fallback id 부여
- (PART/E-BOM 등록은 이미 gridRows 매핑 있음)

E-BOM 조회 특수:
- defaultPageSize=100, pageSizeOptions=[20,50,100,200,500] — BOM 트리 가독성
- onDownload 는 PageHeader 의 정/역전개 엑셀 버튼과 동일 동작(현재 direction)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:23:33 +09:00

304 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// 개발관리 > E-BOM 조회 (M4) — wace structureAscendingList.jsp 1:1
// 정전개(루트→리프) / 역전개(리프→부모) 토글. 동적 LEVEL 컬럼.
// 참조: docs/migration/development/02-ebom.md
import React, { useCallback, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { Loader2, ChevronsRight, ChevronsLeft, FileSpreadsheet } from "lucide-react";
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 { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
const LEVEL_OPTIONS: SmartSelectOption[] = [
{ code: "1", label: "1레벨" },
{ code: "2", label: "2레벨" },
{ code: "3", label: "3레벨" },
{ code: "4", label: "4레벨" },
{ code: "5", label: "5레벨" },
];
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
type Direction = "ascending" | "descending";
const EMPTY_FILTER: BomTreeFilter = {
search_part_no: "", search_part_name: "", search_level: "",
};
export default function EbomSearchPage() {
const [filter, setFilter] = useState<BomTreeFilter>(EMPTY_FILTER);
const [direction, setDirection] = useState<Direction>("ascending");
const [rows, setRows] = useState<BomTreeRow[]>([]);
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);
try {
const fn = dir === "ascending" ? devBomApi.ascending : devBomApi.descending;
const res = await fn(filter);
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 {
setLoading(false);
}
}, [filter]);
// wace structureAscending/DescendingListExcel.jsp 1:1 — 현재 검색 조건 그대로 .xlsx 다운로드
const downloadExcel = useCallback(async (dir: Direction) => {
setExporting(true);
try {
const fn = dir === "ascending" ? devBomApi.excelAscending : devBomApi.excelDescending;
const { blob, fileName } = await fn(filter);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click();
a.remove(); URL.revokeObjectURL(url);
toast.success(`${fileName} 다운로드`);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패");
} finally {
setExporting(false);
}
}, [filter]);
// 자식 보유 행 식별 (다른 행이 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: String(i),
width: "w-[36px]",
align: "center",
});
}
return [
{ key: "__toggle", label: "", width: "w-[36px]", align: "center",
onClick: (row: any) => toggleCollapse(row) },
...levelCols,
// 품번 셀 클릭 → 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-[70px]", align: "center", renderType: "folder" },
{ key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu03_cnt", label: "PDF", width: "w-[70px]", 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, toggleCollapse]);
// 가시 행: 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, i) => {
const expanded: any = { ...r };
const lev = Number(r.lev ?? 0);
for (let j = 1; j <= Math.max(1, maxLevel); j++) {
expanded[`__lev_${j}`] = lev === j ? "*" : "";
}
const childId = r.child_objid ? String(r.child_objid) : "";
const hasChild = childId && hasChildSet.has(childId);
expanded.__toggle = hasChild ? (collapsedChildIds.has(childId) ? "+" : "") : "";
// DataGrid row key — child_objid 우선, 없으면 인덱스 fallback
expanded.id = childId || `bom-${i}`;
return expanded;
});
}, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]);
// ─── 하단 통계 ──────────────────────────────────────────────
const treeSummary = useMemo(() => {
const intFmt = (n: number) => n.toLocaleString();
const qtySum = gridData.reduce((acc, r: any) => acc + Number(r.qty || 0), 0);
const pQtySum = gridData.reduce((acc, r: any) => acc + Number(r.p_qty || 0), 0);
return [
{ label: "모드", value: direction === "ascending" ? "정전개" : "역전개" },
{ label: "표시 행", value: intFmt(gridData.length), suffix: "행" },
{ label: "원본 행", value: intFmt(rows.length), suffix: "행" },
{ label: "MAX_LEVEL", value: String(maxLevel) },
{ label: "수량 합계", value: intFmt(qtySum) },
{ label: "항목수량 합계", value: intFmt(pQtySum) },
];
}, [gridData, rows.length, maxLevel, direction]);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
onReset={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}
actions={
<>
<Button size="sm" onClick={() => runQuery("ascending")} disabled={loading}
variant={direction === "ascending" ? "default" : "secondary"}
className="h-8 gap-1 text-xs">
{loading && direction === "ascending"
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ChevronsRight className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" onClick={() => runQuery("descending")} disabled={loading}
variant={direction === "descending" ? "default" : "secondary"}
className="h-8 gap-1 text-xs">
{loading && direction === "descending"
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ChevronsLeft className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => downloadExcel("ascending")} disabled={exporting}>
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileSpreadsheet className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => downloadExcel("descending")} disabled={exporting}>
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileSpreadsheet className="h-3.5 w-3.5" />}
</Button>
</>
} />
{/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개
(고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */}
<CompactFilterBar
totalText={<>: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()} · MAX_LEVEL = {maxLevel}</>}
>
<CompactFilterField label="품번" width={200}>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
// 품번 선택 시 품명 자동 채움 (wace select2-part 1:1)
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</CompactFilterField>
<CompactFilterField label="품명" width={220}>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
// 품명 선택 시 품번 자동 채움
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</CompactFilterField>
<CompactFilterField label="표시 레벨" width={120}>
<SmartSelect
options={LEVEL_OPTIONS}
value={String(filter.search_level ?? "")}
onValueChange={(v) => setFilter({ ...filter, search_level: v })}
placeholder="전체"
/>
</CompactFilterField>
</CompactFilterBar>
{direction === "descending" && (
<div className="text-xs text-amber-600 px-2">
.
</div>
)}
<DataGrid
columns={columns}
data={gridData}
loading={loading}
showRowNumber
emptyMessage="조건에 맞는 BOM이 없습니다."
gridId={`development-ebom-search-${direction}`}
showColumnSettings
paginationStyle="range"
pageSizeOptions={[20, 50, 100, 200, 500]}
defaultPageSize={100}
summaryStats={treeSummary}
onRefresh={() => runQuery(direction)}
onDownload={() => downloadExcel(direction)}
showChart
/>
<PartDetailDialog
open={partDetailOpen}
onOpenChange={setPartDetailOpen}
objid={partDetailObjid}
/>
</div>
);
}