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

681 lines
26 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ChevronDown, Check, GripVertical } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
// @dnd-kit imports
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
interface SortableRowProps {
id: string;
children: (props: {
attributes: React.HTMLAttributes<HTMLElement>;
listeners: React.HTMLAttributes<HTMLElement> | undefined;
isDragging: boolean;
}) => React.ReactNode;
className?: string;
}
function SortableRow({ id, children, className }: SortableRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "#f0f9ff" : undefined,
};
return (
<tr ref={setNodeRef} style={style} className={className}>
{children({ attributes, listeners, isDragging })}
</tr>
);
}
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; // 데이터 소스 변경 콜백
// 체크박스 선택 관련
selectedRows: Set<number>; // 선택된 행 인덱스
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
// 균등 분배 트리거
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
}
export function RepeaterTable({
columns,
data,
onDataChange,
onRowChange,
onRowDelete,
activeDataSources = {},
onDataSourceChange,
selectedRows,
onSelectionChange,
equalizeWidthsTrigger,
}: RepeaterTableProps) {
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef<HTMLDivElement>(null);
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
const initializedRef = useRef(false);
// DnD 센서 설정
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px 이동해야 드래그 시작 (클릭과 구분)
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 드래그 종료 핸들러
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = data.findIndex((_, idx) => `row-${idx}` === active.id);
const newIndex = data.findIndex((_, idx) => `row-${idx}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newData = arrayMove(data, oldIndex, newIndex);
onDataChange(newData);
// 선택된 행 인덱스도 업데이트
if (selectedRows.size > 0) {
const newSelectedRows = new Set<number>();
selectedRows.forEach((oldIdx) => {
if (oldIdx === oldIndex) {
newSelectedRows.add(newIndex);
} else if (oldIdx > oldIndex && oldIdx <= newIndex) {
newSelectedRows.add(oldIdx - 1);
} else if (oldIdx < oldIndex && oldIdx >= newIndex) {
newSelectedRows.add(oldIdx + 1);
} else {
newSelectedRows.add(oldIdx);
}
});
onSelectionChange(newSelectedRows);
}
}
}
};
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 getAvailableWidth = (): number => {
if (!containerRef.current) return 800;
const containerWidth = containerRef.current.offsetWidth;
// 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px)
return containerWidth - 74;
};
// 텍스트 너비 계산 (한글/영문/숫자 혼합 고려)
const measureTextWidth = (text: string): number => {
if (!text) return 0;
let width = 0;
for (const char of text) {
if (/[가-힣]/.test(char)) {
width += 15; // 한글 (text-xs 12px 기준)
} else if (/[a-zA-Z]/.test(char)) {
width += 9; // 영문
} else if (/[0-9]/.test(char)) {
width += 8; // 숫자
} else if (/[_\-.]/.test(char)) {
width += 6; // 특수문자
} else if (/[\(\)]/.test(char)) {
width += 6; // 괄호
} else {
width += 8; // 기타
}
}
return width;
};
// 해당 컬럼의 가장 긴 글자 너비 계산
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
const column = columns.find((col) => col.field === field);
if (!column) return equalWidth;
// 날짜 필드는 110px (yyyy-MM-dd)
if (column.type === "date") {
return 110;
}
// 해당 컬럼에 값이 있는지 확인
let hasValue = false;
let maxDataWidth = 0;
data.forEach((row) => {
const value = row[field];
if (value !== undefined && value !== null && value !== "") {
hasValue = true;
let displayText = String(value);
if (typeof value === "number") {
displayText = value.toLocaleString();
}
const textWidth = measureTextWidth(displayText) + 20; // padding
maxDataWidth = Math.max(maxDataWidth, textWidth);
}
});
// 값이 없으면 균등 분배 너비 사용
if (!hasValue) {
return equalWidth;
}
// 헤더 텍스트 너비
const headerText = column.label || field;
const headerWidth = measureTextWidth(headerText) + 24; // padding
// 헤더와 데이터 중 큰 값 사용
return Math.max(headerWidth, maxDataWidth);
};
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
const handleDoubleClick = (field: string) => {
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const contentWidth = calculateColumnContentWidth(field, equalWidth);
setColumnWidths((prev) => ({
...prev,
[field]: contentWidth,
}));
};
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
const applyEqualizeWidths = () => {
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
newWidths[col.field] = equalWidth;
});
setColumnWidths(newWidths);
};
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
const applyAutoFitWidths = () => {
if (columns.length === 0) return;
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
});
// 2. 컨테이너 너비와 비교
const totalContentWidth = Object.values(newWidths).reduce((sum, w) => sum + w, 0);
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
if (totalContentWidth < availableWidth) {
const extraSpace = availableWidth - totalContentWidth;
const extraPerColumn = Math.floor(extraSpace / columns.length);
columns.forEach((col) => {
newWidths[col.field] += extraPerColumn;
});
}
// 컨테이너보다 크면 그대로 (스크롤 생성됨)
setColumnWidths(newWidths);
};
// 초기 마운트 시 균등 분배 적용
useEffect(() => {
if (initializedRef.current) return;
if (!containerRef.current || columns.length === 0) return;
const timer = setTimeout(() => {
applyEqualizeWidths();
initializedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}, [columns]);
// 트리거 감지: 1=균등분배, 2=자동맞춤
useEffect(() => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
// 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식)
if (equalizeWidthsTrigger % 2 === 1) {
applyAutoFitWidths();
} else {
applyEqualizeWidths();
}
}, [equalizeWidthsTrigger]);
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 handleSelectAll = (checked: boolean) => {
if (checked) {
// 모든 행 선택
const allIndices = new Set(data.map((_, index) => index));
onSelectionChange(allIndices);
} else {
// 전체 해제
onSelectionChange(new Set());
}
};
// 개별 행 선택 핸들러
const handleRowSelect = (rowIndex: number, checked: boolean) => {
const newSelection = new Set(selectedRows);
if (checked) {
newSelection.add(rowIndex);
} else {
newSelection.delete(rowIndex);
}
onSelectionChange(newSelection);
};
// 전체 선택 상태 계산
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
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) {
// 숫자 포맷팅 함수: 정수/소수점 자동 구분
const formatNumber = (val: any): string => {
if (val === undefined || val === null || val === "") return "0";
const num = typeof val === "number" ? val : parseFloat(val);
if (isNaN(num)) return "0";
// 정수면 소수점 없이, 소수면 소수점 유지
if (Number.isInteger(num)) {
return num.toLocaleString("ko-KR");
} else {
return num.toLocaleString("ko-KR");
}
};
return <div className="px-2 py-1">{column.type === "number" ? formatNumber(value) : value || "-"}</div>;
}
// 편집 가능한 필드
switch (column.type) {
case "number":
// 숫자 표시: 정수/소수점 자동 구분
const displayValue = (() => {
if (value === undefined || value === null || value === "") return "";
const num = typeof value === "number" ? value : parseFloat(value);
if (isNaN(num)) return "";
return num.toString();
})();
return (
<Input
type="text"
inputMode="numeric"
value={displayValue}
onChange={(e) => {
const val = e.target.value;
// 숫자와 소수점만 허용
if (val === "" || /^-?\d*\.?\d*$/.test(val)) {
handleCellEdit(rowIndex, column.field, val === "" ? 0 : parseFloat(val) || 0);
}
}}
className="h-8 w-full min-w-0 rounded-none border-gray-200 text-right text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
);
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)}
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
className="h-8 w-full min-w-0 cursor-pointer rounded-none border border-gray-200 bg-white px-2 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden"
/>
);
case "select":
return (
<Select value={value || ""} onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}>
<SelectTrigger className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
<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 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
);
}
};
// 드래그 아이템 ID 목록
const sortableItems = data.map((_, idx) => `row-${idx}`);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div ref={containerRef} className="border border-gray-200 bg-white">
<div className="max-h-[400px] overflow-x-auto overflow-y-auto">
<table
className="border-collapse text-xs"
style={{
width: `max(100%, ${Object.values(columnWidths).reduce((sum, w) => sum + w, 0) + 74}px)`,
}}
>
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{/* 드래그 핸들 헤더 */}
<th className="w-8 border-r border-b border-gray-200 px-1 py-2 text-center font-medium text-gray-700">
<span className="sr-only"></span>
</th>
{/* 체크박스 헤더 */}
<th className="w-10 border-r border-b border-gray-200 px-3 py-2 text-center font-medium text-gray-700">
<Checkbox
checked={isAllSelected}
// @ts-expect-error - indeterminate는 HTML 속성
data-indeterminate={isIndeterminate}
onCheckedChange={handleSelectAll}
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/>
</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="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 글자 너비에 맞춤"
>
<div className="pointer-events-none flex items-center justify-between">
<div className="pointer-events-auto flex items-center gap-1">
{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 transition-colors hover:text-blue-600",
"-mx-1 rounded px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
)}
>
<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-muted-foreground mb-1 border-b px-2 py-1 text-[10px]">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus-visible:bg-accent focus:outline-none",
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="ml-1 text-red-500">*</span>}
</>
)}
</div>
{/* 리사이즈 핸들 */}
<div
className="pointer-events-auto absolute top-0 right-0 bottom-0 w-1 cursor-col-resize opacity-0 transition-opacity group-hover:opacity-100 hover:bg-blue-500"
onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정"
/>
</div>
</th>
);
})}
</tr>
</thead>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<tbody className="bg-white">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
>
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<SortableRow
key={`row-${rowIndex}`}
id={`row-${rowIndex}`}
className={cn(
"transition-colors hover:bg-blue-50/50",
selectedRows.has(rowIndex) && "bg-blue-50",
)}
>
{({ attributes, listeners, isDragging }) => (
<>
{/* 드래그 핸들 */}
<td className="border-r border-b border-gray-200 px-1 py-1 text-center">
<button
type="button"
className={cn(
"cursor-grab rounded p-1 transition-colors hover:bg-gray-100",
isDragging && "cursor-grabbing",
)}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4 text-gray-400" />
</button>
</td>
{/* 체크박스 */}
<td className="border-r border-b border-gray-200 px-3 py-1 text-center">
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
className="border-gray-400"
/>
</td>
{/* 데이터 컬럼들 */}
{columns.map((col) => (
<td
key={col.field}
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
style={{
width: `${columnWidths[col.field]}px`,
maxWidth: `${columnWidths[col.field]}px`,
}}
>
{renderCell(row, col, rowIndex)}
</td>
))}
</>
)}
</SortableRow>
))
)}
</tbody>
</SortableContext>
</table>
</div>
</div>
</DndContext>
);
}