ERP-node/frontend/lib/meta-components/DataView/DataViewRenderer.tsx

425 lines
14 KiB
TypeScript
Raw Normal View History

2026-03-01 03:39:00 +09:00
/**
* V3 DataView Renderer
* - fetching하고
* - entityJoinApi
* - v2EventBus TABLE_REFRESH
* - , ,
*/
"use client";
import React, { useState, useEffect } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { DataViewComponentConfig } from "@/lib/api/metaComponent";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { v2EventBus } from "@/lib/v2-core/events/EventBus";
import { V2_EVENTS } from "@/lib/v2-core/events/types";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
interface DataViewRendererProps {
id: string;
config: DataViewComponentConfig;
selectedRowsData?: any[];
onSelectedRowsChange?: (rows: any[], data: any[]) => void;
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
tableName?: string;
companyCode?: string;
screenId?: number;
isDesignMode?: boolean;
className?: string;
onRefresh?: () => void;
}
interface SortState {
column: string | null;
order: "asc" | "desc";
}
export function DataViewRenderer({
id,
config,
selectedRowsData = [],
onSelectedRowsChange,
formData,
onFormDataChange,
tableName,
companyCode,
screenId,
isDesignMode = false,
className,
onRefresh,
}: DataViewRendererProps) {
// 상태 관리
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [sortState, setSortState] = useState<SortState>({ column: null, order: "asc" });
const [selectedRowIds, setSelectedRowIds] = useState<Set<string | number>>(new Set());
const [filters, setFilters] = useState<Record<string, any>>({}); // 🔍 검색 필터
const pageSize = config.pageSize || 10;
const totalPages = Math.ceil(totalCount / pageSize);
const targetTableName = config.tableName || tableName;
// 데이터 로드 함수
const loadData = async () => {
if (isDesignMode || !targetTableName) {
return;
}
setLoading(true);
setError(null);
try {
// 🔍 filters를 search 파라미터로 전달
const response = await entityJoinApi.getTableDataWithJoins(targetTableName, {
page: currentPage,
size: pageSize,
sortBy: sortState.column || undefined,
sortOrder: sortState.order,
enableEntityJoin: true,
companyCodeOverride: companyCode,
search: Object.keys(filters).length > 0 ? filters : undefined, // 필터가 있으면 전달
});
setData(response.data || []);
setTotalCount(response.total || 0);
} catch (err: any) {
console.error("DataViewRenderer: 데이터 로드 실패", err);
setError(err.message || "데이터를 불러오는 중 오류가 발생했습니다.");
setData([]);
setTotalCount(0);
} finally {
setLoading(false);
}
};
// 컬럼 목록 추출 (config.columns 또는 데이터에서 자동 추출)
const columns = React.useMemo(() => {
if (config.columns && config.columns.length > 0) {
return config.columns;
}
// 첫 번째 데이터 행에서 컬럼 자동 추출
if (data.length > 0) {
const firstRow = data[0];
return Object.keys(firstRow).map((key) => ({
columnName: key,
columnLabel: key,
visible: true,
}));
}
return [];
}, [config.columns, data]);
// 마운트 시 + tableName/page/sort/filters 변경 시 데이터 로드
useEffect(() => {
loadData();
}, [targetTableName, currentPage, sortState, filters]);
// TABLE_REFRESH 이벤트 구독
useEffect(() => {
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.TABLE_REFRESH,
(payload) => {
// 모든 테이블 새로고침 또는 특정 테이블만
if (!payload.tableName || payload.tableName === targetTableName) {
console.log(`DataViewRenderer: TABLE_REFRESH 이벤트 수신 (${targetTableName})`, payload);
// 🔍 필터가 전달되면 적용
if (payload.filters !== undefined) {
setFilters(payload.filters);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
} else {
// 필터 없으면 그냥 새로고침
loadData();
}
if (onRefresh) {
onRefresh();
}
}
},
{ componentId: id }
);
return () => {
unsubscribe();
};
}, [id, targetTableName]);
// 컬럼 헤더 클릭 (정렬 전환)
const handleSort = (columnName: string) => {
setSortState((prev) => {
if (prev.column === columnName) {
// 같은 컬럼: ASC → DESC → null
if (prev.order === "asc") {
return { column: columnName, order: "desc" };
} else {
return { column: null, order: "asc" };
}
} else {
// 다른 컬럼: ASC로 시작
return { column: columnName, order: "asc" };
}
});
setCurrentPage(1); // 정렬 변경 시 첫 페이지로
};
// 행 클릭 (선택)
const handleRowClick = (row: any, rowIndex: number) => {
const rowId = row.id || rowIndex;
const newSelectedIds = new Set(selectedRowIds);
if (newSelectedIds.has(rowId)) {
newSelectedIds.delete(rowId);
} else {
newSelectedIds.add(rowId);
}
setSelectedRowIds(newSelectedIds);
// 선택된 행 데이터 전달
const selectedRows = data.filter((r, idx) => newSelectedIds.has(r.id || idx));
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedIds) as any[], selectedRows);
}
};
// 페이지 변경
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
// 디자인 모드 렌더링
if (isDesignMode) {
return (
<div className={cn("rounded-lg border bg-muted/30 p-4", className)}>
<div className="mb-2 text-xs font-medium text-muted-foreground">
: {targetTableName || "(테이블 미지정)"}
</div>
<Table>
<TableHeader>
<TableRow>
{columns.length > 0 ? (
columns.map((col) => (
<TableHead key={col.columnName}>
{col.columnLabel || col.columnName}
</TableHead>
))
) : (
<>
<TableHead> 1</TableHead>
<TableHead> 2</TableHead>
<TableHead> 3</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow key={i}>
{columns.length > 0 ? (
columns.map((col) => (
<TableCell key={col.columnName}>
<div className="h-4 w-20 bg-muted-foreground/20 rounded" />
</TableCell>
))
) : (
<>
<TableCell>
<div className="h-4 w-20 bg-muted-foreground/20 rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-20 bg-muted-foreground/20 rounded" />
</TableCell>
<TableCell>
<div className="h-4 w-20 bg-muted-foreground/20 rounded" />
</TableCell>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// 로딩 중
if (loading && data.length === 0) {
return (
<div className={cn("rounded-lg border bg-card p-8 text-center", className)}>
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
</div>
);
}
// 에러 표시
if (error) {
return (
<div className={cn("rounded-lg border border-destructive/50 bg-destructive/10 p-4", className)}>
<p className="text-sm text-destructive"> {error}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={loadData}>
</Button>
</div>
);
}
// 데이터 없음
if (data.length === 0) {
return (
<div className={cn("rounded-lg border bg-card p-8 text-center", className)}>
<p className="text-sm text-muted-foreground"> .</p>
</div>
);
}
// 테이블 렌더링
return (
<div className={cn("rounded-lg border bg-card", className)}>
{/* 🔍 현재 적용된 필터 표시 */}
{Object.keys(filters).length > 0 && (
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/50 px-4 py-2">
<span className="text-xs font-medium text-muted-foreground"> :</span>
{Object.entries(filters).map(([key, value]) => (
<div
key={key}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span>{key}:</span>
<span className="font-semibold">{String(value)}</span>
</div>
))}
</div>
)}
{/* 테이블 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
key={col.columnName}
className={cn("cursor-pointer select-none", {
"font-semibold": sortState.column === col.columnName,
})}
onClick={() => handleSort(col.columnName)}
>
<div className="flex items-center gap-1">
<span>{col.columnLabel || col.columnName}</span>
{sortState.column === col.columnName && (
<>
{sortState.order === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)}
</>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, rowIndex) => {
const rowId = row.id || rowIndex;
const isSelected = selectedRowIds.has(rowId);
return (
<TableRow
key={rowId}
data-state={isSelected ? "selected" : undefined}
className={cn("cursor-pointer", {
"bg-primary/10": isSelected,
})}
onClick={() => handleRowClick(row, rowIndex)}
>
{columns.map((col) => (
<TableCell key={col.columnName}>
{row[col.columnName] !== undefined && row[col.columnName] !== null
? String(row[col.columnName])
: "-"}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 페이징 */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-2">
<div className="text-xs text-muted-foreground">
{totalCount} {(currentPage - 1) * pageSize + 1}~
{Math.min(currentPage * pageSize, totalCount)}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-7 px-2 text-xs"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1;
return (
<Button
key={page}
variant={currentPage === page ? "default" : "ghost"}
size="sm"
onClick={() => handlePageChange(page)}
disabled={loading}
className="h-7 w-7 px-0 text-xs"
>
{page}
</Button>
);
})}
{totalPages > 5 && <span className="text-xs text-muted-foreground">...</span>}
{totalPages > 5 && (
<Button
variant={currentPage === totalPages ? "default" : "ghost"}
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={loading}
className="h-7 w-7 px-0 text-xs"
>
{totalPages}
</Button>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="h-7 px-2 text-xs"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}