797 lines
30 KiB
TypeScript
797 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
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, Plus, Trash2 } from "lucide-react";
|
|
|
|
interface MultiDatabaseConfigProps {
|
|
dataSource: ChartDataSource;
|
|
onChange: (updates: Partial<ChartDataSource>) => void;
|
|
onTestResult?: (data: any[]) => void;
|
|
}
|
|
|
|
interface ExternalConnection {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
}
|
|
|
|
export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }: 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);
|
|
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // 쿼리 테스트 후 발견된 컬럼 목록
|
|
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
|
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
|
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
|
|
|
// 외부 DB 커넥션 목록 로드
|
|
useEffect(() => {
|
|
if (dataSource.connectionType === "external") {
|
|
loadExternalConnections();
|
|
}
|
|
}, [dataSource.connectionType]);
|
|
|
|
const loadExternalConnections = async () => {
|
|
setLoadingConnections(true);
|
|
try {
|
|
// ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
|
|
|
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
|
setExternalConnections(
|
|
connections.map((conn: any) => ({
|
|
id: String(conn.id),
|
|
name: conn.connection_name,
|
|
type: conn.db_type,
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
|
setExternalConnections([]);
|
|
} finally {
|
|
setLoadingConnections(false);
|
|
}
|
|
};
|
|
|
|
// 쿼리 테스트
|
|
const handleTestQuery = async () => {
|
|
if (!dataSource.query) {
|
|
setTestResult({ success: false, message: "SQL 쿼리를 입력해주세요" });
|
|
return;
|
|
}
|
|
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
|
|
try {
|
|
// dashboardApi 사용 (인증 토큰 자동 포함)
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
if (dataSource.connectionType === "external" && dataSource.externalConnectionId) {
|
|
// 외부 DB
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
const result = await ExternalDbConnectionAPI.executeQuery(
|
|
parseInt(dataSource.externalConnectionId),
|
|
dataSource.query,
|
|
);
|
|
|
|
if (result.success && result.data) {
|
|
const rows = Array.isArray(result.data.rows) ? result.data.rows : [];
|
|
const rowCount = rows.length;
|
|
|
|
// 컬럼 목록 및 타입 추출
|
|
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);
|
|
setSampleData(rows.slice(0, 3));
|
|
|
|
console.log("📊 발견된 컬럼:", columns);
|
|
console.log("📊 컬럼 타입:", types);
|
|
}
|
|
|
|
setTestResult({
|
|
success: true,
|
|
message: "쿼리 실행 성공",
|
|
rowCount,
|
|
});
|
|
|
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
|
if (onTestResult && rows && rows.length > 0) {
|
|
onTestResult(rows);
|
|
}
|
|
} else {
|
|
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
|
|
}
|
|
} else {
|
|
// 현재 DB
|
|
const result = await dashboardApi.executeQuery(dataSource.query);
|
|
|
|
// 컬럼 목록 및 타입 추출
|
|
if (result.rows && result.rows.length > 0) {
|
|
const columns = Object.keys(result.rows[0]);
|
|
setAvailableColumns(columns);
|
|
|
|
// 컬럼 타입 분석
|
|
const types: Record<string, string> = {};
|
|
columns.forEach((col) => {
|
|
const value = result.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);
|
|
setSampleData(result.rows.slice(0, 3));
|
|
|
|
console.log("📊 발견된 컬럼:", columns);
|
|
console.log("📊 컬럼 타입:", types);
|
|
}
|
|
|
|
setTestResult({
|
|
success: true,
|
|
message: "쿼리 실행 성공",
|
|
rowCount: result.rowCount || 0,
|
|
});
|
|
|
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
|
if (onTestResult && result.rows && result.rows.length > 0) {
|
|
onTestResult(result.rows);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 rounded-lg border p-3">
|
|
<h5 className="text-xs font-semibold">Database 설정</h5>
|
|
|
|
{/* 커넥션 타입 */}
|
|
<div className="space-y-1">
|
|
<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="text-muted-foreground h-4 w-4 animate-spin" />
|
|
</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">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor={"query-${dataSource.id}"} className="text-xs">
|
|
SQL 쿼리 *
|
|
</Label>
|
|
<Select
|
|
onValueChange={(value) => {
|
|
const samples = {
|
|
users: `SELECT
|
|
dept_name as 부서명,
|
|
COUNT(*) as 회원수
|
|
FROM user_info
|
|
WHERE dept_name IS NOT NULL
|
|
GROUP BY dept_name
|
|
ORDER BY 회원수 DESC`,
|
|
dept: `SELECT
|
|
dept_code as 부서코드,
|
|
dept_name as 부서명,
|
|
location_name as 위치,
|
|
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
|
|
FROM dept_info
|
|
ORDER BY dept_code`,
|
|
usersByDate: `SELECT
|
|
DATE_TRUNC('month', regdate)::date as 월,
|
|
COUNT(*) as 신규사용자수
|
|
FROM user_info
|
|
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
|
|
GROUP BY DATE_TRUNC('month', regdate)
|
|
ORDER BY 월`,
|
|
usersByPosition: `SELECT
|
|
position_name as 직급,
|
|
COUNT(*) as 인원수
|
|
FROM user_info
|
|
WHERE position_name IS NOT NULL
|
|
GROUP BY position_name
|
|
ORDER BY 인원수 DESC`,
|
|
deptHierarchy: `SELECT
|
|
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
|
|
COUNT(*) as 하위부서수
|
|
FROM dept_info
|
|
GROUP BY parent_dept_code
|
|
ORDER BY 하위부서수 DESC`,
|
|
};
|
|
onChange({ query: samples[value as keyof typeof samples] || "" });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 w-32 text-xs">
|
|
<SelectValue placeholder="샘플 쿼리" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="users" className="text-xs">
|
|
부서별 회원수
|
|
</SelectItem>
|
|
<SelectItem value="dept" className="text-xs">
|
|
부서 목록
|
|
</SelectItem>
|
|
<SelectItem value="usersByDate" className="text-xs">
|
|
월별 신규사용자
|
|
</SelectItem>
|
|
<SelectItem value="usersByPosition" className="text-xs">
|
|
직급별 인원수
|
|
</SelectItem>
|
|
<SelectItem value="deptHierarchy" className="text-xs">
|
|
부서 계층구조
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Textarea
|
|
id={"query-${dataSource.id}"}
|
|
value={dataSource.query || ""}
|
|
onChange={(e) => onChange({ query: e.target.value })}
|
|
placeholder="SELECT * FROM table_name WHERE ..."
|
|
className="min-h-[80px] font-mono text-xs"
|
|
/>
|
|
</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-8 text-xs">
|
|
<SelectValue placeholder="마커 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="circle" className="text-xs">
|
|
동그라미
|
|
</SelectItem>
|
|
<SelectItem value="arrow" className="text-xs">
|
|
화살표
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground text-[10px]">지도에 표시할 마커의 모양을 선택합니다</p>
|
|
</div>
|
|
|
|
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
|
<div className="bg-muted/30 space-y-2 rounded-lg border p-2">
|
|
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
|
|
|
{/* 색상 팔레트 */}
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{[
|
|
{ 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-12 flex-col items-center justify-center gap-0.5 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-5 w-5 rounded-full border-2 border-white shadow-sm"
|
|
style={{ backgroundColor: color.marker }}
|
|
/>
|
|
<span className="text-[9px] font-medium">{color.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테스트 버튼 */}
|
|
<div className="space-y-2 border-t pt-2">
|
|
<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-success/10 text-success" : "bg-destructive/10 text-destructive"
|
|
}`}
|
|
>
|
|
{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>
|
|
|
|
{/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */}
|
|
{availableColumns.length > 0 && (
|
|
<div className="space-y-2 border-t pt-2">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
|
{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-6 px-2 text-xs"
|
|
>
|
|
전체
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onChange({ selectedColumns: [] })}
|
|
className="h-6 px-2 text-xs"
|
|
>
|
|
해제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 */}
|
|
{availableColumns.length > 5 && (
|
|
<Input
|
|
placeholder="컬럼 검색..."
|
|
value={columnSearchTerm}
|
|
onChange={(e) => setColumnSearchTerm(e.target.value)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
)}
|
|
|
|
{/* 컬럼 카드 그리드 */}
|
|
<div className="grid max-h-60 grid-cols-1 gap-1.5 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-primary bg-primary/10",
|
|
string: "text-foreground bg-muted",
|
|
date: "text-primary bg-primary/10",
|
|
boolean: "text-success bg-success/10",
|
|
object: "text-warning bg-warning/10",
|
|
unknown: "text-muted-foreground bg-muted",
|
|
}[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 cursor-pointer items-start gap-2 rounded-lg border p-2 transition-all ${
|
|
isSelected
|
|
? "border-primary bg-primary/5 shadow-sm"
|
|
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
|
} `}
|
|
>
|
|
{/* 체크박스 */}
|
|
<div className="mt-0.5 flex-shrink-0">
|
|
<div
|
|
className={`flex h-4 w-4 items-center justify-center rounded border-2 transition-colors ${
|
|
isSelected ? "border-primary bg-primary" : "border-border bg-background"
|
|
} `}
|
|
>
|
|
{isSelected && (
|
|
<svg
|
|
className="text-primary-foreground h-3 w-3"
|
|
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="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{col}</span>
|
|
<span className={`rounded px-1.5 py-0.5 text-xs ${typeColor}`}>
|
|
{typeIcon} {type}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 샘플 데이터 */}
|
|
{sampleData.length > 0 && (
|
|
<div className="text-muted-foreground mt-1.5 text-xs">
|
|
<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-muted-foreground py-8 text-center text-sm">
|
|
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
|
{testResult?.success && availableColumns.length > 0 && (
|
|
<div className="bg-muted/30 space-y-3 rounded-lg border p-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
|
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
|
</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="bg-muted h-8 flex-1 text-xs" />
|
|
|
|
{/* 화살표 */}
|
|
<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-muted-foreground text-[10px]">💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 지도 팝업 필드 설정 (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="bg-muted/30 space-y-2 rounded-lg border 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-muted-foreground text-[10px]">
|
|
마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|