"use client"; import React, { useState, useEffect, useCallback } from "react"; import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } 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"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; onConfigUpdate?: (config: Partial) => void; } /** * 리스트 위젯 컴포넌트 * - DB 쿼리 또는 REST API로 데이터 가져오기 * - 테이블 형태로 데이터 표시 * - 페이지네이션, 정렬, 검색 기능 */ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); const [detailPopupData, setDetailPopupData] = useState | null>(null); const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); 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("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작"); // console.log("🔍 [ListWidget] 클릭한 행 데이터:", row); // console.log("🔍 [ListWidget] 행 컬럼 목록:", 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(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`); }); // console.log("🔍 [ListWidget] 최종 쿼리:", query); const { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.executeQuery(query); // console.log("🔍 [ListWidget] 쿼리 결과:", result); if (result.success && result.rows.length > 0) { setAdditionalDetailData(result.rows[0]); } else { setAdditionalDetailData({}); } } catch (error) { console.error("커스텀 쿼리 실행 실패:", error); 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 (error) { console.error("추가 데이터 로드 실패:", error); 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, data: 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(data[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가 있으면 해당 컬럼만, 없으면 전체 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) => item.column in mergedData); } else { // 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시 if (queryMode === "custom" && additional && Object.keys(additional).length > 0) { basicFields = Object.keys(additional).map((key) => ({ column: key, label: key })); } else { basicFields = Object.keys(row).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", })), }); // 추가 데이터가 있고 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" }, { column: "last_trip_end", label: "종료", format: "datetime" }, { column: "last_trip_distance", label: "거리", format: "distance" }, { column: "last_trip_time", label: "시간", format: "duration" }, { column: "departure", label: "출발지", format: "text" }, { column: "arrival", label: "도착지", format: "text" }, ], }); } // 공차 정보 if (additional.last_empty_start) { groups.push({ id: "empty", title: "공차 정보", icon: "package", color: "orange", fields: [ { column: "last_empty_start", label: "시작", format: "datetime" }, { column: "last_empty_end", label: "종료", format: "datetime" }, { column: "last_empty_distance", label: "거리", format: "distance" }, { column: "last_empty_time", label: "시간", format: "duration" }, ], }); } } return groups; }; // 데이터 로드 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) { params.append(key, value); } }); } // 요청 메서드 (기본값: GET) const requestMethod = element.dataSource.method || "GET"; // 요청 body (POST, PUT, PATCH인 경우) let requestBody = undefined; if (["POST", "PUT", "PATCH"].includes(requestMethod) && element.dataSource.body) { try { requestBody = typeof element.dataSource.body === "string" ? JSON.parse(element.dataSource.body) : element.dataSource.body; } catch { requestBody = element.dataSource.body; } } // headers를 KeyValuePair[] 에서 객체로 변환 const headersObj: Record = {}; if (element.dataSource.headers && Array.isArray(element.dataSource.headers)) { element.dataSource.headers.forEach((h: any) => { if (h.key && h.value) { headersObj[h.key] = h.value; } }); } else if (element.dataSource.headers && typeof element.dataSource.headers === "object") { Object.assign(headersObj, element.dataSource.headers); } const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ url: element.dataSource.endpoint, method: requestMethod, headers: headersObj, queryParams: Object.fromEntries(params), body: requestBody, externalConnectionId: element.dataSource.externalConnectionId, }), }); 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 (
데이터 로딩 중...
); } // 에러 if (error) { return (
⚠️
오류 발생
{error}
); } // 데이터 없음 if (!data) { return (
📋
리스트를 설정하세요
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
); } // 컬럼 설정이 없으면 자동으로 모든 컬럼 표시 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 (
{/* 제목 - 항상 표시 */}

{element.customTitle || element.title}

{/* 테이블 뷰 */} {config.viewMode === "table" && (
{config.showHeader && ( {displayColumns .filter((col) => col.visible) .map((col) => ( {col.label || col.name} ))} )} {paginatedRows.length === 0 ? ( col.visible).length} className="text-muted-foreground text-center" > 데이터가 없습니다 ) : ( paginatedRows.map((row, idx) => ( handleRowClick(row)} > {displayColumns .filter((col) => col.visible) .map((col) => ( {String(row[col.dataKey || col.field] ?? "")} ))} )) )}
)} {/* 카드 뷰 */} {config.viewMode === "card" && (
{paginatedRows.length === 0 ? (
데이터가 없습니다
) : (
{paginatedRows.map((row, idx) => ( handleRowClick(row)} >
{displayColumns .filter((col) => col.visible) .map((col) => (
{col.label || col.name}
{String(row[col.dataKey || col.field] ?? "")}
))}
))}
)}
)} {/* 페이지네이션 */} {config.enablePagination && totalPages > 1 && (
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
{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 }), )} )}
)}
); }