diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index d7c8cf43..158eaa0f 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -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({
}}
/>
+ ) : element.type === "widget" && element.subtype === "list" ? (
+ // 리스트 위젯 렌더링
+
+ {
+ onUpdate(element.id, { listConfig: newConfig as any });
+ }}
+ />
+
) : element.type === "widget" && element.subtype === "todo" ? (
// To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx
index 13da580f..abb328b0 100644
--- a/frontend/components/admin/dashboard/DashboardDesigner.tsx
+++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx
@@ -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) => {
+ 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 && (
-
+ <>
+ {configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
+
+ ) : (
+
+ )}
+ >
)}
);
@@ -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 "위젯 내용이 여기에 표시됩니다";
}
diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx
index c9f9034e..5fbad19a 100644
--- a/frontend/components/admin/dashboard/DashboardSidebar.tsx
+++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx
@@ -207,6 +207,14 @@ export function DashboardSidebar() {
onDragStart={handleDragStart}
className="border-l-4 border-purple-600"
/>
+
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index bc9a3b5e..5b8b2f5d 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -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)
+}
diff --git a/frontend/components/admin/dashboard/widgets/LIST_WIDGET_PLAN.md b/frontend/components/admin/dashboard/widgets/LIST_WIDGET_PLAN.md
new file mode 100644
index 00000000..c952132a
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/LIST_WIDGET_PLAN.md
@@ -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. 테이블 렌더링 구현
diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
new file mode 100644
index 00000000..e7b2781d
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
@@ -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) => void;
+}
+
+/**
+ * 리스트 위젯 컴포넌트
+ * - DB 쿼리 또는 REST API로 데이터 가져오기
+ * - 테이블 형태로 데이터 표시
+ * - 페이지네이션, 정렬, 검색 기능
+ */
+export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(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 (
+
+ );
+ }
+
+ // 에러
+ if (error) {
+ return (
+
+ );
+ }
+
+ // 데이터 또는 설정 없음
+ if (!data || config.columns.length === 0) {
+ return (
+
+
+
📋
+
리스트를 설정하세요
+
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
+
+
+ );
+ }
+
+ // 페이지네이션
+ 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 (
+
+
+
{element.title}
+
+
+ {/* 테이블 */}
+
+
+ {config.showHeader && (
+
+
+ {config.columns
+ .filter((col) => col.visible)
+ .map((col) => (
+
+ {col.label}
+
+ ))}
+
+
+ )}
+
+ {paginatedRows.length === 0 ? (
+
+ col.visible).length}
+ className="text-center text-gray-500"
+ >
+ 데이터가 없습니다
+
+
+ ) : (
+ paginatedRows.map((row, idx) => (
+
+ {config.columns
+ .filter((col) => col.visible)
+ .map((col) => (
+
+ {String(row[col.field] ?? "")}
+
+ ))}
+
+ ))
+ )}
+
+
+
+
+ {/* 페이지네이션 */}
+ {config.enablePagination && totalPages > 1 && (
+
+
+ {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
+
+
+
+
+ {currentPage}
+ /
+ {totalPages}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx
new file mode 100644
index 00000000..d5b4e3d0
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx
@@ -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) => 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(
+ element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
+ );
+ const [queryResult, setQueryResult] = useState(null);
+ const [listConfig, setListConfig] = useState(
+ 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) => {
+ 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 (
+
+
+ {/* 헤더 */}
+
+
+
+
📋 리스트 위젯 설정
+
데이터 소스와 컬럼을 설정하세요
+
+
+
+ {/* 제목 입력 */}
+
+
+ setTitle(e.target.value)}
+ placeholder="예: 사용자 목록"
+ className="mt-1"
+ />
+
+
+
+ {/* 진행 상태 표시 */}
+
+
+
+
= 1 ? "text-blue-600" : "text-gray-400"}`}>
+
= 1 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
+ >
+ 1
+
+
데이터 소스
+
+
+
= 2 ? "text-blue-600" : "text-gray-400"}`}>
+
= 2 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
+ >
+ 2
+
+
데이터 가져오기
+
+
+
= 3 ? "text-blue-600" : "text-gray-400"}`}>
+
= 3 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
+ >
+ 3
+
+
컬럼 설정
+
+
+
+
+
+ {/* 컨텐츠 */}
+
+ {currentStep === 1 && (
+
+ )}
+
+ {currentStep === 2 && (
+
+ {/* 왼쪽: 데이터 소스 설정 */}
+
+ {dataSource.type === "database" ? (
+
+ ) : (
+
+ )}
+
+ {dataSource.type === "database" && (
+
+
+
+ )}
+
+
+ {/* 오른쪽: 데이터 미리보기 */}
+
+ {queryResult && queryResult.rows.length > 0 ? (
+
+
📋 데이터 미리보기
+
+
+ {queryResult.totalRows}개 데이터
+
+
+ {JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
+
+
+
+ ) : (
+
+
+
데이터를 가져온 후 미리보기가 표시됩니다
+
+
+ )}
+
+
+ )}
+
+ {currentStep === 3 && queryResult && (
+
+ {listConfig.columnMode === "auto" ? (
+ setListConfig((prev) => ({ ...prev, columns }))}
+ />
+ ) : (
+ setListConfig((prev) => ({ ...prev, columns }))}
+ />
+ )}
+
+ setListConfig((prev) => ({ ...prev, ...updates }))}
+ />
+
+ )}
+
+
+ {/* 푸터 */}
+
+
+ {queryResult && (
+
+ 📊 {queryResult.rows.length}개 데이터 로드됨
+
+ )}
+
+
+
+ {currentStep > 1 && (
+
+ )}
+
+ {currentStep < 3 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/list-widget/ColumnSelector.tsx b/frontend/components/admin/dashboard/widgets/list-widget/ColumnSelector.tsx
new file mode 100644
index 00000000..f1700dbf
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/list-widget/ColumnSelector.tsx
@@ -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;
+ 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 (
+
+
+
컬럼 선택 및 설정
+
표시할 컬럼을 선택하고 이름을 변경하세요
+
+
+
+ {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 (
+
+
+
handleToggle(field)} className="mt-1" />
+
+
+
+ {field}
+ {previewText && (예: {previewText})}
+
+
+
+
+ {isSelected && selectedCol && (
+
+ {/* 컬럼명 */}
+
+
+ handleLabelChange(field, e.target.value)}
+ placeholder="컬럼명"
+ className="mt-1"
+ />
+
+
+ {/* 정렬 방향 */}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+ {selectedColumns.length === 0 && (
+
+ ⚠️ 최소 1개 이상의 컬럼을 선택해주세요
+
+ )}
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/list-widget/ListTableOptions.tsx b/frontend/components/admin/dashboard/widgets/list-widget/ListTableOptions.tsx
new file mode 100644
index 00000000..134f74ee
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/list-widget/ListTableOptions.tsx
@@ -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) => void;
+}
+
+/**
+ * 리스트 테이블 옵션 설정 컴포넌트
+ * - 페이지 크기, 검색, 정렬 등 설정
+ */
+export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
+ return (
+
+
+
테이블 옵션
+
테이블 동작과 스타일을 설정하세요
+
+
+
+ {/* 컬럼 모드 */}
+
+
+
onChange({ columnMode: value })}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {/* 페이지 크기 */}
+
+
+
+
+
+ {/* 기능 활성화 */}
+
+
+ {/* 스타일 */}
+
+
+
+
+
+
+ onChange({ stripedRows: checked as boolean })}
+ />
+
+
+
+ onChange({ compactMode: checked as boolean })}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/list-widget/ManualColumnEditor.tsx b/frontend/components/admin/dashboard/widgets/list-widget/ManualColumnEditor.tsx
new file mode 100644
index 00000000..1d564816
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/list-widget/ManualColumnEditor.tsx
@@ -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) => {
+ onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
+ };
+
+ return (
+
+
+
+
수동 컬럼 편집
+
직접 컬럼을 추가하고 데이터 필드를 매핑하세요
+
+
+
+
+
+ {columns.map((col, index) => (
+
+
+
+ 컬럼 {index + 1}
+
+
+
+
+ {/* 컬럼명 */}
+
+
+ handleUpdate(col.id, { label: e.target.value })}
+ placeholder="예: 사용자 이름"
+ className="mt-1"
+ />
+
+
+ {/* 데이터 필드 */}
+
+
+
+
+
+ {/* 정렬 방향 */}
+
+
+
+
+
+ {/* 너비 */}
+
+
+
+ handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
+ }
+ placeholder="자동"
+ className="mt-1"
+ />
+
+
+
+ ))}
+
+
+ {columns.length === 0 && (
+
+
컬럼을 추가하여 시작하세요
+
+
+ )}
+
+ );
+}