2025-10-29 09:32:03 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
|
|
|
|
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
|
|
|
|
import { Card } from "@/components/ui/card";
|
2025-10-29 11:52:18 +09:00
|
|
|
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
2025-10-29 09:32:03 +09:00
|
|
|
|
|
|
|
|
|
|
interface ListWidgetProps {
|
|
|
|
|
|
element: DashboardElement;
|
|
|
|
|
|
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 리스트 위젯 컴포넌트
|
|
|
|
|
|
* - DB 쿼리 또는 REST API로 데이터 가져오기
|
|
|
|
|
|
* - 테이블 형태로 데이터 표시
|
|
|
|
|
|
* - 페이지네이션, 정렬, 검색 기능
|
2025-10-15 11:17:09 +09:00
|
|
|
|
*/
|
2025-10-29 09:32:03 +09:00
|
|
|
|
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|
|
|
|
|
const [data, setData] = useState<QueryResult | null>(null);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
|
|
|
|
|
|
|
const config = element.listConfig || {
|
|
|
|
|
|
columnMode: "auto",
|
|
|
|
|
|
viewMode: "table",
|
|
|
|
|
|
columns: [],
|
|
|
|
|
|
pageSize: 10,
|
|
|
|
|
|
enablePagination: true,
|
|
|
|
|
|
showHeader: true,
|
|
|
|
|
|
stripedRows: true,
|
|
|
|
|
|
compactMode: false,
|
|
|
|
|
|
cardColumns: 3,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadData = async () => {
|
2025-10-31 12:10:46 +09:00
|
|
|
|
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-29 09:32:03 +09:00
|
|
|
|
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
let queryResult: QueryResult;
|
|
|
|
|
|
|
|
|
|
|
|
// REST API vs Database 분기
|
|
|
|
|
|
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
|
|
|
|
|
// REST API - 백엔드 프록시를 통한 호출
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
if (element.dataSource.queryParams) {
|
|
|
|
|
|
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
|
|
|
|
|
if (key && value) {
|
|
|
|
|
|
params.append(key, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-29 11:52:18 +09:00
|
|
|
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
2025-10-29 09:32:03 +09:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
url: element.dataSource.endpoint,
|
|
|
|
|
|
method: "GET",
|
|
|
|
|
|
headers: element.dataSource.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 호출 실패");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const apiData = result.data;
|
|
|
|
|
|
|
|
|
|
|
|
// JSON Path 처리
|
|
|
|
|
|
let processedData = apiData;
|
|
|
|
|
|
if (element.dataSource.jsonPath) {
|
|
|
|
|
|
const paths = element.dataSource.jsonPath.split(".");
|
|
|
|
|
|
for (const path of paths) {
|
|
|
|
|
|
if (processedData && typeof processedData === "object" && path in processedData) {
|
|
|
|
|
|
processedData = processedData[path];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
|
|
|
|
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
|
|
|
|
|
|
|
|
|
|
queryResult = {
|
|
|
|
|
|
columns,
|
|
|
|
|
|
rows,
|
|
|
|
|
|
totalRows: rows.length,
|
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
} else if (element.dataSource.query) {
|
|
|
|
|
|
// Database (현재 DB 또는 외부 DB)
|
|
|
|
|
|
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
|
|
|
|
|
// 외부 DB
|
|
|
|
|
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
|
|
|
|
|
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
|
|
|
|
|
parseInt(element.dataSource.externalConnectionId),
|
|
|
|
|
|
element.dataSource.query,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!externalResult.success) {
|
|
|
|
|
|
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
|
|
|
|
|
}
|
|
|
|
|
|
queryResult = {
|
|
|
|
|
|
columns: externalResult.data.columns,
|
|
|
|
|
|
rows: externalResult.data.rows,
|
|
|
|
|
|
totalRows: externalResult.data.rowCount,
|
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 현재 DB
|
|
|
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
|
|
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
|
|
|
|
|
queryResult = {
|
|
|
|
|
|
columns: result.columns,
|
|
|
|
|
|
rows: result.rows,
|
|
|
|
|
|
totalRows: result.rowCount,
|
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setData(queryResult);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
loadData();
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 새로고침 설정
|
|
|
|
|
|
const refreshInterval = element.dataSource?.refreshInterval;
|
|
|
|
|
|
if (refreshInterval && refreshInterval > 0) {
|
|
|
|
|
|
const interval = setInterval(loadData, refreshInterval);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [
|
|
|
|
|
|
element.dataSource?.query,
|
|
|
|
|
|
element.dataSource?.connectionType,
|
|
|
|
|
|
element.dataSource?.externalConnectionId,
|
|
|
|
|
|
element.dataSource?.endpoint,
|
|
|
|
|
|
element.dataSource?.refreshInterval,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 로딩 중
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
2025-10-31 12:10:46 +09:00
|
|
|
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
|
|
|
|
|
<div className="text-foreground text-sm">데이터 로딩 중...</div>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 에러
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="mb-2 text-2xl">⚠️</div>
|
2025-10-31 12:10:46 +09:00
|
|
|
|
<div className="text-destructive text-sm font-medium">오류 발생</div>
|
|
|
|
|
|
<div className="text-muted-foreground mt-1 text-xs">{error}</div>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 없음
|
|
|
|
|
|
if (!data) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="mb-2 text-4xl">📋</div>
|
2025-10-31 12:10:46 +09:00
|
|
|
|
<div className="text-foreground text-sm font-medium">리스트를 설정하세요</div>
|
|
|
|
|
|
<div className="text-muted-foreground mt-1 text-xs">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
|
|
|
|
|
|
const displayColumns =
|
|
|
|
|
|
config.columns.length > 0
|
|
|
|
|
|
? config.columns
|
|
|
|
|
|
: data.columns.map((col) => ({
|
|
|
|
|
|
id: col,
|
|
|
|
|
|
name: col,
|
|
|
|
|
|
dataKey: col,
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 페이지네이션
|
|
|
|
|
|
const totalPages = Math.ceil(data.rows.length / config.pageSize);
|
|
|
|
|
|
const startIdx = (currentPage - 1) * config.pageSize;
|
|
|
|
|
|
const endIdx = startIdx + config.pageSize;
|
|
|
|
|
|
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full w-full flex-col p-4">
|
|
|
|
|
|
{/* 제목 - 항상 표시 */}
|
|
|
|
|
|
<div className="mb-4">
|
2025-10-31 12:10:46 +09:00
|
|
|
|
<h3 className="text-foreground text-sm font-semibold">{element.customTitle || element.title}</h3>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 테이블 뷰 */}
|
|
|
|
|
|
{config.viewMode === "table" && (
|
2025-10-30 15:39:39 +09:00
|
|
|
|
<div className={`flex-1 overflow-auto rounded-lg ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
<Table>
|
|
|
|
|
|
{config.showHeader && (
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
{displayColumns
|
|
|
|
|
|
.filter((col) => col.visible)
|
|
|
|
|
|
.map((col) => (
|
|
|
|
|
|
<TableHead
|
|
|
|
|
|
key={col.id}
|
|
|
|
|
|
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
|
|
|
|
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{col.label || col.name}
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{paginatedRows.length === 0 ? (
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableCell
|
|
|
|
|
|
colSpan={displayColumns.filter((col) => col.visible).length}
|
2025-10-31 12:10:46 +09:00
|
|
|
|
className="text-muted-foreground text-center"
|
2025-10-29 09:32:03 +09:00
|
|
|
|
>
|
|
|
|
|
|
데이터가 없습니다
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
paginatedRows.map((row, idx) => (
|
|
|
|
|
|
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
|
|
|
|
|
{displayColumns
|
|
|
|
|
|
.filter((col) => col.visible)
|
|
|
|
|
|
.map((col) => (
|
|
|
|
|
|
<TableCell
|
|
|
|
|
|
key={col.id}
|
|
|
|
|
|
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
|
|
|
|
|
>
|
|
|
|
|
|
{String(row[col.dataKey || col.field] ?? "")}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 카드 뷰 */}
|
|
|
|
|
|
{config.viewMode === "card" && (
|
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
|
{paginatedRows.length === 0 ? (
|
2025-10-31 12:10:46 +09:00
|
|
|
|
<div className="text-muted-foreground flex h-full items-center justify-center">데이터가 없습니다</div>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
gridTemplateColumns: `repeat(${config.cardColumns || 3}, minmax(0, 1fr))`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{paginatedRows.map((row, idx) => (
|
|
|
|
|
|
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{displayColumns
|
|
|
|
|
|
.filter((col) => col.visible)
|
|
|
|
|
|
.map((col) => (
|
|
|
|
|
|
<div key={col.id}>
|
2025-10-31 12:10:46 +09:00
|
|
|
|
<div className="text-muted-foreground text-xs font-medium">{col.label || col.name}</div>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
<div
|
2025-10-31 12:10:46 +09:00
|
|
|
|
className={`text-foreground font-medium ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
2025-10-29 09:32:03 +09:00
|
|
|
|
>
|
|
|
|
|
|
{String(row[col.dataKey || col.field] ?? "")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-15 11:17:09 +09:00
|
|
|
|
|
2025-10-29 09:32:03 +09:00
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
|
{config.enablePagination && totalPages > 1 && (
|
|
|
|
|
|
<div className="mt-4 flex items-center justify-between text-sm">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-foreground">
|
2025-10-29 09:32:03 +09:00
|
|
|
|
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
이전
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<div className="flex items-center gap-1 px-2">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<span className="text-foreground">{currentPage}</span>
|
|
|
|
|
|
<span className="text-muted-foreground">/</span>
|
|
|
|
|
|
<span className="text-muted-foreground">{totalPages}</span>
|
2025-10-29 09:32:03 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
|
>
|
|
|
|
|
|
다음
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|