From 2e3a430cf7f239c1ca8e38de509262e51af394ae Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 16:49:09 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=E2=80=94=20PageHeader=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4=20(=EB=A9=94=EB=89=B4=EB=AA=85=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A7=A4=EC=B9=AD)=20+=20M-BOM=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=EB=AA=85=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompactFilterBar 마이그레이션 과정에서 M-BOM 페이지 상단 메뉴명/설명이 사라진 회귀를 해결. customer-cs/cs 의 페이지 헤더 패턴을 공용 컴포넌트로 추출. 신설: - components/common/PageHeader.tsx · usePathname() + useMenu() 자동 매칭 → menu_info.menu_name_kor + menu_desc · 명시 props (title/description/actions) 지원 · 동적 라우트 prefix fallback (/foo/123 → /foo 매칭) 적용: - production/mbom/page.tsx 상단에 1줄 추가 DB: - menu_info.menu_desc 보강 (objid 100016/100032) "생산용 BOM 트리 + read-only 조회 (운영판 mBomMgmtList 1:1)" 메모리: feedback_compact_search_pattern.md 갱신 - PageHeader 도 의무 사용 컴포넌트 목록에 추가 - 페이지 구조 표준 코드 예시 명시 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../data-sync/05_mbom_menu_desc.sql | 8 ++ .../COMPANY_16/production/mbom/page.tsx | 2 + frontend/components/common/PageHeader.tsx | 86 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 docs/migration/production/data-sync/05_mbom_menu_desc.sql create mode 100644 frontend/components/common/PageHeader.tsx diff --git a/docs/migration/production/data-sync/05_mbom_menu_desc.sql b/docs/migration/production/data-sync/05_mbom_menu_desc.sql new file mode 100644 index 00000000..5a4201fb --- /dev/null +++ b/docs/migration/production/data-sync/05_mbom_menu_desc.sql @@ -0,0 +1,8 @@ +-- ============================================================ +-- M-BOM 관리 메뉴 menu_desc 보강 (PageHeader 자동 매칭용) +-- 2026-05-13 +-- ============================================================ + +UPDATE menu_info + SET menu_desc = '생산용 BOM 트리 + read-only 조회 (운영판 mBomMgmtList 1:1)' + WHERE objid IN (100016, 100032); diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index 42bdd7c0..775b5a4f 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -15,6 +15,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { PageHeader } from "@/components/common/PageHeader"; import { apiClient } from "@/lib/api/client"; import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; import { MbomDetailDialog } from "@/components/production/MbomDetailDialog"; @@ -140,6 +141,7 @@ export default function MbomMgmtPage() { return (
+ + * + * 액션 슬롯: + * } /> + * + * 원칙: + * - 모든 page.tsx 의 최상위 자식으로 를 배치한다. + * - 메뉴명이 menu_info 에 있다면 props 없이도 자동 매칭되므로 그냥 면 충분. + * - 액션 버튼이 있으면 actions 슬롯에만 넣는다 (검색 영역에 두지 말 것). + */ + +import React, { useMemo } from "react"; +import { usePathname } from "next/navigation"; +import { useMenu } from "@/contexts/MenuContext"; +import type { MenuItem } from "@/lib/api/menu"; +import { cn } from "@/lib/utils"; + +interface PageHeaderProps { + /** 명시 메뉴명. 없으면 usePathname() + MenuContext 자동 매칭. */ + title?: string; + /** 명시 설명. 없으면 자동 매칭 결과의 menu_desc. */ + description?: string; + /** 우측 액션 슬롯 (새로고침/등록/엑셀 다운로드 등) */ + actions?: React.ReactNode; + className?: string; +} + +function findByUrl(menus: MenuItem[], pathname: string): MenuItem | null { + // MenuContext 는 flat 리스트. 정확 매칭 우선, 없으면 prefix(파라미터 라우트 대응). + for (const m of menus) { + if (m.menu_url && m.menu_url === pathname) return m; + } + // 동적 라우트 fallback: /COMPANY_16/foo/123 → /COMPANY_16/foo 매칭 + let best: MenuItem | null = null; + let bestLen = 0; + for (const m of menus) { + if (m.menu_url && pathname.startsWith(m.menu_url) && m.menu_url.length > bestLen) { + best = m; + bestLen = m.menu_url.length; + } + } + return best; +} + +export function PageHeader({ title, description, actions, className }: PageHeaderProps) { + const pathname = usePathname() ?? ""; + // useMenu() 가 Provider 밖에서 호출되면 throw — 안전하게 try + let menu: MenuItem | null = null; + try { + const { userMenus, adminMenus } = useMenu(); + menu = findByUrl(userMenus, pathname) ?? findByUrl(adminMenus, pathname); + } catch { + /* MenuProvider 밖 (스토리북/테스트 등) — 자동 매칭 생략 */ + } + + const resolvedTitle = title ?? menu?.menu_name_kor ?? ""; + const resolvedDesc = description ?? menu?.menu_desc ?? ""; + + if (!resolvedTitle && !resolvedDesc && !actions) return null; + + return ( +
+
+ {resolvedTitle && ( +

{resolvedTitle}

+ )} + {resolvedDesc && ( +

{resolvedDesc}

+ )} +
+ {actions &&
{actions}
} +
+ ); +}