425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|