795 lines
30 KiB
TypeScript
795 lines
30 KiB
TypeScript
"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, ChevronLeft, ChevronRight } from "lucide-react";
|
||
|
||
interface ListWidgetProps {
|
||
element: DashboardElement;
|
||
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||
}
|
||
|
||
/**
|
||
* 리스트 위젯 컴포넌트
|
||
* - DB 쿼리 또는 REST API로 데이터 가져오기
|
||
* - 테이블 형태로 데이터 표시
|
||
* - 페이지네이션, 정렬, 검색 기능
|
||
*/
|
||
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 [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);
|
||
|
||
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("🔍 [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 <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, data: 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(data[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가 있으면 해당 컬럼만, 없으면 전체
|
||
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<string, string> = {};
|
||
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 (
|
||
<div className="flex h-full w-full items-center justify-center">
|
||
<div className="text-center">
|
||
<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>
|
||
</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-destructive text-sm font-medium">오류 발생</div>
|
||
<div className="text-muted-foreground mt-1 text-xs">{error}</div>
|
||
</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>
|
||
<div className="text-foreground text-sm font-medium">리스트를 설정하세요</div>
|
||
<div className="text-muted-foreground mt-1 text-xs">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||
</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 ref={containerRef} className="flex h-full w-full flex-col p-4">
|
||
{/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */}
|
||
{isCompactHeight ? (
|
||
<div className="flex h-full flex-col justify-center p-3">
|
||
{data && data.rows.length > 0 && displayColumns.filter((col) => col.visible).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.filter((col) => col.visible).slice(0, 4).map((col, colIdx) => (
|
||
<span key={col.id} className={colIdx === 0 ? "font-medium" : "text-muted-foreground"}>
|
||
{String(data.rows[currentPage - 1]?.[col.dataKey || col.field] ?? "").substring(0, 25)}
|
||
{colIdx < Math.min(displayColumns.filter((c) => c.visible).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="mb-4">
|
||
<h3 className="text-foreground text-sm font-semibold">{element.customTitle || element.title}</h3>
|
||
</div>
|
||
|
||
{/* 테이블 뷰 */}
|
||
{config.viewMode === "table" && (
|
||
<div className={`flex-1 overflow-auto rounded-lg ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||
<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}
|
||
className="text-muted-foreground text-center"
|
||
>
|
||
데이터가 없습니다
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
paginatedRows.map((row, idx) => (
|
||
<TableRow
|
||
key={idx}
|
||
className={`${config.stripedRows ? "" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-muted/50" : ""}`}
|
||
onClick={() => handleRowClick(row)}
|
||
>
|
||
{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 ? (
|
||
<div className="text-muted-foreground flex h-full items-center justify-center">데이터가 없습니다</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 ${config.rowDetailPopup?.enabled ? "cursor-pointer" : ""}`}
|
||
onClick={() => handleRowClick(row)}
|
||
>
|
||
<div className="space-y-2">
|
||
{displayColumns
|
||
.filter((col) => col.visible)
|
||
.map((col) => (
|
||
<div key={col.id}>
|
||
<div className="text-muted-foreground text-xs font-medium">{col.label || col.name}</div>
|
||
<div
|
||
className={`text-foreground font-medium ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||
>
|
||
{String(row[col.dataKey || col.field] ?? "")}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 페이지네이션 */}
|
||
{config.enablePagination && totalPages > 1 && (
|
||
<div className="mt-4 flex items-center justify-between text-sm">
|
||
<div className="text-foreground">
|
||
{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-foreground">{currentPage}</span>
|
||
<span className="text-muted-foreground">/</span>
|
||
<span className="text-muted-foreground">{totalPages}</span>
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|