Files
wace_rps/frontend/components/common/PageHeader.tsx
T
hjjeong a136867f52 공용 — 검색·초기화 버튼을 PageHeader 우측 액션 영역으로 이전
사용자 보고: "초기화, 검색 버튼은 상단의 메뉴이름 쪽에 다른 버튼들이랑 같이 있으면 될거같아"

CompactFilterBar 안에 있던 [초기화][검색] 버튼이 자리 차지 + 시선 분산.
PageHeader 의 actions 슬롯 옆으로 통합하면서 11개 페이지 일괄 적용.

PageHeader 확장:
  - onSearch / onReset / loading / searchLabel / resetLabel prop 추가
  - actions 뒤에 [초기화][검색] 버튼 자동 렌더 (h-8 / text-xs)

CompactFilterBar 단순화:
  - onSearch / onReset / loading / searchLabel / resetLabel prop 제거
  - children + totalText 만 유지 (필드 컨테이너 + 합계 텍스트)

11개 페이지: <CompactFilterBar onSearch onReset loading> 3 prop 을
              <PageHeader onSearch onReset loading> 로 이동

메모리: feedback_compact_search_pattern.md 에 "검색·초기화 위치 = PageHeader" 박제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:17:24 +09:00

135 lines
4.8 KiB
TypeScript

"use client";
/**
* PageHeader — 페이지 상단 메뉴명 + 설명 + 액션 슬롯.
*
* customer-cs/cs 페이지 패턴 1:1 추출. 모든 RPS 메뉴 페이지의 상단에 의무 배치.
*
* 자동 매칭 (탭 시스템 대응):
* - RPS 는 탭 기반이라 usePathname() 이 /main 으로 고정됨.
* - useTabStore 의 활성 탭 adminUrl → /COMPANY_NN prefix 제거 → menu_info.menu_url 매칭.
* - useCurrent2ndLevelMenuObjid 와 동일 패턴.
*
* 명시 지정:
* <PageHeader title="M-BOM 관리" description="생산용 BOM 트리" actions={...} />
*
* 원칙:
* - 모든 page.tsx 의 최상위 자식으로 <PageHeader /> 를 배치한다.
* - menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭.
*/
import React from "react";
import { usePathname } from "next/navigation";
import { useMenu } from "@/contexts/MenuContext";
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
import type { MenuItem } from "@/lib/api/menu";
import { Button } from "@/components/ui/button";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface PageHeaderProps {
title?: string;
description?: string;
/** 업무 액션 슬롯 (등록/삭제/상신 등). 검색·초기화는 onSearch/onReset 로 전달. */
actions?: React.ReactNode;
/** 검색 핸들러. 지정 시 우측에 검색 버튼 자동 렌더. */
onSearch?: () => void;
/** 초기화 핸들러. 지정 시 우측에 초기화 버튼 자동 렌더. */
onReset?: () => void;
/** 검색 중 로딩 표시 */
loading?: boolean;
searchLabel?: string;
resetLabel?: string;
className?: string;
}
function stripCompanyPrefix(p: string): string {
return p.replace(/^\/COMPANY_\d+/, "") || "/";
}
function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null {
// menu_info.menu_url 이 /COMPANY_16/... 으로 저장되어 있으므로 양쪽 비교
for (const m of menus) {
if (!m.menu_url) continue;
if (m.menu_url === strippedUrl) return m;
if (stripCompanyPrefix(m.menu_url) === strippedUrl) return m;
}
let best: MenuItem | null = null;
let bestLen = 0;
for (const m of menus) {
if (!m.menu_url) continue;
const stripped = stripCompanyPrefix(m.menu_url);
if (strippedUrl.startsWith(stripped) && stripped.length > bestLen) {
best = m;
bestLen = stripped.length;
}
}
return best;
}
export function PageHeader({
title, description, actions, onSearch, onReset, loading,
searchLabel = "검색", resetLabel = "초기화", className,
}: PageHeaderProps) {
const pathname = usePathname() ?? "";
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
let menu: MenuItem | null = null;
try {
const { userMenus, adminMenus } = useMenu();
// RPS 탭 시스템: pathname=/main 이면 활성 탭의 adminUrl 사용
let targetUrl = stripCompanyPrefix(pathname);
const isRootLike = pathname === "/main" || pathname === "/" || pathname === "";
if (isRootLike) {
const activeTab = tabs.find((t: any) => t.id === activeTabId);
if (activeTab?.adminUrl) {
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
}
}
menu = findByUrl(userMenus as MenuItem[], targetUrl) ?? findByUrl(adminMenus as MenuItem[], targetUrl);
} catch {
/* Provider 밖 — 자동 매칭 생략 */
}
const resolvedTitle = title ?? menu?.menu_name_kor ?? "";
const resolvedDesc = description ?? menu?.menu_desc ?? "";
const hasSearchButtons = !!(onSearch || onReset);
if (!resolvedTitle && !resolvedDesc && !actions && !hasSearchButtons) return null;
return (
<div className={cn("flex flex-shrink-0 items-end justify-between gap-3 border-b pb-3", className)}>
<div>
{resolvedTitle && (
<h1 className="text-xl font-bold tracking-tight">{resolvedTitle}</h1>
)}
{resolvedDesc && (
<p className="text-xs text-muted-foreground">{resolvedDesc}</p>
)}
</div>
{(actions || hasSearchButtons) && (
<div className="flex items-center gap-1.5">
{actions}
{hasSearchButtons && (
<>
{onReset && (
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={onReset}>
<RotateCcw className="h-3.5 w-3.5" />
{resetLabel}
</Button>
)}
{onSearch && (
<Button size="sm" className="h-8 gap-1 px-2 text-xs" onClick={onSearch} disabled={loading}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
{searchLabel}
</Button>
)}
</>
)}
</div>
)}
</div>
);
}