"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[]; totalRows: number; executionTime: number; } /** * 리스트 테스트 위젯 (다중 데이터 소스 지원) * - 여러 REST API 연결 가능 * - 여러 Database 연결 가능 * - REST API + Database 혼합 가능 * - 데이터 자동 병합 */ export function ListTestWidget({ element }: ListTestWidgetProps) { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); const [containerHeight, setContainerHeight] = useState(0); const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); const [detailPopupData, setDetailPopupData] = useState | null>(null); const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | 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) => { // 팝업이 비활성화되어 있으면 무시 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 ; case "clock": return ; case "map": return ; case "package": return ; default: return ; } }; // 필드 그룹 렌더링 const renderFieldGroup = (group: FieldGroup, groupData: Record) => { 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 (
{renderIcon(group.icon, group.color)} {group.title}
{group.fields.map((field) => (
{field.label} {formatValue(groupData[field.column], field.format)}
))}
); }; // 기본 필드 그룹 생성 (설정이 없을 경우) const getDefaultFieldGroups = (row: Record, additional: Record | 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(); const allRows: Record[] = []; 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 = {}; 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[]; }; // 컬럼 매핑 적용 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 (
{config.showHeader && ( {displayColumns.map((field) => ( {getHeaderLabel(field)} ))} )} {paginatedRows.map((row, idx) => ( handleRowClick(row)} > {displayColumns.map((field) => ( {String(row[field] ?? "")} ))} ))}
); }; // 카드 뷰 const renderCards = () => { // 헤더명 가져오기 (label 우선, 없으면 field 그대로) const getLabel = (field: string) => { const colConfig = visibleColumnConfigs.find((col: any) => col.field === field); return colConfig?.label || field; }; return (
{paginatedRows.map((row, idx) => ( handleRowClick(row)} > {displayColumns.map((field) => (
{getLabel(field)}: {String(row[field] ?? "")}
))}
))}
); }; return (
{/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} {isCompactHeight ? (
{data && data.rows.length > 0 && displayColumns.length > 0 ? (
{/* 이전 버튼 */} {/* 현재 데이터 */}
{displayColumns.slice(0, 4).map((field, fieldIdx) => ( {String(data.rows[currentPage - 1]?.[field] ?? "").substring(0, 25)} {fieldIdx < Math.min(displayColumns.length, 4) - 1 && " | "} ))}
{/* 다음 버튼 */}
) : (
데이터 없음
)} {/* 현재 위치 표시 (작게) */} {data && data.rows.length > 0 && (
{currentPage} / {data.rows.length}
)}
) : ( <> {/* 헤더 */}

{element?.customTitle || "리스트"}

{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 {lastRefreshTime && ( • {lastRefreshTime.toLocaleTimeString("ko-KR")} )}

{isLoading && }
{/* 컨텐츠 */}
{error ? (

{error}

) : !dataSources || dataSources.length === 0 ? (

데이터 소스를 연결해주세요

) : !data || data.rows.length === 0 ? (

데이터가 없습니다

) : config.viewMode === "card" ? ( renderCards() ) : ( renderTable() )}
{/* 페이지네이션 */} {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && (
총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages})
)} )} {/* 행 상세 팝업 */} {config.rowDetailPopup?.title || "상세 정보"} {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : detailPopupData ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` : "선택된 항목의 상세 정보입니다."} {detailPopupLoading ? (
) : (
{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 }), )} )}
)}
); }