439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2 } from "lucide-react";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface InteractiveDataTableProps {
|
|
component: DataTableComponent;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
}
|
|
|
|
export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|
component,
|
|
className = "",
|
|
style = {},
|
|
}) => {
|
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
|
|
// 검색 가능한 컬럼만 필터링
|
|
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
|
const searchFilters = component.filters || [];
|
|
|
|
// 그리드 컬럼 계산
|
|
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
|
|
|
|
// 페이지 크기 설정
|
|
const pageSize = component.pagination?.pageSize || 10;
|
|
|
|
// 데이터 로드 함수
|
|
const loadData = useCallback(
|
|
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
|
if (!component.tableName) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
console.log("🔍 테이블 데이터 조회:", {
|
|
tableName: component.tableName,
|
|
page,
|
|
pageSize,
|
|
searchParams,
|
|
});
|
|
|
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
|
page,
|
|
size: pageSize,
|
|
search: searchParams,
|
|
});
|
|
|
|
console.log("✅ 테이블 데이터 조회 결과:", result);
|
|
|
|
setData(result.data);
|
|
setTotal(result.total);
|
|
setTotalPages(result.totalPages);
|
|
setCurrentPage(result.page);
|
|
} catch (error) {
|
|
console.error("❌ 테이블 데이터 조회 실패:", error);
|
|
setData([]);
|
|
setTotal(0);
|
|
setTotalPages(1);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[component.tableName, pageSize],
|
|
);
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
loadData(1, searchValues);
|
|
}, [loadData]);
|
|
|
|
// 검색 실행
|
|
const handleSearch = useCallback(() => {
|
|
console.log("🔍 검색 실행:", searchValues);
|
|
loadData(1, searchValues);
|
|
}, [searchValues, loadData]);
|
|
|
|
// 검색값 변경
|
|
const handleSearchValueChange = useCallback((columnName: string, value: any) => {
|
|
setSearchValues((prev) => ({
|
|
...prev,
|
|
[columnName]: value,
|
|
}));
|
|
}, []);
|
|
|
|
// 페이지 변경
|
|
const handlePageChange = useCallback(
|
|
(page: number) => {
|
|
loadData(page, searchValues);
|
|
},
|
|
[loadData, searchValues],
|
|
);
|
|
|
|
// 검색 필터 렌더링
|
|
const renderSearchFilter = (filter: DataTableFilter) => {
|
|
const value = searchValues[filter.columnName] || "";
|
|
|
|
switch (filter.widgetType) {
|
|
case "text":
|
|
case "email":
|
|
case "tel":
|
|
return (
|
|
<Input
|
|
key={filter.columnName}
|
|
placeholder={`${filter.label} 검색...`}
|
|
value={value}
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
onKeyPress={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
|
|
case "number":
|
|
case "decimal":
|
|
return (
|
|
<Input
|
|
key={filter.columnName}
|
|
type="number"
|
|
placeholder={`${filter.label} 입력...`}
|
|
value={value}
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
onKeyPress={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
|
|
case "date":
|
|
return (
|
|
<Input
|
|
key={filter.columnName}
|
|
type="date"
|
|
value={value}
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
/>
|
|
);
|
|
|
|
case "datetime":
|
|
return (
|
|
<Input
|
|
key={filter.columnName}
|
|
type="datetime-local"
|
|
value={value}
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
/>
|
|
);
|
|
|
|
case "select":
|
|
// TODO: 선택 옵션은 추후 구현
|
|
return (
|
|
<Select
|
|
key={filter.columnName}
|
|
value={value}
|
|
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={`${filter.label} 선택...`} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">전체</SelectItem>
|
|
{/* TODO: 동적 옵션 로드 */}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<Input
|
|
key={filter.columnName}
|
|
placeholder={`${filter.label} 검색...`}
|
|
value={value}
|
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
|
onKeyPress={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 셀 값 포맷팅
|
|
const formatCellValue = (value: any, column: DataTableColumn) => {
|
|
if (value === null || value === undefined) return "";
|
|
|
|
switch (column.widgetType) {
|
|
case "date":
|
|
if (value) {
|
|
try {
|
|
const date = new Date(value);
|
|
return date.toLocaleDateString("ko-KR");
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "datetime":
|
|
if (value) {
|
|
try {
|
|
const date = new Date(value);
|
|
return date.toLocaleString("ko-KR");
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "number":
|
|
case "decimal":
|
|
if (typeof value === "number") {
|
|
return value.toLocaleString();
|
|
}
|
|
break;
|
|
|
|
default:
|
|
return String(value);
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
|
|
return (
|
|
<Card className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
|
|
{/* 헤더 */}
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<Database className="text-muted-foreground h-4 w-4" />
|
|
<CardTitle className="text-lg">{component.title || component.label}</CardTitle>
|
|
{loading && (
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
로딩중...
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
{searchFilters.length > 0 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Search className="mr-1 h-3 w-3" />
|
|
필터 {searchFilters.length}개
|
|
</Badge>
|
|
)}
|
|
{component.showSearchButton && (
|
|
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
|
|
<Search className="h-3 w-3" />
|
|
{component.searchButtonText || "검색"}
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
|
|
<RotateCcw className="h-3 w-3" />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 필터 */}
|
|
{searchFilters.length > 0 && (
|
|
<>
|
|
<Separator className="my-2" />
|
|
<div className="space-y-3">
|
|
<CardDescription className="flex items-center gap-2">
|
|
<Search className="h-3 w-3" />
|
|
검색 필터
|
|
</CardDescription>
|
|
<div
|
|
className="grid gap-3"
|
|
style={{
|
|
gridTemplateColumns: searchFilters
|
|
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
|
|
.join(" "),
|
|
}}
|
|
>
|
|
{searchFilters.map((filter: DataTableFilter) => (
|
|
<div key={filter.columnName} className="space-y-1">
|
|
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
|
{renderSearchFilter(filter)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardHeader>
|
|
|
|
{/* 테이블 내용 */}
|
|
<CardContent className="flex-1 p-0">
|
|
<div className="flex h-full flex-col">
|
|
{visibleColumns.length > 0 ? (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
{visibleColumns.map((column: DataTableColumn) => (
|
|
<TableHead
|
|
key={column.id}
|
|
className="px-4 font-semibold"
|
|
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
|
>
|
|
{column.label}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
|
|
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
데이터를 불러오는 중...
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : data.length > 0 ? (
|
|
data.map((row, rowIndex) => (
|
|
<TableRow key={rowIndex} className="hover:bg-muted/50">
|
|
{visibleColumns.map((column: DataTableColumn) => (
|
|
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
|
{formatCellValue(row[column.columnName], column)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
|
|
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
|
<Database className="h-8 w-8" />
|
|
<p>검색 결과가 없습니다</p>
|
|
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* 페이지네이션 */}
|
|
{component.pagination?.enabled && totalPages > 1 && (
|
|
<div className="bg-muted/20 mt-auto border-t">
|
|
<div className="flex items-center justify-between px-6 py-3">
|
|
{component.pagination.showPageInfo && (
|
|
<div className="text-muted-foreground text-sm">
|
|
총 <span className="font-medium">{total.toLocaleString()}</span>개 중{" "}
|
|
<span className="font-medium">{((currentPage - 1) * pageSize + 1).toLocaleString()}</span>-
|
|
<span className="font-medium">{Math.min(currentPage * pageSize, total).toLocaleString()}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center space-x-2">
|
|
{component.pagination.showFirstLast && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handlePageChange(1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="gap-1"
|
|
>
|
|
처음
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="gap-1"
|
|
>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
이전
|
|
</Button>
|
|
<div className="flex items-center gap-1 text-sm font-medium">
|
|
<span>{currentPage}</span>
|
|
<span className="text-muted-foreground">/</span>
|
|
<span>{totalPages}</span>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages || loading}
|
|
className="gap-1"
|
|
>
|
|
다음
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Button>
|
|
{component.pagination.showFirstLast && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handlePageChange(totalPages)}
|
|
disabled={currentPage === totalPages || loading}
|
|
className="gap-1"
|
|
>
|
|
마지막
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
|
<Database className="h-8 w-8" />
|
|
<p className="text-sm">표시할 컬럼이 없습니다</p>
|
|
<p className="text-xs">테이블 설정에서 컬럼을 추가해주세요</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|