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