feat: add ResponsiveSplitPanel component and establish responsive strategy

- Create ResponsiveSplitPanel: universal left-right split layout with
  desktop resizer and mobile collapsible stack
- Migrate V2CategoryManagerComponent to use ResponsiveSplitPanel
- Delete unused ResponsiveContainer (dead code)
- Document responsive component strategy (3 primitives + 1 hook)

Made-with: Cursor
This commit is contained in:
DDD1542 2026-03-10 23:25:22 +09:00
parent 58e958829c
commit b14e862cc3
4 changed files with 457 additions and 232 deletions

View File

@ -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
<ResponsiveDataView<User>
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) => <Button>편집</Button>}
/>
```
**적용 완료 (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
<ResponsiveSplitPanel
left={<TreeView />}
right={<DetailPanel />}
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 # 디자이너 캔버스 렌더러
```

View File

@ -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<HTMLDivElement>(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 (
<div className={cn("flex flex-col gap-0", className)} style={{ height }}>
{/* 좌측 패널 토글 헤더 */}
<button
onClick={() => setLeftCollapsed(!leftCollapsed)}
className={cn(
"flex w-full items-center justify-between border-b px-3 py-2.5",
"bg-muted/50 hover:bg-muted transition-colors",
"text-sm font-medium"
)}
>
<span>{leftTitle}</span>
{leftCollapsed ? (
<ChevronRight className="text-muted-foreground h-4 w-4" />
) : (
<ChevronDown className="text-muted-foreground h-4 w-4" />
)}
</button>
{/* 좌측 패널 (접기/펼치기) */}
{!leftCollapsed && (
<div
className={cn(
"max-h-[40vh] overflow-y-auto border-b",
leftClassName
)}
>
{left}
</div>
)}
{/* 우측 패널 (항상 표시) */}
<div className={cn("min-h-0 flex-1 overflow-y-auto", rightClassName)}>
{right}
</div>
</div>
);
}
// --- 데스크톱 레이아웃: 좌우 분할 ---
return (
<div
ref={containerRef}
className={cn("flex gap-0 overflow-hidden", className)}
style={{ height }}
>
{/* 좌측 패널 (접기 가능) */}
{!leftCollapsed ? (
<>
<div
style={{ width: `${leftWidth}%` }}
className={cn("flex h-full flex-col overflow-hidden pr-3", leftClassName)}
>
<div className="flex-1 overflow-y-auto">{left}</div>
</div>
{/* 리사이저 */}
{showResizer && (
<div
onMouseDown={handleMouseDown}
className="group hover:bg-accent/50 relative flex h-full w-3 shrink-0 cursor-col-resize items-center justify-center border-r transition-colors"
>
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
</div>
)}
</>
) : (
<div className="flex h-full w-8 shrink-0 items-start justify-center border-r pt-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setLeftCollapsed(false)}
title={`${leftTitle} 열기`}
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
</div>
)}
{/* 우측 패널 */}
<div
style={{
width: leftCollapsed ? "calc(100% - 32px)" : `${100 - leftWidth - 1}%`,
}}
className={cn("flex h-full flex-col overflow-hidden pl-3", rightClassName)}
>
{/* 데스크톱 접기 버튼 */}
{!leftCollapsed && (
<div className="mb-1 flex justify-start">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={() => setLeftCollapsed(true)}
title={`${leftTitle} 접기`}
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">{right}</div>
</div>
</div>
);
}
export default ResponsiveSplitPanel;

View File

@ -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<ResponsiveContainerProps> = ({
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 (
<div className={getResponsiveClassName()}>
{children}
</div>
);
};
interface ResponsiveGridProps {
children: React.ReactNode;
cols?: {
mobile?: number;
tablet?: number;
desktop?: number;
};
gap?: string;
className?: string;
}
export const ResponsiveGrid: React.FC<ResponsiveGridProps> = ({
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 (
<div className={`grid ${getGridCols()} gap-${gap} ${className}`}>
{children}
</div>
);
};
interface ResponsiveTextProps {
children: React.ReactNode;
size?: {
mobile?: string;
tablet?: string;
desktop?: string;
};
className?: string;
}
export const ResponsiveText: React.FC<ResponsiveTextProps> = ({
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 (
<div className={`${getTextSize()} ${className}`}>
{children}
</div>
);
};

View File

@ -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<ViewMode>(config.viewMode);
// 좌측 패널 너비 상태
const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0 overflow-hidden" style={{ height: config.height }}>
{/* 좌측: 카테고리 컬럼 리스트 - 스크롤 가능 */}
{config.showColumnList && (
<>
<div
style={{ width: `${leftWidth}%` }}
className="flex h-full flex-col overflow-hidden pr-3"
>
<div className="flex-1 overflow-y-auto">
<CategoryColumnList
tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
/>
</div>
// 우측 패널 콘텐츠
const rightContent = (
<>
{/* 뷰 모드 토글 */}
{config.showViewModeToggle && (
<div className="mb-2 flex items-center justify-end gap-1">
<span className="text-muted-foreground mr-2 text-xs"> :</span>
<div className="flex rounded-md border p-0.5">
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
onClick={() => setViewMode("tree")}
>
<TreeDeciduous className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
onClick={() => setViewMode("list")}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
{/* 리사이저 */}
<div
onMouseDown={handleMouseDown}
className="group hover:bg-accent/50 relative flex h-full w-3 shrink-0 cursor-col-resize items-center justify-center border-r transition-colors"
>
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
</div>
</>
</div>
)}
{/* 우측: 카테고리 값 관리 - 고정 */}
<div style={{ width: config.showColumnList ? `${100 - leftWidth - 1}%` : "100%" }} className="flex h-full flex-col overflow-hidden pl-3">
{/* 뷰 모드 토글 */}
{config.showViewModeToggle && (
<div className="mb-2 flex items-center justify-end gap-1">
<span className="text-muted-foreground mr-2 text-xs"> :</span>
<div className="flex rounded-md border p-0.5">
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
onClick={() => setViewMode("tree")}
>
<TreeDeciduous className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
onClick={() => setViewMode("list")}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
{/* 카테고리 값 관리 */}
<div className="min-h-0 flex-1 overflow-y-auto">
{selectedColumn ? (
viewMode === "tree" ? (
<CategoryValueManagerTree
key={`tree-${selectedColumn.uniqueKey}`}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
/>
) : (
<CategoryValueManager
key={`list-${selectedColumn.uniqueKey}`}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
/>
)
) : (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
<p className="text-muted-foreground text-sm">
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
</p>
</div>
</div>
)}
{/* 카테고리 값 관리 컴포넌트 - 스크롤 가능 */}
<div className="min-h-0 flex-1 overflow-y-auto">
{selectedColumn ? (
viewMode === "tree" ? (
<CategoryValueManagerTree
key={`tree-${selectedColumn.uniqueKey}`}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
/>
) : (
<CategoryValueManager
key={`list-${selectedColumn.uniqueKey}`}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
/>
)
) : (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
<p className="text-muted-foreground text-sm">
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</>
);
if (!config.showColumnList) {
return (
<div className="flex h-full flex-col overflow-hidden" style={{ height: config.height }}>
{rightContent}
</div>
);
}
return (
<ResponsiveSplitPanel
left={
<CategoryColumnList
tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
/>
}
right={rightContent}
leftTitle="카테고리 컬럼"
leftWidth={config.leftPanelWidth}
minLeftWidth={10}
maxLeftWidth={40}
height={config.height}
/>
);
}