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

425 lines
14 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.

/**
* 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>
);
}