From f7fc0debe532391f94d4eaba5cdd839e74675300 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 15 Oct 2025 11:17:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 13 + .../admin/dashboard/DashboardDesigner.tsx | 38 ++- .../admin/dashboard/DashboardSidebar.tsx | 8 + frontend/components/admin/dashboard/types.ts | 25 +- .../dashboard/widgets/LIST_WIDGET_PLAN.md | 211 ++++++++++++ .../admin/dashboard/widgets/ListWidget.tsx | 295 ++++++++++++++++ .../widgets/ListWidgetConfigModal.tsx | 319 ++++++++++++++++++ .../widgets/list-widget/ColumnSelector.tsx | 134 ++++++++ .../widgets/list-widget/ListTableOptions.tsx | 124 +++++++ .../list-widget/ManualColumnEditor.tsx | 150 ++++++++ 10 files changed, 1310 insertions(+), 7 deletions(-) create mode 100644 frontend/components/admin/dashboard/widgets/LIST_WIDGET_PLAN.md create mode 100644 frontend/components/admin/dashboard/widgets/ListWidget.tsx create mode 100644 frontend/components/admin/dashboard/widgets/ListWidgetConfigModal.tsx create mode 100644 frontend/components/admin/dashboard/widgets/list-widget/ColumnSelector.tsx create mode 100644 frontend/components/admin/dashboard/widgets/list-widget/ListTableOptions.tsx create mode 100644 frontend/components/admin/dashboard/widgets/list-widget/ManualColumnEditor.tsx 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 ( +
+
+
⚠️
+
오류 발생
+
{error}
+
+
+ ); + } + + // 데이터 또는 설정 없음 + 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({ enablePagination: checked as boolean })} + /> + +
+
+
+ + {/* 스타일 */} +
+ +
+
+ onChange({ showHeader: checked as boolean })} + /> + +
+
+ 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 && ( +
+
컬럼을 추가하여 시작하세요
+ +
+ )} +
+ ); +}