ERP-node/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx

352 lines
13 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Trash2, ChevronDown, Check } from "lucide-react";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
interface RepeaterTableProps {
columns: RepeaterColumnConfig[];
data: any[];
onDataChange: (newData: any[]) => void;
onRowChange: (index: number, newRow: any) => void;
onRowDelete: (index: number) => void;
// 동적 데이터 소스 관련
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
}
export function RepeaterTable({
columns,
data,
onDataChange,
onRowChange,
onRowDelete,
activeDataSources = {},
onDataSourceChange,
}: RepeaterTableProps) {
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
field: string;
} | null>(null);
// 동적 데이터 소스 Popover 열림 상태
const [openPopover, setOpenPopover] = useState<string | null>(null);
// 컬럼 너비 상태 관리
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
});
// 기본 너비 저장 (리셋용)
const defaultWidths = React.useMemo(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
}, [columns]);
// 리사이즈 상태
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
// 리사이즈 핸들러
const handleMouseDown = (e: React.MouseEvent, field: string) => {
e.preventDefault();
setResizing({
field,
startX: e.clientX,
startWidth: columnWidths[field] || 120,
});
};
// 더블클릭으로 기본 너비로 리셋
const handleDoubleClick = (field: string) => {
setColumnWidths((prev) => ({
...prev,
[field]: defaultWidths[field] || 120,
}));
};
useEffect(() => {
if (!resizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizing) return;
const diff = e.clientX - resizing.startX;
const newWidth = Math.max(60, resizing.startWidth + diff);
setColumnWidths((prev) => ({
...prev,
[resizing.field]: newWidth,
}));
};
const handleMouseUp = () => {
setResizing(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizing, columns, data]);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
// console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행");
// }, [data]);
const handleCellEdit = (rowIndex: number, field: string, value: any) => {
const newRow = { ...data[rowIndex], [field]: value };
onRowChange(rowIndex, newRow);
};
const renderCell = (
row: any,
column: RepeaterColumnConfig,
rowIndex: number
) => {
const isEditing =
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field];
// 계산 필드는 편집 불가
if (column.calculated || !column.editable) {
return (
<div className="px-2 py-1">
{column.type === "number"
? typeof value === "number"
? value.toLocaleString()
: value || "0"
: value || "-"}
</div>
);
}
// 편집 가능한 필드
switch (column.type) {
case "number":
return (
<Input
type="number"
value={value || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
case "date":
// ISO 형식(2025-11-23T00:00:00.000Z)을 yyyy-mm-dd로 변환
const formatDateValue = (val: any): string => {
if (!val) return "";
// 이미 yyyy-mm-dd 형식이면 그대로 반환
if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) {
return val;
}
// ISO 형식이면 날짜 부분만 추출
if (typeof val === "string" && val.includes("T")) {
return val.split("T")[0];
}
// Date 객체이면 변환
if (val instanceof Date) {
return val.toISOString().split("T")[0];
}
return String(val);
};
return (
<Input
type="date"
value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
case "select":
return (
<Select
value={value || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default: // text
return (
<Input
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
}
};
return (
<div className="border border-gray-200 bg-white">
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<table className="w-full text-xs border-collapse">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12">
#
</th>
{columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
: null;
return (
<th
key={col.field}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 기본 너비로 되돌리기"
>
<div className="flex items-center justify-between pointer-events-none">
<div className="flex items-center gap-1 pointer-events-auto">
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1"
)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-red-500 ml-1">*</span>}
</>
)}
</div>
{/* 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정"
/>
</div>
</th>
);
})}
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
</th>
</tr>
</thead>
<tbody className="bg-white">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
>
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors">
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200">
{rowIndex + 1}
</td>
{columns.map((col) => (
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}