c955fe0dac
영업관리/프로젝트관리 패턴(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>
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|