703 lines
27 KiB
TypeScript
703 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } 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) {
|
|
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
|
|
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
|
|
|
|
// 컨테이너 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.filter((col) => !col.hidden).forEach((col) => {
|
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
|
});
|
|
return widths;
|
|
});
|
|
|
|
// 기본 너비 저장 (리셋용)
|
|
const defaultWidths = React.useMemo(() => {
|
|
const widths: Record<string, number> = {};
|
|
visibleColumns.forEach((col) => {
|
|
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
|
});
|
|
return widths;
|
|
}, [visibleColumns]);
|
|
|
|
// 리사이즈 상태
|
|
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 = visibleColumns.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;
|
|
}
|
|
|
|
// 헤더 텍스트 너비 (동적 데이터 소스가 있으면 headerLabel 사용)
|
|
let headerText = column.label || field;
|
|
if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) {
|
|
const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId;
|
|
const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId)
|
|
|| column.dynamicDataSource.options[0];
|
|
if (activeOption?.headerLabel) {
|
|
headerText = activeOption.headerLabel;
|
|
}
|
|
}
|
|
const headerWidth = measureTextWidth(headerText) + 32; // padding + 드롭다운 아이콘
|
|
|
|
// 헤더와 데이터 중 큰 값 사용
|
|
return Math.max(headerWidth, maxDataWidth);
|
|
};
|
|
|
|
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
|
|
const handleDoubleClick = (field: string) => {
|
|
const availableWidth = getAvailableWidth();
|
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
|
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
|
setColumnWidths((prev) => ({
|
|
...prev,
|
|
[field]: contentWidth,
|
|
}));
|
|
};
|
|
|
|
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
|
|
const applyEqualizeWidths = () => {
|
|
const availableWidth = getAvailableWidth();
|
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
|
|
|
const newWidths: Record<string, number> = {};
|
|
visibleColumns.forEach((col) => {
|
|
newWidths[col.field] = equalWidth;
|
|
});
|
|
|
|
setColumnWidths(newWidths);
|
|
};
|
|
|
|
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
|
const applyAutoFitWidths = () => {
|
|
if (visibleColumns.length === 0) return;
|
|
|
|
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
|
|
const availableWidth = getAvailableWidth();
|
|
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
|
|
|
|
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
|
|
const newWidths: Record<string, number> = {};
|
|
visibleColumns.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 / visibleColumns.length);
|
|
visibleColumns.forEach((col) => {
|
|
newWidths[col.field] += extraPerColumn;
|
|
});
|
|
}
|
|
// 컨테이너보다 크면 그대로 (스크롤 생성됨)
|
|
|
|
setColumnWidths(newWidths);
|
|
};
|
|
|
|
// 초기 마운트 시 균등 분배 적용
|
|
useEffect(() => {
|
|
if (initializedRef.current) return;
|
|
if (!containerRef.current || visibleColumns.length === 0) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
applyEqualizeWidths();
|
|
initializedRef.current = true;
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [visibleColumns]);
|
|
|
|
// 트리거 감지: 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, visibleColumns, 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?.filter((option) => option.value && option.value !== "").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>
|
|
{visibleColumns.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>
|
|
{activeOption?.headerLabel || `${col.label} - ${activeOption?.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);
|
|
// 옵션 변경 시 해당 컬럼 너비 재계산
|
|
if (option.headerLabel) {
|
|
const newHeaderWidth = measureTextWidth(option.headerLabel) + 32;
|
|
setColumnWidths((prev) => ({
|
|
...prev,
|
|
[col.field]: Math.max(prev[col.field] || 60, newHeaderWidth),
|
|
}));
|
|
}
|
|
}}
|
|
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={visibleColumns.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>
|
|
{/* 데이터 컬럼들 */}
|
|
{visibleColumns.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>
|
|
);
|
|
}
|