diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index cf8f3cc2..0f6f07cc 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -547,4 +547,93 @@ export class DashboardController { }); } } + + /** + * 테이블 스키마 조회 (날짜 컬럼 감지용) + * POST /api/dashboards/table-schema + */ + async getTableSchema( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.body; + + if (!tableName || typeof tableName !== "string") { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + return; + } + + // 테이블명 검증 (SQL 인젝션 방지) + if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + }); + return; + } + + // PostgreSQL information_schema에서 컬럼 정보 조회 + const query = ` + SELECT + column_name, + data_type, + udt_name + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `; + + const result = await PostgreSQLService.query(query, [ + tableName.toLowerCase(), + ]); + + // 날짜/시간 타입 컬럼 필터링 + const dateColumns = result.rows + .filter((row: any) => { + const dataType = row.data_type?.toLowerCase(); + const udtName = row.udt_name?.toLowerCase(); + return ( + dataType === "timestamp" || + dataType === "timestamp without time zone" || + dataType === "timestamp with time zone" || + dataType === "date" || + dataType === "time" || + dataType === "time without time zone" || + dataType === "time with time zone" || + udtName === "timestamp" || + udtName === "timestamptz" || + udtName === "date" || + udtName === "time" || + udtName === "timetz" + ); + }) + .map((row: any) => row.column_name); + + res.status(200).json({ + success: true, + data: { + tableName, + columns: result.rows.map((row: any) => ({ + name: row.column_name, + type: row.data_type, + udtName: row.udt_name, + })), + dateColumns, + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "테이블 스키마 조회 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "스키마 조회 오류", + }); + } + } } diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts index 7ed7d634..87db696b 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -36,6 +36,12 @@ router.post( dashboardController.fetchExternalApi.bind(dashboardController) ); +// 테이블 스키마 조회 (날짜 컬럼 감지용) +router.post( + "/table-schema", + dashboardController.getTableSchema.bind(dashboardController) +); + // 인증이 필요한 라우트들 router.use(authenticateToken); diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 3c579c87..5c75acb7 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -326,13 +326,17 @@ export function CanvasElement({ try { let result; + // 필터 적용 (날짜 필터 등) + const { applyQueryFilters } = await import("./utils/queryHelpers"); + const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); + // 외부 DB vs 현재 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, + filteredQuery, ); if (!externalResult.success) { @@ -348,7 +352,7 @@ export function CanvasElement({ } else { // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); - result = await dashboardApi.executeQuery(element.dataSource.query); + result = await dashboardApi.executeQuery(filteredQuery); setChartData({ columns: result.columns || [], @@ -367,6 +371,7 @@ export function CanvasElement({ element.dataSource?.query, element.dataSource?.connectionType, element.dataSource?.externalConnectionId, + element.chartConfig, element.type, ]); diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index a7649c8a..2257848d 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { ChartConfig, QueryResult } from "./types"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -10,7 +10,10 @@ import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Separator } from "@/components/ui/separator"; -import { TrendingUp, AlertCircle } from "lucide-react"; +import { AlertCircle } from "lucide-react"; +import { DateFilterPanel } from "./DateFilterPanel"; +import { extractTableNameFromQuery } from "./utils/queryHelpers"; +import { dashboardApi } from "@/lib/api/dashboard"; interface ChartConfigPanelProps { config?: ChartConfig; @@ -18,6 +21,7 @@ interface ChartConfigPanelProps { onConfigChange: (config: ChartConfig) => void; chartType?: string; dataSourceType?: "database" | "api"; // 데이터 소스 타입 + query?: string; // SQL 쿼리 (테이블명 추출용) } /** @@ -32,8 +36,10 @@ export function ChartConfigPanel({ onConfigChange, chartType, dataSourceType, + query, }: ChartConfigPanelProps) { const [currentConfig, setCurrentConfig] = useState(config || {}); + const [dateColumns, setDateColumns] = useState([]); // 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님 const isPieChart = chartType === "pie" || chartType === "donut"; @@ -70,6 +76,34 @@ export function ChartConfigPanel({ return type === "object" || type === "array"; }); + // 테이블 스키마에서 실제 날짜 컬럼 가져오기 + useEffect(() => { + if (!query || !queryResult || dataSourceType === "api") { + // API 소스는 스키마 조회 불가 + setDateColumns([]); + return; + } + + const tableName = extractTableNameFromQuery(query); + + if (!tableName) { + setDateColumns([]); + return; + } + dashboardApi + .getTableSchema(tableName) + .then((schema) => { + // 원본 테이블의 모든 날짜 컬럼을 표시 + // (SELECT에 없어도 WHERE 절에 사용 가능) + setDateColumns(schema.dateColumns); + }) + .catch((error) => { + console.error("❌ 테이블 스키마 조회 실패:", error); + // 실패 시 빈 배열 (날짜 필터 비활성화) + setDateColumns([]); + }); + }, [query, queryResult, dataSourceType]); + return (
{/* 데이터 필드 매핑 */} @@ -80,7 +114,7 @@ export function ChartConfigPanel({
-

📋 API 응답 데이터 미리보기

+

API 응답 데이터 미리보기

총 {queryResult.totalRows}개 데이터 중 첫 번째 행:
@@ -94,7 +128,7 @@ export function ChartConfigPanel({ -
⚠️ 차트에 사용할 수 없는 컬럼 감지
+
차트에 사용할 수 없는 컬럼 감지
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
@@ -106,7 +140,7 @@ export function ChartConfigPanel({
- 💡 해결 방법: JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요. + 해결 방법: JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
예: main 또는{" "} data.items @@ -138,7 +172,7 @@ export function ChartConfigPanel({ - + {simpleColumns.map((col) => { const preview = sampleData[col]; const previewText = @@ -158,7 +192,7 @@ export function ChartConfigPanel({ {simpleColumns.length === 0 && ( -

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+

사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

)}
@@ -176,17 +210,14 @@ export function ChartConfigPanel({ {/* 숫자 타입 우선 표시 */} {numericColumns.length > 0 && ( <> -
✅ 숫자 타입 (권장)
+
숫자 타입 (권장)
{numericColumns.map((col) => { const isSelected = Array.isArray(currentConfig.yAxis) ? currentConfig.yAxis.includes(col) : currentConfig.yAxis === col; return ( -
+
{ @@ -226,7 +257,7 @@ export function ChartConfigPanel({ {simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && ( <> {numericColumns.length > 0 &&
} -
📝 기타 타입
+
기타 타입
{simpleColumns .filter((col) => !numericColumns.includes(col)) .map((col) => { @@ -275,7 +306,7 @@ export function ChartConfigPanel({
{simpleColumns.length === 0 && ( -

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+

사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

)}

팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰) @@ -301,7 +332,7 @@ export function ChartConfigPanel({ - + 없음 - SQL에서 집계됨 합계 (SUM) - 모든 값을 더함 평균 (AVG) - 평균값 계산 @@ -311,7 +342,7 @@ export function ChartConfigPanel({

- 💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계) + 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)

@@ -328,7 +359,7 @@ export function ChartConfigPanel({ - + 없음 {availableColumns.map((col) => ( @@ -387,44 +418,10 @@ export function ChartConfigPanel({ - {/* 설정 미리보기 */} - -
- - 설정 미리보기 -
-
-
- X축: - {currentConfig.xAxis || "미설정"} -
-
- Y축: - - {Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0 - ? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})` - : currentConfig.yAxis || "미설정"} - -
-
- 집계: - {currentConfig.aggregation || "없음"} -
- {currentConfig.groupBy && ( -
- 그룹핑: - {currentConfig.groupBy} -
- )} -
- 데이터 행 수: - {queryResult.rows.length}개 -
- {Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && ( -
✨ 다중 시리즈 차트가 생성됩니다!
- )} -
-
+ {/* 날짜 필터 */} + {dateColumns.length > 0 && ( + + )} {/* 필수 필드 확인 */} {!currentConfig.xAxis && ( diff --git a/frontend/components/admin/dashboard/DateFilterPanel.tsx b/frontend/components/admin/dashboard/DateFilterPanel.tsx new file mode 100644 index 00000000..3fb94bb4 --- /dev/null +++ b/frontend/components/admin/dashboard/DateFilterPanel.tsx @@ -0,0 +1,198 @@ +"use client"; + +import React from "react"; +import { ChartConfig } 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 { Checkbox } from "@/components/ui/checkbox"; +import { Calendar, ChevronDown, ChevronUp } from "lucide-react"; +import { getQuickDateRange } from "./utils/queryHelpers"; + +interface DateFilterPanelProps { + config: ChartConfig; + dateColumns: string[]; + onChange: (updates: Partial) => void; +} + +export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPanelProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + + const dateFilter = config.dateFilter || { + enabled: false, + dateColumn: dateColumns[0] || "", + startDate: "", + endDate: "", + }; + + const handleQuickRange = (range: "today" | "week" | "month" | "year") => { + const { startDate, endDate } = getQuickDateRange(range); + onChange({ + dateFilter: { + ...dateFilter, + enabled: true, + startDate, + endDate, + quickRange: range, + }, + }); + }; + + // 날짜 컬럼이 없으면 표시하지 않음 + if (dateColumns.length === 0) { + return null; + } + + return ( + +
setIsExpanded(!isExpanded)}> +
+ + + {dateFilter.enabled && 활성} +
+ {isExpanded ? : } +
+ + {isExpanded && ( +
+ {/* 필터 활성화 */} +
+ + onChange({ + dateFilter: { + ...dateFilter, + enabled: checked as boolean, + }, + }) + } + /> + +
+ + {dateFilter.enabled && ( + <> + {/* 날짜 컬럼 선택 */} +
+ + +

감지된 날짜 컬럼: {dateColumns.join(", ")}

+
+ + {/* 빠른 선택 */} +
+ +
+ + + + +
+
+ + {/* 직접 입력 */} +
+
+ + + onChange({ + dateFilter: { + ...dateFilter, + startDate: e.target.value, + quickRange: undefined, // 직접 입력 시 빠른 선택 해제 + }, + }) + } + /> +
+
+ + + onChange({ + dateFilter: { + ...dateFilter, + endDate: e.target.value, + quickRange: undefined, // 직접 입력 시 빠른 선택 해제 + }, + }) + } + /> +
+
+ + {/* 필터 정보 */} + {dateFilter.startDate && dateFilter.endDate && ( +
+ 필터 적용: {dateFilter.dateColumn} 컬럼에서 {dateFilter.startDate}부터{" "} + {dateFilter.endDate}까지 데이터를 가져옵니다. +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 2bce88b6..05c254bc 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -10,7 +10,6 @@ import { DatabaseConfig } from "./data-sources/DatabaseConfig"; import { ApiConfig } from "./data-sources/ApiConfig"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Progress } from "@/components/ui/progress"; import { X, ChevronLeft, ChevronRight, Save } from "lucide-react"; interface ElementConfigModalProps { @@ -32,11 +31,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const [chartConfig, setChartConfig] = useState(element.chartConfig || {}); const [queryResult, setQueryResult] = useState(null); const [currentStep, setCurrentStep] = useState<1 | 2>(1); - + // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) - const isSimpleWidget = - element.subtype === "vehicle-status" || - element.subtype === "vehicle-list" || + const isSimpleWidget = + element.subtype === "vehicle-status" || + element.subtype === "vehicle-list" || element.subtype === "status-summary" || // 커스텀 상태 카드 // element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석) element.subtype === "delivery-status" || @@ -45,10 +44,10 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "cargo-list" || element.subtype === "customer-issues" || element.subtype === "driver-management"; - + // 지도 위젯 (위도/경도 매핑 필요) const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; - + // 주석 // 모달이 열릴 때 초기화 useEffect(() => { @@ -94,6 +93,10 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { setQueryResult(result); + + // 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋) + console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화"); + setChartConfig({}); }, []); // 다음 단계로 이동 @@ -117,6 +120,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element dataSource, chartConfig, }; + + console.log(" 저장할 element:", updatedElement); + onSave(updatedElement); onClose(); }, [element, dataSource, chartConfig, onSave, onClose]); @@ -136,11 +142,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const isPieChart = element.subtype === "pie" || element.subtype === "donut"; const isApiSource = dataSource.type === "api"; + // Y축 검증 헬퍼 + const hasYAxis = + chartConfig.yAxis && + (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + const canSave = isSimpleWidget ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 + currentStep === 2 && queryResult && queryResult.rows.length > 0 : isMapWidget ? // 지도 위젯: 위도/경도 매핑 필요 currentStep === 2 && @@ -149,17 +158,17 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element chartConfig.latitudeColumn && chartConfig.longitudeColumn : // 차트: 기존 로직 (2단계에서 차트 설정 필요) - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource - ? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요 - chartConfig.yAxis || - (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) || - chartConfig.aggregation === "count" - : // 일반 차트 (DB): Y축 필수 - chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API + chartConfig.aggregation === "count" + ? true // count는 Y축 없어도 됨 + : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 + : // 일반 차트 (DB): Y축 필수 + hasYAxis); return (
@@ -188,13 +197,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element {/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} {!isSimpleWidget && (
-
+
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
- {Math.round((currentStep / 2) * 100)}% 완료
-
)} @@ -205,7 +212,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )} {currentStep === 2 && ( -
+
{/* 왼쪽: 데이터 설정 */}
{dataSource.type === "database" ? ( @@ -240,23 +247,22 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
) + ) : // 차트: 차트 설정 패널 + queryResult && queryResult.rows.length > 0 ? ( + ) : ( - // 차트: 차트 설정 패널 - queryResult && queryResult.rows.length > 0 ? ( - - ) : ( -
-
-
데이터를 가져온 후 차트 설정이 표시됩니다
-
+
+
+
데이터를 가져온 후 차트 설정이 표시됩니다
- ) +
)}
)} @@ -266,13 +272,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element {/* 모달 푸터 */}
-
- {queryResult && ( - - 📊 {queryResult.rows.length}개 데이터 로드됨 - - )} -
+
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
{!isSimpleWidget && currentStep > 1 && ( diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index ace8c3be..181d80fa 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useCallback } from "react"; -import { ChartDataSource, QueryResult } from "./types"; +import { ChartDataSource, QueryResult, ChartConfig } from "./types"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { dashboardApi } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; @@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Play, Loader2, Database, Code } from "lucide-react"; +import { applyQueryFilters } from "./utils/queryHelpers"; interface QueryEditorProps { dataSource?: ChartDataSource; diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 3a675f5d..ce703662 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -6,6 +6,7 @@ import { Chart } from "./Chart"; import { transformQueryResultToChartData } from "../utils/chartDataTransform"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { dashboardApi } from "@/lib/api/dashboard"; +import { applyQueryFilters } from "../utils/queryHelpers"; interface ChartRendererProps { element: DashboardElement; @@ -105,10 +106,11 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char } else if (element.dataSource.query) { // Database (현재 DB 또는 외부 DB) if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { - // 외부 DB + // 외부 DB - 필터 적용 + const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); const result = await ExternalDbConnectionAPI.executeQuery( parseInt(element.dataSource.externalConnectionId), - element.dataSource.query, + filteredQuery, ); if (!result.success) { @@ -122,14 +124,24 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char executionTime: 0, }; } else { - // 현재 DB - const result = await dashboardApi.executeQuery(element.dataSource.query); + // 현재 DB - 필터 적용 + console.log("📊 ChartRenderer: 현재 DB 쿼리 실행"); + console.log(" 원본 쿼리:", element.dataSource.query); + console.log(" chartConfig:", element.chartConfig); + + const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); + + console.log(" 필터 적용된 쿼리:", filteredQuery); + + const result = await dashboardApi.executeQuery(filteredQuery); queryResult = { columns: result.columns, rows: result.rows, totalRows: result.rowCount, executionTime: 0, }; + + console.log(" 쿼리 결과:", queryResult); } } else { throw new Error("데이터 소스가 올바르게 설정되지 않았습니다"); diff --git a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index 2784def6..09f45411 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; -import { ChartDataSource, QueryResult, ApiResponse } from "../types"; +import { ChartDataSource, QueryResult, KeyValuePair } from "../types"; import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -25,48 +25,72 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(null); + // 헤더를 배열로 정규화 (객체 형식 호환) + const normalizeHeaders = (): KeyValuePair[] => { + if (!dataSource.headers) return []; + if (Array.isArray(dataSource.headers)) return dataSource.headers; + // 객체 형식이면 배열로 변환 + return Object.entries(dataSource.headers as Record).map(([key, value]) => ({ + id: `header_${Date.now()}_${Math.random()}`, + key, + value, + })); + }; + // 헤더 추가 const addHeader = () => { - const headers = dataSource.headers || {}; - const newKey = `header_${Object.keys(headers).length + 1}`; - onChange({ headers: { ...headers, [newKey]: "" } }); + const headers = normalizeHeaders(); + onChange({ + headers: [...headers, { id: `header_${Date.now()}`, key: "", value: "" }], + }); }; // 헤더 제거 - const removeHeader = (key: string) => { - const headers = { ...dataSource.headers }; - delete headers[key]; - onChange({ headers }); + const removeHeader = (id: string) => { + const headers = normalizeHeaders(); + onChange({ headers: headers.filter((h) => h.id !== id) }); }; // 헤더 업데이트 - const updateHeader = (oldKey: string, newKey: string, value: string) => { - const headers = { ...dataSource.headers }; - delete headers[oldKey]; - headers[newKey] = value; - onChange({ headers }); + const updateHeader = (id: string, updates: Partial) => { + const headers = normalizeHeaders(); + onChange({ + headers: headers.map((h) => (h.id === id ? { ...h, ...updates } : h)), + }); + }; + + // 쿼리 파라미터를 배열로 정규화 (객체 형식 호환) + const normalizeQueryParams = (): KeyValuePair[] => { + if (!dataSource.queryParams) return []; + if (Array.isArray(dataSource.queryParams)) return dataSource.queryParams; + // 객체 형식이면 배열로 변환 + return Object.entries(dataSource.queryParams as Record).map(([key, value]) => ({ + id: `param_${Date.now()}_${Math.random()}`, + key, + value, + })); }; // 쿼리 파라미터 추가 const addQueryParam = () => { - const queryParams = dataSource.queryParams || {}; - const newKey = `param_${Object.keys(queryParams).length + 1}`; - onChange({ queryParams: { ...queryParams, [newKey]: "" } }); + const queryParams = normalizeQueryParams(); + onChange({ + queryParams: [...queryParams, { id: `param_${Date.now()}`, key: "", value: "" }], + }); }; // 쿼리 파라미터 제거 - const removeQueryParam = (key: string) => { - const queryParams = { ...dataSource.queryParams }; - delete queryParams[key]; - onChange({ queryParams }); + const removeQueryParam = (id: string) => { + const queryParams = normalizeQueryParams(); + onChange({ queryParams: queryParams.filter((p) => p.id !== id) }); }; // 쿼리 파라미터 업데이트 - const updateQueryParam = (oldKey: string, newKey: string, value: string) => { - const queryParams = { ...dataSource.queryParams }; - delete queryParams[oldKey]; - queryParams[newKey] = value; - onChange({ queryParams }); + const updateQueryParam = (id: string, updates: Partial) => { + const queryParams = normalizeQueryParams(); + onChange({ + queryParams: queryParams.map((p) => (p.id === id ? { ...p, ...updates } : p)), + }); }; // API 테스트 @@ -82,14 +106,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps try { // 쿼리 파라미터 구성 - const params = new URLSearchParams(); - if (dataSource.queryParams) { - Object.entries(dataSource.queryParams).forEach(([key, value]) => { - if (key && value) { - params.append(key, value); - } - }); - } + const params: Record = {}; + const normalizedQueryParams = normalizeQueryParams(); + normalizedQueryParams.forEach(({ key, value }) => { + if (key && value) { + params[key] = value; + } + }); + + // 헤더 구성 + const headers: Record = {}; + const normalizedHeaders = normalizeHeaders(); + normalizedHeaders.forEach(({ key, value }) => { + if (key && value) { + headers[key] = value; + } + }); // 백엔드 프록시를 통한 외부 API 호출 (CORS 우회) const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", { @@ -100,8 +132,8 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps body: JSON.stringify({ url: dataSource.endpoint, method: "GET", - headers: dataSource.headers || {}, - queryParams: Object.fromEntries(params), + headers: headers, + queryParams: params, }), }); @@ -217,31 +249,34 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
- {dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? ( -
- {Object.entries(dataSource.queryParams).map(([key, value]) => ( -
- updateQueryParam(key, e.target.value, value)} - className="flex-1" - /> - updateQueryParam(key, key, e.target.value)} - className="flex-1" - /> - -
- ))} -
- ) : ( -

추가된 파라미터가 없습니다

- )} + {(() => { + const params = normalizeQueryParams(); + return params.length > 0 ? ( +
+ {params.map((param) => ( +
+ updateQueryParam(param.id, { key: e.target.value })} + className="flex-1" + /> + updateQueryParam(param.id, { value: e.target.value })} + className="flex-1" + /> + +
+ ))} +
+ ) : ( +

추가된 파라미터가 없습니다

+ ); + })()}

예: category=electronics, limit=10

@@ -262,8 +297,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps variant="outline" size="sm" onClick={() => { + const headers = normalizeHeaders(); onChange({ - headers: { ...dataSource.headers, Authorization: "Bearer YOUR_TOKEN" }, + headers: [...headers, { id: `header_${Date.now()}`, key: "Authorization", value: "" }], }); }} > @@ -273,8 +309,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps variant="outline" size="sm" onClick={() => { + const headers = normalizeHeaders(); onChange({ - headers: { ...dataSource.headers, "Content-Type": "application/json" }, + headers: [...headers, { id: `header_${Date.now()}`, key: "Content-Type", value: "application/json" }], }); }} > @@ -282,32 +319,35 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
- {dataSource.headers && Object.keys(dataSource.headers).length > 0 ? ( -
- {Object.entries(dataSource.headers).map(([key, value]) => ( -
- updateHeader(key, e.target.value, value)} - className="flex-1" - /> - updateHeader(key, key, e.target.value)} - className="flex-1" - type={key.toLowerCase().includes("auth") ? "password" : "text"} - /> - -
- ))} -
- ) : ( -

추가된 헤더가 없습니다

- )} + {(() => { + const headers = normalizeHeaders(); + return headers.length > 0 ? ( +
+ {headers.map((header) => ( +
+ updateHeader(header.id, { key: e.target.value })} + className="flex-1" + /> + updateHeader(header.id, { value: e.target.value })} + className="flex-1" + type={header.key.toLowerCase().includes("auth") ? "password" : "text"} + /> + +
+ ))} +
+ ) : ( +

추가된 헤더가 없습니다

+ ); + })()} {/* JSON Path */} @@ -358,7 +398,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps {/* 테스트 결과 */} {testResult && ( -
✅ API 호출 성공
+
API 호출 성공
총 {testResult.rows.length}개의 데이터를 불러왔습니다
컬럼: {testResult.columns.join(", ")}
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 9df7d0df..833c033a 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -73,6 +73,13 @@ export interface ResizeHandle { cursor: string; } +// 키-값 쌍 인터페이스 +export interface KeyValuePair { + id: string; // 고유 ID + key: string; // 키 + value: string; // 값 +} + export interface ChartDataSource { type: "database" | "api"; // 데이터 소스 타입 @@ -84,8 +91,8 @@ export interface ChartDataSource { // API 관련 endpoint?: string; // API URL method?: "GET"; // HTTP 메서드 (GET만 지원) - headers?: Record; // 커스텀 헤더 - queryParams?: Record; // URL 쿼리 파라미터 + headers?: KeyValuePair[]; // 커스텀 헤더 (배열) + queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열) jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results") // 공통 @@ -106,6 +113,18 @@ export interface ChartConfig { sortOrder?: "asc" | "desc"; // 정렬 순서 limit?: number; // 데이터 개수 제한 + // 데이터 필터 + dateFilter?: { + enabled: boolean; // 날짜 필터 활성화 + dateColumn?: string; // 날짜 컬럼 + startDate?: string; // 시작일 (YYYY-MM-DD) + endDate?: string; // 종료일 (YYYY-MM-DD) + quickRange?: "today" | "week" | "month" | "year"; // 빠른 선택 + }; + + // 안전장치 + autoLimit?: number; // 자동 LIMIT (기본: 1000) + // 스타일 colors?: string[]; // 차트 색상 팔레트 title?: string; // 차트 제목 diff --git a/frontend/components/admin/dashboard/utils/queryHelpers.ts b/frontend/components/admin/dashboard/utils/queryHelpers.ts new file mode 100644 index 00000000..9fc9fbbb --- /dev/null +++ b/frontend/components/admin/dashboard/utils/queryHelpers.ts @@ -0,0 +1,260 @@ +import { ChartConfig } from "../types"; + +/** + * 쿼리에 안전장치 LIMIT 추가 + */ +export function applySafetyLimit(query: string, limit: number = 1000): string { + const trimmedQuery = query.trim(); + + // 이미 LIMIT이 있으면 그대로 반환 + if (/\bLIMIT\b/i.test(trimmedQuery)) { + return trimmedQuery; + } + + return `${trimmedQuery} LIMIT ${limit}`; +} + +/** + * 날짜 필터를 쿼리에 적용 + */ +export function applyDateFilter(query: string, dateColumn: string, startDate?: string, endDate?: string): string { + if (!dateColumn || (!startDate && !endDate)) { + return query; + } + + const conditions: string[] = []; + + // NULL 값 제외 조건 추가 (필수) + conditions.push(`${dateColumn} IS NOT NULL`); + + if (startDate) { + conditions.push(`${dateColumn} >= '${startDate}'`); + } + + if (endDate) { + // 종료일은 해당 날짜의 23:59:59까지 포함 + conditions.push(`${dateColumn} <= '${endDate} 23:59:59'`); + } + + if (conditions.length === 0) { + return query; + } + + // FROM 절 이후의 WHERE, GROUP BY, ORDER BY, LIMIT 위치 파악 + // 줄바꿈 제거하여 한 줄로 만들기 (정규식 매칭을 위해) + let baseQuery = query.trim().replace(/\s+/g, " "); + let whereClause = ""; + let groupByClause = ""; + let orderByClause = ""; + let limitClause = ""; + + // LIMIT 추출 + const limitMatch = baseQuery.match(/\s+LIMIT\s+\d+\s*$/i); + if (limitMatch) { + limitClause = limitMatch[0]; + baseQuery = baseQuery.substring(0, limitMatch.index); + } + + // ORDER BY 추출 + const orderByMatch = baseQuery.match(/\s+ORDER\s+BY\s+.+$/i); + if (orderByMatch) { + orderByClause = orderByMatch[0]; + baseQuery = baseQuery.substring(0, orderByMatch.index); + } + + // GROUP BY 추출 + const groupByMatch = baseQuery.match(/\s+GROUP\s+BY\s+.+$/i); + if (groupByMatch) { + groupByClause = groupByMatch[0]; + baseQuery = baseQuery.substring(0, groupByMatch.index); + } + + // WHERE 추출 (있으면) + const whereMatch = baseQuery.match(/\s+WHERE\s+.+$/i); + if (whereMatch) { + whereClause = whereMatch[0]; + baseQuery = baseQuery.substring(0, whereMatch.index); + } + + // 날짜 필터 조건 추가 + const filterCondition = conditions.join(" AND "); + if (whereClause) { + // 기존 WHERE 절이 있으면 AND로 연결 + whereClause = `${whereClause} AND ${filterCondition}`; + } else { + // WHERE 절이 없으면 새로 생성 + whereClause = ` WHERE ${filterCondition}`; + } + + // 쿼리 재조립 + const finalQuery = `${baseQuery}${whereClause}${groupByClause}${orderByClause}${limitClause}`; + + console.log("🔧 날짜 필터 적용:"); + console.log(" 원본 쿼리:", query); + console.log(" 최종 쿼리:", finalQuery); + + return finalQuery; +} + +/** + * 빠른 날짜 범위 계산 + */ +export function getQuickDateRange(range: "today" | "week" | "month" | "year"): { + startDate: string; + endDate: string; +} { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (range) { + case "today": + return { + startDate: today.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + + case "week": { + const weekStart = new Date(today); + weekStart.setDate(today.getDate() - today.getDay()); // 일요일부터 + return { + startDate: weekStart.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + } + + case "month": { + const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); + return { + startDate: monthStart.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + } + + case "year": { + const yearStart = new Date(today.getFullYear(), 0, 1); + return { + startDate: yearStart.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + } + + default: + return { + startDate: "", + endDate: "", + }; + } +} + +/** + * 쿼리에서 테이블명 추출 + */ +export function extractTableNameFromQuery(query: string): string | null { + const trimmedQuery = query.trim().toLowerCase(); + + // FROM 절 찾기 + const fromMatch = trimmedQuery.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i); + if (fromMatch) { + return fromMatch[1]; + } + + return null; +} + +/** + * 날짜 컬럼 자동 감지 (서버에서 테이블 스키마 조회 필요) + * 이 함수는 쿼리 결과가 아닌, 원본 테이블의 실제 컬럼 타입을 확인해야 합니다. + * + * 현재는 임시로 컬럼명 기반 추측만 수행합니다. + */ +export function detectDateColumns(columns: string[], rows: Record[]): string[] { + const dateColumns: string[] = []; + + // 컬럼명 기반 추측 (가장 안전한 방법) + columns.forEach((col) => { + const lowerCol = col.toLowerCase(); + if ( + lowerCol.includes("date") || + lowerCol.includes("time") || + lowerCol.includes("created") || + lowerCol.includes("updated") || + lowerCol.includes("modified") || + lowerCol === "reg_date" || + lowerCol === "regdate" || + lowerCol === "update_date" || + lowerCol === "updatedate" || + lowerCol.endsWith("_at") || // created_at, updated_at + lowerCol.endsWith("_date") || // birth_date, start_date + lowerCol.endsWith("_time") // start_time, end_time + ) { + dateColumns.push(col); + } + }); + + // 데이터가 있는 경우, 실제 값도 확인 (추가 검증) + if (rows.length > 0 && dateColumns.length > 0) { + const firstRow = rows[0]; + + // 컬럼명으로 감지된 것들 중에서 실제 날짜 형식인지 재확인 + const validatedColumns = dateColumns.filter((col) => { + const value = firstRow[col]; + + // null이면 스킵 (판단 불가) + if (value == null) return true; + + // Date 객체면 확실히 날짜 + if (value instanceof Date) return true; + + // 문자열이고 날짜 형식이면 날짜 + if (typeof value === "string") { + const parsed = Date.parse(value); + if (!isNaN(parsed)) return true; + } + + // 숫자면 날짜가 아님 (타임스탬프 제외) + if (typeof value === "number") { + // 타임스탬프인지 확인 (1970년 이후의 밀리초 또는 초) + if (value > 946684800000 || (value > 946684800 && value < 2147483647)) { + return true; + } + return false; + } + + return false; + }); + + return validatedColumns; + } + + return dateColumns; +} + +/** + * 쿼리에 필터와 안전장치를 모두 적용 + */ +export function applyQueryFilters(query: string, config?: ChartConfig): string { + console.log("🔍 applyQueryFilters 호출:"); + console.log(" config:", config); + console.log(" dateFilter:", config?.dateFilter); + + let processedQuery = query; + + // 1. 날짜 필터 적용 + if (config?.dateFilter?.enabled && config.dateFilter.dateColumn) { + console.log("✅ 날짜 필터 적용 중..."); + processedQuery = applyDateFilter( + processedQuery, + config.dateFilter.dateColumn, + config.dateFilter.startDate, + config.dateFilter.endDate, + ); + } else { + console.log("⚠️ 날짜 필터 비활성화 또는 설정 없음"); + } + + // 2. 안전장치 LIMIT 적용 + const limit = config?.autoLimit ?? 1000; + processedQuery = applySafetyLimit(processedQuery, limit); + + return processedQuery; +} diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index 9cc8ee5b..f6365854 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -2,28 +2,28 @@ * 대시보드 API 클라이언트 */ -import { DashboardElement } from '@/components/admin/dashboard/types'; +import { DashboardElement } from "@/components/admin/dashboard/types"; // API 기본 설정 -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; // 토큰 가져오기 (실제 인증 시스템에 맞게 수정) function getAuthToken(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem('authToken') || sessionStorage.getItem('authToken'); + if (typeof window === "undefined") return null; + return localStorage.getItem("authToken") || sessionStorage.getItem("authToken"); } // API 요청 헬퍼 async function apiRequest( - endpoint: string, - options: RequestInit = {} + endpoint: string, + options: RequestInit = {}, ): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> { const token = getAuthToken(); - + const config: RequestInit = { headers: { - 'Content-Type': 'application/json', - ...(token && { 'Authorization': `Bearer ${token}` }), + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), ...options.headers, }, ...options, @@ -31,32 +31,32 @@ async function apiRequest( try { const response = await fetch(`${API_BASE_URL}${endpoint}`, config); - + // 응답이 JSON이 아닐 수도 있으므로 안전하게 처리 let result; try { result = await response.json(); } catch (jsonError) { - console.error('JSON Parse Error:', jsonError); + console.error("JSON Parse Error:", jsonError); throw new Error(`서버 응답을 파싱할 수 없습니다. Status: ${response.status}`); } - + if (!response.ok) { - console.error('API Error Response:', { + console.error("API Error Response:", { status: response.status, statusText: response.statusText, - result + result, }); throw new Error(result.message || `HTTP ${response.status}: ${response.statusText}`); } - + return result; } catch (error: any) { - console.error('API Request Error:', { + console.error("API Request Error:", { endpoint, error: error?.message || error, errorObj: error, - config + config, }); throw error; } @@ -99,154 +99,153 @@ export interface DashboardListQuery { // 대시보드 API 함수들 export const dashboardApi = { - /** * 대시보드 생성 */ async createDashboard(data: CreateDashboardRequest): Promise { - const result = await apiRequest('/dashboards', { - method: 'POST', + const result = await apiRequest("/dashboards", { + method: "POST", body: JSON.stringify(data), }); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 생성에 실패했습니다.'); + throw new Error(result.message || "대시보드 생성에 실패했습니다."); } - + return result.data; }, - + /** * 대시보드 목록 조회 */ async getDashboards(query: DashboardListQuery = {}) { const params = new URLSearchParams(); - - if (query.page) params.append('page', query.page.toString()); - if (query.limit) params.append('limit', query.limit.toString()); - if (query.search) params.append('search', query.search); - if (query.category) params.append('category', query.category); - if (typeof query.isPublic === 'boolean') params.append('isPublic', query.isPublic.toString()); - + + if (query.page) params.append("page", query.page.toString()); + if (query.limit) params.append("limit", query.limit.toString()); + if (query.search) params.append("search", query.search); + if (query.category) params.append("category", query.category); + if (typeof query.isPublic === "boolean") params.append("isPublic", query.isPublic.toString()); + const queryString = params.toString(); - const endpoint = `/dashboards${queryString ? `?${queryString}` : ''}`; - + const endpoint = `/dashboards${queryString ? `?${queryString}` : ""}`; + const result = await apiRequest(endpoint); - + if (!result.success) { - throw new Error(result.message || '대시보드 목록 조회에 실패했습니다.'); + throw new Error(result.message || "대시보드 목록 조회에 실패했습니다."); } - + return { dashboards: result.data || [], - pagination: result.pagination + pagination: result.pagination, }; }, - + /** * 내 대시보드 목록 조회 */ async getMyDashboards(query: DashboardListQuery = {}) { const params = new URLSearchParams(); - - if (query.page) params.append('page', query.page.toString()); - if (query.limit) params.append('limit', query.limit.toString()); - if (query.search) params.append('search', query.search); - if (query.category) params.append('category', query.category); - + + if (query.page) params.append("page", query.page.toString()); + if (query.limit) params.append("limit", query.limit.toString()); + if (query.search) params.append("search", query.search); + if (query.category) params.append("category", query.category); + const queryString = params.toString(); - const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ''}`; - + const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ""}`; + const result = await apiRequest(endpoint); - + if (!result.success) { - throw new Error(result.message || '내 대시보드 목록 조회에 실패했습니다.'); + throw new Error(result.message || "내 대시보드 목록 조회에 실패했습니다."); } - + return { dashboards: result.data || [], - pagination: result.pagination + pagination: result.pagination, }; }, - + /** * 대시보드 상세 조회 */ async getDashboard(id: string): Promise { const result = await apiRequest(`/dashboards/${id}`); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 조회에 실패했습니다.'); + throw new Error(result.message || "대시보드 조회에 실패했습니다."); } - + return result.data; }, - + /** * 공개 대시보드 조회 (인증 불필요) */ async getPublicDashboard(id: string): Promise { const result = await apiRequest(`/dashboards/public/${id}`); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 조회에 실패했습니다.'); + throw new Error(result.message || "대시보드 조회에 실패했습니다."); } - + return result.data; }, - + /** * 대시보드 수정 */ async updateDashboard(id: string, data: Partial): Promise { const result = await apiRequest(`/dashboards/${id}`, { - method: 'PUT', + method: "PUT", body: JSON.stringify(data), }); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 수정에 실패했습니다.'); + throw new Error(result.message || "대시보드 수정에 실패했습니다."); } - + return result.data; }, - + /** * 대시보드 삭제 */ async deleteDashboard(id: string): Promise { const result = await apiRequest(`/dashboards/${id}`, { - method: 'DELETE', + method: "DELETE", }); - + if (!result.success) { - throw new Error(result.message || '대시보드 삭제에 실패했습니다.'); + throw new Error(result.message || "대시보드 삭제에 실패했습니다."); } }, - + /** * 공개 대시보드 목록 조회 (인증 불필요) */ async getPublicDashboards(query: DashboardListQuery = {}) { const params = new URLSearchParams(); - - if (query.page) params.append('page', query.page.toString()); - if (query.limit) params.append('limit', query.limit.toString()); - if (query.search) params.append('search', query.search); - if (query.category) params.append('category', query.category); - + + if (query.page) params.append("page", query.page.toString()); + if (query.limit) params.append("limit", query.limit.toString()); + if (query.search) params.append("search", query.search); + if (query.category) params.append("category", query.category); + const queryString = params.toString(); - const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ''}`; - + const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ""}`; + const result = await apiRequest(endpoint); - + if (!result.success) { - throw new Error(result.message || '공개 대시보드 목록 조회에 실패했습니다.'); + throw new Error(result.message || "공개 대시보드 목록 조회에 실패했습니다."); } - + return { dashboards: result.data || [], - pagination: result.pagination + pagination: result.pagination, }; }, @@ -254,17 +253,41 @@ export const dashboardApi = { * 쿼리 실행 (차트 데이터 조회) */ async executeQuery(query: string): Promise<{ columns: string[]; rows: any[]; rowCount: number }> { - const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>('/dashboards/execute-query', { - method: 'POST', + const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>("/dashboards/execute-query", { + method: "POST", body: JSON.stringify({ query }), }); - + if (!result.success || !result.data) { - throw new Error(result.message || '쿼리 실행에 실패했습니다.'); + throw new Error(result.message || "쿼리 실행에 실패했습니다."); } - + return result.data; - } + }, + + /** + * 테이블 스키마 조회 (날짜 컬럼 감지용) + */ + async getTableSchema(tableName: string): Promise<{ + tableName: string; + columns: Array<{ name: string; type: string; udtName: string }>; + dateColumns: string[]; + }> { + const result = await apiRequest<{ + tableName: string; + columns: Array<{ name: string; type: string; udtName: string }>; + dateColumns: string[]; + }>("/dashboards/table-schema", { + method: "POST", + body: JSON.stringify({ tableName }), + }); + + if (!result.success || !result.data) { + throw new Error(result.message || "테이블 스키마 조회에 실패했습니다."); + } + + return result.data; + }, }; // 에러 처리 유틸리티 @@ -272,10 +295,10 @@ export function handleApiError(error: any): string { if (error.message) { return error.message; } - - if (typeof error === 'string') { + + if (typeof error === "string") { return error; } - - return '알 수 없는 오류가 발생했습니다.'; + + return "알 수 없는 오류가 발생했습니다."; }