차트 구현 및 리스트 구현 #98

Merged
hyeonsu merged 17 commits from feature/dashboard into main 2025-10-15 12:00:23 +09:00
10 changed files with 1310 additions and 7 deletions
Showing only changes of commit f7fc0debe5 - Show all commits

View File

@ -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">

View File

@ -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 "위젯 내용이 여기에 표시됩니다";
}

View File

@ -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>

View File

@ -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)
}

View File

@ -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. 테이블 렌더링 구현

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}