ERP-node/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx

998 lines
37 KiB
TypeScript
Raw Normal View History

"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";
2025-10-29 11:52:18 +09:00
import { getApiUrl } from "@/lib/utils/apiUrl";
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>("");
2025-10-28 13:40:17 +09:00
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
// 외부 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;
}
// base_url과 endpoint_path를 조합하여 전체 URL 생성
const fullEndpoint = connection.endpoint_path
? `${connection.base_url}${connection.endpoint_path}`
: connection.base_url;
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,
});
});
}
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
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,
});
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
2025-10-28 13:40:17 +09:00
// UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
queryParams.push({
id: `auth_query_${Date.now()}`,
2025-10-28 13:40:17 +09:00
key: actualKeyName,
value: authConfig.keyValue,
});
}
break;
case "bearer":
if (authConfig.token) {
headers.push({
id: `auth_bearer_${Date.now()}`,
key: "Authorization",
value: `Bearer ${authConfig.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}`,
});
}
break;
case "oauth2":
if (authConfig.accessToken) {
headers.push({
id: `auth_oauth_${Date.now()}`,
key: "Authorization",
value: `Bearer ${authConfig.accessToken}`,
});
}
break;
}
}
// 헤더와 쿼리 파라미터 적용
if (headers.length > 0) {
updates.headers = headers;
}
if (queryParams.length > 0) {
updates.queryParams = queryParams;
}
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;
}
});
2025-10-29 11:52:18 +09:00
const response = await fetch(getApiUrl("/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();
console.log("🌐 [API 테스트 결과]", result.data);
if (result.success) {
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
const parseTextData = (text: string): any[] => {
try {
const lines = text.split('\n').filter(line => {
const trimmed = line.trim();
return trimmed &&
!trimmed.startsWith('#') &&
!trimmed.startsWith('=') &&
!trimmed.startsWith('---');
});
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);
}
}
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') {
const parsedData = parseTextData(data.text);
if (parsedData.length > 0) {
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];
console.log("📊 [최종 파싱된 데이터]", rows);
2025-10-28 13:40:17 +09:00
// 컬럼 목록 및 타입 추출
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));
}
// 위도/경도 또는 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) => {
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>
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
<div className="space-y-2">
<Label htmlFor="marker-type" className="text-xs">
</Label>
<Select
value={dataSource.markerType || "circle"}
onValueChange={(value) => onChange({ markerType: value })}
>
<SelectTrigger id="marker-type" className="h-9 text-xs">
<SelectValue placeholder="마커 선택" />
2025-10-28 13:40:17 +09:00
</SelectTrigger>
<SelectContent>
<SelectItem value="circle" className="text-xs"></SelectItem>
<SelectItem value="arrow" className="text-xs"></SelectItem>
<SelectItem value="truck" className="text-xs"></SelectItem>
2025-10-28 13:40:17 +09:00
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
2025-10-28 13:40:17 +09:00
</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
2025-10-28 13:40:17 +09:00
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
testResult.success
2025-10-29 17:53:03 +09:00
? "bg-success/10 text-success"
: "bg-destructive/10 text-destructive"
}`}
>
{testResult.success ? (
<CheckCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{testResult.message}
</div>
)}
</div>
2025-10-28 13:40:17 +09:00
{/* 컬럼 선택 (메트릭 위젯용) - 개선된 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 = {
2025-10-29 17:53:03 +09:00
number: "text-primary bg-primary/10",
string: "text-muted-foreground bg-muted",
date: "text-primary bg-primary/10",
2025-10-29 17:53:03 +09:00
boolean: "text-success bg-success/10",
object: "text-warning bg-warning/10",
unknown: "text-muted-foreground/50 bg-muted"
2025-10-28 13:40:17 +09:00
}[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"
2025-10-29 17:53:03 +09:00
: "border-border bg-background"
2025-10-28 13:40:17 +09:00
}
`}>
{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>
)}
2025-11-12 19:08:41 +09:00
{/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */}
{availableColumns.length > 0 && (
<div className="space-y-2">
<Label htmlFor="popup-fields" className="text-xs">
</Label>
{/* 기존 팝업 필드 목록 */}
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
<div className="space-y-3">
{dataSource.popupFields.map((field, index) => (
<div key={index} className="space-y-2 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newFields = [...(dataSource.popupFields || [])];
newFields.splice(index, 1);
onChange({ popupFields: newFields });
}}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 필드명 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={field.fieldName}
onValueChange={(value) => {
const newFields = [...(dataSource.popupFields || [])];
newFields[index].fieldName = value;
onChange({ popupFields: newFields });
}}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 라벨 입력 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={field.label || ""}
onChange={(e) => {
const newFields = [...(dataSource.popupFields || [])];
newFields[index].label = e.target.value;
onChange({ popupFields: newFields });
}}
placeholder="예: 차량 번호"
className="h-8 w-full text-xs"
dir="ltr"
/>
</div>
{/* 포맷 선택 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={field.format || "text"}
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
const newFields = [...(dataSource.popupFields || [])];
newFields[index].format = value;
onChange({ popupFields: newFields });
}}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text" className="text-xs"></SelectItem>
<SelectItem value="number" className="text-xs"></SelectItem>
<SelectItem value="date" className="text-xs"></SelectItem>
<SelectItem value="datetime" className="text-xs"></SelectItem>
<SelectItem value="url" className="text-xs">URL</SelectItem>
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
{/* 필드 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newFields = [...(dataSource.popupFields || [])];
newFields.push({
fieldName: availableColumns[0] || "",
label: "",
format: "text",
});
onChange({ popupFields: newFields });
}}
className="h-8 w-full gap-2 text-xs"
disabled={availableColumns.length === 0}
>
<Plus className="h-3 w-3" />
</Button>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
</div>
);
}