ERP-node/frontend/components/admin/dashboard/widgets/ListWidget.tsx

341 lines
12 KiB
TypeScript
Raw Normal View History

2025-10-15 11:17:09 +09:00
"use client";
import React, { useState, useEffect } from "react";
2025-10-22 13:40:15 +09:00
import { DashboardElement, QueryResult, ListColumn } from "../types";
2025-10-15 11:17:09 +09:00
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
2025-10-15 11:29:53 +09:00
import { Card } from "@/components/ui/card";
2025-10-15 11:17:09 +09:00
interface ListWidgetProps {
element: DashboardElement;
}
/**
*
* - DB REST API로
* -
* - , ,
*/
2025-10-22 13:40:15 +09:00
export function ListWidget({ element }: ListWidgetProps) {
2025-10-15 11:17:09 +09:00
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",
2025-10-15 11:29:53 +09:00
viewMode: "table",
2025-10-15 11:17:09 +09:00
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
2025-10-15 11:29:53 +09:00
cardColumns: 3,
2025-10-15 11:17:09 +09:00
};
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
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) {
2025-10-22 13:40:15 +09:00
params.append(key, String(value));
2025-10-15 11:17:09 +09:00
}
});
}
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
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,
);
2025-10-22 13:40:15 +09:00
if (!externalResult.success || !externalResult.data) {
2025-10-15 11:17:09 +09:00
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
}
2025-10-22 13:40:15 +09:00
const resultData = externalResult.data as unknown as {
columns: string[];
rows: Record<string, unknown>[];
rowCount: number;
};
2025-10-15 11:17:09 +09:00
queryResult = {
2025-10-22 13:40:15 +09:00
columns: resultData.columns,
rows: resultData.rows,
totalRows: resultData.rowCount,
2025-10-15 11:17:09 +09:00
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);
}
2025-10-22 13:40:15 +09:00
}, [element.dataSource]);
2025-10-15 11:17:09 +09:00
// 로딩 중
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
<div className="text-sm text-gray-600"> ...</div>
</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>
<div className="text-sm font-medium text-red-600"> </div>
<div className="mt-1 text-xs text-gray-500">{error}</div>
</div>
</div>
);
}
// 데이터 없음
if (!data) {
2025-10-15 11:17:09 +09:00
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
<div className="text-center">
2025-10-22 13:40:15 +09:00
<div className="mt-1 text-xs text-gray-500"> </div>
2025-10-15 11:17:09 +09:00
</div>
</div>
);
}
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
2025-10-22 13:40:15 +09:00
const displayColumns: ListColumn[] =
config.columns.length > 0
? config.columns
: data.columns.map((col) => ({
id: col,
2025-10-22 13:40:15 +09:00
label: col,
field: col,
visible: true,
2025-10-22 13:40:15 +09:00
align: "left" as const,
}));
2025-10-15 11:17:09 +09:00
// 페이지네이션
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 gap-3 p-4">
2025-10-15 11:29:53 +09:00
{/* 테이블 뷰 */}
{config.viewMode === "table" && (
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
<Table>
{config.showHeader && (
<TableHeader>
<TableRow>
{displayColumns
2025-10-15 11:17:09 +09:00
.filter((col) => col.visible)
.map((col) => (
2025-10-15 11:29:53 +09:00
<TableHead
2025-10-15 11:17:09 +09:00
key={col.id}
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
2025-10-15 11:29:53 +09:00
style={{ width: col.width ? `${col.width}px` : undefined }}
2025-10-15 11:17:09 +09:00
>
2025-10-22 13:40:15 +09:00
{col.label}
2025-10-15 11:29:53 +09:00
</TableHead>
2025-10-15 11:17:09 +09:00
))}
</TableRow>
2025-10-15 11:29:53 +09:00
</TableHeader>
2025-10-15 11:17:09 +09:00
)}
2025-10-15 11:29:53 +09:00
<TableBody>
{paginatedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={displayColumns.filter((col) => col.visible).length}
2025-10-15 11:29:53 +09:00
className="text-center text-gray-500"
>
</TableCell>
</TableRow>
) : (
paginatedRows.map((row, idx) => (
2025-10-23 12:50:38 +09:00
<TableRow key={idx} className={config.stripedRows && idx % 2 === 1 ? "bg-muted/50" : ""}>
{displayColumns
2025-10-15 11:29:53 +09:00
.filter((col) => col.visible)
.map((col) => (
<TableCell
key={col.id}
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
>
2025-10-22 13:40:15 +09:00
{String(row[col.field] ?? "")}
2025-10-15 11:29:53 +09:00
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
{/* 카드 뷰 */}
{config.viewMode === "card" && (
<div className="flex-1 overflow-auto">
{paginatedRows.length === 0 ? (
<div className="flex h-full items-center justify-center text-gray-500"> </div>
) : (
<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
2025-10-15 11:29:53 +09:00
.filter((col) => col.visible)
.map((col) => (
<div key={col.id}>
2025-10-22 13:40:15 +09:00
<div className="text-xs font-medium text-gray-500">{col.label}</div>
2025-10-15 11:29:53 +09:00
<div
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
>
2025-10-22 13:40:15 +09:00
{String(row[col.field] ?? "")}
2025-10-15 11:29:53 +09:00
</div>
</div>
))}
</div>
</Card>
))}
</div>
)}
</div>
)}
2025-10-15 11:17:09 +09:00
{/* 페이지네이션 */}
{config.enablePagination && totalPages > 1 && (
<div className="flex shrink-0 items-center justify-between border-t pt-3 text-sm">
2025-10-15 11:17:09 +09:00
<div className="text-gray-600">
{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">
<span className="text-gray-700">{currentPage}</span>
<span className="text-gray-400">/</span>
<span className="text-gray-500">{totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</div>
);
}