restapi 여러개 띄우는거 작업 가능하게 하는거 진행중
This commit is contained in:
parent
4f2cf6c0ff
commit
5b394473f4
|
|
@ -606,16 +606,32 @@ export class DashboardController {
|
|||
}
|
||||
});
|
||||
|
||||
// 외부 API 호출
|
||||
// 외부 API 호출 (타임아웃 30초)
|
||||
// @ts-ignore - node-fetch dynamic import
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
|
||||
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
|
||||
const controller = new (global as any).AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
|
|
@ -623,7 +639,40 @@ export class DashboardController {
|
|||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Content-Type에 따라 응답 파싱
|
||||
const contentType = response.headers.get("content-type");
|
||||
let data: any;
|
||||
|
||||
// 한글 인코딩 처리 (EUC-KR → UTF-8)
|
||||
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
|
||||
urlObj.hostname.includes('data.go.kr');
|
||||
|
||||
if (isKoreanApi) {
|
||||
// 한국 정부 API는 EUC-KR 인코딩 사용
|
||||
const buffer = await response.arrayBuffer();
|
||||
const decoder = new TextDecoder('euc-kr');
|
||||
const text = decoder.decode(buffer);
|
||||
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { text, contentType };
|
||||
}
|
||||
} else if (contentType && contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
} else if (contentType && contentType.includes("text/")) {
|
||||
// 텍스트 응답 (CSV, 일반 텍스트 등)
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
} else {
|
||||
// 기타 응답 (JSON으로 시도)
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export class ExternalRestApiConnectionService {
|
|||
try {
|
||||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, default_headers,
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -110,7 +110,7 @@ export class ExternalRestApiConnectionService {
|
|||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, default_headers,
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -167,10 +167,10 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
const query = `
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, default_headers,
|
||||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -178,6 +178,7 @@ export class ExternalRestApiConnectionService {
|
|||
data.connection_name,
|
||||
data.description || null,
|
||||
data.base_url,
|
||||
data.endpoint_path || null,
|
||||
JSON.stringify(data.default_headers || {}),
|
||||
data.auth_type,
|
||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||
|
|
@ -261,6 +262,12 @@ export class ExternalRestApiConnectionService {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.endpoint_path !== undefined) {
|
||||
updateFields.push(`endpoint_path = $${paramIndex}`);
|
||||
params.push(data.endpoint_path);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_headers !== undefined) {
|
||||
updateFields.push(`default_headers = $${paramIndex}`);
|
||||
params.push(JSON.stringify(data.default_headers));
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export class RiskAlertService {
|
|||
disp: 0,
|
||||
authKey: apiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
timeout: 30000, // 30초로 증가
|
||||
responseType: 'arraybuffer', // 인코딩 문제 해결
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface ExternalRestApiConnection {
|
|||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [connectionName, setConnectionName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [endpointPath, setEndpointPath] = useState("");
|
||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||
const [authType, setAuthType] = useState<AuthType>("none");
|
||||
const [authConfig, setAuthConfig] = useState<any>({});
|
||||
|
|
@ -55,6 +56,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setConnectionName(connection.connection_name);
|
||||
setDescription(connection.description || "");
|
||||
setBaseUrl(connection.base_url);
|
||||
setEndpointPath(connection.endpoint_path || "");
|
||||
setDefaultHeaders(connection.default_headers || {});
|
||||
setAuthType(connection.auth_type);
|
||||
setAuthConfig(connection.auth_config || {});
|
||||
|
|
@ -67,6 +69,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setConnectionName("");
|
||||
setDescription("");
|
||||
setBaseUrl("");
|
||||
setEndpointPath("");
|
||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||
setAuthType("none");
|
||||
setAuthConfig({});
|
||||
|
|
@ -175,6 +178,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
connection_name: connectionName,
|
||||
description: description || undefined,
|
||||
base_url: baseUrl,
|
||||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
|
|
@ -257,6 +261,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-path">엔드포인트 경로</Label>
|
||||
<Input
|
||||
id="endpoint-path"
|
||||
value={endpointPath}
|
||||
onChange={(e) => setEndpointPath(e.target.value)}
|
||||
placeholder="/api/typ01/url/wrn_now_data.php"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
API 엔드포인트 경로를 입력하세요 (선택사항)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -60,6 +60,24 @@ const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/Ma
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 지도 위젯 (REST API 지원)
|
||||
const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
|
||||
const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
|
||||
const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
|
||||
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
|
||||
ssr: false,
|
||||
|
|
@ -851,6 +869,21 @@ export function CanvasElement({
|
|||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapSummaryWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "map-test" ? (
|
||||
// 🧪 테스트용 지도 위젯 (REST API 지원)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "map-test-v2" ? (
|
||||
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapTestWidgetV2 element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "chart-test" ? (
|
||||
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<ChartTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
|
|
|||
|
|
@ -194,7 +194,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
// 요소들 설정
|
||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||
setElements(dashboard.elements);
|
||||
// chartConfig.dataSources를 element.dataSources로 복사 (프론트엔드 호환성)
|
||||
const elementsWithDataSources = dashboard.elements.map((el) => ({
|
||||
...el,
|
||||
dataSources: el.chartConfig?.dataSources || el.dataSources,
|
||||
}));
|
||||
|
||||
setElements(elementsWithDataSources);
|
||||
|
||||
// elementCounter를 가장 큰 ID 번호로 설정
|
||||
const maxId = dashboard.elements.reduce((max, el) => {
|
||||
|
|
@ -459,7 +465,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
showHeader: el.showHeader,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig,
|
||||
// dataSources는 chartConfig에 포함시켜서 저장 (백엔드 스키마 수정 불필요)
|
||||
chartConfig:
|
||||
el.dataSources && el.dataSources.length > 0
|
||||
? { ...el.chartConfig, dataSources: el.dataSources }
|
||||
: el.chartConfig,
|
||||
listConfig: el.listConfig,
|
||||
yardConfig: el.yardConfig,
|
||||
customMetricConfig: el.customMetricConfig,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,11 @@ export function DashboardTopMenu({
|
|||
<SelectValue placeholder="위젯 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectGroup>
|
||||
<SelectLabel>🧪 테스트 위젯 (다중 데이터 소스)</SelectLabel>
|
||||
<SelectItem value="map-test-v2">🧪 지도 테스트 V2</SelectItem>
|
||||
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
|
|
@ -188,6 +193,7 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
<SelectItem value="map-test">🧪 지도 테스트 (REST API)</SelectItem>
|
||||
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./t
|
|||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { DataSourceSelector } from "./data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
|
|
@ -17,6 +18,7 @@ interface ElementConfigModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (element: DashboardElement) => void;
|
||||
onPreview?: (element: DashboardElement) => void; // 실시간 미리보기용 (저장 전)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,7 +26,7 @@ interface ElementConfigModalProps {
|
|||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave, onPreview }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
|
|
@ -61,7 +63,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
|
||||
// 주석
|
||||
// 모달이 열릴 때 초기화
|
||||
|
|
@ -132,7 +134,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
// 🎯 실시간 미리보기: chartConfig 변경 시 즉시 부모에게 전달
|
||||
if (onPreview) {
|
||||
onPreview({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
dataSource: dataSource,
|
||||
customTitle: customTitle,
|
||||
showHeader: showHeader,
|
||||
});
|
||||
}
|
||||
}, [element, dataSource, customTitle, showHeader, onPreview]);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
|
|
@ -208,12 +221,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요)
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 위도/경도 매핑 필요
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
? // 지도 위젯: 타일맵 URL 또는 위도/경도 매핑 필요
|
||||
element.subtype === "map-test"
|
||||
? // 🧪 지도 테스트 위젯: 타일맵 URL만 있으면 저장 가능
|
||||
currentStep === 2 && chartConfig.tileMapUrl
|
||||
: // 기존 지도 위젯: 쿼리 결과 + 위도/경도 필수
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
|
|
@ -324,7 +341,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<div>
|
||||
{isMapWidget ? (
|
||||
// 지도 위젯: 위도/경도 매핑 패널
|
||||
queryResult && queryResult.rows.length > 0 ? (
|
||||
element.subtype === "map-test" ? (
|
||||
// 🧪 지도 테스트 위젯: 타일맵 URL 필수, 마커 데이터 선택사항
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : queryResult && queryResult.rows.length > 0 ? (
|
||||
// 기존 지도 위젯: 쿼리 결과 필수
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./t
|
|||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
|
||||
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
|
||||
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
||||
import { X } from "lucide-react";
|
||||
|
|
@ -33,6 +35,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
const [dataSources, setDataSources] = useState<ChartDataSource[]>([]);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [customTitle, setCustomTitle] = useState<string>("");
|
||||
|
|
@ -42,6 +45,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCustomTitle(element.customTitle || "");
|
||||
|
|
@ -89,9 +94,23 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
}, []);
|
||||
const handleChartConfigChange = useCallback(
|
||||
(newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
|
||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용)
|
||||
if (element && element.subtype === "map-test" && newConfig.tileMapUrl) {
|
||||
onApply({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
dataSource: dataSource,
|
||||
customTitle: customTitle,
|
||||
showHeader: showHeader,
|
||||
});
|
||||
}
|
||||
},
|
||||
[element, dataSource, customTitle, showHeader, onApply],
|
||||
);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
|
|
@ -103,17 +122,27 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const handleApply = useCallback(() => {
|
||||
if (!element) return;
|
||||
|
||||
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
|
||||
console.log("🔧 적용 버튼 클릭 - dataSources:", element.dataSources);
|
||||
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
|
||||
|
||||
// 다중 데이터 소스 위젯 체크
|
||||
const isMultiDS = element.subtype === "map-test-v2" || element.subtype === "chart-test";
|
||||
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
dataSource,
|
||||
chartConfig,
|
||||
// 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장
|
||||
chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig,
|
||||
dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성
|
||||
dataSource: isMultiDS ? undefined : dataSource,
|
||||
customTitle: customTitle.trim() || undefined,
|
||||
showHeader,
|
||||
};
|
||||
|
||||
console.log("🔧 적용할 요소:", updatedElement);
|
||||
onApply(updatedElement);
|
||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
||||
}, [element, dataSource, chartConfig, customTitle, showHeader, onApply]);
|
||||
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
|
||||
|
||||
// 요소가 없으면 렌더링하지 않음
|
||||
if (!element) return null;
|
||||
|
|
@ -184,13 +213,17 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
const isMapWidget =
|
||||
element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
|
||||
// 헤더 전용 위젯
|
||||
const isHeaderOnlyWidget =
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
||||
|
||||
// 다중 데이터 소스 테스트 위젯
|
||||
const isMultiDataSourceWidget = element.subtype === "map-test-v2" || element.subtype === "chart-test";
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
|
@ -205,14 +238,18 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const canApply =
|
||||
isTitleChanged ||
|
||||
isHeaderChanged ||
|
||||
(isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
||||
(isMultiDataSourceWidget
|
||||
? true // 다중 데이터 소스 위젯은 항상 적용 가능
|
||||
: isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? element.subtype === "map-test"
|
||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터
|
||||
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -269,8 +306,48 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다중 데이터 소스 위젯 */}
|
||||
{isMultiDataSourceWidget && (
|
||||
<>
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<MultiDataSourceConfig dataSources={dataSources} onChange={setDataSources} />
|
||||
</div>
|
||||
|
||||
{/* 지도 테스트 V2: 타일맵 URL 설정 */}
|
||||
{element.subtype === "map-test-v2" && (
|
||||
<div className="rounded-lg bg-white shadow-sm">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase">
|
||||
타일맵 설정 (선택사항)
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">기본 VWorld 타일맵 사용 중</div>
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="border-t p-3">
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
||||
{!isHeaderOnlyWidget && (
|
||||
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
||||
|
||||
|
|
@ -303,52 +380,82 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
/>
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isSimpleWidget &&
|
||||
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
element.subtype === "map-test" ? (
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api" className="mt-2 space-y-2">
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isSimpleWidget &&
|
||||
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
element.subtype === "map-test" ? (
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,415 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { ChartConfig, QueryResult, ChartDataSource } from './types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from '@/lib/api/externalDbConnection';
|
||||
|
||||
interface MapTestConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 테스트 위젯 설정 패널
|
||||
* - 타일맵 URL 설정 (VWorld, OpenStreetMap 등)
|
||||
* - 위도/경도 컬럼 매핑
|
||||
* - 라벨/상태 컬럼 설정
|
||||
*/
|
||||
export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapTestConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
const [connections, setConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [tileMapSources, setTileMapSources] = useState<Array<{ id: string; url: string }>>([
|
||||
{ id: `tilemap_${Date.now()}`, url: '' }
|
||||
]);
|
||||
|
||||
// config prop 변경 시 currentConfig 동기화
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setCurrentConfig(config);
|
||||
console.log('🔄 config 업데이트:', config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// 외부 API 커넥션 목록 불러오기 (REST API만)
|
||||
useEffect(() => {
|
||||
const loadApiConnections = async () => {
|
||||
try {
|
||||
const apiConnections = await ExternalDbConnectionAPI.getApiConnections({ is_active: 'Y' });
|
||||
setConnections(apiConnections);
|
||||
console.log('✅ REST API 커넥션 로드 완료:', apiConnections);
|
||||
console.log(`📊 총 ${apiConnections.length}개의 REST API 커넥션`);
|
||||
} catch (error) {
|
||||
console.error('❌ REST API 커넥션 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// 타일맵 URL을 템플릿 형식으로 변환 (10/856/375.png → {z}/{y}/{x}.png)
|
||||
const convertToTileTemplate = (url: string): string => {
|
||||
// 이미 템플릿 형식이면 그대로 반환
|
||||
if (url.includes('{z}') && url.includes('{y}') && url.includes('{x}')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 특정 타일 URL 패턴 감지: /숫자/숫자/숫자.png
|
||||
const tilePattern = /\/(\d+)\/(\d+)\/(\d+)\.(png|jpg|jpeg)$/i;
|
||||
const match = url.match(tilePattern);
|
||||
|
||||
if (match) {
|
||||
// /10/856/375.png → /{z}/{y}/{x}.png
|
||||
const convertedUrl = url.replace(tilePattern, '/{z}/{y}/{x}.$4');
|
||||
console.log('🔄 타일 URL 자동 변환:', url, '→', convertedUrl);
|
||||
return convertedUrl;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
// tileMapUrl이 업데이트되면 자동으로 템플릿 형식으로 변환
|
||||
if (updates.tileMapUrl) {
|
||||
updates.tileMapUrl = convertToTileTemplate(updates.tileMapUrl);
|
||||
}
|
||||
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
|
||||
// 타일맵 소스 추가
|
||||
const addTileMapSource = () => {
|
||||
setTileMapSources([...tileMapSources, { id: `tilemap_${Date.now()}`, url: '' }]);
|
||||
};
|
||||
|
||||
// 타일맵 소스 제거
|
||||
const removeTileMapSource = (id: string) => {
|
||||
if (tileMapSources.length === 1) return; // 최소 1개는 유지
|
||||
setTileMapSources(tileMapSources.filter(s => s.id !== id));
|
||||
};
|
||||
|
||||
// 타일맵 소스 업데이트
|
||||
const updateTileMapSource = (id: string, url: string) => {
|
||||
setTileMapSources(tileMapSources.map(s => s.id === id ? { ...s, url } : s));
|
||||
// 첫 번째 타일맵 URL을 config에 저장
|
||||
const firstUrl = id === tileMapSources[0].id ? url : tileMapSources[0].url;
|
||||
updateConfig({ tileMapUrl: firstUrl });
|
||||
};
|
||||
|
||||
// 외부 커넥션에서 URL 가져오기
|
||||
const loadFromConnection = (sourceId: string, connectionId: string) => {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId);
|
||||
if (connection) {
|
||||
console.log('🔗 선택된 커넥션:', connection.connection_name, '→', connection.base_url);
|
||||
updateTileMapSource(sourceId, connection.base_url);
|
||||
}
|
||||
};
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
// 기상특보 데이터인지 감지 (reg_ko, wrn 컬럼이 있으면 기상특보)
|
||||
const isWeatherAlertData = availableColumns.includes('reg_ko') && availableColumns.includes('wrn');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 타일맵 URL 설정 (외부 커넥션 또는 직접 입력) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
타일맵 소스 (지도 배경)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</Label>
|
||||
|
||||
{/* 외부 커넥션 선택 */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const connectionId = e.target.value;
|
||||
if (connectionId) {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId);
|
||||
if (connection) {
|
||||
console.log('🗺️ 타일맵 커넥션 선택:', connection.connection_name, '→', connection.base_url);
|
||||
updateConfig({ tileMapUrl: connection.base_url });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs h-8 bg-white"
|
||||
>
|
||||
<option value="">저장된 커넥션 선택</option>
|
||||
{connections.map((conn) => (
|
||||
<option key={conn.id} value={conn.id?.toString()}>
|
||||
{conn.connection_name}
|
||||
{conn.description && ` (${conn.description})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* 타일맵 URL 직접 입력 */}
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.tileMapUrl || ''}
|
||||
onChange={(e) => updateConfig({ tileMapUrl: e.target.value })}
|
||||
placeholder="https://api.vworld.kr/req/wmts/1.0.0/{API_KEY}/Base/{z}/{y}/{x}.png"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 {'{z}/{y}/{x}'}는 그대로 입력하세요 (지도 라이브러리가 자동 치환)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타일맵 소스 목록 */}
|
||||
{/* <div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
타일맵 소스 (REST API)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTileMapSource}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tileMapSources.map((source, index) => (
|
||||
<div key={source.id} className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
외부 커넥션 선택 (선택사항)
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => loadFromConnection(source.id, e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs h-8 bg-white"
|
||||
>
|
||||
<option value="">직접 입력 또는 커넥션 선택</option>
|
||||
{connections.map((conn) => (
|
||||
<option key={conn.id} value={conn.id?.toString()}>
|
||||
{conn.connection_name}
|
||||
{conn.description && ` (${conn.description})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={source.url}
|
||||
onChange={(e) => updateTileMapSource(source.id, e.target.value)}
|
||||
placeholder="https://api.vworld.kr/req/wmts/1.0.0/{API_KEY}/Base/{z}/{y}/{x}.png"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
{tileMapSources.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeTileMapSource(source.id)}
|
||||
className="h-8 w-8 text-gray-500 hover:text-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 {'{z}/{y}/{x}'}는 그대로 입력하세요 (지도 라이브러리가 자동 치환)
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* 지도 제목 */}
|
||||
{/* <div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="위치 지도"
|
||||
className="h-10 text-xs"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* 구분선 */}
|
||||
{/* <div className="border-t pt-3">
|
||||
<h5 className="text-xs font-semibold text-gray-700 mb-2">📍 마커 데이터 설정 (선택사항)</h5>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
데이터 소스 탭에서 API 또는 데이터베이스를 연결하면 마커를 표시할 수 있습니다.
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{/* {!queryResult && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-xs">
|
||||
💡 데이터 소스를 연결하고 쿼리를 실행하면 마커 설정이 가능합니다.
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && !isWeatherAlertData && (
|
||||
<>
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
위도 컬럼 (Latitude)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
경도 컬럼 (Longitude)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기상특보 데이터 안내 */}
|
||||
{queryResult && isWeatherAlertData && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-blue-800 text-xs">
|
||||
🚨 기상특보 데이터가 감지되었습니다. 지역명(reg_ko)을 기준으로 자동으로 영역이 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult && (
|
||||
<>
|
||||
|
||||
{/* 날씨 정보 표시 옵션 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeather || false}
|
||||
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>날씨 정보 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeatherAlerts || false}
|
||||
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>기상특보 영역 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>타일맵:</strong> {currentConfig.tileMapUrl ? '✅ 설정됨' : '❌ 미설정'}</div>
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||
<div><strong>라벨:</strong> {currentConfig.labelColumn || '없음'}</div>
|
||||
<div><strong>상태:</strong> {currentConfig.statusColumn || '없음'}</div>
|
||||
<div><strong>날씨 표시:</strong> {currentConfig.showWeather ? '활성화' : '비활성화'}</div>
|
||||
<div><strong>기상특보 표시:</strong> {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}</div>
|
||||
<div><strong>데이터 개수:</strong> {queryResult.rows.length}개</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{/* {!currentConfig.tileMapUrl && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-xs">
|
||||
⚠️ 타일맵 URL을 입력해야 지도가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -9,6 +9,15 @@ import { Plus, X, Play, AlertCircle } from "lucide-react";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
|
||||
// 개별 API 소스 인터페이스
|
||||
interface ApiSource {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
headers: KeyValuePair[];
|
||||
queryParams: KeyValuePair[];
|
||||
jsonPath?: string;
|
||||
}
|
||||
|
||||
interface ApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
|
|
@ -52,8 +61,15 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
console.log("불러온 커넥션:", connection);
|
||||
|
||||
// 커넥션 설정을 API 설정에 자동 적용
|
||||
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
||||
const fullEndpoint = connection.endpoint_path
|
||||
? `${connection.base_url}${connection.endpoint_path}`
|
||||
: connection.base_url;
|
||||
|
||||
console.log("전체 엔드포인트:", fullEndpoint);
|
||||
|
||||
const updates: Partial<ChartDataSource> = {
|
||||
endpoint: connection.base_url,
|
||||
endpoint: fullEndpoint,
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
|
|
@ -119,6 +135,8 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
}
|
||||
}
|
||||
|
||||
updates.type = "api"; // ⭐ 중요: type을 api로 명시
|
||||
updates.method = "GET"; // 기본 메서드
|
||||
updates.headers = headers;
|
||||
updates.queryParams = queryParams;
|
||||
console.log("최종 업데이트:", updates);
|
||||
|
|
@ -201,6 +219,17 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
return;
|
||||
}
|
||||
|
||||
// 타일맵 URL 감지 (이미지 파일이므로 테스트 불가)
|
||||
const isTilemapUrl =
|
||||
dataSource.endpoint.includes('{z}') &&
|
||||
dataSource.endpoint.includes('{y}') &&
|
||||
dataSource.endpoint.includes('{x}');
|
||||
|
||||
if (isTilemapUrl) {
|
||||
setTestError("타일맵 URL은 테스트할 수 없습니다. 지도 위젯에서 직접 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setTestResult(null);
|
||||
|
|
@ -248,7 +277,36 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
throw new Error(apiResponse.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = apiResponse.data;
|
||||
let apiData = apiResponse.data;
|
||||
|
||||
// 텍스트 응답인 경우 파싱
|
||||
if (apiData && typeof apiData === "object" && "text" in apiData && typeof apiData.text === "string") {
|
||||
const textData = apiData.text;
|
||||
|
||||
// CSV 형식 파싱 (기상청 API)
|
||||
if (textData.includes("#START7777") || textData.includes(",")) {
|
||||
const lines = textData.split("\n").filter((line) => line.trim() && !line.startsWith("#"));
|
||||
const parsedRows = lines.map((line) => {
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
return {
|
||||
reg_up: values[0] || "",
|
||||
reg_up_ko: values[1] || "",
|
||||
reg_id: values[2] || "",
|
||||
reg_ko: values[3] || "",
|
||||
tm_fc: values[4] || "",
|
||||
tm_ef: values[5] || "",
|
||||
wrn: values[6] || "",
|
||||
lvl: values[7] || "",
|
||||
cmd: values[8] || "",
|
||||
ed_tm: values[9] || "",
|
||||
};
|
||||
});
|
||||
apiData = parsedRows;
|
||||
} else {
|
||||
// 일반 텍스트는 그대로 반환
|
||||
apiData = [{ text: textData }];
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Path 처리
|
||||
let data = apiData;
|
||||
|
|
@ -313,41 +371,47 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 외부 커넥션 선택 */}
|
||||
{apiConnections.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
{/* 외부 커넥션 선택 - 항상 표시 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]" position="popper" sideOffset={4}>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.length > 0 ? (
|
||||
apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
{conn.connection_name}
|
||||
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-connections" disabled className="text-xs text-gray-500">
|
||||
등록된 커넥션이 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,529 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource, KeyValuePair } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
|
||||
interface MultiApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (data: any) => void; // 테스트 결과 데이터 전달
|
||||
}
|
||||
|
||||
export default function MultiApiConfig({ dataSource, onChange, onTestResult }: MultiApiConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
||||
|
||||
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
||||
|
||||
// 외부 API 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
const loadApiConnections = async () => {
|
||||
const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" });
|
||||
setApiConnections(connections);
|
||||
};
|
||||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// 외부 커넥션 선택 핸들러
|
||||
const handleConnectionSelect = async (connectionId: string) => {
|
||||
setSelectedConnectionId(connectionId);
|
||||
|
||||
if (!connectionId || connectionId === "manual") {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId));
|
||||
if (!connection) {
|
||||
console.error("커넥션을 찾을 수 없습니다:", connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("불러온 커넥션:", connection);
|
||||
|
||||
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
||||
const fullEndpoint = connection.endpoint_path
|
||||
? `${connection.base_url}${connection.endpoint_path}`
|
||||
: connection.base_url;
|
||||
|
||||
console.log("전체 엔드포인트:", fullEndpoint);
|
||||
|
||||
const updates: Partial<ChartDataSource> = {
|
||||
endpoint: fullEndpoint,
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
// 기본 헤더가 있으면 적용
|
||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||
headers.push({
|
||||
id: `header_${Date.now()}_${Math.random()}`,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
console.log("기본 헤더 적용:", headers);
|
||||
}
|
||||
|
||||
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
||||
if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) {
|
||||
const authConfig = connection.auth_config;
|
||||
|
||||
switch (connection.auth_type) {
|
||||
case "api-key":
|
||||
if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) {
|
||||
headers.push({
|
||||
id: `auth_header_${Date.now()}`,
|
||||
key: authConfig.keyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 헤더 추가:", authConfig.keyName);
|
||||
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
||||
queryParams.push({
|
||||
id: `auth_query_${Date.now()}`,
|
||||
key: authConfig.keyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 쿼리 파라미터 추가:", authConfig.keyName);
|
||||
}
|
||||
break;
|
||||
|
||||
case "bearer":
|
||||
if (authConfig.token) {
|
||||
headers.push({
|
||||
id: `auth_bearer_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.token}`,
|
||||
});
|
||||
console.log("Bearer Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
case "basic":
|
||||
if (authConfig.username && authConfig.password) {
|
||||
const credentials = btoa(`${authConfig.username}:${authConfig.password}`);
|
||||
headers.push({
|
||||
id: `auth_basic_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Basic ${credentials}`,
|
||||
});
|
||||
console.log("Basic Auth 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
case "oauth2":
|
||||
if (authConfig.accessToken) {
|
||||
headers.push({
|
||||
id: `auth_oauth_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.accessToken}`,
|
||||
});
|
||||
console.log("OAuth2 Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더와 쿼리 파라미터 적용
|
||||
if (headers.length > 0) {
|
||||
updates.headers = headers;
|
||||
}
|
||||
if (queryParams.length > 0) {
|
||||
updates.queryParams = queryParams;
|
||||
}
|
||||
|
||||
console.log("최종 업데이트:", updates);
|
||||
onChange(updates);
|
||||
};
|
||||
|
||||
// 헤더 추가
|
||||
const handleAddHeader = () => {
|
||||
const headers = dataSource.headers || [];
|
||||
onChange({
|
||||
headers: [...headers, { id: Date.now().toString(), key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 헤더 삭제
|
||||
const handleDeleteHeader = (id: string) => {
|
||||
const headers = (dataSource.headers || []).filter((h) => h.id !== id);
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const handleUpdateHeader = (id: string, field: "key" | "value", value: string) => {
|
||||
const headers = (dataSource.headers || []).map((h) =>
|
||||
h.id === id ? { ...h, [field]: value } : h
|
||||
);
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const handleAddQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || [];
|
||||
onChange({
|
||||
queryParams: [...queryParams, { id: Date.now().toString(), key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 삭제
|
||||
const handleDeleteQueryParam = (id: string) => {
|
||||
const queryParams = (dataSource.queryParams || []).filter((q) => q.id !== id);
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const handleUpdateQueryParam = (id: string, field: "key" | "value", value: string) => {
|
||||
const queryParams = (dataSource.queryParams || []).map((q) =>
|
||||
q.id === id ? { ...q, [field]: value } : q
|
||||
);
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
const handleTestApi = async () => {
|
||||
if (!dataSource.endpoint) {
|
||||
setTestResult({ success: false, message: "API URL을 입력해주세요" });
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, string> = {};
|
||||
(dataSource.queryParams || []).forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParams[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
(dataSource.headers || []).forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: dataSource.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
|
||||
const parseTextData = (text: string): any[] => {
|
||||
try {
|
||||
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
const lines = text.split('\n').filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed &&
|
||||
!trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('=') &&
|
||||
!trimmed.startsWith('---');
|
||||
});
|
||||
|
||||
console.log(`📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
|
||||
|
||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||
if (values.length >= 4) {
|
||||
const obj: any = {
|
||||
code: values[0] || '', // 지역 코드 (예: L1070000)
|
||||
region: values[1] || '', // 지역명 (예: 경상북도)
|
||||
subCode: values[2] || '', // 하위 코드 (예: L1071600)
|
||||
subRegion: values[3] || '', // 하위 지역명 (예: 영주시)
|
||||
tmFc: values[4] || '', // 발표시각
|
||||
type: values[5] || '', // 특보종류 (강풍, 호우 등)
|
||||
level: values[6] || '', // 등급 (주의, 경보)
|
||||
status: values[7] || '', // 발표상태
|
||||
description: values.slice(8).join(', ').trim() || '',
|
||||
name: values[3] || values[1] || values[0], // 하위 지역명 우선
|
||||
};
|
||||
|
||||
result.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📊 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ 텍스트 파싱 오류:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// JSON Path로 데이터 추출
|
||||
let data = result.data;
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
||||
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
data = parsedData;
|
||||
}
|
||||
} else if (dataSource.jsonPath) {
|
||||
const pathParts = dataSource.jsonPath.split(".");
|
||||
for (const part of pathParts) {
|
||||
data = data?.[part];
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
||||
const hasLocationData = rows.some((row) => {
|
||||
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
|
||||
const hasCoordinates = row.coordinates && Array.isArray(row.coordinates);
|
||||
const hasRegionCode = row.code || row.areaCode || row.regionCode;
|
||||
return hasLatLng || hasCoordinates || hasRegionCode;
|
||||
});
|
||||
|
||||
if (hasLocationData) {
|
||||
const markerCount = rows.filter(r =>
|
||||
((r.lat || r.latitude) && (r.lng || r.longitude)) ||
|
||||
r.code || r.areaCode || r.regionCode
|
||||
).length;
|
||||
const polygonCount = rows.filter(r => r.coordinates && Array.isArray(r.coordinates)).length;
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견`
|
||||
});
|
||||
|
||||
// 부모에게 테스트 결과 전달 (지도 미리보기용)
|
||||
if (onTestResult) {
|
||||
onTestResult(rows);
|
||||
}
|
||||
} else {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: result.message || "API 호출 실패" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h5 className="text-sm font-semibold">REST API 설정</h5>
|
||||
|
||||
{/* 외부 연결 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`connection-${dataSource.id}`} className="text-xs">
|
||||
외부 연결 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedConnectionId}
|
||||
onValueChange={handleConnectionSelect}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="외부 연결 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id?.toString() || ""} className="text-xs">
|
||||
{conn.connection_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
외부 연결을 선택하면 API URL이 자동으로 입력됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API URL (직접 입력 또는 수정) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`endpoint-${dataSource.id}`} className="text-xs">
|
||||
API URL *
|
||||
</Label>
|
||||
<Input
|
||||
id={`endpoint-${dataSource.id}`}
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => {
|
||||
console.log("📝 API URL 변경:", e.target.value);
|
||||
onChange({ endpoint: e.target.value });
|
||||
}}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
외부 연결을 선택하거나 직접 입력할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
||||
JSON Path (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id={`jsonPath-\${dataSource.id}`}
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
placeholder="예: data.results"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
응답 JSON에서 데이터를 추출할 경로
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">쿼리 파라미터</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddQueryParam}
|
||||
className="h-6 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{(dataSource.queryParams || []).map((param) => (
|
||||
<div key={param.id} className="flex gap-2">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(e) => handleUpdateQueryParam(param.id, "key", e.target.value)}
|
||||
placeholder="키"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={param.value}
|
||||
onChange={(e) => handleUpdateQueryParam(param.id, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteQueryParam(param.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">헤더</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddHeader}
|
||||
className="h-6 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{(dataSource.headers || []).map((header) => (
|
||||
<div key={header.id} className="flex gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => handleUpdateHeader(header.id, "key", e.target.value)}
|
||||
placeholder="키"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => handleUpdateHeader(header.id, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteHeader(header.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestApi}
|
||||
disabled={testing || !dataSource.endpoint}
|
||||
className="h-8 w-full gap-2 text-xs"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
"API 테스트"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs \${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import MultiApiConfig from "./MultiApiConfig";
|
||||
import MultiDatabaseConfig from "./MultiDatabaseConfig";
|
||||
|
||||
interface MultiDataSourceConfigProps {
|
||||
dataSources: ChartDataSource[];
|
||||
onChange: (dataSources: ChartDataSource[]) => void;
|
||||
}
|
||||
|
||||
export default function MultiDataSourceConfig({
|
||||
dataSources = [],
|
||||
onChange,
|
||||
}: MultiDataSourceConfigProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
|
||||
);
|
||||
const [previewData, setPreviewData] = useState<any[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// 새 데이터 소스 추가
|
||||
const handleAddDataSource = () => {
|
||||
const newId = Date.now().toString();
|
||||
const newSource: ChartDataSource = {
|
||||
id: newId,
|
||||
name: `데이터 소스 ${dataSources.length + 1}`,
|
||||
type: "api",
|
||||
};
|
||||
|
||||
onChange([...dataSources, newSource]);
|
||||
setActiveTab(newId);
|
||||
};
|
||||
|
||||
// 데이터 소스 삭제
|
||||
const handleDeleteDataSource = (id: string) => {
|
||||
const filtered = dataSources.filter((ds) => ds.id !== id);
|
||||
onChange(filtered);
|
||||
|
||||
// 삭제 후 첫 번째 탭으로 이동
|
||||
if (filtered.length > 0) {
|
||||
setActiveTab(filtered[0].id || "0");
|
||||
} else {
|
||||
setActiveTab("new");
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleUpdateDataSource = (id: string, updates: Partial<ChartDataSource>) => {
|
||||
const updated = dataSources.map((ds) =>
|
||||
ds.id === id ? { ...ds, ...updates } : ds
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">데이터 소스 관리</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
여러 데이터 소스를 연결하여 데이터를 통합할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDataSource}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스가 없는 경우 */}
|
||||
{dataSources.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
연결된 데이터 소스가 없습니다
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleAddDataSource}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
첫 번째 데이터 소스 추가
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* 탭 UI */
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full justify-start overflow-x-auto">
|
||||
{dataSources.map((ds, index) => (
|
||||
<TabsTrigger
|
||||
key={ds.id}
|
||||
value={ds.id || index.toString()}
|
||||
className="text-xs"
|
||||
>
|
||||
{ds.name || `소스 ${index + 1}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{dataSources.map((ds, index) => (
|
||||
<TabsContent
|
||||
key={ds.id}
|
||||
value={ds.id || index.toString()}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* 데이터 소스 기본 정보 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
{/* 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`name-${ds.id}`} className="text-xs">
|
||||
데이터 소스 이름
|
||||
</Label>
|
||||
<Input
|
||||
id={`name-${ds.id}`}
|
||||
value={ds.name || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateDataSource(ds.id!, { name: e.target.value })
|
||||
}
|
||||
placeholder="예: 기상특보, 교통정보"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">데이터 소스 타입</Label>
|
||||
<RadioGroup
|
||||
value={ds.type}
|
||||
onValueChange={(value: "database" | "api") =>
|
||||
handleUpdateDataSource(ds.id!, { type: value })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="api" id={`api-${ds.id}`} />
|
||||
<Label
|
||||
htmlFor={`api-${ds.id}`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
REST API
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="database" id={`db-${ds.id}`} />
|
||||
<Label
|
||||
htmlFor={`db-${ds.id}`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
Database
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div className="border-t pt-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDataSource(ds.id!)}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
이 데이터 소스 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지도 표시 방식 선택 (지도 위젯만) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">지도 표시 방식</Label>
|
||||
<RadioGroup
|
||||
value={ds.mapDisplayType || "auto"}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateDataSource(ds.id!, { mapDisplayType: value as "auto" | "marker" | "polygon" })
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id={`auto-${ds.id}`} />
|
||||
<Label htmlFor={`auto-${ds.id}`} className="text-xs font-normal cursor-pointer">
|
||||
자동 (데이터 기반)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="marker" id={`marker-${ds.id}`} />
|
||||
<Label htmlFor={`marker-${ds.id}`} className="text-xs font-normal cursor-pointer">
|
||||
📍 마커
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="polygon" id={`polygon-${ds.id}`} />
|
||||
<Label htmlFor={`polygon-${ds.id}`} className="text-xs font-normal cursor-pointer">
|
||||
🔷 영역
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ds.mapDisplayType === "marker" && "모든 데이터를 마커로 표시합니다"}
|
||||
{ds.mapDisplayType === "polygon" && "모든 데이터를 영역(폴리곤)으로 표시합니다"}
|
||||
{(!ds.mapDisplayType || ds.mapDisplayType === "auto") && "데이터에 coordinates가 있으면 영역, 없으면 마커로 자동 표시"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타입별 설정 */}
|
||||
{ds.type === "api" ? (
|
||||
<MultiApiConfig
|
||||
dataSource={ds}
|
||||
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
||||
onTestResult={(data) => {
|
||||
setPreviewData(data);
|
||||
setShowPreview(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MultiDatabaseConfig
|
||||
dataSource={ds}
|
||||
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* 지도 미리보기 */}
|
||||
{showPreview && previewData.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold">
|
||||
데이터 미리보기 ({previewData.length}건)
|
||||
</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"적용" 버튼을 눌러 지도에 표시하세요
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] space-y-2 overflow-y-auto">
|
||||
{previewData.map((item, index) => {
|
||||
const hasLatLng = (item.lat || item.latitude) && (item.lng || item.longitude);
|
||||
const hasCoordinates = item.coordinates && Array.isArray(item.coordinates);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded border bg-background p-3 text-xs"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="font-medium">
|
||||
{item.name || item.title || item.area || item.region || `항목 ${index + 1}`}
|
||||
</div>
|
||||
{(item.status || item.level) && (
|
||||
<div className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
(item.status || item.level)?.includes('경보') || (item.status || item.level)?.includes('위험')
|
||||
? 'bg-red-100 text-red-700'
|
||||
: (item.status || item.level)?.includes('주의')
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{item.status || item.level}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasLatLng && (
|
||||
<div className="text-muted-foreground">
|
||||
📍 마커: ({item.lat || item.latitude}, {item.lng || item.longitude})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCoordinates && (
|
||||
<div className="text-muted-foreground">
|
||||
🔷 영역: {item.coordinates.length}개 좌표
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item.type || item.description) && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{item.type && `${item.type} `}
|
||||
{item.description && item.description !== item.type && `- ${item.description}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
|
||||
interface MultiDatabaseConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
}
|
||||
|
||||
interface ExternalConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatabaseConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
|
||||
// 외부 DB 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
if (dataSource.connectionType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [dataSource.connectionType]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
setLoadingConnections(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/reports/external-connections", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const connections = Array.isArray(result.data) ? result.data : result.data.data || [];
|
||||
setExternalConnections(connections);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("외부 DB 커넥션 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 쿼리 테스트
|
||||
const handleTestQuery = async () => {
|
||||
if (!dataSource.query) {
|
||||
setTestResult({ success: false, message: "SQL 쿼리를 입력해주세요" });
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
connectionType: dataSource.connectionType || "current",
|
||||
externalConnectionId: dataSource.externalConnectionId,
|
||||
query: dataSource.query,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const rowCount = Array.isArray(result.data) ? result.data.length : 0;
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: "쿼리 실행 성공",
|
||||
rowCount,
|
||||
});
|
||||
} else {
|
||||
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h5 className="text-sm font-semibold">Database 설정</h5>
|
||||
|
||||
{/* 커넥션 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">데이터베이스 연결</Label>
|
||||
<RadioGroup
|
||||
value={dataSource.connectionType || "current"}
|
||||
onValueChange={(value: "current" | "external") =>
|
||||
onChange({ connectionType: value })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="current" id={`current-\${dataSource.id}`} />
|
||||
<Label
|
||||
htmlFor={`current-\${dataSource.id}`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
현재 데이터베이스
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="external" id={`external-\${dataSource.id}`} />
|
||||
<Label
|
||||
htmlFor={`external-\${dataSource.id}`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
외부 데이터베이스
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 외부 DB 선택 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`external-conn-\${dataSource.id}`} className="text-xs">
|
||||
외부 데이터베이스 선택 *
|
||||
</Label>
|
||||
{loadingConnections ? (
|
||||
<div className="flex h-10 items-center justify-center rounded-md border">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={dataSource.externalConnectionId || ""}
|
||||
onValueChange={(value) => onChange({ externalConnectionId: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="외부 DB 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id} className="text-xs">
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SQL 쿼리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
||||
SQL 쿼리 *
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`query-\${dataSource.id}`}
|
||||
value={dataSource.query || ""}
|
||||
onChange={(e) => onChange({ query: e.target.value })}
|
||||
placeholder="SELECT * FROM table_name WHERE ..."
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
SELECT 쿼리만 허용됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestQuery}
|
||||
disabled={testing || !dataSource.query}
|
||||
className="h-8 w-full gap-2 text-xs"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
"쿼리 테스트"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs \${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
<div>
|
||||
{testResult.message}
|
||||
{testResult.rowCount !== undefined && (
|
||||
<span className="ml-1">({testResult.rowCount}행)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,9 @@ export type ElementSubtype =
|
|||
| "vehicle-list" // (구버전 - 호환용)
|
||||
| "vehicle-map" // (구버전 - 호환용)
|
||||
| "map-summary" // 범용 지도 카드 (통합)
|
||||
| "map-test" // 🧪 지도 테스트 위젯 (REST API 지원)
|
||||
| "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
|
||||
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
|
||||
| "delivery-status"
|
||||
| "status-summary" // 범용 상태 카드 (통합)
|
||||
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||
|
|
@ -97,7 +100,8 @@ export interface DashboardElement {
|
|||
customTitle?: string; // 사용자 정의 제목 (옵션)
|
||||
showHeader?: boolean; // 헤더 표시 여부 (기본값: true)
|
||||
content: string;
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정 (단일, 하위 호환용)
|
||||
dataSources?: ChartDataSource[]; // 다중 데이터 소스 설정 (테스트 위젯용)
|
||||
chartConfig?: ChartConfig; // 차트 설정
|
||||
clockConfig?: ClockConfig; // 시계 설정
|
||||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
|
|
@ -125,6 +129,8 @@ export interface KeyValuePair {
|
|||
}
|
||||
|
||||
export interface ChartDataSource {
|
||||
id?: string; // 고유 ID (다중 데이터 소스용)
|
||||
name?: string; // 사용자 지정 이름 (예: "기상특보", "교통정보")
|
||||
type: "database" | "api"; // 데이터 소스 타입
|
||||
|
||||
// DB 커넥션 관련
|
||||
|
|
@ -143,6 +149,7 @@ export interface ChartDataSource {
|
|||
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
lastError?: string; // 마지막 오류 메시지
|
||||
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
|
|
@ -199,6 +206,7 @@ export interface ChartConfig {
|
|||
stackMode?: "normal" | "percent"; // 누적 모드
|
||||
|
||||
// 지도 관련 설정
|
||||
tileMapUrl?: string; // 타일맵 URL (예: VWorld, OpenStreetMap)
|
||||
latitudeColumn?: string; // 위도 컬럼
|
||||
longitudeColumn?: string; // 경도 컬럼
|
||||
labelColumn?: string; // 라벨 컬럼
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import dynamic from "next/dynamic";
|
|||
|
||||
// 위젯 동적 import - 모든 위젯
|
||||
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
||||
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
||||
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
||||
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
||||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
||||
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
||||
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
||||
|
|
@ -76,6 +79,12 @@ function renderWidget(element: DashboardElement) {
|
|||
return <ClockWidget element={element} />;
|
||||
case "map-summary":
|
||||
return <MapSummaryWidget element={element} />;
|
||||
case "map-test":
|
||||
return <MapTestWidget element={element} />;
|
||||
case "map-test-v2":
|
||||
return <MapTestWidgetV2 element={element} />;
|
||||
case "chart-test":
|
||||
return <ChartTestWidget element={element} />;
|
||||
case "risk-alert":
|
||||
return <RiskAlertWidget element={element} />;
|
||||
case "calendar":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
interface ChartTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
|
||||
|
||||
export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
console.log("🧪 ChartTestWidget 렌더링!", element);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
const dataSources = element?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 \${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 모든 데이터 소스를 병렬로 로딩
|
||||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 "\${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
if (source.type === "api") {
|
||||
return await loadRestApiData(source);
|
||||
} else if (source.type === "database") {
|
||||
return await loadDatabaseData(source);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (err: any) {
|
||||
console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err);
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 성공한 데이터만 병합
|
||||
const allData: any[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled" && Array.isArray(result.value)) {
|
||||
const sourceData = result.value.map((item: any) => ({
|
||||
...item,
|
||||
_source: dataSources[index].name || dataSources[index].id || `소스 \${index + 1}`,
|
||||
}));
|
||||
allData.push(...sourceData);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`);
|
||||
setData(allData);
|
||||
} catch (err: any) {
|
||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [element?.dataSources]);
|
||||
|
||||
// REST API 데이터 로딩
|
||||
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (source.queryParams) {
|
||||
source.queryParams.forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParams[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (source.headers) {
|
||||
source.headers.forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: source.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 호출 실패: \${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "API 호출 실패");
|
||||
}
|
||||
|
||||
let apiData = result.data;
|
||||
if (source.jsonPath) {
|
||||
const pathParts = source.jsonPath.split(".");
|
||||
for (const part of pathParts) {
|
||||
apiData = apiData?.[part];
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(apiData) ? apiData : [apiData];
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (source: ChartDataSource): Promise<any[]> => {
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/dashboards/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
connectionType: source.connectionType || "current",
|
||||
externalConnectionId: source.externalConnectionId,
|
||||
query: source.query,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`데이터베이스 쿼리 실패: \${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "쿼리 실패");
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (element?.dataSources && element.dataSources.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
}
|
||||
}, [element?.dataSources, loadMultipleDataSources]);
|
||||
|
||||
const chartType = element?.subtype || "line";
|
||||
const chartConfig = element?.chartConfig || {};
|
||||
|
||||
const renderChart = () => {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">데이터가 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const xAxis = chartConfig.xAxis || Object.keys(data[0])[0];
|
||||
const yAxis = chartConfig.yAxis || Object.keys(data[0])[1];
|
||||
|
||||
switch (chartType) {
|
||||
case "line":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={xAxis} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={yAxis} stroke="#3b82f6" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "bar":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={xAxis} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={yAxis} fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "pie":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={yAxis}
|
||||
nameKey={xAxis}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-\${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
지원하지 않는 차트 타입: {chartType}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
||||
</p>
|
||||
</div>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : !element?.dataSources || element.dataSources.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 연결해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderChart()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="border-t p-2 text-xs text-muted-foreground">
|
||||
총 {data.length}개 데이터 표시 중
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,863 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
||||
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
||||
interface MapTestWidgetV2Props {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface MarkerData {
|
||||
id?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
name: string;
|
||||
status?: string;
|
||||
description?: string;
|
||||
source?: string; // 어느 데이터 소스에서 왔는지
|
||||
}
|
||||
|
||||
interface PolygonData {
|
||||
id?: string;
|
||||
name: string;
|
||||
coordinates: [number, number][] | [number, number][][]; // 단일 폴리곤 또는 멀티 폴리곤
|
||||
status?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const [polygons, setPolygons] = useState<PolygonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||
|
||||
console.log("🧪 MapTestWidgetV2 렌더링!", element);
|
||||
console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 모든 데이터 소스를 병렬로 로딩
|
||||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
if (source.type === "api") {
|
||||
return await loadRestApiData(source);
|
||||
} else if (source.type === "database") {
|
||||
return await loadDatabaseData(source);
|
||||
}
|
||||
|
||||
return { markers: [], polygons: [] };
|
||||
} catch (err: any) {
|
||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
||||
return { markers: [], polygons: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 성공한 데이터만 병합
|
||||
const allMarkers: MarkerData[] = [];
|
||||
const allPolygons: PolygonData[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
console.log(`🔍 결과 ${index}:`, result);
|
||||
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] };
|
||||
console.log(`✅ 데이터 소스 ${index} 성공:`, value);
|
||||
|
||||
// 마커 병합
|
||||
if (value.markers && Array.isArray(value.markers)) {
|
||||
console.log(` → 마커 ${value.markers.length}개 추가`);
|
||||
allMarkers.push(...value.markers);
|
||||
}
|
||||
|
||||
// 폴리곤 병합
|
||||
if (value.polygons && Array.isArray(value.polygons)) {
|
||||
console.log(` → 폴리곤 ${value.polygons.length}개 추가`);
|
||||
allPolygons.push(...value.polygons);
|
||||
}
|
||||
} else if (result.status === "rejected") {
|
||||
console.error(`❌ 데이터 소스 ${index} 실패:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`);
|
||||
console.log("📍 최종 마커 데이터:", allMarkers);
|
||||
console.log("🔷 최종 폴리곤 데이터:", allPolygons);
|
||||
|
||||
setMarkers(allMarkers);
|
||||
setPolygons(allPolygons);
|
||||
} catch (err: any) {
|
||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [element?.dataSources]);
|
||||
|
||||
// REST API 데이터 로딩
|
||||
const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 구성
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (source.queryParams) {
|
||||
source.queryParams.forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParams[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 헤더 구성
|
||||
const headers: Record<string, string> = {};
|
||||
if (source.headers) {
|
||||
source.headers.forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 백엔드 프록시를 통해 API 호출
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: source.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 호출 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "API 호출 실패");
|
||||
}
|
||||
|
||||
// 데이터 추출 및 파싱
|
||||
let data = result.data;
|
||||
|
||||
// 텍스트 형식 데이터 체크 (기상청 API 등)
|
||||
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
||||
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
return convertToMapData(parsedData, source.name || source.id || "API");
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Path로 데이터 추출
|
||||
if (source.jsonPath) {
|
||||
const pathParts = source.jsonPath.split(".");
|
||||
for (const part of pathParts) {
|
||||
data = data?.[part];
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
|
||||
return convertToMapData(rows, source.name || source.id || "API", source.mapDisplayType);
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/dashboards/query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
connectionType: source.connectionType || "current",
|
||||
externalConnectionId: source.externalConnectionId,
|
||||
query: source.query,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`데이터베이스 쿼리 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "쿼리 실패");
|
||||
}
|
||||
|
||||
const rows = result.data || [];
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
|
||||
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
|
||||
};
|
||||
|
||||
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
||||
const parseTextData = (text: string): any[] => {
|
||||
try {
|
||||
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
const lines = text.split('\n').filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed &&
|
||||
!trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('=') &&
|
||||
!trimmed.startsWith('---');
|
||||
});
|
||||
|
||||
console.log(` 📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
// CSV 형식으로 파싱
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
|
||||
|
||||
console.log(` 라인 ${i}:`, values);
|
||||
|
||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||
if (values.length >= 4) {
|
||||
const obj: any = {
|
||||
code: values[0] || '', // 지역 코드 (예: L1070000)
|
||||
region: values[1] || '', // 지역명 (예: 경상북도)
|
||||
subCode: values[2] || '', // 하위 코드 (예: L1071600)
|
||||
subRegion: values[3] || '', // 하위 지역명 (예: 영주시)
|
||||
tmFc: values[4] || '', // 발표시각
|
||||
type: values[5] || '', // 특보종류 (강풍, 호우 등)
|
||||
level: values[6] || '', // 등급 (주의, 경보)
|
||||
status: values[7] || '', // 발표상태
|
||||
description: values.slice(8).join(', ').trim() || '',
|
||||
};
|
||||
|
||||
// 지역 이름 설정 (하위 지역명 우선, 없으면 상위 지역명)
|
||||
obj.name = obj.subRegion || obj.region || obj.code;
|
||||
|
||||
result.push(obj);
|
||||
console.log(` ✅ 파싱 성공:`, obj);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" 📊 최종 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(" ❌ 텍스트 파싱 오류:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터를 마커와 폴리곤으로 변환
|
||||
const convertToMapData = (rows: any[], sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon"): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||||
console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행, 표시 방식:", mapDisplayType || "auto");
|
||||
|
||||
if (rows.length === 0) return { markers: [], polygons: [] };
|
||||
|
||||
const markers: MarkerData[] = [];
|
||||
const polygons: PolygonData[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
console.log(` 행 ${index}:`, row);
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (row && typeof row === 'object' && row.text && typeof row.text === 'string') {
|
||||
console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(row.text);
|
||||
console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
||||
|
||||
// 파싱된 데이터를 재귀적으로 변환
|
||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType);
|
||||
markers.push(...result.markers);
|
||||
polygons.push(...result.polygons);
|
||||
return; // 이 행은 처리 완료
|
||||
}
|
||||
|
||||
// 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드)
|
||||
if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) {
|
||||
console.log(` → coordinates 발견:`, row.coordinates.length, "개");
|
||||
// coordinates가 [lat, lng] 배열의 배열인지 확인
|
||||
const firstCoord = row.coordinates[0];
|
||||
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
||||
console.log(` → 폴리곤으로 처리:`, row.name);
|
||||
polygons.push({
|
||||
id: row.id || row.code || `polygon-${index}`,
|
||||
name: row.name || row.title || `영역 ${index + 1}`,
|
||||
coordinates: row.coordinates as [number, number][],
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
});
|
||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
// 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만)
|
||||
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
|
||||
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
|
||||
console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
name: regionName,
|
||||
coordinates: MARITIME_ZONES[regionName] as [number, number][],
|
||||
status: row.status || row.level,
|
||||
description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
});
|
||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||
}
|
||||
|
||||
// 마커 데이터 처리 (위도/경도가 있는 경우)
|
||||
let lat = row.lat || row.latitude || row.y;
|
||||
let lng = row.lng || row.longitude || row.x;
|
||||
|
||||
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
|
||||
if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) {
|
||||
const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId;
|
||||
console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`);
|
||||
const coords = getCoordinatesByRegionCode(regionCode);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 지역명으로도 시도
|
||||
if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) {
|
||||
const regionName = row.name || row.area || row.region || row.location;
|
||||
console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`);
|
||||
const coords = getCoordinatesByRegionName(regionName);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
console.log(` → 마커로 처리: (${lat}, ${lng})`);
|
||||
markers.push({
|
||||
id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
latitude: Number(lat),
|
||||
longitude: Number(lng),
|
||||
name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`,
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
});
|
||||
} else {
|
||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||
if (regionName) {
|
||||
console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
||||
name: regionName,
|
||||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
||||
console.log(` 데이터:`, row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`);
|
||||
return { markers, polygons };
|
||||
};
|
||||
|
||||
// 상태에 따른 색상 반환
|
||||
const getColorByStatus = (status?: string): string => {
|
||||
if (!status) return "#3b82f6"; // 기본 파란색
|
||||
|
||||
const statusLower = status.toLowerCase();
|
||||
if (statusLower.includes("경보") || statusLower.includes("위험")) return "#ef4444"; // 빨강
|
||||
if (statusLower.includes("주의")) return "#f59e0b"; // 주황
|
||||
if (statusLower.includes("정상")) return "#10b981"; // 초록
|
||||
|
||||
return "#3b82f6"; // 기본 파란색
|
||||
};
|
||||
|
||||
// 지역 코드를 위도/경도로 변환
|
||||
const getCoordinatesByRegionCode = (code: string): { lat: number; lng: number } | null => {
|
||||
// 기상청 지역 코드 매핑 (예시)
|
||||
const regionCodeMap: Record<string, { lat: number; lng: number }> = {
|
||||
// 서울/경기
|
||||
"11": { lat: 37.5665, lng: 126.9780 }, // 서울
|
||||
"41": { lat: 37.4138, lng: 127.5183 }, // 경기
|
||||
|
||||
// 강원
|
||||
"42": { lat: 37.8228, lng: 128.1555 }, // 강원
|
||||
|
||||
// 충청
|
||||
"43": { lat: 36.6357, lng: 127.4913 }, // 충북
|
||||
"44": { lat: 36.5184, lng: 126.8000 }, // 충남
|
||||
|
||||
// 전라
|
||||
"45": { lat: 35.7175, lng: 127.1530 }, // 전북
|
||||
"46": { lat: 34.8679, lng: 126.9910 }, // 전남
|
||||
|
||||
// 경상
|
||||
"47": { lat: 36.4919, lng: 128.8889 }, // 경북
|
||||
"48": { lat: 35.4606, lng: 128.2132 }, // 경남
|
||||
|
||||
// 제주
|
||||
"50": { lat: 33.4996, lng: 126.5312 }, // 제주
|
||||
|
||||
// 광역시
|
||||
"26": { lat: 35.1796, lng: 129.0756 }, // 부산
|
||||
"27": { lat: 35.8714, lng: 128.6014 }, // 대구
|
||||
"28": { lat: 35.1595, lng: 126.8526 }, // 광주
|
||||
"29": { lat: 36.3504, lng: 127.3845 }, // 대전
|
||||
"30": { lat: 35.5384, lng: 129.3114 }, // 울산
|
||||
"31": { lat: 36.8000, lng: 127.7000 }, // 세종
|
||||
};
|
||||
|
||||
return regionCodeMap[code] || null;
|
||||
};
|
||||
|
||||
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준)
|
||||
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
|
||||
// 제주도 해역
|
||||
제주도남부앞바다: [[33.25, 126.0], [33.25, 126.85], [33.0, 126.85], [33.0, 126.0]],
|
||||
제주도남쪽바깥먼바다: [[33.15, 125.7], [33.15, 127.3], [32.5, 127.3], [32.5, 125.7]],
|
||||
제주도동부앞바다: [[33.4, 126.7], [33.4, 127.25], [33.05, 127.25], [33.05, 126.7]],
|
||||
제주도남동쪽안쪽먼바다: [[33.3, 126.85], [33.3, 127.95], [32.65, 127.95], [32.65, 126.85]],
|
||||
제주도남서쪽안쪽먼바다: [[33.3, 125.35], [33.3, 126.45], [32.7, 126.45], [32.7, 125.35]],
|
||||
// 남해 해역
|
||||
남해동부앞바다: [[34.65, 128.3], [34.65, 129.65], [33.95, 129.65], [33.95, 128.3]],
|
||||
남해동부안쪽먼바다: [[34.25, 127.95], [34.25, 129.75], [33.45, 129.75], [33.45, 127.95]],
|
||||
남해동부바깥먼바다: [[33.65, 127.95], [33.65, 130.35], [32.45, 130.35], [32.45, 127.95]],
|
||||
// 동해 해역
|
||||
경북북부앞바다: [[36.65, 129.2], [36.65, 130.1], [35.95, 130.1], [35.95, 129.2]],
|
||||
경북남부앞바다: [[36.15, 129.1], [36.15, 129.95], [35.45, 129.95], [35.45, 129.1]],
|
||||
동해남부남쪽안쪽먼바다: [[35.65, 129.35], [35.65, 130.65], [34.95, 130.65], [34.95, 129.35]],
|
||||
동해남부남쪽바깥먼바다: [[35.25, 129.45], [35.25, 131.15], [34.15, 131.15], [34.15, 129.45]],
|
||||
동해남부북쪽안쪽먼바다: [[36.6, 129.65], [36.6, 130.95], [35.85, 130.95], [35.85, 129.65]],
|
||||
동해남부북쪽바깥먼바다: [[36.65, 130.35], [36.65, 132.15], [35.85, 132.15], [35.85, 130.35]],
|
||||
// 강원 해역
|
||||
강원북부앞바다: [[38.15, 128.4], [38.15, 129.55], [37.45, 129.55], [37.45, 128.4]],
|
||||
강원중부앞바다: [[37.65, 128.7], [37.65, 129.6], [36.95, 129.6], [36.95, 128.7]],
|
||||
강원남부앞바다: [[37.15, 128.9], [37.15, 129.85], [36.45, 129.85], [36.45, 128.9]],
|
||||
동해중부안쪽먼바다: [[38.55, 129.35], [38.55, 131.15], [37.25, 131.15], [37.25, 129.35]],
|
||||
동해중부바깥먼바다: [[38.6, 130.35], [38.6, 132.55], [37.65, 132.55], [37.65, 130.35]],
|
||||
// 울릉도·독도
|
||||
"울릉도.독도": [[37.7, 130.7], [37.7, 132.0], [37.4, 132.0], [37.4, 130.7]],
|
||||
};
|
||||
|
||||
// 지역명을 위도/경도로 변환
|
||||
const getCoordinatesByRegionName = (name: string): { lat: number; lng: number } | null => {
|
||||
// 먼저 해상 구역인지 확인
|
||||
if (MARITIME_ZONES[name]) {
|
||||
// 폴리곤의 중심점 계산
|
||||
const coords = MARITIME_ZONES[name];
|
||||
const centerLat = coords.reduce((sum, c) => sum + c[0], 0) / coords.length;
|
||||
const centerLng = coords.reduce((sum, c) => sum + c[1], 0) / coords.length;
|
||||
return { lat: centerLat, lng: centerLng };
|
||||
}
|
||||
|
||||
const regionNameMap: Record<string, { lat: number; lng: number }> = {
|
||||
// 서울/경기
|
||||
"서울": { lat: 37.5665, lng: 126.9780 },
|
||||
"서울특별시": { lat: 37.5665, lng: 126.9780 },
|
||||
"경기": { lat: 37.4138, lng: 127.5183 },
|
||||
"경기도": { lat: 37.4138, lng: 127.5183 },
|
||||
"인천": { lat: 37.4563, lng: 126.7052 },
|
||||
"인천광역시": { lat: 37.4563, lng: 126.7052 },
|
||||
|
||||
// 강원
|
||||
"강원": { lat: 37.8228, lng: 128.1555 },
|
||||
"강원도": { lat: 37.8228, lng: 128.1555 },
|
||||
"강원특별자치도": { lat: 37.8228, lng: 128.1555 },
|
||||
|
||||
// 충청
|
||||
"충북": { lat: 36.6357, lng: 127.4913 },
|
||||
"충청북도": { lat: 36.6357, lng: 127.4913 },
|
||||
"충남": { lat: 36.5184, lng: 126.8000 },
|
||||
"충청남도": { lat: 36.5184, lng: 126.8000 },
|
||||
"대전": { lat: 36.3504, lng: 127.3845 },
|
||||
"대전광역시": { lat: 36.3504, lng: 127.3845 },
|
||||
"세종": { lat: 36.8000, lng: 127.7000 },
|
||||
"세종특별자치시": { lat: 36.8000, lng: 127.7000 },
|
||||
|
||||
// 전라
|
||||
"전북": { lat: 35.7175, lng: 127.1530 },
|
||||
"전북특별자치도": { lat: 35.7175, lng: 127.1530 },
|
||||
"전라북도": { lat: 35.7175, lng: 127.1530 },
|
||||
"전남": { lat: 34.8679, lng: 126.9910 },
|
||||
"전라남도": { lat: 34.8679, lng: 126.9910 },
|
||||
"광주": { lat: 35.1595, lng: 126.8526 },
|
||||
"광주광역시": { lat: 35.1595, lng: 126.8526 },
|
||||
|
||||
// 경상
|
||||
"경북": { lat: 36.4919, lng: 128.8889 },
|
||||
"경상북도": { lat: 36.4919, lng: 128.8889 },
|
||||
"포항": { lat: 36.0190, lng: 129.3435 },
|
||||
"포항시": { lat: 36.0190, lng: 129.3435 },
|
||||
"경주": { lat: 35.8562, lng: 129.2247 },
|
||||
"경주시": { lat: 35.8562, lng: 129.2247 },
|
||||
"안동": { lat: 36.5684, lng: 128.7294 },
|
||||
"안동시": { lat: 36.5684, lng: 128.7294 },
|
||||
"영주": { lat: 36.8056, lng: 128.6239 },
|
||||
"영주시": { lat: 36.8056, lng: 128.6239 },
|
||||
"경남": { lat: 35.4606, lng: 128.2132 },
|
||||
"경상남도": { lat: 35.4606, lng: 128.2132 },
|
||||
"창원": { lat: 35.2280, lng: 128.6811 },
|
||||
"창원시": { lat: 35.2280, lng: 128.6811 },
|
||||
"진주": { lat: 35.1800, lng: 128.1076 },
|
||||
"진주시": { lat: 35.1800, lng: 128.1076 },
|
||||
"부산": { lat: 35.1796, lng: 129.0756 },
|
||||
"부산광역시": { lat: 35.1796, lng: 129.0756 },
|
||||
"대구": { lat: 35.8714, lng: 128.6014 },
|
||||
"대구광역시": { lat: 35.8714, lng: 128.6014 },
|
||||
"울산": { lat: 35.5384, lng: 129.3114 },
|
||||
"울산광역시": { lat: 35.5384, lng: 129.3114 },
|
||||
|
||||
// 제주
|
||||
"제주": { lat: 33.4996, lng: 126.5312 },
|
||||
"제주도": { lat: 33.4996, lng: 126.5312 },
|
||||
"제주특별자치도": { lat: 33.4996, lng: 126.5312 },
|
||||
|
||||
// 울릉도/독도
|
||||
"울릉도": { lat: 37.4845, lng: 130.9057 },
|
||||
"울릉도.독도": { lat: 37.4845, lng: 130.9057 },
|
||||
"독도": { lat: 37.2433, lng: 131.8642 },
|
||||
};
|
||||
|
||||
// 정확한 매칭
|
||||
if (regionNameMap[name]) {
|
||||
return regionNameMap[name];
|
||||
}
|
||||
|
||||
// 부분 매칭 (예: "서울시 강남구" → "서울")
|
||||
for (const [key, value] of Object.entries(regionNameMap)) {
|
||||
if (name.includes(key)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 데이터를 마커로 변환 (하위 호환성)
|
||||
const convertToMarkers = (rows: any[]): MarkerData[] => {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// 위도/경도 컬럼 찾기
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
const latColumn = columns.find((col) =>
|
||||
/^(lat|latitude|위도|y)$/i.test(col)
|
||||
);
|
||||
const lngColumn = columns.find((col) =>
|
||||
/^(lng|lon|longitude|경도|x)$/i.test(col)
|
||||
);
|
||||
const nameColumn = columns.find((col) =>
|
||||
/^(name|title|이름|명칭|location)$/i.test(col)
|
||||
);
|
||||
|
||||
if (!latColumn || !lngColumn) {
|
||||
console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다.");
|
||||
return [];
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row, index) => {
|
||||
const lat = parseFloat(row[latColumn]);
|
||||
const lng = parseFloat(row[lngColumn]);
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id || `marker-${index}`,
|
||||
lat,
|
||||
lng,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
name: row[nameColumn || "name"] || `위치 ${index + 1}`,
|
||||
status: row.status,
|
||||
description: JSON.stringify(row, null, 2),
|
||||
};
|
||||
})
|
||||
.filter((marker): marker is MarkerData => marker !== null);
|
||||
};
|
||||
|
||||
// GeoJSON 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadGeoJsonData = async () => {
|
||||
try {
|
||||
const response = await fetch("/geojson/korea-municipalities.json");
|
||||
const data = await response.json();
|
||||
console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구");
|
||||
setGeoJsonData(data);
|
||||
} catch (err) {
|
||||
console.error("❌ GeoJSON 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
loadGeoJsonData();
|
||||
}, []);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
console.log("🔄 useEffect 트리거! dataSources:", dataSources);
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
} else {
|
||||
console.log("⚠️ dataSources가 없거나 비어있음");
|
||||
setMarkers([]);
|
||||
setPolygons([]);
|
||||
}
|
||||
}, [JSON.stringify(element?.dataSources || element?.chartConfig?.dataSources), loadMultipleDataSources]);
|
||||
|
||||
// 타일맵 URL (chartConfig에서 가져오기)
|
||||
const tileMapUrl = element?.chartConfig?.tileMapUrl ||
|
||||
`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||||
|
||||
// 지도 중심점 계산
|
||||
const center: [number, number] = markers.length > 0
|
||||
? [
|
||||
markers.reduce((sum, m) => sum + m.lat, 0) / markers.length,
|
||||
markers.reduce((sum, m) => sum + m.lng, 0) / markers.length,
|
||||
]
|
||||
: [37.5665, 126.978]; // 기본: 서울
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "지도 테스트 V2 (다중 데이터 소스)"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
||||
</p>
|
||||
</div>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
|
||||
{/* 지도 */}
|
||||
<div className="relative flex-1">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : !element?.dataSources || element.dataSources.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 연결해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={13}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
className="z-0"
|
||||
>
|
||||
<TileLayer
|
||||
url={tileMapUrl}
|
||||
attribution='© VWorld'
|
||||
maxZoom={19}
|
||||
/>
|
||||
|
||||
{/* 폴리곤 렌더링 */}
|
||||
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
||||
{geoJsonData && polygons.length > 0 && (
|
||||
<GeoJSON
|
||||
data={geoJsonData}
|
||||
style={(feature: any) => {
|
||||
const regionName = feature?.properties?.CTP_KOR_NM || feature?.properties?.SIG_KOR_NM;
|
||||
const matchingPolygon = polygons.find(p =>
|
||||
p.name === regionName ||
|
||||
p.name?.includes(regionName) ||
|
||||
regionName?.includes(p.name)
|
||||
);
|
||||
|
||||
if (matchingPolygon) {
|
||||
return {
|
||||
fillColor: matchingPolygon.color || "#3b82f6",
|
||||
fillOpacity: 0.3,
|
||||
color: matchingPolygon.color || "#3b82f6",
|
||||
weight: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fillOpacity: 0,
|
||||
opacity: 0,
|
||||
};
|
||||
}}
|
||||
onEachFeature={(feature: any, layer: any) => {
|
||||
const regionName = feature?.properties?.CTP_KOR_NM || feature?.properties?.SIG_KOR_NM;
|
||||
const matchingPolygon = polygons.find(p =>
|
||||
p.name === regionName ||
|
||||
p.name?.includes(regionName) ||
|
||||
regionName?.includes(p.name)
|
||||
);
|
||||
|
||||
if (matchingPolygon) {
|
||||
layer.bindPopup(`
|
||||
<div class="min-w-[200px]">
|
||||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ''}
|
||||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ''}
|
||||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 폴리곤 렌더링 (해상 구역만) */}
|
||||
{polygons.filter(p => MARITIME_ZONES[p.name]).map((polygon) => (
|
||||
<Polygon
|
||||
key={polygon.id}
|
||||
positions={polygon.coordinates}
|
||||
pathOptions={{
|
||||
color: polygon.color || "#3b82f6",
|
||||
fillColor: polygon.color || "#3b82f6",
|
||||
fillOpacity: 0.3,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||||
{polygon.source && (
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
출처: {polygon.source}
|
||||
</div>
|
||||
)}
|
||||
{polygon.status && (
|
||||
<div className="mb-1 text-xs">
|
||||
상태: {polygon.status}
|
||||
</div>
|
||||
)}
|
||||
{polygon.description && (
|
||||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
position={[marker.lat, marker.lng]}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="mb-2 font-semibold">{marker.name}</div>
|
||||
{marker.source && (
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
출처: {marker.source}
|
||||
</div>
|
||||
)}
|
||||
{marker.status && (
|
||||
<div className="mb-1 text-xs">
|
||||
상태: {marker.status}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
{(markers.length > 0 || polygons.length > 0) && (
|
||||
<div className="border-t p-2 text-xs text-muted-foreground">
|
||||
{markers.length > 0 && `마커 ${markers.length}개`}
|
||||
{markers.length > 0 && polygons.length > 0 && " · "}
|
||||
{polygons.length > 0 && `영역 ${polygons.length}개`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ export interface ExternalApiConnection {
|
|||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ExternalRestApiConnection {
|
|||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ export interface WeatherAlert {
|
|||
location: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
polygon?: { lat: number; lng: number }[]; // 폴리곤 경계 좌표
|
||||
center?: { lat: number; lng: number }; // 중심점 좌표
|
||||
}
|
||||
|
||||
export async function getWeatherAlerts(): Promise<WeatherAlert[]> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue