2025-10-27 18:33:15 +09:00
|
|
|
"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";
|
2025-10-28 09:32:03 +09:00
|
|
|
import { Plus, Trash2, Database, Globe } from "lucide-react";
|
2025-10-27 18:33:15 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
2025-10-28 09:32:03 +09:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
2025-10-27 18:33:15 +09:00
|
|
|
import MultiApiConfig from "./MultiApiConfig";
|
|
|
|
|
import MultiDatabaseConfig from "./MultiDatabaseConfig";
|
|
|
|
|
|
|
|
|
|
interface MultiDataSourceConfigProps {
|
|
|
|
|
dataSources: ChartDataSource[];
|
|
|
|
|
onChange: (dataSources: ChartDataSource[]) => void;
|
2025-10-28 17:40:48 +09:00
|
|
|
onTestResult?: (result: { columns: string[]; rows: any[] }, dataSourceId: string) => void;
|
2025-10-27 18:33:15 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function MultiDataSourceConfig({
|
|
|
|
|
dataSources = [],
|
|
|
|
|
onChange,
|
2025-10-28 17:40:48 +09:00
|
|
|
onTestResult,
|
2025-10-27 18:33:15 +09:00
|
|
|
}: MultiDataSourceConfigProps) {
|
|
|
|
|
const [activeTab, setActiveTab] = useState<string>(
|
|
|
|
|
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
|
|
|
|
|
);
|
|
|
|
|
const [previewData, setPreviewData] = useState<any[]>([]);
|
|
|
|
|
const [showPreview, setShowPreview] = useState(false);
|
2025-10-28 09:32:03 +09:00
|
|
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
2025-10-27 18:33:15 +09:00
|
|
|
|
2025-10-28 09:32:03 +09:00
|
|
|
// 새 데이터 소스 추가 (타입 지정)
|
|
|
|
|
const handleAddDataSource = (type: "api" | "database") => {
|
2025-10-27 18:33:15 +09:00
|
|
|
const newId = Date.now().toString();
|
|
|
|
|
const newSource: ChartDataSource = {
|
|
|
|
|
id: newId,
|
2025-10-28 09:32:03 +09:00
|
|
|
name: `${type === "api" ? "REST API" : "Database"} ${dataSources.length + 1}`,
|
|
|
|
|
type,
|
2025-10-27 18:33:15 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onChange([...dataSources, newSource]);
|
|
|
|
|
setActiveTab(newId);
|
2025-10-28 09:32:03 +09:00
|
|
|
setShowAddMenu(false);
|
2025-10-27 18:33:15 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 데이터 소스 삭제
|
|
|
|
|
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>
|
2025-10-28 13:40:17 +09:00
|
|
|
<DropdownMenu open={showAddMenu} onOpenChange={setShowAddMenu}>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 gap-2 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem onClick={() => handleAddDataSource("api")}>
|
|
|
|
|
<Globe className="mr-2 h-4 w-4" />
|
|
|
|
|
REST API 추가
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => handleAddDataSource("database")}>
|
|
|
|
|
<Database className="mr-2 h-4 w-4" />
|
|
|
|
|
Database 추가
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
2025-10-27 18:33:15 +09:00
|
|
|
</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>
|
2025-10-28 13:40:17 +09:00
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="default"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 gap-2 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
첫 번째 데이터 소스 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="center">
|
|
|
|
|
<DropdownMenuItem onClick={() => handleAddDataSource("api")}>
|
|
|
|
|
<Globe className="mr-2 h-4 w-4" />
|
|
|
|
|
REST API 추가
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => handleAddDataSource("database")}>
|
|
|
|
|
<Database className="mr-2 h-4 w-4" />
|
|
|
|
|
Database 추가
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
2025-10-27 18:33:15 +09:00
|
|
|
</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);
|
2025-10-28 17:40:48 +09:00
|
|
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
|
|
|
|
if (onTestResult && data.length > 0 && ds.id) {
|
|
|
|
|
const columns = Object.keys(data[0]);
|
|
|
|
|
onTestResult({ columns, rows: data }, ds.id);
|
|
|
|
|
}
|
2025-10-27 18:33:15 +09:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<MultiDatabaseConfig
|
|
|
|
|
dataSource={ds}
|
|
|
|
|
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
2025-10-28 17:40:48 +09:00
|
|
|
onTestResult={(data) => {
|
|
|
|
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
|
|
|
|
if (onTestResult && data.length > 0 && ds.id) {
|
|
|
|
|
const columns = Object.keys(data[0]);
|
|
|
|
|
onTestResult({ columns, rows: data }, ds.id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-10-27 18:33:15 +09:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</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('위험')
|
2025-10-29 17:53:03 +09:00
|
|
|
? 'bg-destructive/10 text-destructive'
|
2025-10-27 18:33:15 +09:00
|
|
|
: (item.status || item.level)?.includes('주의')
|
2025-10-29 17:53:03 +09:00
|
|
|
? 'bg-warning/10 text-warning'
|
|
|
|
|
: 'bg-primary/10 text-primary'
|
2025-10-27 18:33:15 +09:00
|
|
|
}`}>
|
|
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|