기간 필터링 추가

This commit is contained in:
dohyeons 2025-10-15 15:05:20 +09:00
parent d709515d6d
commit eff3b45dc9
11 changed files with 797 additions and 137 deletions

View File

@ -547,4 +547,93 @@ export class DashboardController {
});
}
}
/**
* ( )
* POST /api/dashboards/table-schema
*/
async getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
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
: "스키마 조회 오류",
});
}
}
}

View File

@ -36,6 +36,12 @@ router.post(
dashboardController.fetchExternalApi.bind(dashboardController)
);
// 테이블 스키마 조회 (날짜 컬럼 감지용)
router.post(
"/table-schema",
dashboardController.getTableSchema.bind(dashboardController)
);
// 인증이 필요한 라우트들
router.use(authenticateToken);

View File

@ -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,
]);

View File

@ -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<ChartConfig>(config || {});
const [dateColumns, setDateColumns] = useState<string[]>([]);
// 원형/도넛 차트 또는 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 (
<div className="space-y-6">
{/* 데이터 필드 매핑 */}
@ -138,7 +183,7 @@ export function ChartConfigPanel({
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectContent className="z-[99999]">
{simpleColumns.map((col) => {
const preview = sampleData[col];
const previewText =
@ -301,7 +346,7 @@ export function ChartConfigPanel({
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="z-[99999]">
<SelectItem value="none"> - SQL에서 </SelectItem>
<SelectItem value="sum"> (SUM) - </SelectItem>
<SelectItem value="avg"> (AVG) - </SelectItem>
@ -328,7 +373,7 @@ export function ChartConfigPanel({
<SelectTrigger>
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectContent className="z-[99999]">
<SelectItem value="__none__"></SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col} value={col}>
@ -426,6 +471,11 @@ export function ChartConfigPanel({
</div>
</Card>
{/* 날짜 필터 */}
{dateColumns.length > 0 && (
<DateFilterPanel config={currentConfig} dateColumns={dateColumns} onChange={updateConfig} />
)}
{/* 필수 필드 확인 */}
{!currentConfig.xAxis && (
<Alert variant="destructive">

View File

@ -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<ChartConfig>) => 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 (
<Card className="p-4">
<div className="flex cursor-pointer items-center justify-between" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-blue-600" />
<Label className="cursor-pointer text-sm font-medium text-gray-700"> ()</Label>
{dateFilter.enabled && <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"></span>}
</div>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
{isExpanded && (
<div className="mt-4 space-y-4">
{/* 필터 활성화 */}
<div className="flex items-center gap-2">
<Checkbox
id="enableDateFilter"
checked={dateFilter.enabled}
onCheckedChange={(checked) =>
onChange({
dateFilter: {
...dateFilter,
enabled: checked as boolean,
},
})
}
/>
<Label htmlFor="enableDateFilter" className="cursor-pointer text-sm font-normal">
</Label>
</div>
{dateFilter.enabled && (
<>
{/* 날짜 컬럼 선택 */}
<div>
<Label className="mb-2 text-sm font-medium text-gray-700"> </Label>
<Select
value={dateFilter.dateColumn || ""}
onValueChange={(value) =>
onChange({
dateFilter: {
...dateFilter,
dateColumn: value,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="날짜 컬럼 선택" />
</SelectTrigger>
<SelectContent className="z-[99999]">
{dateColumns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> : {dateColumns.join(", ")}</p>
</div>
{/* 빠른 선택 */}
<div>
<Label className="mb-2 text-sm font-medium text-gray-700"> </Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={dateFilter.quickRange === "today" ? "default" : "outline"}
size="sm"
onClick={() => handleQuickRange("today")}
>
</Button>
<Button
type="button"
variant={dateFilter.quickRange === "week" ? "default" : "outline"}
size="sm"
onClick={() => handleQuickRange("week")}
>
</Button>
<Button
type="button"
variant={dateFilter.quickRange === "month" ? "default" : "outline"}
size="sm"
onClick={() => handleQuickRange("month")}
>
</Button>
<Button
type="button"
variant={dateFilter.quickRange === "year" ? "default" : "outline"}
size="sm"
onClick={() => handleQuickRange("year")}
>
</Button>
</div>
</div>
{/* 직접 입력 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="mb-2 text-sm font-medium text-gray-700"></Label>
<Input
type="date"
value={dateFilter.startDate || ""}
onChange={(e) =>
onChange({
dateFilter: {
...dateFilter,
startDate: e.target.value,
quickRange: undefined, // 직접 입력 시 빠른 선택 해제
},
})
}
/>
</div>
<div>
<Label className="mb-2 text-sm font-medium text-gray-700"></Label>
<Input
type="date"
value={dateFilter.endDate || ""}
onChange={(e) =>
onChange({
dateFilter: {
...dateFilter,
endDate: e.target.value,
quickRange: undefined, // 직접 입력 시 빠른 선택 해제
},
})
}
/>
</div>
</div>
{/* 필터 정보 */}
{dateFilter.startDate && dateFilter.endDate && (
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800">
<strong> :</strong> {dateFilter.dateColumn} {dateFilter.startDate}{" "}
{dateFilter.endDate} .
</div>
)}
</>
)}
</div>
)}
</Card>
);
}

View File

@ -32,17 +32,17 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
const [queryResult, setQueryResult] = useState<QueryResult | null>(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 (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
@ -199,7 +204,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{currentStep === 2 && (
<div className={`grid ${isSimpleWidget ? 'grid-cols-1' : 'grid-cols-2'} gap-6`}>
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
{/* 왼쪽: 데이터 설정 */}
<div className="space-y-6">
{dataSource.type === "database" ? (
@ -234,23 +239,22 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
</div>
</div>
)
) : // 차트: 차트 설정 패널
queryResult && queryResult.rows.length > 0 ? (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
) : (
// 차트: 차트 설정 패널
queryResult && queryResult.rows.length > 0 ? (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
<div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
<div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
)
</div>
)}
</div>
)}

View File

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

View File

@ -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("데이터 소스가 올바르게 설정되지 않았습니다");

View File

@ -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; // 차트 제목

View File

@ -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, any>[]): 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;
}

View File

@ -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<T>(
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<T>(
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<Dashboard> {
const result = await apiRequest<Dashboard>('/dashboards', {
method: 'POST',
const result = await apiRequest<Dashboard>("/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<Dashboard[]>(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<Dashboard[]>(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<Dashboard> {
const result = await apiRequest<Dashboard>(`/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<Dashboard> {
const result = await apiRequest<Dashboard>(`/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<CreateDashboardRequest>): Promise<Dashboard> {
const result = await apiRequest<Dashboard>(`/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<void> {
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<Dashboard[]>(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 "알 수 없는 오류가 발생했습니다.";
}