ERP-node/frontend/components/screen/templates/DataTableTemplate.tsx

604 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
/**
* 데이터 테이블 템플릿 컴포넌트
* 기존 하드코딩된 데이터 테이블 기능을 독립적인 컴포넌트로 분리
*/
export interface DataTableTemplateProps {
/**
* 테이블 제목
*/
title?: string;
/**
* 테이블 설명
*/
description?: string;
/**
* 컬럼 정의
*/
columns?: Array<{
id: string;
label: string;
type: string;
visible: boolean;
sortable: boolean;
filterable: boolean;
width?: number;
}>;
/**
* 필터 설정
*/
filters?: Array<{
id: string;
label: string;
type: "text" | "select" | "date" | "number";
options?: Array<{ label: string; value: string }>;
}>;
/**
* 페이징 설정
*/
pagination?: {
enabled: boolean;
pageSize: number;
pageSizeOptions: number[];
showPageSizeSelector: boolean;
showPageInfo: boolean;
showFirstLast: boolean;
};
/**
* 액션 버튼 설정
*/
actions?: {
showSearchButton: boolean;
searchButtonText: string;
enableExport: boolean;
enableRefresh: boolean;
enableAdd: boolean;
enableEdit: boolean;
enableDelete: boolean;
addButtonText: string;
editButtonText: string;
deleteButtonText: string;
};
/**
* 스타일 설정
*/
style?: React.CSSProperties;
/**
* 클래스명
*/
className?: string;
/**
* 미리보기 모드 (실제 데이터 없이 구조만 표시)
*/
isPreview?: boolean;
}
export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
title = "데이터 테이블",
description = "데이터를 표시하고 관리하는 테이블",
columns = [],
filters = [],
pagination = {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
actions = {
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
enableAdd: true,
enableEdit: true,
enableDelete: true,
addButtonText: "추가",
editButtonText: "수정",
deleteButtonText: "삭제",
},
style,
className = "",
isPreview = true,
}) => {
const { user } = useAuth();
// 🆕 검색 필터 관련 상태
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set());
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, string>>({});
// 설정된 컬럼만 사용 (자동 생성 안함)
const defaultColumns = React.useMemo(() => {
return columns || [];
}, [columns]);
// 미리보기용 샘플 데이터
const sampleData = React.useMemo(() => {
if (!isPreview) return [];
return [
{ id: 1, name: "홍길동", email: "hong@example.com", status: "활성", created_date: "2024-01-15" },
{ id: 2, name: "김철수", email: "kim@example.com", status: "비활성", created_date: "2024-01-14" },
{ id: 3, name: "이영희", email: "lee@example.com", status: "활성", created_date: "2024-01-13" },
];
}, [isPreview]);
const visibleColumns = defaultColumns.filter((col) => col.visible);
// 🆕 컬럼명 -> 라벨 매핑
const columnLabels = React.useMemo(() => {
const labels: Record<string, string> = {};
defaultColumns.forEach(col => {
labels[col.id] = col.label;
});
return labels;
}, [defaultColumns]);
// 🆕 localStorage에서 필터 설정 복원
useEffect(() => {
if (user?.userId && title) {
const storageKey = `datatable-search-filter-${user.userId}-${title}`;
const savedFilter = localStorage.getItem(storageKey);
if (savedFilter) {
try {
const parsed = JSON.parse(savedFilter);
setSearchFilterColumns(new Set(parsed));
} catch (e) {
console.error("필터 설정 복원 실패:", e);
}
}
}
}, [user?.userId, title]);
// 🆕 필터 저장 함수
const handleSaveSearchFilter = useCallback(() => {
if (user?.userId && title) {
const storageKey = `datatable-search-filter-${user.userId}-${title}`;
const filterArray = Array.from(searchFilterColumns);
localStorage.setItem(storageKey, JSON.stringify(filterArray));
toast.success("검색 필터 설정이 저장되었습니다.");
}
}, [user?.userId, title, searchFilterColumns]);
// 🆕 필터 토글 함수
const handleToggleFilterColumn = useCallback((columnId: string) => {
setSearchFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnId)) {
newSet.delete(columnId);
} else {
newSet.add(columnId);
}
return newSet;
});
}, []);
return (
<Card className={`h-full w-full ${className}`} style={style}>
{/* 헤더 영역 */}
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center space-x-2">
<Table className="h-5 w-5" />
<span>{title}</span>
</CardTitle>
{description && <p className="text-muted-foreground mt-1 text-sm">{description}</p>}
</div>
{/* 액션 버튼들 */}
<div className="flex items-center space-x-2">
{actions.enableRefresh && (
<Button variant="outline" size="sm">
<RefreshCw className="h-4 w-4" />
</Button>
)}
{actions.enableExport && (
<Button variant="outline" size="sm">
<Download className="mr-1 h-4 w-4" />
</Button>
)}
{actions.enableAdd && (
<Button size="sm">
<Plus className="mr-1 h-4 w-4" />
{actions.addButtonText}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 🆕 검색 필터 설정 버튼 영역 */}
{defaultColumns.length > 0 && (
<div className="flex items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
disabled={isPreview}
className="gap-2"
>
<Filter className="h-4 w-4" />
{searchFilterColumns.size > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
{searchFilterColumns.size}
</Badge>
)}
</Button>
</div>
)}
{/* 🆕 선택된 컬럼의 검색 입력 필드 */}
{searchFilterColumns.size > 0 && (
<div className="grid grid-cols-1 gap-3 rounded-lg border bg-gray-50/50 p-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from(searchFilterColumns).map((columnId) => {
const column = defaultColumns.find(col => col.id === columnId);
if (!column) return null;
return (
<div key={columnId} className="space-y-1.5">
<label className="text-xs font-medium text-gray-700">
{column.label}
</label>
<Input
placeholder={`${column.label} 검색...`}
value={searchValues[columnId] || ""}
onChange={(e) => setSearchValues(prev => ({...prev, [columnId]: e.target.value}))}
disabled={isPreview}
className="h-9 text-sm"
/>
</div>
);
})}
</div>
)}
{/* 검색 및 필터 영역 */}
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
{/* 검색 입력 */}
<div className="flex items-center space-x-2">
{actions.showSearchButton && (
<Button variant="outline" disabled={isPreview}>
<Search className="mr-2 h-4 w-4" />
{actions.searchButtonText}
</Button>
)}
</div>
{/* 기존 필터 영역 (이제는 사용하지 않음) */}
{filters.length > 0 && (
<div className="flex items-center space-x-2">
<Filter className="text-muted-foreground h-4 w-4" />
{filters.slice(0, 3).map((filter, index) => (
<Select key={filter.id || `filter-${index}`} disabled={isPreview}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder={filter.label} />
</SelectTrigger>
<SelectContent>
{filter.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
{/* 데이터 테이블 */}
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full">
{/* 테이블 헤더 */}
<thead className="bg-muted/50 border-b">
<tr>
{/* 선택 체크박스 */}
<th className="w-12 p-3">
<Checkbox disabled={isPreview} />
</th>
{/* 컬럼 헤더 */}
{visibleColumns.map((column) => (
<th key={column.id} className="p-3 text-left font-medium" style={{ width: column.width }}>
<div className="flex items-center space-x-1">
<span>{column.label}</span>
{column.sortable && (
<div className="flex flex-col">
<div className="bg-muted-foreground h-1 w-1"></div>
<div className="bg-muted-foreground mt-0.5 h-1 w-1"></div>
</div>
)}
</div>
</th>
))}
{/* 액션 컬럼 */}
{(actions.enableEdit || actions.enableDelete) && <th className="w-24 p-3 text-center"></th>}
</tr>
</thead>
{/* 테이블 바디 */}
<tbody>
{isPreview ? (
// 미리보기 데이터
sampleData.map((row, index) => (
<tr key={index} className="hover:bg-muted/30 border-b">
<td className="p-3">
<Checkbox disabled />
</td>
{visibleColumns.map((column) => (
<td key={column.id} className="p-3">
{column.type === "select" && column.id === "status" ? (
<Badge variant={row[column.id] === "활성" ? "default" : "secondary"}>
{row[column.id]}
</Badge>
) : (
<span className="text-sm">{row[column.id]}</span>
)}
</td>
))}
{(actions.enableEdit || actions.enableDelete) && (
<td className="p-3">
<div className="flex items-center justify-center space-x-1">
{actions.enableEdit && (
<Button variant="ghost" size="sm" disabled>
<Edit className="h-3 w-3" />
</Button>
)}
{actions.enableDelete && (
<Button variant="ghost" size="sm" disabled>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
)}
</div>
</td>
)}
</tr>
))
) : (
// 실제 데이터가 없는 경우 플레이스홀더
<tr>
<td colSpan={visibleColumns.length + 2} className="text-muted-foreground p-8 text-center">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 페이징 영역 */}
{pagination.enabled && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
{pagination.showPageInfo && <span>{isPreview ? "1-3 of 3" : "0-0 of 0"} </span>}
</div>
<div className="flex items-center space-x-2">
{/* 페이지 크기 선택 */}
{pagination.showPageSizeSelector && (
<div className="flex items-center space-x-2">
<span className="text-muted-foreground text-sm">:</span>
<Select disabled={isPreview}>
<SelectTrigger className="w-16">
<SelectValue placeholder={pagination.pageSize.toString()} />
</SelectTrigger>
<SelectContent>
{pagination.pageSizeOptions.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 페이지 네비게이션 */}
<div className="flex items-center space-x-1">
{pagination.showFirstLast && (
<Button variant="outline" size="sm" disabled>
«
</Button>
)}
<Button variant="outline" size="sm" disabled>
</Button>
<Button variant="outline" size="sm" className="bg-primary text-primary-foreground">
1
</Button>
<Button variant="outline" size="sm" disabled>
</Button>
{pagination.showFirstLast && (
<Button variant="outline" size="sm" disabled>
»
</Button>
)}
</div>
</div>
</div>
)}
</CardContent>
{/* 🆕 검색 필터 설정 다이얼로그 */}
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-2 overflow-y-auto py-4">
{defaultColumns.map((column) => (
<div key={column.id} className="flex items-center space-x-3 rounded-lg p-3 hover:bg-gray-50">
<Checkbox
id={`filter-${column.id}`}
checked={searchFilterColumns.has(column.id)}
onCheckedChange={() => handleToggleFilterColumn(column.id)}
/>
<label htmlFor={`filter-${column.id}`} className="flex-1 cursor-pointer text-sm">
{column.label}
<span className="ml-2 text-xs text-gray-500">({column.type})</span>
</label>
</div>
))}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsFilterSettingOpen(false)}>
</Button>
<Button onClick={() => {
handleSaveSearchFilter();
setIsFilterSettingOpen(false);
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
/**
* 데이터 테이블 템플릿 기본 설정
*/
export const getDefaultDataTableConfig = () => ({
template_code: "advanced-data-table-v2",
template_name: "고급 데이터 테이블 v2",
template_name_eng: "Advanced Data Table v2",
description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
category: "table",
icon_name: "table",
default_size: {
width: 1000,
height: 680,
},
layout_config: {
components: [
{
type: "datatable",
label: "고급 데이터 테이블",
position: { x: 0, y: 0 },
size: { width: 1000, height: 680 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "0",
},
// 데이터 테이블 전용 설정
columns: [
{ id: "id", label: "ID", type: "number", visible: true, sortable: true, filterable: false, width: 80 },
{ id: "name", label: "이름", type: "text", visible: true, sortable: true, filterable: true, width: 150 },
{ id: "email", label: "이메일", type: "email", visible: true, sortable: true, filterable: true, width: 200 },
{ id: "status", label: "상태", type: "select", visible: true, sortable: true, filterable: true, width: 100 },
{
id: "created_date",
label: "생성일",
type: "date",
visible: true,
sortable: true,
filterable: true,
width: 120,
},
],
filters: [
{
id: "status",
label: "상태",
type: "select",
options: [
{ label: "전체", value: "" },
{ label: "활성", value: "active" },
{ label: "비활성", value: "inactive" },
],
},
{ id: "name", label: "이름", type: "text" },
{ id: "email", label: "이메일", type: "text" },
],
pagination: {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50, 100],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
actions: {
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
enableAdd: true,
enableEdit: true,
enableDelete: true,
addButtonText: "추가",
editButtonText: "수정",
deleteButtonText: "삭제",
},
// 모달 설정
addModalConfig: {
title: "새 데이터 추가",
description: "테이블에 새로운 데이터를 추가합니다.",
width: "lg",
layout: "two-column",
gridColumns: 2,
fieldOrder: ["name", "email", "status"],
requiredFields: ["name", "email"],
hiddenFields: ["id", "created_date"],
advancedFieldConfigs: {
status: {
type: "select",
options: [
{ label: "활성", value: "active" },
{ label: "비활성", value: "inactive" },
],
},
},
submitButtonText: "추가",
cancelButtonText: "취소",
},
},
],
},
});
export default DataTableTemplate;