이희진 진행사항 중간세이브

This commit is contained in:
leeheejin
2025-10-28 13:40:17 +09:00
parent d5e72ce901
commit 1291f9287c
14 changed files with 1842 additions and 125 deletions

View File

@@ -3,6 +3,7 @@
import React, { 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";
@@ -25,6 +26,10 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
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(() => {
@@ -36,19 +41,19 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
const loadExternalConnections = async () => {
setLoadingConnections(true);
try {
const response = await fetch("/api/admin/reports/external-connections", {
credentials: "include",
});
// ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
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);
}
}
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);
console.error("외부 DB 커넥션 로드 실패:", error);
setExternalConnections([]);
} finally {
setLoadingConnections(false);
}
@@ -77,7 +82,41 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
);
if (result.success && result.data) {
const rowCount = Array.isArray(result.data.rows) ? result.data.rows.length : 0;
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: "쿼리 실행 성공",
@@ -89,6 +128,39 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
} 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: "쿼리 실행 성공",
@@ -183,6 +255,34 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
</p>
</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>
{/* 테스트 버튼 */}
<div className="space-y-2 border-t pt-4">
<Button
@@ -204,7 +304,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
{testResult && (
<div
className={`flex items-center gap-2 rounded-md p-2 text-xs \${
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"
@@ -224,6 +324,158 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
</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>
)}
</div>
);
}