897 lines
33 KiB
TypeScript
897 lines
33 KiB
TypeScript
"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>("");
|
|
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
|
|
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
|
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
|
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
|
|
|
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) {
|
|
// UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환
|
|
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
|
|
queryParams.push({
|
|
id: `auth_query_${Date.now()}`,
|
|
key: actualKeyName,
|
|
value: authConfig.keyValue,
|
|
});
|
|
console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", 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];
|
|
|
|
// 컬럼 목록 및 타입 추출
|
|
if (rows.length > 0) {
|
|
const columns = Object.keys(rows[0]);
|
|
setAvailableColumns(columns);
|
|
|
|
// 컬럼 타입 분석 (첫 번째 행 기준)
|
|
const types: Record<string, string> = {};
|
|
columns.forEach(col => {
|
|
const value = rows[0][col];
|
|
if (value === null || value === undefined) {
|
|
types[col] = "unknown";
|
|
} else if (typeof value === "number") {
|
|
types[col] = "number";
|
|
} else if (typeof value === "boolean") {
|
|
types[col] = "boolean";
|
|
} else if (typeof value === "string") {
|
|
// 날짜 형식 체크
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
types[col] = "date";
|
|
} else {
|
|
types[col] = "string";
|
|
}
|
|
} else {
|
|
types[col] = "object";
|
|
}
|
|
});
|
|
setColumnTypes(types);
|
|
|
|
// 샘플 데이터 저장 (최대 3개)
|
|
setSampleData(rows.slice(0, 3));
|
|
|
|
console.log("📊 발견된 컬럼:", columns);
|
|
console.log("📊 컬럼 타입:", types);
|
|
}
|
|
|
|
// 위도/경도 또는 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">
|
|
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
|
자동 새로고침 간격
|
|
</Label>
|
|
<Select
|
|
value={String(dataSource.refreshInterval || 0)}
|
|
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="새로고침 안 함" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">새로고침 안 함</SelectItem>
|
|
<SelectItem value="10">10초마다</SelectItem>
|
|
<SelectItem value="30">30초마다</SelectItem>
|
|
<SelectItem value="60">1분마다</SelectItem>
|
|
<SelectItem value="300">5분마다</SelectItem>
|
|
<SelectItem value="600">10분마다</SelectItem>
|
|
<SelectItem value="1800">30분마다</SelectItem>
|
|
<SelectItem value="3600">1시간마다</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
설정한 간격마다 자동으로 데이터를 다시 불러옵니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
|
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
|
<h5 className="text-xs font-semibold">🎨 지도 색상 선택</h5>
|
|
|
|
{/* 색상 팔레트 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">색상</Label>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{[
|
|
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
|
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
|
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
|
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
|
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
|
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
|
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
|
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
|
].map((color) => {
|
|
const isSelected = dataSource.markerColor === color.marker;
|
|
return (
|
|
<button
|
|
key={color.name}
|
|
type="button"
|
|
onClick={() => onChange({
|
|
markerColor: color.marker,
|
|
polygonColor: color.polygon,
|
|
polygonOpacity: 0.5,
|
|
})}
|
|
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
|
|
isSelected
|
|
? "border-primary bg-primary/10 shadow-md"
|
|
: "border-border bg-background hover:border-primary/50"
|
|
}`}
|
|
>
|
|
<div
|
|
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
|
|
style={{ backgroundColor: color.marker }}
|
|
/>
|
|
<span className="text-[10px] font-medium">{color.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
선택한 색상이 마커와 폴리곤에 모두 적용됩니다
|
|
</p>
|
|
</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>
|
|
|
|
{/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */}
|
|
{availableColumns.length > 0 && (
|
|
<div className="space-y-3 border-t pt-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-sm font-semibold">메트릭 컬럼 선택</Label>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
|
? `${dataSource.selectedColumns.length}개 컬럼 선택됨`
|
|
: "모든 컬럼 표시"}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onChange({ selectedColumns: availableColumns })}
|
|
className="h-7 text-xs"
|
|
>
|
|
전체
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onChange({ selectedColumns: [] })}
|
|
className="h-7 text-xs"
|
|
>
|
|
해제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
{availableColumns.length > 5 && (
|
|
<Input
|
|
placeholder="컬럼 검색..."
|
|
value={columnSearchTerm}
|
|
onChange={(e) => setColumnSearchTerm(e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
)}
|
|
|
|
{/* 컬럼 카드 그리드 */}
|
|
<div className="grid grid-cols-1 gap-2 max-h-80 overflow-y-auto">
|
|
{availableColumns
|
|
.filter(col =>
|
|
!columnSearchTerm ||
|
|
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
|
)
|
|
.map((col) => {
|
|
const isSelected =
|
|
!dataSource.selectedColumns ||
|
|
dataSource.selectedColumns.length === 0 ||
|
|
dataSource.selectedColumns.includes(col);
|
|
|
|
const type = columnTypes[col] || "unknown";
|
|
const typeIcon = {
|
|
number: "🔢",
|
|
string: "📝",
|
|
date: "📅",
|
|
boolean: "✓",
|
|
object: "📦",
|
|
unknown: "❓"
|
|
}[type];
|
|
|
|
const typeColor = {
|
|
number: "text-blue-600 bg-blue-50",
|
|
string: "text-gray-600 bg-gray-50",
|
|
date: "text-purple-600 bg-purple-50",
|
|
boolean: "text-green-600 bg-green-50",
|
|
object: "text-orange-600 bg-orange-50",
|
|
unknown: "text-gray-400 bg-gray-50"
|
|
}[type];
|
|
|
|
return (
|
|
<div
|
|
key={col}
|
|
onClick={() => {
|
|
const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
|
? dataSource.selectedColumns
|
|
: availableColumns;
|
|
|
|
const newSelected = isSelected
|
|
? currentSelected.filter(c => c !== col)
|
|
: [...currentSelected, col];
|
|
|
|
onChange({ selectedColumns: newSelected });
|
|
}}
|
|
className={`
|
|
relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all
|
|
${isSelected
|
|
? "border-primary bg-primary/5 shadow-sm"
|
|
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
|
}
|
|
`}
|
|
>
|
|
{/* 체크박스 */}
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
<div className={`
|
|
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
|
${isSelected
|
|
? "border-primary bg-primary"
|
|
: "border-gray-300 bg-background"
|
|
}
|
|
`}>
|
|
{isSelected && (
|
|
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컬럼 정보 */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium truncate">{col}</span>
|
|
<span className={`text-xs px-1.5 py-0.5 rounded ${typeColor}`}>
|
|
{typeIcon} {type}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 샘플 데이터 */}
|
|
{sampleData.length > 0 && (
|
|
<div className="mt-1.5 text-xs text-muted-foreground">
|
|
<span className="font-medium">예시:</span>{" "}
|
|
{sampleData.slice(0, 2).map((row, i) => (
|
|
<span key={i}>
|
|
{String(row[col]).substring(0, 20)}
|
|
{String(row[col]).length > 20 && "..."}
|
|
{i < Math.min(sampleData.length - 1, 1) && ", "}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 검색 결과 없음 */}
|
|
{columnSearchTerm && availableColumns.filter(col =>
|
|
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
|
).length === 0 && (
|
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
|
{testResult?.success && availableColumns.length > 0 && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
|
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
|
</p>
|
|
</div>
|
|
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onChange({ columnMapping: {} })}
|
|
className="h-7 text-xs"
|
|
>
|
|
초기화
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 매핑 목록 */}
|
|
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
|
<div className="space-y-2">
|
|
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
|
<div key={original} className="flex items-center gap-2">
|
|
{/* 원본 컬럼 (읽기 전용) */}
|
|
<Input
|
|
value={original}
|
|
disabled
|
|
className="h-8 flex-1 text-xs bg-muted"
|
|
/>
|
|
|
|
{/* 화살표 */}
|
|
<span className="text-muted-foreground text-xs">→</span>
|
|
|
|
{/* 표시 이름 (편집 가능) */}
|
|
<Input
|
|
value={mapped}
|
|
onChange={(e) => {
|
|
const newMapping = { ...dataSource.columnMapping };
|
|
newMapping[original] = e.target.value;
|
|
onChange({ columnMapping: newMapping });
|
|
}}
|
|
placeholder="표시 이름"
|
|
className="h-8 flex-1 text-xs"
|
|
/>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newMapping = { ...dataSource.columnMapping };
|
|
delete newMapping[original];
|
|
onChange({ columnMapping: newMapping });
|
|
}}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<XCircle className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 매핑 추가 */}
|
|
<Select
|
|
value=""
|
|
onValueChange={(col) => {
|
|
const newMapping = { ...dataSource.columnMapping } || {};
|
|
newMapping[col] = col; // 기본값은 원본과 동일
|
|
onChange({ columnMapping: newMapping });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableColumns
|
|
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
|
.map(col => (
|
|
<SelectItem key={col} value={col} className="text-xs">
|
|
{col}
|
|
</SelectItem>
|
|
))
|
|
}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|