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:
parent
58e958829c
commit
b14e862cc3
|
|
@ -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 # 디자이너 캔버스 렌더러
|
||||
```
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue