feat(repeater-table): 행 드래그 앤 드롭 및 컬럼 너비 관리 기능 추가
- @dnd-kit 라이브러리로 행 순서 드래그 앤 드롭 구현 - SortableRow 컴포넌트로 드래그 가능한 테이블 행 구현 - GripVertical 아이콘 드래그 핸들 추가 - 드래그 시 선택된 행 인덱스 자동 재계산 - "균등 분배" 버튼으로 컬럼 너비 컨테이너에 맞게 균등 분배 - 컬럼 헤더 더블클릭으로 데이터 기준 자동 확장/복구 토글 - Input 컴포넌트 min-w-0 w-full 적용으로 컬럼 너비 초과 방지
This commit is contained in:
parent
56608001ff
commit
342042d761
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Columns } from "lucide-react";
|
||||||
import { ItemSelectionModal } from "./ItemSelectionModal";
|
import { ItemSelectionModal } from "./ItemSelectionModal";
|
||||||
import { RepeaterTable } from "./RepeaterTable";
|
import { RepeaterTable } from "./RepeaterTable";
|
||||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
|
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
|
||||||
|
|
@ -331,6 +331,9 @@ export function ModalRepeaterTableComponent({
|
||||||
// 체크박스 선택 상태
|
// 체크박스 선택 상태
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// 균등 분배 트리거 (값이 변경되면 RepeaterTable에서 균등 분배 실행)
|
||||||
|
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
|
||||||
|
|
||||||
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -820,9 +823,23 @@ export function ModalRepeaterTableComponent({
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{localValue.length > 0 && `${localValue.length}개 항목`}
|
<span className="text-sm text-muted-foreground">
|
||||||
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
{localValue.length > 0 && `${localValue.length}개 항목`}
|
||||||
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
||||||
|
</span>
|
||||||
|
{columns.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
title="컬럼 너비 균등 분배"
|
||||||
|
>
|
||||||
|
<Columns className="h-3.5 w-3.5 mr-1" />
|
||||||
|
균등 분배
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedRows.size > 0 && (
|
{selectedRows.size > 0 && (
|
||||||
|
|
@ -855,6 +872,7 @@ export function ModalRepeaterTableComponent({
|
||||||
onDataSourceChange={handleDataSourceChange}
|
onDataSourceChange={handleDataSourceChange}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
|
equalizeWidthsTrigger={equalizeWidthsTrigger}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 선택 모달 */}
|
{/* 항목 선택 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,68 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { ChevronDown, Check } from "lucide-react";
|
import { ChevronDown, Check, GripVertical } from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RepeaterColumnConfig } from "./types";
|
import { RepeaterColumnConfig } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface RepeaterTableProps {
|
||||||
columns: RepeaterColumnConfig[];
|
columns: RepeaterColumnConfig[];
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -21,6 +75,8 @@ interface RepeaterTableProps {
|
||||||
// 체크박스 선택 관련
|
// 체크박스 선택 관련
|
||||||
selectedRows: Set<number>; // 선택된 행 인덱스
|
selectedRows: Set<number>; // 선택된 행 인덱스
|
||||||
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
|
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
|
||||||
|
// 균등 분배 트리거
|
||||||
|
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterTable({
|
export function RepeaterTable({
|
||||||
|
|
@ -33,7 +89,58 @@ export function RepeaterTable({
|
||||||
onDataSourceChange,
|
onDataSourceChange,
|
||||||
selectedRows,
|
selectedRows,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
equalizeWidthsTrigger,
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
|
// 컨테이너 ref - 실제 너비 측정용
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
|
||||||
|
const [isEqualizedMode, setIsEqualizedMode] = useState(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<{
|
const [editingCell, setEditingCell] = useState<{
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
field: string;
|
field: string;
|
||||||
|
|
@ -71,15 +178,100 @@ export function RepeaterTable({
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startWidth: columnWidths[field] || 120,
|
startWidth: columnWidths[field] || 120,
|
||||||
});
|
});
|
||||||
|
// 수동 조정 시 균등 분배 모드 해제
|
||||||
|
setIsEqualizedMode(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 더블클릭으로 기본 너비로 리셋
|
// 컬럼 확장 상태 추적 (토글용)
|
||||||
const handleDoubleClick = (field: string) => {
|
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
|
||||||
setColumnWidths((prev) => ({
|
|
||||||
...prev,
|
// 데이터 기준 최적 너비 계산
|
||||||
[field]: defaultWidths[field] || 120,
|
const calculateAutoFitWidth = (field: string): number => {
|
||||||
}));
|
const column = columns.find(col => col.field === field);
|
||||||
|
if (!column) return 120;
|
||||||
|
|
||||||
|
// 헤더 텍스트 길이 (대략 8px per character + padding)
|
||||||
|
const headerWidth = (column.label?.length || field.length) * 8 + 40;
|
||||||
|
|
||||||
|
// 데이터 중 가장 긴 텍스트 찾기
|
||||||
|
let maxDataWidth = 0;
|
||||||
|
data.forEach(row => {
|
||||||
|
const value = row[field];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
let displayText = String(value);
|
||||||
|
|
||||||
|
// 숫자는 천단위 구분자 포함
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
displayText = value.toLocaleString();
|
||||||
|
}
|
||||||
|
// 날짜는 yyyy-mm-dd 형식
|
||||||
|
if (column.type === 'date' && displayText.includes('T')) {
|
||||||
|
displayText = displayText.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대략적인 너비 계산 (8px per character + padding)
|
||||||
|
const textWidth = displayText.length * 8 + 32;
|
||||||
|
maxDataWidth = Math.max(maxDataWidth, textWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
|
||||||
|
const optimalWidth = Math.max(headerWidth, maxDataWidth);
|
||||||
|
return Math.min(Math.max(optimalWidth, 60), 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 더블클릭으로 auto-fit / 기본 너비 토글
|
||||||
|
const handleDoubleClick = (field: string) => {
|
||||||
|
// 개별 컬럼 조정 시 균등 분배 모드 해제
|
||||||
|
setIsEqualizedMode(false);
|
||||||
|
|
||||||
|
setExpandedColumns(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(field)) {
|
||||||
|
// 확장 상태 → 기본 너비로 복구
|
||||||
|
newSet.delete(field);
|
||||||
|
setColumnWidths(prevWidths => ({
|
||||||
|
...prevWidths,
|
||||||
|
[field]: defaultWidths[field] || 120,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 기본 상태 → 데이터 기준 auto-fit
|
||||||
|
newSet.add(field);
|
||||||
|
const autoWidth = calculateAutoFitWidth(field);
|
||||||
|
setColumnWidths(prevWidths => ({
|
||||||
|
...prevWidths,
|
||||||
|
[field]: autoWidth,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 균등 분배 트리거 감지
|
||||||
|
useEffect(() => {
|
||||||
|
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// 실제 컨테이너 너비 측정
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
|
||||||
|
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
|
||||||
|
const checkboxColumnWidth = 40;
|
||||||
|
const borderWidth = 2;
|
||||||
|
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
|
||||||
|
|
||||||
|
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
|
||||||
|
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
||||||
|
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
columns.forEach((col) => {
|
||||||
|
newWidths[col.field] = equalWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnWidths(newWidths);
|
||||||
|
setExpandedColumns(new Set()); // 확장 상태 초기화
|
||||||
|
setIsEqualizedMode(true); // 균등 분배 모드 활성화
|
||||||
|
}, [equalizeWidthsTrigger, columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
|
|
@ -176,7 +368,7 @@ export function RepeaterTable({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
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"
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -204,7 +396,7 @@ export function RepeaterTable({
|
||||||
type="date"
|
type="date"
|
||||||
value={formatDateValue(value)}
|
value={formatDateValue(value)}
|
||||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.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"
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -216,7 +408,7 @@ export function RepeaterTable({
|
||||||
handleCellEdit(rowIndex, column.field, 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">
|
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -235,30 +427,49 @@ export function RepeaterTable({
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.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"
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 아이템 ID 목록
|
||||||
|
const sortableItems = data.map((_, idx) => `row-${idx}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 bg-white">
|
<DndContext
|
||||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
sensors={sensors}
|
||||||
<table className="w-full text-xs border-collapse">
|
collisionDetection={closestCenter}
|
||||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
onDragEnd={handleDragEnd}
|
||||||
<tr>
|
>
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
|
<div ref={containerRef} className="border border-gray-200 bg-white">
|
||||||
<Checkbox
|
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||||
checked={isAllSelected}
|
<table
|
||||||
// @ts-ignore - indeterminate는 HTML 속성
|
className={cn(
|
||||||
data-indeterminate={isIndeterminate}
|
"text-xs border-collapse",
|
||||||
onCheckedChange={handleSelectAll}
|
isEqualizedMode && "w-full"
|
||||||
className={cn(
|
)}
|
||||||
"border-gray-400",
|
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
|
||||||
isIndeterminate && "data-[state=checked]:bg-primary"
|
>
|
||||||
)}
|
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||||
/>
|
<tr>
|
||||||
</th>
|
{/* 드래그 핸들 헤더 */}
|
||||||
|
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
|
||||||
|
<span className="sr-only">순서</span>
|
||||||
|
</th>
|
||||||
|
{/* 체크박스 헤더 */}
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
// @ts-ignore - indeterminate는 HTML 속성
|
||||||
|
data-indeterminate={isIndeterminate}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
className={cn(
|
||||||
|
"border-gray-400",
|
||||||
|
isIndeterminate && "data-[state=checked]:bg-primary"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||||
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||||
|
|
@ -266,13 +477,15 @@ export function RepeaterTable({
|
||||||
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isExpanded = expandedColumns.has(col.field);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.field}
|
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"
|
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` }}
|
style={{ width: `${columnWidths[col.field]}px` }}
|
||||||
onDoubleClick={() => handleDoubleClick(col.field)}
|
onDoubleClick={() => handleDoubleClick(col.field)}
|
||||||
title="더블클릭하여 기본 너비로 되돌리기"
|
title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between pointer-events-none">
|
<div className="flex items-center justify-between pointer-events-none">
|
||||||
<div className="flex items-center gap-1 pointer-events-auto">
|
<div className="flex items-center gap-1 pointer-events-auto">
|
||||||
|
|
@ -344,46 +557,74 @@ export function RepeaterTable({
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white">
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columns.length + 1}
|
|
||||||
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
|
|
||||||
>
|
|
||||||
추가된 항목이 없습니다
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
</thead>
|
||||||
data.map((row, rowIndex) => (
|
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
||||||
<tr
|
<tbody className="bg-white">
|
||||||
key={rowIndex}
|
{data.length === 0 ? (
|
||||||
className={cn(
|
<tr>
|
||||||
"hover:bg-blue-50/50 transition-colors",
|
<td
|
||||||
selectedRows.has(rowIndex) && "bg-blue-50"
|
colSpan={columns.length + 2}
|
||||||
)}
|
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
추가된 항목이 없습니다
|
||||||
<Checkbox
|
|
||||||
checked={selectedRows.has(rowIndex)}
|
|
||||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
|
||||||
className="border-gray-400"
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
))}
|
</tr>
|
||||||
</tr>
|
) : (
|
||||||
))
|
data.map((row, rowIndex) => (
|
||||||
)}
|
<SortableRow
|
||||||
</tbody>
|
key={`row-${rowIndex}`}
|
||||||
</table>
|
id={`row-${rowIndex}`}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-blue-50/50 transition-colors",
|
||||||
|
selectedRows.has(rowIndex) && "bg-blue-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ attributes, listeners, isDragging }) => (
|
||||||
|
<>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
|
||||||
|
isDragging && "cursor-grabbing"
|
||||||
|
)}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{/* 체크박스 */}
|
||||||
|
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(rowIndex)}
|
||||||
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
||||||
|
className="border-gray-400"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* 데이터 컬럼들 */}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.field}
|
||||||
|
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden"
|
||||||
|
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }}
|
||||||
|
>
|
||||||
|
{renderCell(row, col, rowIndex)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SortableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue