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}
} +
+ ); +}