From 20a429eecb97c500b842df17693bfaec45a7bbad Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 11:43:29 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BOM=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9A=B4=EC=98=81=ED=8C=90=201:1=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20=EB=8B=A4=EC=88=98=20=E2=80=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=ED=8F=BC=C2=B7=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=C2=B7=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=C2=B7STATUS=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 검증으로 발견된 5가지 함정 일괄 정정. (1) ebom-search 검색폼 운영판 1:1 — wace structureAscendingList.jsp 노출 필드만: - 제거: 프로젝트 OBJID (raw input), UNIT_CODE (raw input) 운영판도 고객사/프로젝트번호/유닛명 모두 주석 처리되어 노출 안 됨 - 유지: 품번 / 품명 / 표시 레벨 (1~5 select) - BomTreeFilter.search_level 추가 + ascending/descending CTE 에 T.lev <= $search_level::int (2) 품번/품명 자동완성 (wace select2-part 1:1): - 영업관리 PartSelect 는 item_info 마스터 기반 → 개발관리(part_mng)용 별도 컴포넌트 신설 - backend GET /api/development/part/options : IS_LAST='1' part_mng 전체 (영업관리 sales/parts 패턴) - frontend DevPartSelect.tsx : SmartSelect 캐시 + mode partNo/partName 분리 - ebom-search 페이지 단순 Input → DevPartSelect 교체 - 품번 선택 시 품명 자동 채움 / 품명 선택 시 품번 자동 채움 (운영판 select2-part 1:1) (3) BomReportStatusDialog 운영판 1:1 재작성 — wace structureStatusChangePopup.jsp: - 잘못된 점: read-only 박스 + 상태 select(create/changeDesign/deploy 3옵션) - 정정: 5필드 모두 편집 가능 (CommCodeSelect 제품구분 / 품번 input / 품명 input / Version input / 상태 Y/N 라디오) — 운영 매퍼 updateStructureStatus 5컬럼 UPDATE 1:1 - 헤더 파란 바 + 4컬럼 테이블(25%/75%) + 저장/닫기 중앙 배치 (운영판 스타일 1:1) (4) DataGrid id 매핑 — 체크박스 ID 키 불일치 함정: - DataGrid 는 row.id 로 체크박스 ID 관리, 백엔드 응답은 row.objid (postgres lowercase) - 결과: checkedIds[0] 가 undefined → 상태변경/수정/삭제 다이얼로그가 objid=undefined 로 열려 detail 호출 안 됨 → 빈 폼 표시 (사용자 지적 "기본 정보 표시 안됨") - 일괄 수정 (3 페이지) : ebom-regist / part-regist / part-search gridRows = useMemo(() => rows.map(r => ({ ...r, id: r.objid })), [rows]) 영업관리 페이지 동일 패턴 1:1 (5) STATUS_TITLE 매핑 운영판 1:1 — 운영 그리드는 'Y'/'N' 글자 그대로 표시: CASE UPPER(T.STATUS) WHEN 'CREATE' THEN '등록중' WHEN 'CHANGEDESIGN' THEN '설계변경미배포' WHEN 'DEPLOY' THEN '배포완료' ELSE COALESCE(T.STATUS, '') END AS STATUS_TITLE - 운영 매퍼는 ELSE '' 이지만 RPS 는 raw fallback (사용자 화면에서 식별 가능) - 'Y'/'N' 매핑 라벨 추가 → 운영 스크린샷 확인 후 제거 (운영판은 raw) 미해결 (별 작업): - 확정일 (DEPLOY_DATE) 표시 — 운영판은 별도 "배포" 액션 (deployBomReport 매퍼) 으로 채움. RPS ebom-regist 에 배포 버튼 미구현 → 신규 BOM 확정일 빈값. 별 PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/devPartController.ts | 20 +++ backend-node/src/routes/devPartRoutes.ts | 3 + backend-node/src/services/devBomService.ts | 17 +- .../development/ebom-regist/page.tsx | 5 +- .../development/ebom-search/page.tsx | 56 ++++--- .../development/part-regist/page.tsx | 5 +- .../development/part-search/page.tsx | 5 +- .../development/BomReportStatusDialog.tsx | 151 ++++++++++++------ .../components/development/DevPartSelect.tsx | 102 ++++++++++++ frontend/lib/api/devBom.ts | 1 + 10 files changed, 295 insertions(+), 70 deletions(-) create mode 100644 frontend/components/development/DevPartSelect.tsx diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts index affa0977..8dbc4a2b 100644 --- a/backend-node/src/controllers/devPartController.ts +++ b/backend-node/src/controllers/devPartController.ts @@ -117,6 +117,26 @@ export async function deploy(req: AuthenticatedRequest, res: Response) { // 운영판 wace: openPartExcelImportPopUp.jsp → partParsingExcelFile.do + partUploadSave.do // 본 RPS 구현: 파일을 메모리 파싱 → 검증 결과(NOTE 포함) 반환 / 저장 시 신규 part_no 만 INSERT. +// PART 자동완성 옵션 (IS_LAST='1' 전체) — wace select2-part 1:1 +// GET /api/development/part/options +// response: { rows: [{ objid, part_no, part_name }] } +export async function partOptions(req: AuthenticatedRequest, res: Response) { + try { + const pool = (await import("../database/db")).getPool(); + const r = await pool.query( + `SELECT OBJID::varchar AS objid, PART_NO AS part_no, PART_NAME AS part_name + FROM PART_MNG + WHERE COALESCE(IS_LAST,'') = '1' + AND PART_NO IS NOT NULL AND PART_NO <> '' + ORDER BY PART_NO` + ); + return res.json({ success: true, data: { rows: r.rows } }); + } catch (e: any) { + logger.error("PART options 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + export async function excelParse(req: AuthenticatedRequest, res: Response) { try { const file = (req as any).file as Express.Multer.File | undefined; diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts index d7dbd428..f72130db 100644 --- a/backend-node/src/routes/devPartRoutes.ts +++ b/backend-node/src/routes/devPartRoutes.ts @@ -27,6 +27,9 @@ router.get("/part/list", ctrl.getList); router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse); router.post("/part/excel-save", ctrl.excelSave); +// PART 자동완성 옵션 (select2-part 1:1) — /:objid 보다 위 +router.get("/part/options", ctrl.partOptions); + // 다중 삭제 (body: { objids: string[] }) — /:objid 보다 위 router.delete("/part", ctrl.removeMany); diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts index dfcf0d20..747ef180 100644 --- a/backend-node/src/services/devBomService.ts +++ b/backend-node/src/services/devBomService.ts @@ -50,6 +50,7 @@ export interface BomTreeFilter { unit_code?: string; search_part_no?: string; search_part_name?: string; + search_level?: string | number; // wace 1:1 — 1~5 표시 레벨 (lev <= search_level) } // ─── 공용 파라미터 빌더 ──────────────────────────────────── @@ -99,11 +100,12 @@ export async function list(filter: BomReportListFilter) { T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO, T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME, T.STATUS, + -- 운영판 wace 매퍼 1:1 (CREATE/CHANGEDESIGN/DEPLOY 만 라벨, 그 외 'Y'/'N' 등은 raw 표시) CASE UPPER(T.STATUS) WHEN 'CREATE' THEN '등록중' WHEN 'CHANGEDESIGN' THEN '설계변경미배포' WHEN 'DEPLOY' THEN '배포완료' - ELSE '' END AS STATUS_TITLE, + ELSE COALESCE(T.STATUS, '') END AS STATUS_TITLE, T.WRITER, UI.dept_name AS DEPT_NAME, UI.user_name AS USER_NAME, COALESCE(UI.dept_name || '/' || UI.user_name, '') AS DEPT_USER_NAME, T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE, @@ -236,6 +238,10 @@ export async function ascending(filter: BomTreeFilter) { finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_name}%`); } + if (filter.search_level) { + finalConds.push(`T.lev <= $${idx++}::int`); + params.push(filter.search_level); + } const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; const sql = ` @@ -445,6 +451,14 @@ export async function descending(filter: BomTreeFilter) { } const anchorWhere = anchorConds.join(" AND "); + // 표시 레벨 필터 (wace search_level 1:1) + const levelWhereParts: string[] = []; + if (filter.search_level) { + levelWhereParts.push(`T.lev <= $${idx++}::int`); + params.push(filter.search_level); + } + const levelWhere = levelWhereParts.length ? `WHERE ${levelWhereParts.join(" AND ")}` : ""; + const sql = ` WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS ( SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, @@ -472,6 +486,7 @@ export async function descending(filter: BomTreeFilter) { (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level FROM TREE T LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ${levelWhere} ORDER BY T.path `; const r = await pool.query(sql, params); diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index 2b8340f9..df1cf2a9 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -110,6 +110,9 @@ export default function EbomRegistPage() { [openTree], ); + // DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid (lowercase) 이므로 매핑 + const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]); + return (
@@ -177,7 +180,7 @@ export default function EbomRegistPage() {
-
- - setFilter({ ...filter, project_name: e.target.value })} - placeholder="project_mgmt.objid" /> - - - setFilter({ ...filter, unit_code: e.target.value })} - placeholder="pms_wbs_task.objid" /> - + {/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개 + (고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */} +
- setFilter({ ...filter, search_part_no: e.target.value })} - placeholder="part_no LIKE" /> + setFilter((prev) => ({ + ...prev, + search_part_no: v, + // 품번 선택 시 품명 자동 채움 (wace select2-part 1:1) + search_part_name: row?.part_name ?? prev.search_part_name, + }))} /> - setFilter({ ...filter, search_part_name: e.target.value })} - placeholder="part_name LIKE" /> + setFilter((prev) => ({ + ...prev, + search_part_name: v, + // 품명 선택 시 품번 자동 채움 + search_part_no: row?.part_no ?? prev.search_part_no, + }))} /> + + +
@@ -163,7 +179,7 @@ export default function EbomSearchPage() {
{direction === "descending" && (
- 역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다. + 역전개는 품번 또는 품명 검색 조건이 필요합니다.
)}
diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx index 4717b1ed..a5e3be52 100644 --- a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -100,6 +100,9 @@ export default function PartRegistPage() { [], ); + // DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid 이므로 매핑 + const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]); + // 등록 const handleCreate = () => { setFormMode("create"); @@ -208,7 +211,7 @@ export default function PartRegistPage() {
rows.map((r) => ({ ...r, id: r.objid })), [rows]); + const handleCreate = () => { setFormMode("create"); setFormObjid(null); setFormOpen(true); }; @@ -171,7 +174,7 @@ export default function PartSearchPage() {
E-BOM 상태 변경 다이얼로그. -// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드. +// 개발관리 > E-BOM 상태 변경 다이얼로그 — wace structureStatusChangePopup.jsp 1:1 +// +// 운영 매퍼 updateStructureStatus 5필드 UPDATE : +// PRODUCT_CD / PART_NO / PART_NAME / REVISION / STATUS (모두 편집 가능) +// 상태는 Y / N 라디오 (운영판 그대로). +// +// 이전 구현은 read-only 요약 박스 + 상태 select(create/changeDesign/deploy) 였으나 +// 운영판과 완전히 달라 정정. import React, { useEffect, useState } from "react"; import { @@ -12,13 +18,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loader2, Save } from "lucide-react"; import { toast } from "sonner"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devBomApi, BomReportRow } from "@/lib/api/devBom"; -const STATUS_OPTIONS = [ - { code: "create", label: "등록중" }, - { code: "changeDesign", label: "설계변경미배포" }, - { code: "deploy", label: "배포완료" }, -]; +const PRODUCT_GROUP = "0000001"; // 제품구분 comm_code interface Props { open: boolean; @@ -28,9 +31,11 @@ interface Props { } export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) { - const [row, setRow] = useState(null); - const [status, setStatus] = useState(""); + const [productCd, setProductCd] = useState(""); + const [partNo, setPartNo] = useState(""); + const [partName, setPartName] = useState(""); const [version, setVersion] = useState(""); + const [status, setStatus] = useState(""); // 운영판 Y / N const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -39,16 +44,18 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr let alive = true; setLoading(true); devBomApi.detail(objid) - .then((data) => { + .then((data: BomReportRow | null) => { if (!alive) return; if (!data) { toast.error("E-BOM 보고서를 찾을 수 없습니다."); onOpenChange(false); return; } - setRow(data); - setStatus(data.status ?? ""); + setProductCd(data.product_cd ?? ""); + setPartNo(data.part_no ?? ""); + setPartName(data.part_name ?? ""); setVersion(data.revision ?? ""); + setStatus(data.status ?? ""); }) .catch((e: any) => { toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); @@ -58,14 +65,26 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr return () => { alive = false; }; }, [open, objid, onOpenChange]); + // wace fn_check 1:1 — 제품구분 / 품번 필수, 상태 필수 + const validate = (): string | null => { + if (!productCd) return "제품구분은 필수입니다."; + if (!partNo.trim()) return "품번은 필수입니다."; + if (!status) return "상태를 선택하세요."; + return null; + }; + const handleSave = async () => { if (!objid) return; - if (!status) return toast.error("상태를 선택하세요."); + const err = validate(); + if (err) return toast.error(err); setSaving(true); try { await devBomApi.updateStatus(objid, { - status, + product_cd: productCd, + part_no: partNo, + part_name: partName, version: version || undefined, + status, }); toast.success("상태가 변경되었습니다."); onSaved(); @@ -79,52 +98,92 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr return ( - - - E-BOM 상태 변경 + + + E-BOM 상태 변경 - {loading || !row ? ( -
+ {loading ? ( +
) : ( -
-
-
제품구분: {row.product_name ?? row.product_cd ?? "—"}
-
품번: {row.part_no ?? "—"}
-
품명: {row.part_name ?? "—"}
-
현재상태: {row.status_title ?? row.status ?? "—"}
-
-
- - -
-
- - setVersion(e.target.value)} placeholder="예: RE, A, B..." /> -
+
+ {/* 운영판 colgroup 1:1 (25% / 75%) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
제품구분 + +
품번 setPartNo(e.target.value)} />
품명 setPartName(e.target.value)} />
Version setVersion(e.target.value)} />
상태 +
+ + +
+
)} - - + +
); } + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +function Td({ children }: { children: React.ReactNode }) { + return {children}; +} +function Req() { + return *; +} diff --git a/frontend/components/development/DevPartSelect.tsx b/frontend/components/development/DevPartSelect.tsx new file mode 100644 index 00000000..d2d4944e --- /dev/null +++ b/frontend/components/development/DevPartSelect.tsx @@ -0,0 +1,102 @@ +"use client"; + +// 개발관리 PART 자동완성 셀렉트 — wace select2-part 1:1 (영업관리 PartSelect 패턴 동일). +// +// 영업관리 PartSelect 는 item_info(영업 마스터) 기반. +// 개발관리는 part_mng 기반이므로 별도 컴포넌트. +// +// - part_mng IS_LAST='1' 전체를 한 번 캐시 (objid 기준 단일 소스) +// - mode='partNo' : 라벨로 part_no 표시 +// - mode='partName': 라벨로 part_name 표시 +// - 선택 시 onValueChange(part_no/part_name 텍스트, 원본 row) — ebom-search 가 LIKE 가 아닌 +// 완전일치 검색을 하므로 value 는 텍스트 그대로 전달 + +import React, { useEffect, useState } from "react"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { apiClient } from "@/lib/api/client"; + +interface DevPartRow { + objid: string; + part_no: string; + part_name: string; +} + +interface DevPartSelectProps { + mode: "partNo" | "partName"; + /** 현재 선택값 — part_no 또는 part_name 텍스트 */ + value: string; + /** 선택 시 텍스트 + 원본 row */ + onValueChange: (value: string, row?: DevPartRow) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +let cachedRows: DevPartRow[] | null = null; +let inflight: Promise | null = null; + +const fetchParts = async (): Promise => { + if (cachedRows) return cachedRows; + if (inflight) return inflight; + inflight = (async () => { + const res = await apiClient.get("/development/part/options"); + const rows = (res.data?.data?.rows ?? []) as any[]; + cachedRows = rows + .filter((r) => r.objid != null) + .map((r) => ({ + objid: String(r.objid), + part_no: r.part_no ?? "", + part_name: r.part_name ?? "", + })); + return cachedRows!; + })(); + try { + return await inflight; + } finally { + inflight = null; + } +}; + +const toOptions = (rows: DevPartRow[], mode: DevPartSelectProps["mode"]): SmartSelectOption[] => { + const seen = new Set(); + const result: SmartSelectOption[] = []; + for (const r of rows) { + const label = mode === "partNo" ? r.part_no : r.part_name; + if (!label || seen.has(label)) continue; + seen.add(label); + result.push({ code: label, label }); + } + return result; +}; + +export function DevPartSelect({ + mode, value, onValueChange, + placeholder = mode === "partNo" ? "품번 입력하여 검색..." : "품명 입력하여 검색...", + disabled, className, +}: DevPartSelectProps) { + const [options, setOptions] = useState( + cachedRows ? toOptions(cachedRows, mode) : [], + ); + + useEffect(() => { + let alive = true; + fetchParts() + .then((rows) => { if (alive) setOptions(toOptions(rows, mode)); }) + .catch(() => {}); + return () => { alive = false; }; + }, [mode]); + + return ( + { + const row = cachedRows?.find((r) => (mode === "partNo" ? r.part_no : r.part_name) === v); + onValueChange(v, row); + }} + placeholder={placeholder} + disabled={disabled} + className={className} + /> + ); +} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 6da36456..865d0740 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -90,6 +90,7 @@ export interface BomTreeFilter { unit_code?: string; search_part_no?: string; search_part_name?: string; + search_level?: string | number; // wace 1:1 — 1~5 표시 레벨 } export interface BomTreeRow {