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

604 lines
20 KiB
TypeScript
Raw Normal View History

2025-09-09 17:42:23 +09:00
"use client";
import React, { useState, useEffect, useCallback } from "react";
2025-09-09 17:42:23 +09:00
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";
2025-11-05 16:36:32 +09:00
import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, DialogFooter, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
2025-09-09 17:42:23 +09:00
/**
* 릿
*
*/
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>>({});
2025-09-23 14:26:18 +09:00
// 설정된 컬럼만 사용 (자동 생성 안함)
2025-09-09 17:42:23 +09:00
const defaultColumns = React.useMemo(() => {
2025-09-23 14:26:18 +09:00
return columns || [];
2025-09-09 17:42:23 +09:00
}, [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;
});
}, []);
2025-09-09 17:42:23 +09:00
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>
)}
2025-09-09 17:42:23 +09:00
{/* 검색 및 필터 영역 */}
<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" />
2025-09-09 17:42:23 +09:00
{actions.searchButtonText}
</Button>
)}
</div>
{/* 기존 필터 영역 (이제는 사용하지 않음) */}
2025-09-09 17:42:23 +09:00
{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}>
2025-09-09 17:42:23 +09:00
<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>
2025-09-09 17:42:23 +09:00
</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;