ERP-node/frontend/components/dashboard/widgets/ListTestWidget.tsx

354 lines
11 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
interface ListTestWidgetProps {
element: DashboardElement;
}
interface QueryResult {
columns: string[];
rows: Record<string, any>[];
totalRows: number;
executionTime: number;
}
/**
* 리스트 테스트 위젯 (다중 데이터 소스 지원)
* - 여러 REST API 연결 가능
* - 여러 Database 연결 가능
* - REST API + Database 혼합 가능
* - 데이터 자동 병합
*/
export function ListTestWidget({ element }: ListTestWidgetProps) {
const [data, setData] = useState<QueryResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
console.log("🧪 ListTestWidget 렌더링!", element);
const config = element.listConfig || {
columnMode: "auto",
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
};
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (!dataSources || dataSources.length === 0) {
console.log("⚠️ 데이터 소스가 없습니다.");
return;
}
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
setIsLoading(true);
setError(null);
try {
// 모든 데이터 소스를 병렬로 로딩
const results = await Promise.allSettled(
dataSources.map(async (source) => {
try {
console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
if (source.type === "api") {
return await loadRestApiData(source);
} else if (source.type === "database") {
return await loadDatabaseData(source);
}
return { columns: [], rows: [] };
} catch (err: any) {
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
return { columns: [], rows: [] };
}
})
);
// 성공한 데이터만 병합
const allColumns = new Set<string>();
const allRows: Record<string, any>[] = [];
results.forEach((result, index) => {
if (result.status === "fulfilled") {
const { columns, rows } = result.value;
// 컬럼 수집
columns.forEach((col: string) => allColumns.add(col));
// 행 병합 (소스 정보 추가)
const sourceName = dataSources[index].name || dataSources[index].id || `소스 ${index + 1}`;
rows.forEach((row: any) => {
allRows.push({
...row,
_source: sourceName,
});
});
}
});
const finalColumns = Array.from(allColumns);
// _source 컬럼을 맨 앞으로
const sortedColumns = finalColumns.includes("_source")
? ["_source", ...finalColumns.filter((c) => c !== "_source")]
: finalColumns;
setData({
columns: sortedColumns,
rows: allRows,
totalRows: allRows.length,
executionTime: 0,
});
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
setIsLoading(false);
}
}, [element?.dataSources, element?.chartConfig?.dataSources]);
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
if (!source.endpoint) {
throw new Error("API endpoint가 없습니다.");
}
const params = new URLSearchParams();
if (source.queryParams) {
Object.entries(source.queryParams).forEach(([key, value]) => {
if (key && value) {
params.append(key, String(value));
}
});
}
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: source.headers || {},
queryParams: Object.fromEntries(params),
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "외부 API 호출 실패");
}
let processedData = result.data;
// JSON Path 처리
if (source.jsonPath) {
const paths = source.jsonPath.split(".");
for (const path of paths) {
if (processedData && typeof processedData === "object" && path in processedData) {
processedData = processedData[path];
} else {
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
}
}
}
const rows = Array.isArray(processedData) ? processedData : [processedData];
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
return { columns, rows };
};
// Database 데이터 로딩
const loadDatabaseData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
if (!source.query) {
throw new Error("SQL 쿼리가 없습니다.");
}
if (source.connectionType === "external" && source.externalConnectionId) {
// 외부 DB
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 {
columns: string[];
rows: Record<string, unknown>[];
};
return {
columns: resultData.columns,
rows: resultData.rows,
};
} else {
// 현재 DB
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(source.query);
return {
columns: result.columns,
rows: result.rows,
};
}
};
// 초기 로드
useEffect(() => {
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (dataSources && dataSources.length > 0) {
loadMultipleDataSources();
}
}, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]);
// 페이지네이션
const pageSize = config.pageSize || 10;
const totalPages = data ? Math.ceil(data.totalRows / pageSize) : 0;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedRows = data?.rows.slice(startIndex, endIndex) || [];
// 테이블 뷰
const renderTable = () => (
<div className="overflow-auto">
<Table>
{config.showHeader && (
<TableHeader>
<TableRow>
{data?.columns.map((col) => (
<TableHead key={col} className="whitespace-nowrap">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
{data?.columns.map((col) => (
<TableCell key={col} className="whitespace-nowrap">
{String(row[col] ?? "")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
// 카드 뷰
const renderCards = () => (
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
{paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4">
{data?.columns.map((col) => (
<div key={col} className="mb-2">
<span className="font-semibold">{col}: </span>
<span>{String(row[col] ?? "")}</span>
</div>
))}
</Card>
))}
</div>
);
return (
<div className="flex h-full flex-col rounded-lg border bg-card shadow-sm">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
</h3>
<p className="text-xs text-muted-foreground">
{(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}
</p>
</div>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-auto p-4">
{error ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
</div>
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
) : !data || data.rows.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
) : config.viewMode === "card" ? (
renderCards()
) : (
renderTable()
)}
</div>
{/* 페이지네이션 */}
{config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && (
<div className="flex items-center justify-between border-t p-4">
<div className="text-sm text-muted-foreground">
{data.totalRows} ( {currentPage}/{totalPages})
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</div>
);
}