948 lines
34 KiB
TypeScript
948 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { DashboardElement, ChartDataSource, FieldGroup } 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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
|
|
|
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);
|
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
|
const [containerHeight, setContainerHeight] = useState<number>(0);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// 행 상세 팝업 상태
|
|
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
|
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
|
|
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
|
|
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | null>(null);
|
|
|
|
// 컨테이너 높이 감지
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
setContainerHeight(entry.contentRect.height);
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(container);
|
|
return () => resizeObserver.disconnect();
|
|
}, []);
|
|
|
|
// 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하)
|
|
const elementHeight = element?.size?.height || 0;
|
|
const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300);
|
|
|
|
// // console.log("🧪 ListTestWidget 렌더링!", element);
|
|
|
|
const dataSources = useMemo(() => {
|
|
// 다중 데이터 소스 우선
|
|
const multiSources = element?.dataSources || element?.chartConfig?.dataSources;
|
|
if (multiSources && multiSources.length > 0) {
|
|
return multiSources;
|
|
}
|
|
|
|
// 단일 데이터 소스 fallback (배열로 변환)
|
|
if (element?.dataSource) {
|
|
return [element.dataSource];
|
|
}
|
|
|
|
return [];
|
|
}, [element?.dataSources, element?.chartConfig?.dataSources, element?.dataSource]);
|
|
|
|
// // console.log("📊 dataSources 확인:", {
|
|
// hasDataSources: !!dataSources,
|
|
// dataSourcesLength: dataSources?.length || 0,
|
|
// dataSources: dataSources,
|
|
// });
|
|
|
|
const config = element.listConfig || {
|
|
columnMode: "auto",
|
|
viewMode: "table",
|
|
columns: [],
|
|
pageSize: 10,
|
|
enablePagination: true,
|
|
showHeader: true,
|
|
stripedRows: true,
|
|
compactMode: false,
|
|
cardColumns: 3,
|
|
};
|
|
|
|
// 행 클릭 핸들러 - 팝업 열기
|
|
const handleRowClick = useCallback(
|
|
async (row: Record<string, any>) => {
|
|
// 팝업이 비활성화되어 있으면 무시
|
|
if (!config.rowDetailPopup?.enabled) return;
|
|
|
|
setDetailPopupData(row);
|
|
setDetailPopupOpen(true);
|
|
setAdditionalDetailData(null);
|
|
setDetailPopupLoading(false);
|
|
|
|
// 추가 데이터 조회 설정이 있으면 실행
|
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
|
if (additionalQuery?.enabled) {
|
|
const queryMode = additionalQuery.queryMode || "table";
|
|
|
|
// 커스텀 쿼리 모드
|
|
if (queryMode === "custom" && additionalQuery.customQuery) {
|
|
setDetailPopupLoading(true);
|
|
try {
|
|
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
|
let query = additionalQuery.customQuery;
|
|
// console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
|
|
// console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
|
|
// console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
|
|
|
|
Object.keys(row).forEach((key) => {
|
|
const value = row[key];
|
|
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
|
// SQL 인젝션 방지를 위해 값 이스케이프
|
|
const safeValue = typeof value === "string"
|
|
? value.replace(/'/g, "''")
|
|
: value;
|
|
query = query.replace(placeholder, String(safeValue ?? ""));
|
|
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
|
|
});
|
|
|
|
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
|
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
const result = await dashboardApi.executeQuery(query);
|
|
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
|
|
|
|
if (result.success && result.rows.length > 0) {
|
|
setAdditionalDetailData(result.rows[0]);
|
|
} else {
|
|
setAdditionalDetailData({});
|
|
}
|
|
} catch (err) {
|
|
console.error("커스텀 쿼리 실행 실패:", err);
|
|
setAdditionalDetailData({});
|
|
} finally {
|
|
setDetailPopupLoading(false);
|
|
}
|
|
}
|
|
// 테이블 조회 모드
|
|
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
|
const matchValue = row[sourceColumn];
|
|
|
|
if (matchValue !== undefined && matchValue !== null) {
|
|
setDetailPopupLoading(true);
|
|
try {
|
|
const query = `
|
|
SELECT *
|
|
FROM ${additionalQuery.tableName}
|
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
|
LIMIT 1;
|
|
`;
|
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
const result = await dashboardApi.executeQuery(query);
|
|
|
|
if (result.success && result.rows.length > 0) {
|
|
setAdditionalDetailData(result.rows[0]);
|
|
} else {
|
|
setAdditionalDetailData({});
|
|
}
|
|
} catch (err) {
|
|
console.error("추가 데이터 로드 실패:", err);
|
|
setAdditionalDetailData({});
|
|
} finally {
|
|
setDetailPopupLoading(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[config.rowDetailPopup],
|
|
);
|
|
|
|
// 값 포맷팅 함수
|
|
const formatValue = (value: any, format?: string): string => {
|
|
if (value === null || value === undefined) return "-";
|
|
|
|
switch (format) {
|
|
case "date":
|
|
try {
|
|
const dateVal = new Date(value);
|
|
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
case "datetime":
|
|
try {
|
|
const dateVal = new Date(value);
|
|
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
case "number":
|
|
return Number(value).toLocaleString("ko-KR");
|
|
case "currency":
|
|
return `${Number(value).toLocaleString("ko-KR")}원`;
|
|
case "boolean":
|
|
return value ? "예" : "아니오";
|
|
case "distance":
|
|
return typeof value === "number" ? `${value.toFixed(1)} km` : String(value);
|
|
case "duration":
|
|
return typeof value === "number" ? `${value}분` : String(value);
|
|
default:
|
|
return String(value);
|
|
}
|
|
};
|
|
|
|
// 아이콘 렌더링
|
|
const renderIcon = (icon?: string, color?: string) => {
|
|
const colorClass =
|
|
color === "blue"
|
|
? "text-blue-600"
|
|
: color === "orange"
|
|
? "text-orange-600"
|
|
: color === "green"
|
|
? "text-green-600"
|
|
: color === "red"
|
|
? "text-red-600"
|
|
: color === "purple"
|
|
? "text-purple-600"
|
|
: "text-gray-600";
|
|
|
|
switch (icon) {
|
|
case "truck":
|
|
return <Truck className={`h-4 w-4 ${colorClass}`} />;
|
|
case "clock":
|
|
return <Clock className={`h-4 w-4 ${colorClass}`} />;
|
|
case "map":
|
|
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
|
|
case "package":
|
|
return <Package className={`h-4 w-4 ${colorClass}`} />;
|
|
default:
|
|
return <Info className={`h-4 w-4 ${colorClass}`} />;
|
|
}
|
|
};
|
|
|
|
// 필드 그룹 렌더링
|
|
const renderFieldGroup = (group: FieldGroup, groupData: Record<string, any>) => {
|
|
const colorClass =
|
|
group.color === "blue"
|
|
? "text-blue-600"
|
|
: group.color === "orange"
|
|
? "text-orange-600"
|
|
: group.color === "green"
|
|
? "text-green-600"
|
|
: group.color === "red"
|
|
? "text-red-600"
|
|
: group.color === "purple"
|
|
? "text-purple-600"
|
|
: "text-gray-600";
|
|
|
|
return (
|
|
<div key={group.id} className="rounded-lg border p-4">
|
|
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
|
|
{renderIcon(group.icon, group.color)}
|
|
{group.title}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
|
{group.fields.map((field) => (
|
|
<div key={field.column} className="flex flex-col gap-0.5">
|
|
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
|
{field.label}
|
|
</span>
|
|
<span className="font-medium break-words">{formatValue(groupData[field.column], field.format)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 기본 필드 그룹 생성 (설정이 없을 경우)
|
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
|
const groups: FieldGroup[] = [];
|
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
|
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
|
|
|
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
|
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
|
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
|
? { ...row, ...additional } // additional이 row를 덮어씀
|
|
: row;
|
|
|
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
|
const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
|
let basicFields: { column: string; label: string }[] = [];
|
|
|
|
if (displayColumns && displayColumns.length > 0) {
|
|
// DisplayColumnConfig 형식 지원
|
|
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
|
basicFields = displayColumns
|
|
.map((colConfig) => {
|
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
|
return { column, label };
|
|
})
|
|
.filter((item) => allKeys.includes(item.column));
|
|
} else {
|
|
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
|
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
|
basicFields = Object.keys(additional)
|
|
.filter((key) => !key.startsWith("_"))
|
|
.map((key) => ({ column: key, label: key }));
|
|
} else {
|
|
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
|
}
|
|
}
|
|
|
|
groups.push({
|
|
id: "basic",
|
|
title: "기본 정보",
|
|
icon: "info",
|
|
color: "gray",
|
|
fields: basicFields.map((item) => ({
|
|
column: item.column,
|
|
label: item.label,
|
|
format: "text" as const,
|
|
})),
|
|
});
|
|
|
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
|
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
|
// 운행 정보
|
|
if (additional.last_trip_start || additional.last_trip_end) {
|
|
groups.push({
|
|
id: "trip",
|
|
title: "운행 정보",
|
|
icon: "truck",
|
|
color: "blue",
|
|
fields: [
|
|
{ column: "last_trip_start", label: "시작", format: "datetime" as const },
|
|
{ column: "last_trip_end", label: "종료", format: "datetime" as const },
|
|
{ column: "last_trip_distance", label: "거리", format: "distance" as const },
|
|
{ column: "last_trip_time", label: "시간", format: "duration" as const },
|
|
{ column: "departure", label: "출발지", format: "text" as const },
|
|
{ column: "arrival", label: "도착지", format: "text" as const },
|
|
],
|
|
});
|
|
}
|
|
|
|
// 공차 정보
|
|
if (additional.last_empty_start) {
|
|
groups.push({
|
|
id: "empty",
|
|
title: "공차 정보",
|
|
icon: "package",
|
|
color: "orange",
|
|
fields: [
|
|
{ column: "last_empty_start", label: "시작", format: "datetime" as const },
|
|
{ column: "last_empty_end", label: "종료", format: "datetime" as const },
|
|
{ column: "last_empty_distance", label: "거리", format: "distance" as const },
|
|
{ column: "last_empty_time", label: "시간", format: "duration" as const },
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
};
|
|
|
|
// visible 컬럼 설정 객체 배열 (field + label)
|
|
const visibleColumnConfigs = useMemo(() => {
|
|
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
|
return config.columns.filter((col: any) => col.visible !== false);
|
|
}
|
|
return [];
|
|
}, [config.columns]);
|
|
|
|
// 표시할 컬럼 필드명 (데이터 접근용)
|
|
const displayColumns = useMemo(() => {
|
|
if (!data?.columns) return [];
|
|
|
|
// 컬럼 설정이 있으면 field 사용
|
|
if (visibleColumnConfigs.length > 0) {
|
|
return visibleColumnConfigs.map((col: any) => col.field);
|
|
}
|
|
|
|
// 자동 모드: 모든 컬럼 표시
|
|
return data.columns;
|
|
}, [data?.columns, visibleColumnConfigs]);
|
|
|
|
// 다중 데이터 소스 로딩
|
|
const loadMultipleDataSources = useCallback(async () => {
|
|
console.log("[ListTestWidget] dataSources:", dataSources);
|
|
|
|
if (!dataSources || dataSources.length === 0) {
|
|
console.log("[ListTestWidget] 데이터 소스가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
console.log(`[ListTestWidget] ${dataSources.length}개의 데이터 소스 로딩 시작...`, dataSources[0]);
|
|
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,
|
|
});
|
|
setLastRefreshTime(new Date());
|
|
|
|
// console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [dataSources]);
|
|
|
|
// 수동 새로고침 핸들러
|
|
const handleManualRefresh = useCallback(() => {
|
|
// console.log("🔄 수동 새로고침 버튼 클릭");
|
|
loadMultipleDataSources();
|
|
}, [loadMultipleDataSources]);
|
|
|
|
// 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));
|
|
}
|
|
});
|
|
}
|
|
|
|
// 요청 메서드 (기본값: GET)
|
|
const requestMethod = source.method || "GET";
|
|
|
|
// 요청 body (POST, PUT, PATCH인 경우)
|
|
let requestBody = undefined;
|
|
if (["POST", "PUT", "PATCH"].includes(requestMethod) && source.body) {
|
|
try {
|
|
// body가 문자열이면 JSON 파싱 시도
|
|
requestBody = typeof source.body === "string" ? JSON.parse(source.body) : source.body;
|
|
} catch {
|
|
// 파싱 실패하면 문자열 그대로 사용
|
|
requestBody = source.body;
|
|
}
|
|
}
|
|
|
|
// headers를 KeyValuePair[] 에서 객체로 변환
|
|
const headersObj: Record<string, string> = {};
|
|
if (source.headers && Array.isArray(source.headers)) {
|
|
source.headers.forEach((h: any) => {
|
|
if (h.key && h.value) {
|
|
headersObj[h.key] = h.value;
|
|
}
|
|
});
|
|
} else if (source.headers && typeof source.headers === "object") {
|
|
// 이미 객체인 경우 그대로 사용
|
|
Object.assign(headersObj, source.headers);
|
|
}
|
|
|
|
const requestPayload = {
|
|
url: source.endpoint,
|
|
method: requestMethod,
|
|
headers: headersObj,
|
|
queryParams: Object.fromEntries(params),
|
|
body: requestBody,
|
|
externalConnectionId: source.externalConnectionId,
|
|
};
|
|
|
|
console.log("[ListTestWidget] API 요청:", requestPayload);
|
|
|
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify(requestPayload),
|
|
});
|
|
|
|
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}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
// console.log("✅ API 응답:", result);
|
|
|
|
if (!result.success) {
|
|
console.error("❌ API 실패:", result);
|
|
throw new Error(result.message || result.error || "외부 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}"에서 데이터를 찾을 수 없습니다`);
|
|
}
|
|
}
|
|
}
|
|
|
|
let rows = Array.isArray(processedData) ? processedData : [processedData];
|
|
|
|
// 컬럼 매핑 적용
|
|
rows = applyColumnMapping(rows, source.columnMapping);
|
|
|
|
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>[];
|
|
};
|
|
|
|
// 컬럼 매핑 적용
|
|
const mappedRows = applyColumnMapping(resultData.rows, source.columnMapping);
|
|
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : resultData.columns;
|
|
|
|
return {
|
|
columns,
|
|
rows: mappedRows,
|
|
};
|
|
} else {
|
|
// 현재 DB
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
const result = await dashboardApi.executeQuery(source.query);
|
|
|
|
// // console.log("💾 내부 DB 쿼리 결과:", {
|
|
// hasRows: !!result.rows,
|
|
// rowCount: result.rows?.length || 0,
|
|
// hasColumns: !!result.columns,
|
|
// columnCount: result.columns?.length || 0,
|
|
// firstRow: result.rows?.[0],
|
|
// resultKeys: Object.keys(result),
|
|
// });
|
|
|
|
// 컬럼 매핑 적용
|
|
const mappedRows = applyColumnMapping(result.rows, source.columnMapping);
|
|
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : result.columns;
|
|
|
|
// // console.log("✅ 매핑 후:", {
|
|
// columns,
|
|
// rowCount: mappedRows.length,
|
|
// firstMappedRow: mappedRows[0],
|
|
// });
|
|
|
|
return {
|
|
columns,
|
|
rows: mappedRows,
|
|
};
|
|
}
|
|
};
|
|
|
|
// 초기 로드
|
|
useEffect(() => {
|
|
if (dataSources && dataSources.length > 0) {
|
|
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 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 = () => {
|
|
// 헤더명 가져오기 (label 우선, 없으면 field 그대로)
|
|
const getHeaderLabel = (field: string) => {
|
|
const colConfig = visibleColumnConfigs.find((col: any) => col.field === field);
|
|
return colConfig?.label || field;
|
|
};
|
|
|
|
return (
|
|
<div className="overflow-auto">
|
|
<Table>
|
|
{config.showHeader && (
|
|
<TableHeader>
|
|
<TableRow>
|
|
{displayColumns.map((field) => (
|
|
<TableHead key={field} className="whitespace-nowrap">
|
|
{getHeaderLabel(field)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
)}
|
|
<TableBody>
|
|
{paginatedRows.map((row, idx) => (
|
|
<TableRow
|
|
key={idx}
|
|
className={`${config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-accent" : ""}`}
|
|
onClick={() => handleRowClick(row)}
|
|
>
|
|
{displayColumns.map((field) => (
|
|
<TableCell key={field} className="whitespace-nowrap">
|
|
{String(row[field] ?? "")}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 카드 뷰
|
|
const renderCards = () => {
|
|
// 헤더명 가져오기 (label 우선, 없으면 field 그대로)
|
|
const getLabel = (field: string) => {
|
|
const colConfig = visibleColumnConfigs.find((col: any) => col.field === field);
|
|
return colConfig?.label || field;
|
|
};
|
|
|
|
return (
|
|
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
|
{paginatedRows.map((row, idx) => (
|
|
<Card
|
|
key={idx}
|
|
className={`p-4 ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-shadow hover:shadow-md" : ""}`}
|
|
onClick={() => handleRowClick(row)}
|
|
>
|
|
{displayColumns.map((field) => (
|
|
<div key={field} className="mb-2">
|
|
<span className="font-semibold">{getLabel(field)}: </span>
|
|
<span>{String(row[field] ?? "")}</span>
|
|
</div>
|
|
))}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="flex h-full flex-col bg-card shadow-sm">
|
|
{/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */}
|
|
{isCompactHeight ? (
|
|
<div className="flex h-full flex-col justify-center p-3">
|
|
{data && data.rows.length > 0 && displayColumns.length > 0 ? (
|
|
<div className="flex items-center gap-2">
|
|
{/* 이전 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 shrink-0 p-0"
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{/* 현재 데이터 */}
|
|
<div className="flex-1 truncate rounded bg-muted/50 px-3 py-2 text-sm">
|
|
{displayColumns.slice(0, 4).map((field, fieldIdx) => (
|
|
<span key={field} className={fieldIdx === 0 ? "font-medium" : "text-muted-foreground"}>
|
|
{String(data.rows[currentPage - 1]?.[field] ?? "").substring(0, 25)}
|
|
{fieldIdx < Math.min(displayColumns.length, 4) - 1 && " | "}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* 다음 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 shrink-0 p-0"
|
|
onClick={() => setCurrentPage((p) => Math.min(data.rows.length, p + 1))}
|
|
disabled={currentPage === data.rows.length}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-sm text-muted-foreground">데이터 없음</div>
|
|
)}
|
|
|
|
{/* 현재 위치 표시 (작게) */}
|
|
{data && data.rows.length > 0 && (
|
|
<div className="mt-1 text-center text-[10px] text-muted-foreground">
|
|
{currentPage} / {data.rows.length}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 헤더 */}
|
|
<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">
|
|
{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
|
|
{lastRefreshTime && (
|
|
<span className="ml-2">
|
|
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</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" />}
|
|
</div>
|
|
</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>
|
|
) : !dataSources || 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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 행 상세 팝업 */}
|
|
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
|
|
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
|
|
<DialogDescription>
|
|
{detailPopupLoading
|
|
? "추가 정보를 로딩 중입니다..."
|
|
: detailPopupData
|
|
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
|
|
: "선택된 항목의 상세 정보입니다."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{detailPopupLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{detailPopupData && (
|
|
<>
|
|
{/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */}
|
|
{config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0
|
|
? // 설정된 필드 그룹 렌더링
|
|
config.rowDetailPopup.fieldGroups.map((group) =>
|
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
|
)
|
|
: // 기본 필드 그룹 렌더링
|
|
getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) =>
|
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button onClick={() => setDetailPopupOpen(false)}>닫기</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|