리스트 위젯 구현
This commit is contained in:
parent
14d079b34f
commit
f7fc0debe5
|
|
@ -63,6 +63,7 @@ import { ClockWidget } from "./widgets/ClockWidget";
|
|||
import { CalendarWidget } from "./widgets/CalendarWidget";
|
||||
// 기사 관리 위젯 임포트
|
||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||
import { ListWidget } from "./widgets/ListWidget";
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -373,6 +374,8 @@ export function CanvasElement({
|
|||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
case "driver-management":
|
||||
return "bg-gradient-to-br from-blue-400 to-indigo-600";
|
||||
case "list":
|
||||
return "bg-gradient-to-br from-cyan-400 to-blue-600";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
|
|
@ -508,6 +511,16 @@ export function CanvasElement({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "list" ? (
|
||||
// 리스트 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ListWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { listConfig: newConfig as any });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||
// To-Do 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DashboardCanvas } from "./DashboardCanvas";
|
|||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { DashboardToolbar } from "./DashboardToolbar";
|
||||
import { ElementConfigModal } from "./ElementConfigModal";
|
||||
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
|
|
@ -156,6 +157,16 @@ export default function DashboardDesigner() {
|
|||
[updateElement],
|
||||
);
|
||||
|
||||
// 리스트 위젯 설정 저장 (Partial 업데이트)
|
||||
const saveListWidgetConfig = useCallback(
|
||||
(updates: Partial<DashboardElement>) => {
|
||||
if (configModalElement) {
|
||||
updateElement(configModalElement.id, updates);
|
||||
}
|
||||
},
|
||||
[configModalElement, updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
|
|
@ -271,12 +282,23 @@ export default function DashboardDesigner() {
|
|||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
<>
|
||||
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||
<ListWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -313,6 +335,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "📅 달력 위젯";
|
||||
case "driver-management":
|
||||
return "🚚 기사 관리 위젯";
|
||||
case "list":
|
||||
return "📋 리스트 위젯";
|
||||
default:
|
||||
return "🔧 위젯";
|
||||
}
|
||||
|
|
@ -351,6 +375,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|||
return "calendar";
|
||||
case "driver-management":
|
||||
return "driver-management";
|
||||
case "list":
|
||||
return "list-widget";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,14 @@ export function DashboardSidebar() {
|
|||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export type ElementSubtype =
|
|||
| "todo"
|
||||
| "booking-alert"
|
||||
| "maintenance"
|
||||
| "document"; // 위젯 타입
|
||||
| "document"
|
||||
| "list"; // 위젯 타입
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
|
@ -50,6 +51,7 @@ export interface DashboardElement {
|
|||
clockConfig?: ClockConfig; // 시계 설정
|
||||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||
}
|
||||
|
||||
export interface DragData {
|
||||
|
|
@ -207,3 +209,24 @@ export interface ChartDataset {
|
|||
data: number[]; // 데이터 값
|
||||
color?: string; // 색상
|
||||
}
|
||||
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
columnMode: "auto" | "manual"; // 컬럼 설정 방식 (자동 or 수동)
|
||||
columns: ListColumn[]; // 컬럼 정의
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
enablePagination: boolean; // 페이지네이션 활성화 (기본: true)
|
||||
showHeader: boolean; // 헤더 표시 (기본: true)
|
||||
stripedRows: boolean; // 줄무늬 행 (기본: true)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
export interface ListColumn {
|
||||
id: string; // 고유 ID
|
||||
label: string; // 표시될 컬럼명
|
||||
field: string; // 데이터 필드명
|
||||
width?: number; // 너비 (px)
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
visible?: boolean; // 표시 여부 (기본: true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
# 리스트 위젯 개발 계획서
|
||||
|
||||
## 📋 개요
|
||||
|
||||
차트와 동일한 방식으로 데이터를 가져오는 리스트(테이블) 위젯 개발
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 기능
|
||||
|
||||
### 1. 데이터 소스 (차트와 동일)
|
||||
|
||||
- **내부 DB**: 현재 데이터베이스 쿼리
|
||||
- **외부 DB**: 외부 커넥션 관리에서 설정된 DB 쿼리
|
||||
- **REST API**: 외부 API 호출 (GET 방식)
|
||||
|
||||
### 2. 컬럼 설정
|
||||
|
||||
사용자가 두 가지 방식으로 컬럼을 설정할 수 있음:
|
||||
|
||||
#### 방식 1: 데이터 기반 자동 생성
|
||||
|
||||
1. 쿼리/API 실행 → 데이터 가져옴
|
||||
2. 사용자가 표시할 컬럼 선택
|
||||
3. 컬럼명을 원하는대로 변경 가능
|
||||
4. 컬럼 순서 조정 가능
|
||||
|
||||
```
|
||||
예시:
|
||||
데이터: { userId: 1, userName: "홍길동", deptCode: "DPT001" }
|
||||
↓
|
||||
사용자 설정:
|
||||
- userId → "사용자 ID"
|
||||
- userName → "이름"
|
||||
- deptCode → "부서 코드"
|
||||
```
|
||||
|
||||
#### 방식 2: 수동 컬럼 정의
|
||||
|
||||
1. 사용자가 직접 컬럼 추가
|
||||
2. 각 컬럼의 이름 지정
|
||||
3. 각 컬럼에 들어갈 데이터 필드 매핑
|
||||
|
||||
```
|
||||
예시:
|
||||
컬럼 1: "직원 정보" → userName 필드
|
||||
컬럼 2: "소속" → deptCode 필드
|
||||
컬럼 3: "등록일" → regDate 필드
|
||||
```
|
||||
|
||||
### 3. 테이블 기능
|
||||
|
||||
- **페이지네이션**: 한 페이지당 표시 개수 설정 (10, 20, 50, 100)
|
||||
- **정렬**: 컬럼 클릭 시 오름차순/내림차순 정렬
|
||||
- **검색**: 전체 컬럼에서 키워드 검색
|
||||
- **자동 새로고침**: 설정된 시간마다 자동으로 데이터 갱신
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 구조 설계
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/admin/dashboard/widgets/
|
||||
├── ListWidget.tsx # 메인 위젯 컴포넌트
|
||||
├── ListWidgetConfigModal.tsx # 설정 모달
|
||||
└── list-widget/
|
||||
├── ColumnSelector.tsx # 컬럼 선택 UI
|
||||
├── ManualColumnEditor.tsx # 수동 컬럼 편집 UI
|
||||
└── ListTable.tsx # 실제 테이블 렌더링
|
||||
```
|
||||
|
||||
### 데이터 타입 (`types.ts`에 추가)
|
||||
|
||||
```typescript
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
// 컬럼 설정 방식
|
||||
columnMode: "auto" | "manual"; // 자동 or 수동
|
||||
|
||||
// 컬럼 정의
|
||||
columns: ListColumn[];
|
||||
|
||||
// 테이블 옵션
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
enableSearch: boolean; // 검색 활성화 (기본: true)
|
||||
enableSort: boolean; // 정렬 활성화 (기본: true)
|
||||
enablePagination: boolean; // 페이지네이션 활성화 (기본: true)
|
||||
|
||||
// 스타일
|
||||
showHeader: boolean; // 헤더 표시 (기본: true)
|
||||
stripedRows: boolean; // 줄무늬 행 (기본: true)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
export interface ListColumn {
|
||||
id: string; // 고유 ID
|
||||
label: string; // 표시될 컬럼명
|
||||
field: string; // 데이터 필드명
|
||||
width?: number; // 너비 (px)
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
sortable?: boolean; // 정렬 가능 여부
|
||||
visible?: boolean; // 표시 여부
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 개발 단계
|
||||
|
||||
### Phase 1: 기본 구조 ✅ (예정)
|
||||
|
||||
- [ ] `ListWidget.tsx` 기본 컴포넌트 생성
|
||||
- [ ] `types.ts`에 타입 정의 추가
|
||||
- [ ] `DashboardSidebar.tsx`에 리스트 위젯 추가
|
||||
|
||||
### Phase 2: 데이터 소스 연동 ✅ (예정)
|
||||
|
||||
- [ ] 차트의 데이터 소스 로직 재사용
|
||||
- [ ] `ListWidgetConfigModal.tsx` 생성
|
||||
- Step 1: 데이터 소스 선택 (DB/API)
|
||||
- Step 2: 쿼리/API 설정 및 실행
|
||||
- Step 3: 컬럼 설정
|
||||
|
||||
### Phase 3: 컬럼 설정 UI ✅ (예정)
|
||||
|
||||
- [ ] `ColumnSelector.tsx`: 데이터 기반 자동 생성
|
||||
- 컬럼 선택 (체크박스)
|
||||
- 컬럼명 변경 (인라인 편집)
|
||||
- 순서 조정 (드래그 앤 드롭)
|
||||
- [ ] `ManualColumnEditor.tsx`: 수동 컬럼 정의
|
||||
- 컬럼 추가/삭제
|
||||
- 컬럼명 입력
|
||||
- 데이터 필드 매핑
|
||||
|
||||
### Phase 4: 테이블 렌더링 ✅ (예정)
|
||||
|
||||
- [ ] `ListTable.tsx` 구현
|
||||
- 기본 테이블 렌더링
|
||||
- 페이지네이션
|
||||
- 정렬 기능
|
||||
- 검색 기능
|
||||
- 반응형 디자인
|
||||
|
||||
### Phase 5: 자동 새로고침 ✅ (예정)
|
||||
|
||||
- [ ] 자동 새로고침 로직 구현 (차트와 동일)
|
||||
- [ ] 수동 새로고침 버튼 추가
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 설계
|
||||
|
||||
### 위젯 크기별 표시
|
||||
|
||||
- **작은 크기 (2x2)**: 컬럼 3개까지만 표시, 페이지네이션 간략화
|
||||
- **중간 크기 (3x3)**: 전체 기능 표시
|
||||
- **큰 크기 (4x4+)**: 더 많은 행 표시
|
||||
|
||||
### 설정 모달 플로우
|
||||
|
||||
```
|
||||
Step 1: 데이터 소스 선택
|
||||
├─ 데이터베이스
|
||||
│ ├─ 현재 DB
|
||||
│ └─ 외부 DB (커넥션 선택)
|
||||
└─ REST API
|
||||
|
||||
↓
|
||||
|
||||
Step 2: 데이터 가져오기
|
||||
├─ SQL 쿼리 작성 (DB인 경우)
|
||||
├─ API URL 설정 (API인 경우)
|
||||
└─ [실행] 버튼 클릭 → 데이터 미리보기
|
||||
|
||||
↓
|
||||
|
||||
Step 3: 컬럼 설정
|
||||
├─ 방식 선택: [자동 생성] / [수동 편집]
|
||||
├─ 컬럼 선택 및 설정
|
||||
├─ 테이블 옵션 설정
|
||||
└─ [저장] 버튼
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 참고 사항
|
||||
|
||||
### 차트와의 차이점
|
||||
|
||||
- **차트**: X축/Y축 매핑, 집계 함수, 그룹핑
|
||||
- **리스트**: 컬럼 선택, 정렬, 검색, 페이지네이션
|
||||
|
||||
### 재사용 가능한 컴포넌트
|
||||
|
||||
- `DataSourceSelector` (차트에서 사용 중)
|
||||
- `DatabaseConfig` (차트에서 사용 중)
|
||||
- `ApiConfig` (차트에서 사용 중)
|
||||
- `QueryEditor` (차트에서 사용 중)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
1. `types.ts`에 타입 추가
|
||||
2. `ListWidget.tsx` 기본 구조 생성
|
||||
3. 사이드바에 위젯 추가
|
||||
4. 설정 모달 구현
|
||||
5. 테이블 렌더링 구현
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 컴포넌트
|
||||
* - DB 쿼리 또는 REST API로 데이터 가져오기
|
||||
* - 테이블 형태로 데이터 표시
|
||||
* - 페이지네이션, 정렬, 검색 기능
|
||||
*/
|
||||
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const config = element.listConfig || {
|
||||
columnMode: "auto",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let queryResult: QueryResult;
|
||||
|
||||
// REST API vs Database 분기
|
||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||
// REST API - 백엔드 프록시를 통한 호출
|
||||
const params = new URLSearchParams();
|
||||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: element.dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: element.dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let processedData = apiData;
|
||||
if (element.dataSource.jsonPath) {
|
||||
const paths = element.dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
queryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else if (element.dataSource.query) {
|
||||
// Database (현재 DB 또는 외부 DB)
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
queryResult = {
|
||||
columns: externalResult.data.columns,
|
||||
rows: externalResult.data.rows,
|
||||
totalRows: externalResult.data.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
||||
}
|
||||
|
||||
setData(queryResult);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 설정
|
||||
const refreshInterval = element.dataSource?.refreshInterval;
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.endpoint,
|
||||
element.dataSource?.refreshInterval,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm text-gray-600">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-red-600">오류 발생</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 또는 설정 없음
|
||||
if (!data || config.columns.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(data.rows.length / config.pageSize);
|
||||
const startIdx = (currentPage - 1) * config.pageSize;
|
||||
const endIdx = startIdx + config.pageSize;
|
||||
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{element.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||
<Table>
|
||||
{config.showHeader && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{paginatedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={config.columns.filter((col) => col.visible).length}
|
||||
className="text-center text-gray-500"
|
||||
>
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-600">
|
||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-gray-700">{currentPage}</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-500">{totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig, ListColumn } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
|
||||
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
||||
import { QueryEditor } from "../QueryEditor";
|
||||
import { ColumnSelector } from "./list-widget/ColumnSelector";
|
||||
import { ManualColumnEditor } from "./list-widget/ManualColumnEditor";
|
||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
||||
|
||||
interface ListWidgetConfigModalProps {
|
||||
isOpen: boolean;
|
||||
element: DashboardElement;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 설정 모달
|
||||
* - 3단계 설정: 데이터 소스 → 데이터 가져오기 → 컬럼 설정
|
||||
*/
|
||||
export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: ListWidgetConfigModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
||||
element.listConfig || {
|
||||
columnMode: "auto",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 모달 열릴 때 element에서 설정 로드 (한 번만)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// element가 변경되었을 때만 설정을 다시 로드
|
||||
setTitle(element.title || "📋 리스트");
|
||||
|
||||
// 기존 dataSource가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.dataSource) {
|
||||
setDataSource(element.dataSource);
|
||||
}
|
||||
|
||||
// 기존 listConfig가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.listConfig) {
|
||||
setListConfig(element.listConfig);
|
||||
}
|
||||
|
||||
// 현재 스텝은 1로 초기화
|
||||
setCurrentStep(1);
|
||||
}
|
||||
}, [isOpen, element.id]); // element.id가 변경될 때만 재실행
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
}));
|
||||
} else {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "api",
|
||||
method: "GET",
|
||||
}));
|
||||
}
|
||||
|
||||
// 데이터 소스 타입 변경 시에는 쿼리 결과만 초기화 (컬럼 설정은 유지)
|
||||
setQueryResult(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 쿼리 실행 결과 처리
|
||||
const handleQueryTest = useCallback(
|
||||
(result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 자동 모드이고 기존 컬럼이 없을 때만 자동 생성
|
||||
if (listConfig.columnMode === "auto" && result.columns.length > 0 && listConfig.columns.length === 0) {
|
||||
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
||||
id: `col_${idx}`,
|
||||
label: col,
|
||||
field: col,
|
||||
align: "left",
|
||||
visible: true,
|
||||
}));
|
||||
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
||||
}
|
||||
},
|
||||
[listConfig.columnMode, listConfig.columns.length],
|
||||
);
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 이전 단계
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
title,
|
||||
dataSource,
|
||||
listConfig,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-[90vw] max-w-6xl flex-col rounded-xl border bg-white shadow-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-4 border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">📋 리스트 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터 소스와 컬럼을 설정하세요</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 제목 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="list-title" className="text-sm font-medium">
|
||||
리스트 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="list-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 사용자 목록"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 1 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 1 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 소스</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 2 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 2 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 가져오기</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 3 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 3 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">컬럼 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 소스 설정 */}
|
||||
<div>
|
||||
{dataSource.type === "database" ? (
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
|
||||
{dataSource.type === "database" && (
|
||||
<div className="mt-4">
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 데이터 미리보기 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<h3 className="mb-3 font-semibold text-gray-800">📋 데이터 미리보기</h3>
|
||||
<div className="overflow-x-auto rounded bg-white p-3">
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
{queryResult.totalRows}개 데이터
|
||||
</Badge>
|
||||
<pre className="text-xs text-gray-700">
|
||||
{JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 미리보기가 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && queryResult && (
|
||||
<div className="space-y-6">
|
||||
{listConfig.columnMode === "auto" ? (
|
||||
<ColumnSelector
|
||||
availableColumns={queryResult.columns}
|
||||
selectedColumns={listConfig.columns}
|
||||
sampleData={queryResult.rows[0]}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
) : (
|
||||
<ManualColumnEditor
|
||||
availableFields={queryResult.columns}
|
||||
columns={listConfig.columns}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListTableOptions
|
||||
config={listConfig}
|
||||
onChange={(updates) => setListConfig((prev) => ({ ...prev, ...updates }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{currentStep < 3 ? (
|
||||
<Button onClick={handleNext} disabled={currentStep === 2 && !queryResult}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
interface ColumnSelectorProps {
|
||||
availableColumns: string[];
|
||||
selectedColumns: ListColumn[];
|
||||
sampleData: Record<string, any>;
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 선택 컴포넌트 (자동 모드)
|
||||
* - 쿼리 결과에서 컬럼 선택
|
||||
* - 컬럼명 변경
|
||||
* - 정렬, 너비, 정렬 방향 설정
|
||||
*/
|
||||
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||
// 컬럼 선택/해제
|
||||
const handleToggle = (field: string) => {
|
||||
const exists = selectedColumns.find((col) => col.field === field);
|
||||
if (exists) {
|
||||
onChange(selectedColumns.filter((col) => col.field !== field));
|
||||
} else {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${selectedColumns.length}`,
|
||||
label: field,
|
||||
field,
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...selectedColumns, newCol]);
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 라벨 변경
|
||||
const handleLabelChange = (field: string, label: string) => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
|
||||
};
|
||||
|
||||
// 정렬 방향 변경
|
||||
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||
<p className="text-sm text-gray-600">표시할 컬럼을 선택하고 이름을 변경하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{availableColumns.map((field) => {
|
||||
const selectedCol = selectedColumns.find((col) => col.field === field);
|
||||
const isSelected = !!selectedCol;
|
||||
const preview = sampleData[field];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && selectedCol && (
|
||||
<div className="ml-7 grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="컬럼명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedColumns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
|
||||
⚠️ 최소 1개 이상의 컬럼을 선택해주세요
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListWidgetConfig } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
interface ListTableOptionsProps {
|
||||
config: ListWidgetConfig;
|
||||
onChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 테이블 옵션 설정 컴포넌트
|
||||
* - 페이지 크기, 검색, 정렬 등 설정
|
||||
*/
|
||||
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">테이블 옵션</h3>
|
||||
<p className="text-sm text-gray-600">테이블 동작과 스타일을 설정하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 컬럼 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">컬럼 설정 방식</Label>
|
||||
<RadioGroup
|
||||
value={config.columnMode}
|
||||
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto" className="cursor-pointer font-normal">
|
||||
자동 (쿼리 결과에서 선택)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer font-normal">
|
||||
수동 (직접 추가 및 매핑)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">페이지당 행 수</Label>
|
||||
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기능 활성화 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">기능</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enablePagination"
|
||||
checked={config.enablePagination}
|
||||
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
|
||||
페이지네이션
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스타일 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">스타일</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader}
|
||||
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stripedRows"
|
||||
checked={config.stripedRows}
|
||||
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
|
||||
줄무늬 행
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compactMode"
|
||||
checked={config.compactMode}
|
||||
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
|
||||
압축 모드 (작은 크기)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
interface ManualColumnEditorProps {
|
||||
availableFields: string[];
|
||||
columns: ListColumn[];
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 컬럼 편집 컴포넌트
|
||||
* - 사용자가 직접 컬럼 추가/삭제
|
||||
* - 컬럼명과 데이터 필드 직접 매핑
|
||||
*/
|
||||
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||
// 새 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
label: `컬럼 ${columns.length + 1}`,
|
||||
field: availableFields[0] || "",
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...columns, newCol]);
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(columns.filter((col) => col.id !== id));
|
||||
};
|
||||
|
||||
// 컬럼 속성 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||
<p className="text-sm text-gray-600">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{columns.map((col, index) => (
|
||||
<div key={col.id} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름 *</Label>
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="예: 사용자 이름"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 필드 *</Label>
|
||||
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="자동"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
|
||||
<div className="text-sm text-gray-600">컬럼을 추가하여 시작하세요</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
|
||||
<Plus className="h-4 w-4" />첫 번째 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue