From eff3b45dc9adf1e1e2ff844d5bf6773c0627c565 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 15 Oct 2025 15:05:20 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/DashboardController.ts | 89 ++++++ backend-node/src/routes/dashboardRoutes.ts | 6 + .../admin/dashboard/CanvasElement.tsx | 9 +- .../admin/dashboard/ChartConfigPanel.tsx | 58 +++- .../admin/dashboard/DateFilterPanel.tsx | 198 +++++++++++++ .../admin/dashboard/ElementConfigModal.tsx | 76 ++--- .../admin/dashboard/QueryEditor.tsx | 3 +- .../admin/dashboard/charts/ChartRenderer.tsx | 20 +- frontend/components/admin/dashboard/types.ts | 12 + .../admin/dashboard/utils/queryHelpers.ts | 260 ++++++++++++++++++ frontend/lib/api/dashboard.ts | 203 ++++++++------ 11 files changed, 797 insertions(+), 137 deletions(-) create mode 100644 frontend/components/admin/dashboard/DateFilterPanel.tsx create mode 100644 frontend/components/admin/dashboard/utils/queryHelpers.ts 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 92a39cb5..2b8daba3 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -295,13 +295,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) { @@ -317,7 +321,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 || [], @@ -336,6 +340,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..2c7ff14b 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"; @@ -11,6 +11,9 @@ 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 { 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,45 @@ export function ChartConfigPanel({ return type === "object" || type === "array"; }); + // 테이블 스키마에서 실제 날짜 컬럼 가져오기 + useEffect(() => { + if (!query || !queryResult || dataSourceType === "api") { + // API 소스는 스키마 조회 불가 + setDateColumns([]); + return; + } + + const tableName = extractTableNameFromQuery(query); + console.log("📋 쿼리에서 추출한 테이블명:", tableName); + console.log("📋 원본 쿼리:", query); + + if (!tableName) { + console.log("⚠️ 테이블명을 추출할 수 없습니다."); + setDateColumns([]); + return; + } + + console.log("🔍 테이블 스키마 조회 중:", tableName); + dashboardApi + .getTableSchema(tableName) + .then((schema) => { + console.log("✅ 스키마 조회 성공:", schema); + console.log("📅 전체 날짜 컬럼:", schema.dateColumns); + console.log("📊 쿼리 결과 컬럼:", availableColumns); + + // 원본 테이블의 모든 날짜 컬럼을 표시 + // (SELECT에 없어도 WHERE 절에 사용 가능) + setDateColumns(schema.dateColumns); + console.log("✨ 사용 가능한 날짜 필터 컬럼:", schema.dateColumns); + }) + .catch((error) => { + console.error("❌ 테이블 스키마 조회 실패:", error); + // 실패 시 빈 배열 (날짜 필터 비활성화) + setDateColumns([]); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, queryResult, dataSourceType]); + return (
{/* 데이터 필드 매핑 */} @@ -138,7 +183,7 @@ export function ChartConfigPanel({ - + {simpleColumns.map((col) => { const preview = sampleData[col]; const previewText = @@ -301,7 +346,7 @@ export function ChartConfigPanel({ - + 없음 - SQL에서 집계됨 합계 (SUM) - 모든 값을 더함 평균 (AVG) - 평균값 계산 @@ -328,7 +373,7 @@ export function ChartConfigPanel({ - + 없음 {availableColumns.map((col) => ( @@ -426,6 +471,11 @@ export function ChartConfigPanel({
+ {/* 날짜 필터 */} + {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 9dca3f95..ead75bd5 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -32,17 +32,17 @@ 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 === "delivery-status" || element.subtype === "driver-management"; - + // 지도 위젯 (위도/경도 매핑 필요) const isMapWidget = element.subtype === "vehicle-map"; - + // 주석 // 모달이 열릴 때 초기화 useEffect(() => { @@ -106,11 +106,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 저장 처리 const handleSave = useCallback(() => { + console.log("💾 저장 버튼 클릭!"); + console.log(" 현재 chartConfig:", chartConfig); + console.log(" dateFilter:", chartConfig?.dateFilter); + const updatedElement: DashboardElement = { ...element, dataSource, chartConfig, }; + + console.log(" 저장할 element:", updatedElement); + onSave(updatedElement); onClose(); }, [element, dataSource, chartConfig, onSave, onClose]); @@ -132,9 +139,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const canSave = isSimpleWidget ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 + currentStep === 2 && queryResult && queryResult.rows.length > 0 : isMapWidget ? // 지도 위젯: 위도/경도 매핑 필요 currentStep === 2 && @@ -143,17 +148,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: 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)); return (
@@ -199,7 +204,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )} {currentStep === 2 && ( -
+
{/* 왼쪽: 데이터 설정 */}
{dataSource.type === "database" ? ( @@ -234,23 +239,22 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
) + ) : // 차트: 차트 설정 패널 + queryResult && queryResult.rows.length > 0 ? ( + ) : ( - // 차트: 차트 설정 패널 - queryResult && queryResult.rows.length > 0 ? ( - - ) : ( -
-
-
데이터를 가져온 후 차트 설정이 표시됩니다
-
+
+
+
데이터를 가져온 후 차트 설정이 표시됩니다
- ) +
)}
)} 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/types.ts b/frontend/components/admin/dashboard/types.ts index 337da311..27cb4992 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -106,6 +106,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 "알 수 없는 오류가 발생했습니다."; }