diff --git a/docs/responsive-component-strategy.md b/docs/responsive-component-strategy.md new file mode 100644 index 00000000..b7feab2f --- /dev/null +++ b/docs/responsive-component-strategy.md @@ -0,0 +1,155 @@ +# WACE 반응형 컴포넌트 전략 + +## 개요 + +WACE 프로젝트의 모든 반응형 UI는 **3개의 레이아웃 프리미티브 + 1개의 훅**으로 통일한다. +컴포넌트마다 새로 타입을 정의하거나 리사이저를 구현하지 않는다. + +## 아키텍처 + +``` +┌─────────────────────────────────────────────────┐ +│ useResponsive() 훅 │ +│ isMobile | isTablet | isDesktop | width │ +└──────────┬──────────┬──────────┬────────────────┘ + │ │ │ + ┌───────▼──┐ ┌────▼─────┐ ┌─▼──────────────┐ + │ 데이터 │ │ 좌우분할 │ │ 캔버스(디자이너)│ + │ 목록 │ │ 패널 │ │ 화면 │ + └──────────┘ └──────────┘ └────────────────┘ + ResponsiveDataView ResponsiveSplitPanel ResponsiveGridRenderer +``` + +## 1. useResponsive (훅) + +**위치**: `frontend/lib/hooks/useResponsive.ts` + +모든 반응형 판단의 기반. 직접 breakpoint 분기가 필요할 때만 사용. +가능하면 아래 레이아웃 컴포넌트를 쓰고, 훅 직접 사용은 최소화. + +| 반환값 | 브레이크포인트 | 해상도 | +|--------|---------------|--------| +| isMobile | xs, sm | < 768px | +| isTablet | md | 768 ~ 1023px | +| isDesktop | lg, xl, 2xl | >= 1024px | + +## 2. ResponsiveDataView (데이터 목록) + +**위치**: `frontend/components/common/ResponsiveDataView.tsx` +**패턴**: 데스크톱 = 테이블, 모바일 = 카드 리스트 +**적용 대상**: 모든 목록/리스트 화면 + +```tsx + + data={users} + columns={columns} + keyExtractor={(u) => u.id} + cardTitle={(u) => u.name} + cardFields={[ + { label: "이메일", render: (u) => u.email }, + { label: "부서", render: (u) => u.dept }, + ]} + renderActions={(u) => } +/> +``` + +**적용 완료 (12개 화면)**: +- UserTable, CompanyTable, UserAuthTable +- DataFlowList, ScreenList +- system-notices, approvalTemplate, standards +- batch-management, mail/receive, flowMgmtList +- exconList, exCallConfList + +## 3. ResponsiveSplitPanel (좌우 분할) + +**위치**: `frontend/components/common/ResponsiveSplitPanel.tsx` +**패턴**: 데스크톱 = 좌우 분할(리사이저 포함), 모바일 = 세로 스택(접기/펼치기) +**적용 대상**: 카테고리관리, 메뉴관리, 부서관리, BOM 등 좌우 분할 레이아웃 + +```tsx +} + right={} + leftTitle="카테고리" + leftWidth={25} + minLeftWidth={10} + maxLeftWidth={40} + height="calc(100vh - 120px)" +/> +``` + +**Props**: +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| left | ReactNode | 필수 | 좌측 패널 콘텐츠 | +| right | ReactNode | 필수 | 우측 패널 콘텐츠 | +| leftTitle | string | "목록" | 모바일 접기 헤더 | +| leftWidth | number | 25 | 초기 좌측 너비(%) | +| minLeftWidth | number | 10 | 최소 좌측 너비(%) | +| maxLeftWidth | number | 50 | 최대 좌측 너비(%) | +| showResizer | boolean | true | 리사이저 표시 | +| collapsedOnMobile | boolean | true | 모바일 기본 접힘 | +| height | string | "100%" | 컨테이너 높이 | + +**동작**: +- 데스크톱(>= 1024px): 좌우 분할 + 드래그 리사이저 + 좌측 접기 버튼 +- 모바일(< 1024px): 세로 스택, 좌측 패널 40vh 제한, 접기/펼치기 + +**마이그레이션 후보**: +- `V2CategoryManagerComponent` (완료) +- `SplitPanelLayoutComponent` (v1, v2) +- `BomTreeComponent` +- `ScreenSplitPanel` +- menu/page.tsx (메뉴 관리) +- departments/page.tsx (부서 관리) + +## 4. ResponsiveGridRenderer (디자이너 캔버스) + +**위치**: `frontend/components/screen/ResponsiveGridRenderer.tsx` +**패턴**: 데스크톱(비전폭 컴포넌트) = 캔버스 스케일링, 그 외 = Flex 그리드 +**적용 대상**: 화면 디자이너로 만든 동적 화면 + +이 컴포넌트는 화면 디자이너 시스템 전용. 일반 개발에서 직접 사용하지 않음. + +## 사용 가이드 + +### 새 화면 만들 때 + +| 화면 유형 | 사용 컴포넌트 | +|-----------|--------------| +| 데이터 목록 (테이블) | `ResponsiveDataView` | +| 좌우 분할 (트리+상세) | `ResponsiveSplitPanel` | +| 디자이너 화면 | `ResponsiveGridRenderer` (자동) | +| 단순 레이아웃 | Tailwind 반응형 (`flex-col lg:flex-row`) | + +### 금지 사항 + +1. 컴포넌트 내부에 `isDraggingRef`, `handleMouseDown/Move/Up` 직접 구현 금지 + -> `ResponsiveSplitPanel` 사용 +2. `hidden lg:block` / `lg:hidden` 패턴으로 테이블/카드 이중 렌더링 금지 + -> `ResponsiveDataView` 사용 +3. `window.innerWidth` 직접 체크 금지 + -> `useResponsive()` 훅 사용 +4. 반응형 분기를 위한 새로운 타입/인터페이스 정의 금지 + -> 기존 프리미티브의 Props 사용 + +### 폐기 예정 컴포넌트 + +| 컴포넌트 | 대체 | 상태 | +|----------|------|------| +| `ResponsiveContainer` | Tailwind 또는 `useResponsive` | 미사용, 삭제 예정 | +| `ResponsiveGrid` | Tailwind `grid-cols-*` | 미사용, 삭제 예정 | +| `ResponsiveText` | Tailwind `text-sm lg:text-lg` | 미사용, 삭제 예정 | + +## 파일 구조 + +``` +frontend/ +├── lib/hooks/ +│ └── useResponsive.ts # 브레이크포인트 훅 (기반) +├── components/common/ +│ ├── ResponsiveDataView.tsx # 테이블/카드 전환 +│ └── ResponsiveSplitPanel.tsx # 좌우 분할 반응형 +└── components/screen/ + └── ResponsiveGridRenderer.tsx # 디자이너 캔버스 렌더러 +``` diff --git a/frontend/components/common/ResponsiveSplitPanel.tsx b/frontend/components/common/ResponsiveSplitPanel.tsx new file mode 100644 index 00000000..860254a1 --- /dev/null +++ b/frontend/components/common/ResponsiveSplitPanel.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React, { useState, useRef, useCallback, useEffect, ReactNode } from "react"; +import { GripVertical, ChevronDown, ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export interface ResponsiveSplitPanelProps { + /** 좌측 패널 콘텐츠 */ + left: ReactNode; + /** 우측 패널 콘텐츠 */ + right: ReactNode; + + /** 좌측 패널 제목 (모바일 접기/펼치기 시 표시) */ + leftTitle?: string; + /** 좌측 패널 기본 너비 (%, 기본: 25) */ + leftWidth?: number; + /** 좌측 패널 최소 너비 (%, 기본: 10) */ + minLeftWidth?: number; + /** 좌측 패널 최대 너비 (%, 기본: 50) */ + maxLeftWidth?: number; + + /** 리사이저 표시 여부 (기본: true) */ + showResizer?: boolean; + /** 모바일에서 좌측 패널 기본 접힘 여부 (기본: true) */ + collapsedOnMobile?: boolean; + + /** 컨테이너 높이 (기본: "100%") */ + height?: string; + /** 추가 className */ + className?: string; + /** 좌측 패널 추가 className */ + leftClassName?: string; + /** 우측 패널 추가 className */ + rightClassName?: string; +} + +const MOBILE_BREAKPOINT = 1024; + +export function ResponsiveSplitPanel({ + left, + right, + leftTitle = "목록", + leftWidth: initialLeftWidth = 25, + minLeftWidth = 10, + maxLeftWidth = 50, + showResizer = true, + collapsedOnMobile = true, + height = "100%", + className, + leftClassName, + rightClassName, +}: ResponsiveSplitPanelProps) { + const [leftWidth, setLeftWidth] = useState(initialLeftWidth); + const [isMobileView, setIsMobileView] = useState(false); + const [leftCollapsed, setLeftCollapsed] = useState(false); + const containerRef = useRef(null); + const isDraggingRef = useRef(false); + + // 뷰포트 감지 + useEffect(() => { + const checkMobile = () => { + const mobile = window.innerWidth < MOBILE_BREAKPOINT; + setIsMobileView(mobile); + if (mobile && collapsedOnMobile) { + setLeftCollapsed(true); + } + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, [collapsedOnMobile]); + + // 데스크톱 리사이저 + const handleMouseDown = useCallback(() => { + isDraggingRef.current = true; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const pct = ((e.clientX - rect.left) / rect.width) * 100; + if (pct >= minLeftWidth && pct <= maxLeftWidth) { + setLeftWidth(pct); + } + }, + [minLeftWidth, maxLeftWidth] + ); + + const handleMouseUp = useCallback(() => { + isDraggingRef.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + + // --- 모바일 레이아웃: 세로 스택 --- + if (isMobileView) { + return ( +
+ {/* 좌측 패널 토글 헤더 */} + + + {/* 좌측 패널 (접기/펼치기) */} + {!leftCollapsed && ( +
+ {left} +
+ )} + + {/* 우측 패널 (항상 표시) */} +
+ {right} +
+
+ ); + } + + // --- 데스크톱 레이아웃: 좌우 분할 --- + return ( +
+ {/* 좌측 패널 (접기 가능) */} + {!leftCollapsed ? ( + <> +
+
{left}
+
+ + {/* 리사이저 */} + {showResizer && ( +
+ +
+ )} + + ) : ( +
+ +
+ )} + + {/* 우측 패널 */} +
+ {/* 데스크톱 접기 버튼 */} + {!leftCollapsed && ( +
+ +
+ )} +
{right}
+
+
+ ); +} + +export default ResponsiveSplitPanel; diff --git a/frontend/components/layout/ResponsiveContainer.tsx b/frontend/components/layout/ResponsiveContainer.tsx deleted file mode 100644 index 74cf731f..00000000 --- a/frontend/components/layout/ResponsiveContainer.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import React from "react"; -import { useResponsive } from "@/lib/hooks/useResponsive"; - -interface ResponsiveContainerProps { - children: React.ReactNode; - className?: string; - mobileClassName?: string; - tabletClassName?: string; - desktopClassName?: string; - breakpoint?: "sm" | "md" | "lg" | "xl" | "2xl"; -} - -export const ResponsiveContainer: React.FC = ({ - children, - className = "", - mobileClassName = "", - tabletClassName = "", - desktopClassName = "", - breakpoint = "md", -}) => { - const { isMobile, isTablet, isDesktop } = useResponsive(); - - const getResponsiveClassName = () => { - let responsiveClass = className; - - if (isMobile) { - responsiveClass += ` ${mobileClassName}`; - } else if (isTablet) { - responsiveClass += ` ${tabletClassName}`; - } else if (isDesktop) { - responsiveClass += ` ${desktopClassName}`; - } - - return responsiveClass.trim(); - }; - - return ( -
- {children} -
- ); -}; - -interface ResponsiveGridProps { - children: React.ReactNode; - cols?: { - mobile?: number; - tablet?: number; - desktop?: number; - }; - gap?: string; - className?: string; -} - -export const ResponsiveGrid: React.FC = ({ - children, - cols = { mobile: 1, tablet: 2, desktop: 3 }, - gap = "4", - className = "", -}) => { - const { isMobile, isTablet, isDesktop } = useResponsive(); - - const getGridCols = () => { - if (isMobile) return `grid-cols-${cols.mobile || 1}`; - if (isTablet) return `grid-cols-${cols.tablet || 2}`; - if (isDesktop) return `grid-cols-${cols.desktop || 3}`; - return "grid-cols-1"; - }; - - return ( -
- {children} -
- ); -}; - -interface ResponsiveTextProps { - children: React.ReactNode; - size?: { - mobile?: string; - tablet?: string; - desktop?: string; - }; - className?: string; -} - -export const ResponsiveText: React.FC = ({ - children, - size = { mobile: "text-sm", tablet: "text-base", desktop: "text-lg" }, - className = "", -}) => { - const { isMobile, isTablet, isDesktop } = useResponsive(); - - const getTextSize = () => { - if (isMobile) return size.mobile || "text-sm"; - if (isTablet) return size.tablet || "text-base"; - if (isDesktop) return size.desktop || "text-lg"; - return "text-base"; - }; - - return ( -
- {children} -
- ); -}; diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx index 615ea61d..2e654c7a 100644 --- a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx @@ -6,13 +6,14 @@ * - 3단계 계층 구조 지원 (대분류/중분류/소분류) */ -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; -import { GripVertical, LayoutList, TreeDeciduous } from "lucide-react"; +import { LayoutList, TreeDeciduous } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel"; import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types"; interface V2CategoryManagerComponentProps { @@ -69,141 +70,100 @@ export function V2CategoryManagerComponent({ // 뷰 모드 상태 const [viewMode, setViewMode] = useState(config.viewMode); - // 좌측 패널 너비 상태 - const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth); - const containerRef = useRef(null); - const isDraggingRef = useRef(false); - - // 리사이저 핸들러 - const handleMouseDown = useCallback(() => { - isDraggingRef.current = true; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - }, []); - - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!isDraggingRef.current || !containerRef.current) return; - - const containerRect = containerRef.current.getBoundingClientRect(); - const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100; - - if (newLeftWidth >= 10 && newLeftWidth <= 40) { - setLeftWidth(newLeftWidth); - } - }, []); - - const handleMouseUp = useCallback(() => { - isDraggingRef.current = false; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }, []); - - useEffect(() => { - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [handleMouseMove, handleMouseUp]); - // 컬럼 선택 핸들러 const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => { const columnName = uniqueKey.split(".")[1]; setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName }); }, []); - return ( -
- {/* 좌측: 카테고리 컬럼 리스트 - 스크롤 가능 */} - {config.showColumnList && ( - <> -
-
- -
+ // 우측 패널 콘텐츠 + const rightContent = ( + <> + {/* 뷰 모드 토글 */} + {config.showViewModeToggle && ( +
+ 보기 방식: +
+ +
- - {/* 리사이저 */} -
- -
- +
)} - {/* 우측: 카테고리 값 관리 - 고정 */} -
- {/* 뷰 모드 토글 */} - {config.showViewModeToggle && ( -
- 보기 방식: -
- - + {/* 카테고리 값 관리 */} +
+ {selectedColumn ? ( + viewMode === "tree" ? ( + + ) : ( + + ) + ) : ( +
+
+ +

+ {config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} +

)} - - {/* 카테고리 값 관리 컴포넌트 - 스크롤 가능 */} -
- {selectedColumn ? ( - viewMode === "tree" ? ( - - ) : ( - - ) - ) : ( -
-
- -

- {config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} -

-
-
- )} -
-
+ + ); + + if (!config.showColumnList) { + return ( +
+ {rightContent} +
+ ); + } + + return ( + + } + right={rightContent} + leftTitle="카테고리 컬럼" + leftWidth={config.leftPanelWidth} + minLeftWidth={10} + maxLeftWidth={40} + height={config.height} + /> ); }