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

795 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}