이희진 진행사항 중간세이브
This commit is contained in:
parent
d5e72ce901
commit
1291f9287c
|
|
@ -78,12 +78,20 @@ const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/Cha
|
||||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ListTestWidget = dynamic(() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), {
|
const ListTestWidget = dynamic(
|
||||||
|
() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
|
const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||||
});
|
});
|
||||||
|
|
@ -904,6 +912,11 @@ export function CanvasElement({
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
<CustomMetricTestWidget element={element} />
|
<CustomMetricTestWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
|
) : element.type === "widget" && element.subtype === "risk-alert-test" ? (
|
||||||
|
// 🧪 테스트용 리스크/알림 위젯 (다중 데이터 소스)
|
||||||
|
<div className="widget-interactive-area h-full w-full">
|
||||||
|
<RiskAlertTestWidget element={element} />
|
||||||
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||||
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,8 @@ export function DashboardTopMenu({
|
||||||
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
||||||
<SelectItem value="list-test">🧪 리스트 테스트</SelectItem>
|
<SelectItem value="list-test">🧪 리스트 테스트</SelectItem>
|
||||||
<SelectItem value="custom-metric-test">🧪 커스텀 메트릭 테스트</SelectItem>
|
<SelectItem value="custom-metric-test">🧪 커스텀 메트릭 테스트</SelectItem>
|
||||||
|
<SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem>
|
||||||
|
<SelectItem value="risk-alert-test">🧪 리스크/알림 테스트</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>데이터 위젯</SelectLabel>
|
<SelectLabel>데이터 위젯</SelectLabel>
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,16 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
|
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
|
||||||
console.log("🔧 적용 버튼 클릭 - dataSources:", element.dataSources);
|
console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources);
|
||||||
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
|
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
|
||||||
|
|
||||||
// 다중 데이터 소스 위젯 체크
|
// 다중 데이터 소스 위젯 체크
|
||||||
const isMultiDS = element.subtype === "map-test-v2" || element.subtype === "chart-test";
|
const isMultiDS =
|
||||||
|
element.subtype === "map-test-v2" ||
|
||||||
|
element.subtype === "chart-test" ||
|
||||||
|
element.subtype === "list-test" ||
|
||||||
|
element.subtype === "custom-metric-test" ||
|
||||||
|
element.subtype === "risk-alert-test";
|
||||||
|
|
||||||
const updatedElement: DashboardElement = {
|
const updatedElement: DashboardElement = {
|
||||||
...element,
|
...element,
|
||||||
|
|
@ -226,7 +231,9 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
||||||
element.subtype === "map-test-v2" ||
|
element.subtype === "map-test-v2" ||
|
||||||
element.subtype === "chart-test" ||
|
element.subtype === "chart-test" ||
|
||||||
element.subtype === "list-test" ||
|
element.subtype === "list-test" ||
|
||||||
element.subtype === "custom-metric-test";
|
element.subtype === "custom-metric-test" ||
|
||||||
|
element.subtype === "status-summary-test" ||
|
||||||
|
element.subtype === "risk-alert-test";
|
||||||
|
|
||||||
// 저장 가능 여부 확인
|
// 저장 가능 여부 확인
|
||||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
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);
|
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
||||||
|
|
||||||
|
|
@ -88,12 +92,14 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
});
|
});
|
||||||
console.log("API Key 헤더 추가:", authConfig.keyName);
|
console.log("API Key 헤더 추가:", authConfig.keyName);
|
||||||
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
||||||
|
// UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환
|
||||||
|
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
|
||||||
queryParams.push({
|
queryParams.push({
|
||||||
id: `auth_query_${Date.now()}`,
|
id: `auth_query_${Date.now()}`,
|
||||||
key: authConfig.keyName,
|
key: actualKeyName,
|
||||||
value: authConfig.keyValue,
|
value: authConfig.keyValue,
|
||||||
});
|
});
|
||||||
console.log("API Key 쿼리 파라미터 추가:", authConfig.keyName);
|
console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", authConfig.keyName, ")");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -299,6 +305,41 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
const rows = Array.isArray(data) ? data : [data];
|
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 필드 또는 지역 코드 체크
|
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
||||||
const hasLocationData = rows.some((row) => {
|
const hasLocationData = rows.some((row) => {
|
||||||
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
|
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
|
||||||
|
|
@ -488,6 +529,34 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
|
|
||||||
{/* 테스트 버튼 */}
|
{/* 테스트 버튼 */}
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -509,7 +578,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<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
|
testResult.success
|
||||||
? "bg-green-50 text-green-700"
|
? "bg-green-50 text-green-700"
|
||||||
: "bg-red-50 text-red-700"
|
: "bg-red-50 text-red-700"
|
||||||
|
|
@ -524,6 +593,158 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,15 +78,28 @@ export default function MultiDataSourceConfig({
|
||||||
여러 데이터 소스를 연결하여 데이터를 통합할 수 있습니다
|
여러 데이터 소스를 연결하여 데이터를 통합할 수 있습니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<DropdownMenu open={showAddMenu} onOpenChange={setShowAddMenu}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAddDataSource}
|
|
||||||
className="h-8 gap-2 text-xs"
|
className="h-8 gap-2 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데이터 소스가 없는 경우 */}
|
{/* 데이터 소스가 없는 경우 */}
|
||||||
|
|
@ -95,15 +108,28 @@ export default function MultiDataSourceConfig({
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
연결된 데이터 소스가 없습니다
|
연결된 데이터 소스가 없습니다
|
||||||
</p>
|
</p>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAddDataSource}
|
|
||||||
className="h-8 gap-2 text-xs"
|
className="h-8 gap-2 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
첫 번째 데이터 소스 추가
|
첫 번째 데이터 소스 추가
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* 탭 UI */
|
/* 탭 UI */
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
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 [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
|
||||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
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 커넥션 목록 로드
|
// 외부 DB 커넥션 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -36,19 +41,19 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
const loadExternalConnections = async () => {
|
const loadExternalConnections = async () => {
|
||||||
setLoadingConnections(true);
|
setLoadingConnections(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/reports/external-connections", {
|
// ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
|
||||||
credentials: "include",
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
});
|
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||||
|
|
||||||
if (response.ok) {
|
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
||||||
const result = await response.json();
|
setExternalConnections(connections.map((conn: any) => ({
|
||||||
if (result.success && result.data) {
|
id: String(conn.id),
|
||||||
const connections = Array.isArray(result.data) ? result.data : result.data.data || [];
|
name: conn.connection_name,
|
||||||
setExternalConnections(connections);
|
type: conn.db_type,
|
||||||
}
|
})));
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 커넥션 로드 실패:", error);
|
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
||||||
|
setExternalConnections([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingConnections(false);
|
setLoadingConnections(false);
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +82,41 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
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({
|
setTestResult({
|
||||||
success: true,
|
success: true,
|
||||||
message: "쿼리 실행 성공",
|
message: "쿼리 실행 성공",
|
||||||
|
|
@ -89,6 +128,39 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
} else {
|
} else {
|
||||||
// 현재 DB
|
// 현재 DB
|
||||||
const result = await dashboardApi.executeQuery(dataSource.query);
|
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({
|
setTestResult({
|
||||||
success: true,
|
success: true,
|
||||||
message: "쿼리 실행 성공",
|
message: "쿼리 실행 성공",
|
||||||
|
|
@ -183,6 +255,34 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
</p>
|
</p>
|
||||||
</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>
|
||||||
|
|
||||||
{/* 테스트 버튼 */}
|
{/* 테스트 버튼 */}
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -204,7 +304,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<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
|
testResult.success
|
||||||
? "bg-green-50 text-green-700"
|
? "bg-green-50 text-green-700"
|
||||||
: "bg-red-50 text-red-700"
|
: "bg-red-50 text-red-700"
|
||||||
|
|
@ -224,6 +324,158 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export type ElementSubtype =
|
||||||
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
|
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
|
||||||
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
|
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
|
||||||
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
|
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
|
||||||
|
| "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스)
|
||||||
|
| "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
|
||||||
| "delivery-status"
|
| "delivery-status"
|
||||||
| "status-summary" // 범용 상태 카드 (통합)
|
| "status-summary" // 범용 상태 카드 (통합)
|
||||||
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
|
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||||
|
|
@ -152,6 +154,9 @@ export interface ChartDataSource {
|
||||||
lastExecuted?: string; // 마지막 실행 시간
|
lastExecuted?: string; // 마지막 실행 시간
|
||||||
lastError?: string; // 마지막 오류 메시지
|
lastError?: string; // 마지막 오류 메시지
|
||||||
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
||||||
|
|
||||||
|
// 메트릭 설정 (CustomMetricTestWidget용)
|
||||||
|
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartConfig {
|
export interface ChartConfig {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,12 @@ const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { s
|
||||||
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
||||||
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
||||||
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
||||||
const ListTestWidget = dynamic(() => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false });
|
const ListTestWidget = dynamic(
|
||||||
|
() => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
||||||
|
const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
|
||||||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
||||||
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
||||||
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
||||||
|
|
@ -91,6 +95,8 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <ListTestWidget element={element} />;
|
return <ListTestWidget element={element} />;
|
||||||
case "custom-metric-test":
|
case "custom-metric-test":
|
||||||
return <CustomMetricTestWidget element={element} />;
|
return <CustomMetricTestWidget element={element} />;
|
||||||
|
case "risk-alert-test":
|
||||||
|
return <RiskAlertTestWidget element={element} />;
|
||||||
case "risk-alert":
|
case "risk-alert":
|
||||||
return <RiskAlertWidget element={element} />;
|
return <RiskAlertWidget element={element} />;
|
||||||
case "calendar":
|
case "calendar":
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
|
|
@ -29,9 +30,14 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
console.log("🧪 ChartTestWidget 렌더링!", element);
|
console.log("🧪 ChartTestWidget 렌더링!", element);
|
||||||
|
|
||||||
|
const dataSources = useMemo(() => {
|
||||||
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||||
|
|
||||||
// 다중 데이터 소스 로딩
|
// 다중 데이터 소스 로딩
|
||||||
const loadMultipleDataSources = useCallback(async () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||||
|
|
@ -81,6 +87,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
|
|
||||||
console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`);
|
console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`);
|
||||||
setData(allData);
|
setData(allData);
|
||||||
|
setLastRefreshTime(new Date());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
console.error("❌ 데이터 로딩 중 오류:", err);
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -89,6 +96,12 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [element?.dataSources]);
|
}, [element?.dataSources]);
|
||||||
|
|
||||||
|
// 수동 새로고침 핸들러
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, [loadMultipleDataSources]);
|
||||||
|
|
||||||
// REST API 데이터 로딩
|
// REST API 데이터 로딩
|
||||||
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
||||||
if (!source.endpoint) {
|
if (!source.endpoint) {
|
||||||
|
|
@ -174,12 +187,36 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
return result.data || [];
|
return result.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
|
||||||
if (dataSources && dataSources.length > 0) {
|
if (dataSources && dataSources.length > 0) {
|
||||||
loadMultipleDataSources();
|
loadMultipleDataSources();
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]);
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
|
// 자동 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSources || dataSources.length === 0) return;
|
||||||
|
|
||||||
|
const intervals = dataSources
|
||||||
|
.map((ds) => ds.refreshInterval)
|
||||||
|
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||||
|
|
||||||
|
if (intervals.length === 0) return;
|
||||||
|
|
||||||
|
const minInterval = Math.min(...intervals);
|
||||||
|
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
console.log("🔄 자동 새로고침 실행");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, minInterval * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("⏹️ 자동 새로고침 정리");
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
const chartType = element?.subtype || "line";
|
const chartType = element?.subtype || "line";
|
||||||
const chartConfig = element?.chartConfig || {};
|
const chartConfig = element?.chartConfig || {};
|
||||||
|
|
@ -267,11 +304,28 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
|
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨
|
{dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
|
||||||
|
{lastRefreshTime && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 p-4">
|
<div className="flex-1 p-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
interface CustomMetricTestWidgetProps {
|
interface CustomMetricTestWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -54,10 +55,25 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
const [metrics, setMetrics] = useState<any[]>([]);
|
const [metrics, setMetrics] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
||||||
|
|
||||||
const metricConfig = element?.customMetricConfig?.metrics || [];
|
const dataSources = useMemo(() => {
|
||||||
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||||
|
|
||||||
|
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
|
||||||
|
const metricConfig = useMemo(() => {
|
||||||
|
return element?.customMetricConfig?.metrics || [
|
||||||
|
{
|
||||||
|
label: "총 개수",
|
||||||
|
field: "id",
|
||||||
|
aggregation: "count",
|
||||||
|
color: "indigo",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [element?.customMetricConfig?.metrics]);
|
||||||
|
|
||||||
// 다중 데이터 소스 로딩
|
// 다중 데이터 소스 로딩
|
||||||
const loadMultipleDataSources = useCallback(async () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
|
|
@ -73,43 +89,203 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 모든 데이터 소스를 병렬로 로딩
|
// 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리)
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
dataSources.map(async (source) => {
|
dataSources.map(async (source, sourceIndex) => {
|
||||||
try {
|
try {
|
||||||
console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
|
||||||
|
|
||||||
|
let rows: any[] = [];
|
||||||
if (source.type === "api") {
|
if (source.type === "api") {
|
||||||
return await loadRestApiData(source);
|
rows = await loadRestApiData(source);
|
||||||
} else if (source.type === "database") {
|
} else if (source.type === "database") {
|
||||||
return await loadDatabaseData(source);
|
rows = await loadDatabaseData(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
||||||
|
sourceIndex: sourceIndex,
|
||||||
|
rows: rows,
|
||||||
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
||||||
return [];
|
return {
|
||||||
|
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
||||||
|
sourceIndex: sourceIndex,
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 성공한 데이터만 병합
|
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
||||||
const allRows: any[] = [];
|
|
||||||
|
// 각 데이터 소스별로 메트릭 생성
|
||||||
|
const allMetrics: any[] = [];
|
||||||
|
const colors = ["indigo", "green", "blue", "purple", "orange", "gray"];
|
||||||
|
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
if (result.status === "fulfilled" && Array.isArray(result.value)) {
|
if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) {
|
||||||
allRows.push(...result.value);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sourceName, rows } = result.value;
|
||||||
|
|
||||||
|
// 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면)
|
||||||
|
const hasAggregatedData = rows.length > 0 && rows.length <= 100;
|
||||||
|
|
||||||
|
if (hasAggregatedData && rows.length > 0) {
|
||||||
|
const firstRow = rows[0];
|
||||||
|
const columns = Object.keys(firstRow);
|
||||||
|
|
||||||
|
// 숫자 컬럼 찾기
|
||||||
|
const numericColumns = columns.filter(col => {
|
||||||
|
const value = firstRow[col];
|
||||||
|
return typeof value === 'number' || !isNaN(Number(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 문자열 컬럼 찾기
|
||||||
|
const stringColumns = columns.filter(col => {
|
||||||
|
const value = firstRow[col];
|
||||||
|
return typeof value === 'string' || !numericColumns.includes(col);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
||||||
|
전체: columns,
|
||||||
|
숫자: numericColumns,
|
||||||
|
문자열: stringColumns
|
||||||
|
});
|
||||||
|
|
||||||
|
// 숫자 컬럼이 있으면 집계된 데이터로 판단
|
||||||
|
if (numericColumns.length > 0) {
|
||||||
|
console.log(`✅ [${sourceName}] 집계된 데이터, 각 행을 메트릭으로 변환`);
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
// 라벨: 첫 번째 문자열 컬럼
|
||||||
|
const labelField = stringColumns[0] || columns[0];
|
||||||
|
const label = String(row[labelField] || `항목 ${index + 1}`);
|
||||||
|
|
||||||
|
// 값: 첫 번째 숫자 컬럼
|
||||||
|
const valueField = numericColumns[0] || columns[1] || columns[0];
|
||||||
|
const value = Number(row[valueField]) || 0;
|
||||||
|
|
||||||
|
console.log(` [${sourceName}] 메트릭: ${label} = ${value}`);
|
||||||
|
|
||||||
|
allMetrics.push({
|
||||||
|
label: `${sourceName} - ${label}`,
|
||||||
|
value: value,
|
||||||
|
field: valueField,
|
||||||
|
aggregation: "custom",
|
||||||
|
color: colors[allMetrics.length % colors.length],
|
||||||
|
sourceName: sourceName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시
|
||||||
|
console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`);
|
||||||
|
|
||||||
|
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||||
|
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||||
|
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
|
||||||
|
);
|
||||||
|
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||||
|
|
||||||
|
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
||||||
|
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
||||||
|
|
||||||
|
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||||
|
|
||||||
|
columnsToShow.forEach((col) => {
|
||||||
|
// 해당 컬럼이 실제로 존재하는지 확인
|
||||||
|
if (!columns.includes(col)) {
|
||||||
|
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해당 컬럼의 고유값 개수 계산
|
||||||
|
const uniqueValues = new Set(rows.map(row => row[col]));
|
||||||
|
const uniqueCount = uniqueValues.size;
|
||||||
|
|
||||||
|
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||||
|
|
||||||
|
allMetrics.push({
|
||||||
|
label: `${sourceName} - ${col} (고유값)`,
|
||||||
|
value: uniqueCount,
|
||||||
|
field: col,
|
||||||
|
aggregation: "distinct",
|
||||||
|
color: colors[allMetrics.length % colors.length],
|
||||||
|
sourceName: sourceName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 총 행 개수도 추가
|
||||||
|
allMetrics.push({
|
||||||
|
label: `${sourceName} - 총 개수`,
|
||||||
|
value: rows.length,
|
||||||
|
field: "count",
|
||||||
|
aggregation: "count",
|
||||||
|
color: colors[allMetrics.length % colors.length],
|
||||||
|
sourceName: sourceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시
|
||||||
|
console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
|
||||||
|
|
||||||
|
const firstRow = rows[0];
|
||||||
|
const columns = Object.keys(firstRow);
|
||||||
|
|
||||||
|
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||||
|
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||||
|
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
|
||||||
|
);
|
||||||
|
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||||
|
|
||||||
|
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
||||||
|
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
||||||
|
|
||||||
|
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||||
|
|
||||||
|
// 각 컬럼별 고유값 개수
|
||||||
|
columnsToShow.forEach((col) => {
|
||||||
|
// 해당 컬럼이 실제로 존재하는지 확인
|
||||||
|
if (!columns.includes(col)) {
|
||||||
|
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueValues = new Set(rows.map(row => row[col]));
|
||||||
|
const uniqueCount = uniqueValues.size;
|
||||||
|
|
||||||
|
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||||
|
|
||||||
|
allMetrics.push({
|
||||||
|
label: `${sourceName} - ${col} (고유값)`,
|
||||||
|
value: uniqueCount,
|
||||||
|
field: col,
|
||||||
|
aggregation: "distinct",
|
||||||
|
color: colors[allMetrics.length % colors.length],
|
||||||
|
sourceName: sourceName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 총 행 개수
|
||||||
|
allMetrics.push({
|
||||||
|
label: `${sourceName} - 총 개수`,
|
||||||
|
value: rows.length,
|
||||||
|
field: "count",
|
||||||
|
aggregation: "count",
|
||||||
|
color: colors[allMetrics.length % colors.length],
|
||||||
|
sourceName: sourceName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
|
||||||
|
setMetrics(allMetrics);
|
||||||
// 메트릭 계산
|
setLastRefreshTime(new Date());
|
||||||
const calculatedMetrics = metricConfig.map((metric) => ({
|
|
||||||
...metric,
|
|
||||||
value: calculateMetric(allRows, metric.field, metric.aggregation),
|
|
||||||
}));
|
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -117,6 +293,75 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
|
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
|
||||||
|
|
||||||
|
// 수동 새로고침 핸들러
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, [loadMultipleDataSources]);
|
||||||
|
|
||||||
|
// XML 데이터 파싱
|
||||||
|
const parseXmlData = (xmlText: string): any[] => {
|
||||||
|
console.log("🔍 XML 파싱 시작");
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||||
|
|
||||||
|
const records = xmlDoc.getElementsByTagName("record");
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
const record = records[i];
|
||||||
|
const obj: any = {};
|
||||||
|
|
||||||
|
for (let j = 0; j < record.children.length; j++) {
|
||||||
|
const child = record.children[j];
|
||||||
|
obj[child.tagName] = child.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ XML 파싱 실패:", error);
|
||||||
|
throw new Error("XML 파싱 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 텍스트/CSV 데이터 파싱
|
||||||
|
const parseTextData = (text: string): any[] => {
|
||||||
|
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||||
|
|
||||||
|
// XML 감지
|
||||||
|
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||||
|
console.log("📄 XML 형식 감지");
|
||||||
|
return parseXmlData(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 파싱
|
||||||
|
console.log("📄 CSV 형식으로 파싱 시도");
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
|
||||||
|
const headers = lines[0].split(",").map(h => h.trim());
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const values = lines[i].split(",");
|
||||||
|
const obj: any = {};
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
obj[header] = values[index]?.trim() || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
// REST API 데이터 로딩
|
// REST API 데이터 로딩
|
||||||
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
||||||
if (!source.endpoint) {
|
if (!source.endpoint) {
|
||||||
|
|
@ -124,13 +369,25 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// queryParams 배열 또는 객체 처리
|
||||||
if (source.queryParams) {
|
if (source.queryParams) {
|
||||||
|
if (Array.isArray(source.queryParams)) {
|
||||||
|
source.queryParams.forEach((param: any) => {
|
||||||
|
if (param.key && param.value) {
|
||||||
|
params.append(param.key, String(param.value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
Object.entries(source.queryParams).forEach(([key, value]) => {
|
Object.entries(source.queryParams).forEach(([key, value]) => {
|
||||||
if (key && value) {
|
if (key && value) {
|
||||||
params.append(key, String(value));
|
params.append(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
|
||||||
|
|
||||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -146,17 +403,34 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
const errorText = await response.text();
|
||||||
|
console.error("❌ API 호출 실패:", {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: errorText.substring(0, 500),
|
||||||
|
});
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
console.log("✅ API 응답:", result);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || "외부 API 호출 실패");
|
console.error("❌ API 실패:", result);
|
||||||
|
throw new Error(result.message || result.error || "외부 API 호출 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
let processedData = result.data;
|
let processedData = result.data;
|
||||||
|
|
||||||
|
// 텍스트/XML 데이터 처리
|
||||||
|
if (typeof processedData === "string") {
|
||||||
|
console.log("📄 텍스트 형식 데이터 감지");
|
||||||
|
processedData = parseTextData(processedData);
|
||||||
|
} else if (processedData && typeof processedData === "object" && processedData.text) {
|
||||||
|
console.log("📄 래핑된 텍스트 데이터 감지");
|
||||||
|
processedData = parseTextData(processedData.text);
|
||||||
|
}
|
||||||
|
|
||||||
// JSON Path 처리
|
// JSON Path 처리
|
||||||
if (source.jsonPath) {
|
if (source.jsonPath) {
|
||||||
const paths = source.jsonPath.split(".");
|
const paths = source.jsonPath.split(".");
|
||||||
|
|
@ -167,6 +441,18 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!Array.isArray(processedData) && typeof processedData === "object") {
|
||||||
|
// JSON Path 없으면 자동으로 배열 찾기
|
||||||
|
console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
|
||||||
|
const arrayKeys = ["data", "items", "result", "records", "rows", "list"];
|
||||||
|
|
||||||
|
for (const key of arrayKeys) {
|
||||||
|
if (Array.isArray(processedData[key])) {
|
||||||
|
console.log(`✅ 배열 발견: ${key}`);
|
||||||
|
processedData = processedData[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(processedData) ? processedData : [processedData];
|
return Array.isArray(processedData) ? processedData : [processedData];
|
||||||
|
|
@ -206,11 +492,34 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
|
|
||||||
// 초기 로드
|
// 초기 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
|
||||||
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
|
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
|
||||||
loadMultipleDataSources();
|
loadMultipleDataSources();
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources, metricConfig]);
|
}, [dataSources, loadMultipleDataSources, metricConfig]);
|
||||||
|
|
||||||
|
// 자동 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSources || dataSources.length === 0) return;
|
||||||
|
|
||||||
|
const intervals = dataSources
|
||||||
|
.map((ds) => ds.refreshInterval)
|
||||||
|
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||||
|
|
||||||
|
if (intervals.length === 0) return;
|
||||||
|
|
||||||
|
const minInterval = Math.min(...intervals);
|
||||||
|
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
console.log("🔄 자동 새로고침 실행");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, minInterval * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("⏹️ 자동 새로고침 정리");
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
// 메트릭 카드 렌더링
|
// 메트릭 카드 렌더링
|
||||||
const renderMetricCard = (metric: any, index: number) => {
|
const renderMetricCard = (metric: any, index: number) => {
|
||||||
|
|
@ -238,6 +547,15 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 메트릭 개수에 따라 그리드 컬럼 동적 결정
|
||||||
|
const getGridCols = () => {
|
||||||
|
const count = metrics.length;
|
||||||
|
if (count === 0) return "grid-cols-1";
|
||||||
|
if (count === 1) return "grid-cols-1";
|
||||||
|
if (count <= 4) return "grid-cols-1 sm:grid-cols-2";
|
||||||
|
return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col rounded-lg border bg-card shadow-sm">
|
<div className="flex h-full flex-col rounded-lg border bg-card shadow-sm">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -247,11 +565,28 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
{element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"}
|
{element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨
|
{dataSources?.length || 0}개 데이터 소스 • {metrics.length}개 메트릭
|
||||||
|
{lastRefreshTime && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컨텐츠 */}
|
{/* 컨텐츠 */}
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
|
@ -272,7 +607,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className={`grid gap-4 ${getGridCols()}`}>
|
||||||
{metrics.map((metric, index) => renderMetricCard(metric, index))}
|
{metrics.map((metric, index) => renderMetricCard(metric, index))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
interface ListTestWidgetProps {
|
interface ListTestWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -30,9 +30,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
console.log("🧪 ListTestWidget 렌더링!", element);
|
console.log("🧪 ListTestWidget 렌더링!", element);
|
||||||
|
|
||||||
|
const dataSources = useMemo(() => {
|
||||||
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||||
|
|
||||||
const config = element.listConfig || {
|
const config = element.listConfig || {
|
||||||
columnMode: "auto",
|
columnMode: "auto",
|
||||||
viewMode: "table",
|
viewMode: "table",
|
||||||
|
|
@ -114,6 +119,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
totalRows: allRows.length,
|
totalRows: allRows.length,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
});
|
});
|
||||||
|
setLastRefreshTime(new Date());
|
||||||
|
|
||||||
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -123,6 +129,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||||
|
|
||||||
|
// 수동 새로고침 핸들러
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, [loadMultipleDataSources]);
|
||||||
|
|
||||||
// REST API 데이터 로딩
|
// REST API 데이터 로딩
|
||||||
const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
|
const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
|
||||||
if (!source.endpoint) {
|
if (!source.endpoint) {
|
||||||
|
|
@ -152,13 +164,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("❌ API 호출 실패:", {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: errorText.substring(0, 500),
|
||||||
|
});
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
console.log("✅ API 응답:", result);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || "외부 API 호출 실패");
|
console.error("❌ API 실패:", result);
|
||||||
|
throw new Error(result.message || result.error || "외부 API 호출 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
let processedData = result.data;
|
let processedData = result.data;
|
||||||
|
|
@ -222,11 +242,34 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
|
|
||||||
// 초기 로드
|
// 초기 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
|
||||||
if (dataSources && dataSources.length > 0) {
|
if (dataSources && dataSources.length > 0) {
|
||||||
loadMultipleDataSources();
|
loadMultipleDataSources();
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]);
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
|
// 자동 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSources || dataSources.length === 0) return;
|
||||||
|
|
||||||
|
const intervals = dataSources
|
||||||
|
.map((ds) => ds.refreshInterval)
|
||||||
|
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||||
|
|
||||||
|
if (intervals.length === 0) return;
|
||||||
|
|
||||||
|
const minInterval = Math.min(...intervals);
|
||||||
|
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
console.log("🔄 자동 새로고침 실행");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, minInterval * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("⏹️ 자동 새로고침 정리");
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
// 페이지네이션
|
// 페이지네이션
|
||||||
const pageSize = config.pageSize || 10;
|
const pageSize = config.pageSize || 10;
|
||||||
|
|
@ -290,11 +333,28 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
|
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨
|
{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
|
||||||
|
{lastRefreshTime && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컨텐츠 */}
|
{/* 컨텐츠 */}
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||||
|
|
@ -60,6 +61,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||||
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
console.log("🧪 MapTestWidgetV2 렌더링!", element);
|
console.log("🧪 MapTestWidgetV2 렌더링!", element);
|
||||||
console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
|
console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
|
||||||
|
|
@ -136,6 +138,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
setMarkers(allMarkers);
|
setMarkers(allMarkers);
|
||||||
setPolygons(allPolygons);
|
setPolygons(allPolygons);
|
||||||
|
setLastRefreshTime(new Date());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
console.error("❌ 데이터 로딩 중 오류:", err);
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -144,6 +147,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
}, [dataSources]);
|
}, [dataSources]);
|
||||||
|
|
||||||
|
// 수동 새로고침 핸들러
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, [loadMultipleDataSources]);
|
||||||
|
|
||||||
// REST API 데이터 로딩
|
// REST API 데이터 로딩
|
||||||
const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||||
console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||||
|
|
@ -263,11 +272,47 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
|
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// XML 데이터 파싱 (UTIC API 등)
|
||||||
|
const parseXmlData = (xmlText: string): any[] => {
|
||||||
|
try {
|
||||||
|
console.log(" 📄 XML 파싱 시작");
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||||
|
|
||||||
|
const records = xmlDoc.getElementsByTagName("record");
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
const record = records[i];
|
||||||
|
const obj: any = {};
|
||||||
|
|
||||||
|
for (let j = 0; j < record.children.length; j++) {
|
||||||
|
const child = record.children[j];
|
||||||
|
obj[child.tagName] = child.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(" ❌ XML 파싱 실패:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
||||||
const parseTextData = (text: string): any[] => {
|
const parseTextData = (text: string): any[] => {
|
||||||
try {
|
try {
|
||||||
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
||||||
|
|
||||||
|
// XML 형식 감지
|
||||||
|
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||||
|
console.log(" 📄 XML 형식 데이터 감지");
|
||||||
|
return parseXmlData(text);
|
||||||
|
}
|
||||||
|
|
||||||
const lines = text.split('\n').filter(line => {
|
const lines = text.split('\n').filter(line => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
return trimmed &&
|
return trimmed &&
|
||||||
|
|
@ -382,8 +427,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 마커 데이터 처리 (위도/경도가 있는 경우)
|
// 마커 데이터 처리 (위도/경도가 있는 경우)
|
||||||
let lat = row.lat || row.latitude || row.y;
|
let lat = row.lat || row.latitude || row.y || row.locationDataY;
|
||||||
let lng = row.lng || row.longitude || row.x;
|
let lng = row.lng || row.longitude || row.x || row.locationDataX;
|
||||||
|
|
||||||
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
|
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
|
||||||
if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) {
|
if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) {
|
||||||
|
|
@ -715,6 +760,31 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
}, [dataSources, loadMultipleDataSources]);
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
|
// 자동 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSources || dataSources.length === 0) return;
|
||||||
|
|
||||||
|
// 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기
|
||||||
|
const intervals = dataSources
|
||||||
|
.map((ds) => ds.refreshInterval)
|
||||||
|
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||||
|
|
||||||
|
if (intervals.length === 0) return;
|
||||||
|
|
||||||
|
const minInterval = Math.min(...intervals);
|
||||||
|
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
console.log("🔄 자동 새로고침 실행");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, minInterval * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("⏹️ 자동 새로고침 정리");
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
// 타일맵 URL (chartConfig에서 가져오기)
|
// 타일맵 URL (chartConfig에서 가져오기)
|
||||||
const tileMapUrl = element?.chartConfig?.tileMapUrl ||
|
const tileMapUrl = element?.chartConfig?.tileMapUrl ||
|
||||||
`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||||||
|
|
@ -737,10 +807,27 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
||||||
|
{lastRefreshTime && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 지도 */}
|
{/* 지도 */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
|
|
@ -769,19 +856,22 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
{/* 폴리곤 렌더링 */}
|
{/* 폴리곤 렌더링 */}
|
||||||
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
||||||
{geoJsonData && polygons.length > 0 && (
|
{(() => {
|
||||||
|
console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
|
||||||
|
geoJsonData: !!geoJsonData,
|
||||||
|
polygonsLength: polygons.length,
|
||||||
|
polygonNames: polygons.map(p => p.name),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
{geoJsonData && polygons.length > 0 ? (
|
||||||
<GeoJSON
|
<GeoJSON
|
||||||
|
key={JSON.stringify(polygons.map(p => p.id))} // 폴리곤 변경 시 재렌더링
|
||||||
data={geoJsonData}
|
data={geoJsonData}
|
||||||
style={(feature: any) => {
|
style={(feature: any) => {
|
||||||
const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도)
|
const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도)
|
||||||
const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군)
|
const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군)
|
||||||
|
|
||||||
// 🔍 디버그: GeoJSON 속성 확인
|
|
||||||
if (ctpName === "경상북도" || sigName?.includes("군위") || sigName?.includes("영천")) {
|
|
||||||
console.log(`🔍 GeoJSON 속성:`, { ctpName, sigName, properties: feature?.properties });
|
|
||||||
console.log(`🔍 매칭 시도할 폴리곤:`, polygons.map(p => p.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명)
|
// 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명)
|
||||||
const matchingPolygon = polygons.find(p => {
|
const matchingPolygon = polygons.find(p => {
|
||||||
if (!p.name) return false;
|
if (!p.name) return false;
|
||||||
|
|
@ -859,6 +949,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<>{console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`)}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 폴리곤 렌더링 (해상 구역만) */}
|
{/* 폴리곤 렌더링 (해상 구역만) */}
|
||||||
|
|
@ -902,21 +994,79 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
key={marker.id}
|
key={marker.id}
|
||||||
position={[marker.lat, marker.lng]}
|
position={[marker.lat, marker.lng]}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup maxWidth={350}>
|
||||||
<div className="min-w-[200px]">
|
<div className="min-w-[250px] max-w-[350px]">
|
||||||
<div className="mb-2 font-semibold">{marker.name}</div>
|
{/* 제목 */}
|
||||||
|
<div className="mb-2 border-b pb-2">
|
||||||
|
<div className="text-base font-bold">{marker.name}</div>
|
||||||
{marker.source && (
|
{marker.source && (
|
||||||
<div className="mb-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
출처: {marker.source}
|
📡 {marker.source}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 정보 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{marker.description && (
|
||||||
|
<div className="rounded bg-muted p-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold text-foreground">상세 정보</div>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(marker.description);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{parsed.incidenteTypeCd === "1" && (
|
||||||
|
<div className="font-semibold text-destructive">🚨 교통사고</div>
|
||||||
|
)}
|
||||||
|
{parsed.incidenteTypeCd === "2" && (
|
||||||
|
<div className="font-semibold text-warning">🚧 도로공사</div>
|
||||||
|
)}
|
||||||
|
{parsed.addressJibun && (
|
||||||
|
<div>📍 {parsed.addressJibun}</div>
|
||||||
|
)}
|
||||||
|
{parsed.addressNew && parsed.addressNew !== parsed.addressJibun && (
|
||||||
|
<div>📍 {parsed.addressNew}</div>
|
||||||
|
)}
|
||||||
|
{parsed.roadName && (
|
||||||
|
<div>🛣️ {parsed.roadName}</div>
|
||||||
|
)}
|
||||||
|
{parsed.linkName && (
|
||||||
|
<div>🔗 {parsed.linkName}</div>
|
||||||
|
)}
|
||||||
|
{parsed.incidentMsg && (
|
||||||
|
<div className="mt-2 border-t pt-2">💬 {parsed.incidentMsg}</div>
|
||||||
|
)}
|
||||||
|
{parsed.eventContent && (
|
||||||
|
<div className="mt-2 border-t pt-2">📝 {parsed.eventContent}</div>
|
||||||
|
)}
|
||||||
|
{parsed.startDate && (
|
||||||
|
<div className="text-[10px]">🕐 {parsed.startDate}</div>
|
||||||
|
)}
|
||||||
|
{parsed.endDate && (
|
||||||
|
<div className="text-[10px]">🕐 종료: {parsed.endDate}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return marker.description;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{marker.status && (
|
{marker.status && (
|
||||||
<div className="mb-1 text-xs">
|
<div className="text-xs">
|
||||||
상태: {marker.status}
|
<span className="font-semibold">상태:</span> {marker.status}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
{/* 좌표 */}
|
||||||
|
<div className="border-t pt-2 text-[10px] text-muted-foreground">
|
||||||
|
📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,586 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon } from "lucide-react";
|
||||||
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
|
|
||||||
|
type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other";
|
||||||
|
|
||||||
|
interface Alert {
|
||||||
|
id: string;
|
||||||
|
type: AlertType;
|
||||||
|
severity: "high" | "medium" | "low";
|
||||||
|
title: string;
|
||||||
|
location?: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiskAlertTestWidgetProps {
|
||||||
|
element: DashboardElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProps) {
|
||||||
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<AlertType | "all">("all");
|
||||||
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const dataSources = useMemo(() => {
|
||||||
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||||
|
|
||||||
|
const parseTextData = (text: string): any[] => {
|
||||||
|
// XML 형식 감지
|
||||||
|
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||||
|
console.log("📄 XML 형식 데이터 감지");
|
||||||
|
return parseXmlData(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 형식 (기상청 특보)
|
||||||
|
console.log("📄 CSV 형식 데이터 감지");
|
||||||
|
const lines = text.split("\n").filter((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return trimmed && !trimmed.startsWith("#") && trimmed !== "=";
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.map((line) => {
|
||||||
|
const values = line.split(",");
|
||||||
|
const obj: any = {};
|
||||||
|
|
||||||
|
if (values.length >= 11) {
|
||||||
|
obj.code = values[0];
|
||||||
|
obj.region = values[1];
|
||||||
|
obj.subCode = values[2];
|
||||||
|
obj.subRegion = values[3];
|
||||||
|
obj.tmFc = values[4];
|
||||||
|
obj.tmEf = values[5];
|
||||||
|
obj.warning = values[6];
|
||||||
|
obj.level = values[7];
|
||||||
|
obj.status = values[8];
|
||||||
|
obj.period = values[9];
|
||||||
|
obj.name = obj.subRegion || obj.region || obj.code;
|
||||||
|
} else {
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
obj[`field_${index}`] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseXmlData = (xmlText: string): any[] => {
|
||||||
|
try {
|
||||||
|
// 간단한 XML 파싱 (DOMParser 사용)
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||||
|
|
||||||
|
const records = xmlDoc.getElementsByTagName("record");
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
const record = records[i];
|
||||||
|
const obj: any = {};
|
||||||
|
|
||||||
|
// 모든 자식 노드를 객체로 변환
|
||||||
|
for (let j = 0; j < record.children.length; j++) {
|
||||||
|
const child = record.children[j];
|
||||||
|
obj[child.tagName] = child.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ XML 파싱 실패:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRestApiData = useCallback(async (source: ChartDataSource) => {
|
||||||
|
if (!source.endpoint) {
|
||||||
|
throw new Error("API endpoint가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿼리 파라미터 처리
|
||||||
|
const queryParamsObj: Record<string, string> = {};
|
||||||
|
if (source.queryParams && Array.isArray(source.queryParams)) {
|
||||||
|
source.queryParams.forEach((param) => {
|
||||||
|
if (param.key && param.value) {
|
||||||
|
queryParamsObj[param.key] = param.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헤더 처리
|
||||||
|
const headersObj: Record<string, string> = {};
|
||||||
|
if (source.headers && Array.isArray(source.headers)) {
|
||||||
|
source.headers.forEach((header) => {
|
||||||
|
if (header.key && header.value) {
|
||||||
|
headersObj[header.key] = header.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🌐 API 호출 준비:", {
|
||||||
|
endpoint: source.endpoint,
|
||||||
|
queryParams: queryParamsObj,
|
||||||
|
headers: headersObj,
|
||||||
|
});
|
||||||
|
console.log("🔍 원본 source.queryParams:", source.queryParams);
|
||||||
|
console.log("🔍 원본 source.headers:", source.headers);
|
||||||
|
|
||||||
|
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: source.endpoint,
|
||||||
|
method: "GET",
|
||||||
|
headers: headersObj,
|
||||||
|
queryParams: queryParamsObj,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🌐 API 응답 상태:", response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "API 호출 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiData = result.data;
|
||||||
|
|
||||||
|
console.log("🔍 API 응답 데이터 타입:", typeof apiData);
|
||||||
|
console.log("🔍 API 응답 데이터 (처음 500자):", typeof apiData === "string" ? apiData.substring(0, 500) : JSON.stringify(apiData).substring(0, 500));
|
||||||
|
|
||||||
|
// 백엔드가 {text: "XML..."} 형태로 감싼 경우 처리
|
||||||
|
if (apiData && typeof apiData === "object" && apiData.text && typeof apiData.text === "string") {
|
||||||
|
console.log("📦 백엔드가 text 필드로 감싼 데이터 감지");
|
||||||
|
apiData = parseTextData(apiData.text);
|
||||||
|
console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||||
|
} else if (typeof apiData === "string") {
|
||||||
|
console.log("📄 텍스트 형식 데이터 감지, 파싱 시도");
|
||||||
|
apiData = parseTextData(apiData);
|
||||||
|
console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||||
|
} else if (Array.isArray(apiData)) {
|
||||||
|
console.log("✅ 이미 배열 형태의 데이터입니다.");
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도.");
|
||||||
|
apiData = [apiData];
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON Path 적용
|
||||||
|
if (source.jsonPath && typeof apiData === "object" && !Array.isArray(apiData)) {
|
||||||
|
const paths = source.jsonPath.split(".");
|
||||||
|
for (const path of paths) {
|
||||||
|
if (apiData && typeof apiData === "object" && path in apiData) {
|
||||||
|
apiData = apiData[path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(apiData) ? apiData : [apiData];
|
||||||
|
return convertToAlerts(rows, source.name || source.id || "API");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDatabaseData = useCallback(async (source: ChartDataSource) => {
|
||||||
|
if (!source.query) {
|
||||||
|
throw new Error("SQL 쿼리가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||||
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
|
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||||
|
parseInt(source.externalConnectionId),
|
||||||
|
source.query
|
||||||
|
);
|
||||||
|
if (!externalResult.success || !externalResult.data) {
|
||||||
|
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||||
|
}
|
||||||
|
const resultData = externalResult.data as unknown as { rows: Record<string, unknown>[] };
|
||||||
|
return convertToAlerts(resultData.rows, source.name || source.id || "Database");
|
||||||
|
} else {
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(source.query);
|
||||||
|
return convertToAlerts(result.rows, source.name || source.id || "Database");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const convertToAlerts = useCallback((rows: any[], sourceName: string): Alert[] => {
|
||||||
|
console.log("🔄 convertToAlerts 호출:", rows.length, "개 행");
|
||||||
|
|
||||||
|
return rows.map((row: any, index: number) => {
|
||||||
|
// 타입 결정 (UTIC XML 기준)
|
||||||
|
let type: AlertType = "other";
|
||||||
|
|
||||||
|
// incidenteTypeCd: 1=사고, 2=공사, 3=행사, 4=기타
|
||||||
|
if (row.incidenteTypeCd) {
|
||||||
|
const typeCode = String(row.incidenteTypeCd);
|
||||||
|
if (typeCode === "1") {
|
||||||
|
type = "accident";
|
||||||
|
} else if (typeCode === "2") {
|
||||||
|
type = "construction";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 기상 특보 데이터 (warning 필드가 있으면 무조건 날씨)
|
||||||
|
else if (row.warning) {
|
||||||
|
type = "weather";
|
||||||
|
}
|
||||||
|
// 일반 데이터
|
||||||
|
else if (row.type || row.타입 || row.alert_type) {
|
||||||
|
type = (row.type || row.타입 || row.alert_type) as AlertType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 심각도 결정
|
||||||
|
let severity: "high" | "medium" | "low" = "medium";
|
||||||
|
|
||||||
|
if (type === "accident") {
|
||||||
|
severity = "high"; // 사고는 항상 높음
|
||||||
|
} else if (type === "construction") {
|
||||||
|
severity = "medium"; // 공사는 중간
|
||||||
|
} else if (row.level === "경보") {
|
||||||
|
severity = "high";
|
||||||
|
} else if (row.level === "주의" || row.level === "주의보") {
|
||||||
|
severity = "medium";
|
||||||
|
} else if (row.severity || row.심각도 || row.priority) {
|
||||||
|
severity = (row.severity || row.심각도 || row.priority) as "high" | "medium" | "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제목 생성 (UTIC XML 기준)
|
||||||
|
let title = "";
|
||||||
|
|
||||||
|
if (type === "accident") {
|
||||||
|
// incidenteSubTypeCd: 1=추돌, 2=접촉, 3=전복, 4=추락, 5=화재, 6=침수, 7=기타
|
||||||
|
const subType = row.incidenteSubTypeCd;
|
||||||
|
const subTypeMap: { [key: string]: string } = {
|
||||||
|
"1": "추돌사고", "2": "접촉사고", "3": "전복사고",
|
||||||
|
"4": "추락사고", "5": "화재사고", "6": "침수사고", "7": "기타사고"
|
||||||
|
};
|
||||||
|
title = subTypeMap[String(subType)] || "교통사고";
|
||||||
|
} else if (type === "construction") {
|
||||||
|
title = "도로공사";
|
||||||
|
} else if (type === "weather" && row.warning && row.level) {
|
||||||
|
// 날씨 특보: 공백 제거
|
||||||
|
const warning = String(row.warning).trim();
|
||||||
|
const level = String(row.level).trim();
|
||||||
|
title = `${warning} ${level}`;
|
||||||
|
} else {
|
||||||
|
title = row.title || row.제목 || row.name || "알림";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위치 정보 (UTIC XML 기준) - 공백 제거
|
||||||
|
let location = row.addressJibun || row.addressNew ||
|
||||||
|
row.roadName || row.linkName ||
|
||||||
|
row.subRegion || row.region ||
|
||||||
|
row.location || row.위치 || undefined;
|
||||||
|
|
||||||
|
if (location && typeof location === "string") {
|
||||||
|
location = location.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설명 생성 (간결하게)
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
if (row.incidentMsg) {
|
||||||
|
description = row.incidentMsg;
|
||||||
|
} else if (row.eventContent) {
|
||||||
|
description = row.eventContent;
|
||||||
|
} else if (row.period) {
|
||||||
|
description = `발효 기간: ${row.period}`;
|
||||||
|
} else if (row.description || row.설명 || row.content) {
|
||||||
|
description = row.description || row.설명 || row.content;
|
||||||
|
} else {
|
||||||
|
// 설명이 없으면 위치 정보만 표시
|
||||||
|
description = location || "상세 정보 없음";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타임스탬프
|
||||||
|
const timestamp = row.startDate || row.eventDate ||
|
||||||
|
row.tmFc || row.tmEf ||
|
||||||
|
row.timestamp || row.created_at ||
|
||||||
|
new Date().toISOString();
|
||||||
|
|
||||||
|
const alert: Alert = {
|
||||||
|
id: row.id || row.alert_id || row.incidentId || row.eventId ||
|
||||||
|
row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`,
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
timestamp,
|
||||||
|
source: sourceName,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(` ✅ Alert ${index}:`, alert);
|
||||||
|
return alert;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
|
if (!dataSources || dataSources.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
dataSources.map(async (source, index) => {
|
||||||
|
console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type);
|
||||||
|
if (source.type === "api") {
|
||||||
|
const alerts = await loadRestApiData(source);
|
||||||
|
console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||||
|
return alerts;
|
||||||
|
} else {
|
||||||
|
const alerts = await loadDatabaseData(source);
|
||||||
|
console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const allAlerts: Alert[] = [];
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림");
|
||||||
|
allAlerts.push(...result.value);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ 결과 ${index + 1} 실패:`, result.reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료");
|
||||||
|
allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
setAlerts(allAlerts);
|
||||||
|
setLastRefreshTime(new Date());
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ 데이터 로딩 실패:", err);
|
||||||
|
setError(err.message || "데이터 로딩 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dataSources, loadRestApiData, loadDatabaseData]);
|
||||||
|
|
||||||
|
// 수동 새로고침 핸들러
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, [loadMultipleDataSources]);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataSources && dataSources.length > 0) {
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}
|
||||||
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
|
// 자동 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataSources || dataSources.length === 0) return;
|
||||||
|
|
||||||
|
// 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기
|
||||||
|
const intervals = dataSources
|
||||||
|
.map((ds) => ds.refreshInterval)
|
||||||
|
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||||
|
|
||||||
|
if (intervals.length === 0) return;
|
||||||
|
|
||||||
|
const minInterval = Math.min(...intervals);
|
||||||
|
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
console.log("🔄 자동 새로고침 실행");
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}, minInterval * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("⏹️ 자동 새로고침 정리");
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
|
const getTypeIcon = (type: AlertType) => {
|
||||||
|
switch (type) {
|
||||||
|
case "accident": return <AlertTriangle className="h-4 w-4" />;
|
||||||
|
case "weather": return <Cloud className="h-4 w-4" />;
|
||||||
|
case "construction": return <Construction className="h-4 w-4" />;
|
||||||
|
default: return <AlertTriangle className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityColor = (severity: "high" | "medium" | "low") => {
|
||||||
|
switch (severity) {
|
||||||
|
case "high": return "bg-red-500";
|
||||||
|
case "medium": return "bg-yellow-500";
|
||||||
|
case "low": return "bg-blue-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAlerts = filter === "all" ? alerts : alerts.filter(a => a.type === filter);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
|
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<p className="text-sm">⚠️ {error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadMultipleDataSources}
|
||||||
|
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataSources || dataSources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-3">
|
||||||
|
<div className="max-w-xs space-y-2 text-center">
|
||||||
|
<div className="text-3xl">🚨</div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">🧪 리스크/알림 테스트 위젯</h3>
|
||||||
|
<div className="space-y-1.5 text-xs text-gray-600">
|
||||||
|
<p className="font-medium">다중 데이터 소스 지원</p>
|
||||||
|
<ul className="space-y-0.5 text-left">
|
||||||
|
<li>• 여러 REST API 동시 연결</li>
|
||||||
|
<li>• 여러 Database 동시 연결</li>
|
||||||
|
<li>• REST API + Database 혼합 가능</li>
|
||||||
|
<li>• 알림 타입별 필터링</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||||
|
<p className="font-medium">⚙️ 설정 방법</p>
|
||||||
|
<p>데이터 소스를 추가하고 저장하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-red-50 to-orange-50">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b bg-white/80 p-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{element?.customTitle || "리스크/알림 테스트"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림
|
||||||
|
{lastRefreshTime && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컨텐츠 */}
|
||||||
|
<div className="flex-1 overflow-hidden p-2">
|
||||||
|
<div className="mb-2 flex gap-1 overflow-x-auto">
|
||||||
|
<Button
|
||||||
|
variant={filter === "all" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
전체 ({alerts.length})
|
||||||
|
</Button>
|
||||||
|
{["accident", "weather", "construction"].map((type) => {
|
||||||
|
const count = alerts.filter(a => a.type === type).length;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant={filter === type ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter(type as AlertType)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{type === "accident" && "사고"}
|
||||||
|
{type === "weather" && "날씨"}
|
||||||
|
{type === "construction" && "공사"}
|
||||||
|
{" "}({count})
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-1.5 overflow-y-auto">
|
||||||
|
{filteredAlerts.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-500">
|
||||||
|
<p className="text-sm">알림이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAlerts.map((alert) => (
|
||||||
|
<Card key={alert.id} className="border-l-4 p-2" style={{ borderLeftColor: alert.severity === "high" ? "#ef4444" : alert.severity === "medium" ? "#f59e0b" : "#3b82f6" }}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-red-100 text-red-600" : alert.severity === "medium" ? "bg-yellow-100 text-yellow-600" : "bg-blue-100 text-blue-600"}`}>
|
||||||
|
{getTypeIcon(alert.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<h4 className="text-xs font-semibold truncate">{alert.title}</h4>
|
||||||
|
<Badge variant={alert.severity === "high" ? "destructive" : "secondary"} className="h-4 text-[10px]">
|
||||||
|
{alert.severity === "high" && "긴급"}
|
||||||
|
{alert.severity === "medium" && "주의"}
|
||||||
|
{alert.severity === "low" && "정보"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{alert.location && (
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">📍 {alert.location}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5 line-clamp-2">{alert.description}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[9px] text-gray-400">
|
||||||
|
<span>{new Date(alert.timestamp).toLocaleString("ko-KR")}</span>
|
||||||
|
{alert.source && <span>· {alert.source}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ const nextConfig = {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
destination: "http://host.docker.internal:8080/api/:path*",
|
destination: "http://localhost:8080/api/:path*",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue