feat(RepeaterTable): 컬럼 너비 자동 맞춤 기능 추가
- 균등 분배 / 자동 맞춤 토글 방식으로 변경 - measureTextWidth(): 한글/영문/숫자별 픽셀 계산 - applyAutoFitWidths(): 글자 너비 기준 컬럼 조정 - 계산 규칙 결과 필드를 드롭다운으로 변경
This commit is contained in:
parent
1c6eb2ae61
commit
fdb9ef9167
|
|
@ -31,8 +31,8 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
|
||||
interface SortableRowProps {
|
||||
id: string;
|
||||
children: (props: {
|
||||
attributes: React.HTMLAttributes<HTMLElement>;
|
||||
children: (props: {
|
||||
attributes: React.HTMLAttributes<HTMLElement>;
|
||||
listeners: React.HTMLAttributes<HTMLElement> | undefined;
|
||||
isDragging: boolean;
|
||||
}) => React.ReactNode;
|
||||
|
|
@ -40,14 +40,7 @@ interface SortableRowProps {
|
|||
}
|
||||
|
||||
function SortableRow({ id, children, className }: SortableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -93,9 +86,9 @@ export function RepeaterTable({
|
|||
}: RepeaterTableProps) {
|
||||
// 컨테이너 ref - 실제 너비 측정용
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
|
||||
const [isEqualizedMode, setIsEqualizedMode] = useState(false);
|
||||
|
||||
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// DnD 센서 설정
|
||||
const sensors = useSensors(
|
||||
|
|
@ -106,7 +99,7 @@ export function RepeaterTable({
|
|||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// 드래그 종료 핸들러
|
||||
|
|
@ -140,15 +133,15 @@ export function RepeaterTable({
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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> = {};
|
||||
|
|
@ -157,7 +150,7 @@ export function RepeaterTable({
|
|||
});
|
||||
return widths;
|
||||
});
|
||||
|
||||
|
||||
// 기본 너비 저장 (리셋용)
|
||||
const defaultWidths = React.useMemo(() => {
|
||||
const widths: Record<string, number> = {};
|
||||
|
|
@ -166,10 +159,10 @@ export function RepeaterTable({
|
|||
});
|
||||
return widths;
|
||||
}, [columns]);
|
||||
|
||||
|
||||
// 리사이즈 상태
|
||||
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
||||
|
||||
|
||||
// 리사이즈 핸들러
|
||||
const handleMouseDown = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -178,104 +171,163 @@ export function RepeaterTable({
|
|||
startX: e.clientX,
|
||||
startWidth: columnWidths[field] || 120,
|
||||
});
|
||||
// 수동 조정 시 균등 분배 모드 해제
|
||||
setIsEqualizedMode(false);
|
||||
};
|
||||
|
||||
// 컬럼 확장 상태 추적 (토글용)
|
||||
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
|
||||
|
||||
// 데이터 기준 최적 너비 계산
|
||||
const calculateAutoFitWidth = (field: string): number => {
|
||||
const column = columns.find(col => col.field === field);
|
||||
if (!column) return 120;
|
||||
// 컨테이너 가용 너비 계산
|
||||
const getAvailableWidth = (): number => {
|
||||
if (!containerRef.current) return 800;
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
// 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px)
|
||||
return containerWidth - 74;
|
||||
};
|
||||
|
||||
// 헤더 텍스트 길이 (대략 8px per character + padding)
|
||||
const headerWidth = (column.label?.length || field.length) * 8 + 40;
|
||||
// 텍스트 너비 계산 (한글/영문/숫자 혼합 고려)
|
||||
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 => {
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = row[field];
|
||||
if (value !== undefined && value !== null) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
hasValue = true;
|
||||
let displayText = String(value);
|
||||
|
||||
// 숫자는 천단위 구분자 포함
|
||||
if (typeof value === 'number') {
|
||||
|
||||
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;
|
||||
|
||||
const textWidth = measureTextWidth(displayText) + 20; // padding
|
||||
maxDataWidth = Math.max(maxDataWidth, textWidth);
|
||||
}
|
||||
});
|
||||
|
||||
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
|
||||
const optimalWidth = Math.max(headerWidth, maxDataWidth);
|
||||
return Math.min(Math.max(optimalWidth, 60), 400);
|
||||
// 값이 없으면 균등 분배 너비 사용
|
||||
if (!hasValue) {
|
||||
return equalWidth;
|
||||
}
|
||||
|
||||
// 헤더 텍스트 너비
|
||||
const headerText = column.label || field;
|
||||
const headerWidth = measureTextWidth(headerText) + 24; // padding
|
||||
|
||||
// 헤더와 데이터 중 큰 값 사용
|
||||
return Math.max(headerWidth, maxDataWidth);
|
||||
};
|
||||
|
||||
// 더블클릭으로 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;
|
||||
});
|
||||
const availableWidth = getAvailableWidth();
|
||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
||||
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[field]: contentWidth,
|
||||
}));
|
||||
};
|
||||
|
||||
// 균등 분배 트리거 감지
|
||||
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 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);
|
||||
setExpandedColumns(new Set()); // 확장 상태 초기화
|
||||
setIsEqualizedMode(true); // 균등 분배 모드 활성화
|
||||
}, [equalizeWidthsTrigger, columns]);
|
||||
|
||||
};
|
||||
|
||||
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
||||
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;
|
||||
|
|
@ -285,14 +337,14 @@ export function RepeaterTable({
|
|||
[resizing.field]: newWidth,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
|
@ -336,13 +388,8 @@ export function RepeaterTable({
|
|||
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 renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => {
|
||||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||
const value = row[column.field];
|
||||
|
||||
// 계산 필드는 편집 불가
|
||||
|
|
@ -359,14 +406,8 @@ export function RepeaterTable({
|
|||
return num.toLocaleString("ko-KR");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
{column.type === "number"
|
||||
? formatNumber(value)
|
||||
: value || "-"}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className="px-2 py-1">{column.type === "number" ? formatNumber(value) : value || "-"}</div>;
|
||||
}
|
||||
|
||||
// 편집 가능한 필드
|
||||
|
|
@ -377,22 +418,22 @@ export function RepeaterTable({
|
|||
if (value === undefined || value === null || value === "") return "";
|
||||
const num = typeof value === "number" ? value : parseFloat(value);
|
||||
if (isNaN(num)) return "";
|
||||
// 정수면 소수점 없이, 소수면 소수점 유지
|
||||
if (Number.isInteger(num)) {
|
||||
return num.toString();
|
||||
} else {
|
||||
return num.toString();
|
||||
}
|
||||
return num.toString();
|
||||
})();
|
||||
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayValue}
|
||||
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 min-w-0 w-full"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -414,25 +455,21 @@ export function RepeaterTable({
|
|||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Input
|
||||
<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 min-w-0 w-full"
|
||||
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 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
|
||||
<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>
|
||||
|
|
@ -451,7 +488,7 @@ export function RepeaterTable({
|
|||
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 min-w-0 w-full"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -461,126 +498,113 @@ export function RepeaterTable({
|
|||
const sortableItems = data.map((_, idx) => `row-${idx}`);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div ref={containerRef} className="border border-gray-200 bg-white">
|
||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table
|
||||
className={cn(
|
||||
"text-xs border-collapse",
|
||||
isEqualizedMode && "w-full"
|
||||
)}
|
||||
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
|
||||
<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="bg-gray-50 sticky top-0 z-10">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50">
|
||||
<tr>
|
||||
{/* 드래그 핸들 헤더 */}
|
||||
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
|
||||
<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="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
|
||||
<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-ignore - indeterminate는 HTML 속성
|
||||
// @ts-expect-error - indeterminate는 HTML 속성
|
||||
data-indeterminate={isIndeterminate}
|
||||
onCheckedChange={handleSelectAll}
|
||||
className={cn(
|
||||
"border-gray-400",
|
||||
isIndeterminate && "data-[state=checked]:bg-primary"
|
||||
)}
|
||||
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;
|
||||
|
||||
const isExpanded = expandedColumns.has(col.field);
|
||||
|
||||
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={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
|
||||
>
|
||||
<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}
|
||||
{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)}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
|
||||
데이터 소스 선택
|
||||
</div>
|
||||
{col.dynamicDataSource!.options.map((option) => (
|
||||
<PopoverTrigger asChild>
|
||||
<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"
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
<span>{col.label}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<>
|
||||
{col.label}
|
||||
{col.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
{/* 리사이즈 핸들 */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
||||
|
|
@ -589,7 +613,7 @@ export function RepeaterTable({
|
|||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + 2}
|
||||
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
|
||||
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
||||
>
|
||||
추가된 항목이 없습니다
|
||||
</td>
|
||||
|
|
@ -600,19 +624,19 @@ export function RepeaterTable({
|
|||
key={`row-${rowIndex}`}
|
||||
id={`row-${rowIndex}`}
|
||||
className={cn(
|
||||
"hover:bg-blue-50/50 transition-colors",
|
||||
selectedRows.has(rowIndex) && "bg-blue-50"
|
||||
"transition-colors hover:bg-blue-50/50",
|
||||
selectedRows.has(rowIndex) && "bg-blue-50",
|
||||
)}
|
||||
>
|
||||
{({ attributes, listeners, isDragging }) => (
|
||||
<>
|
||||
{/* 드래그 핸들 */}
|
||||
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
|
||||
<td className="border-r border-b border-gray-200 px-1 py-1 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
|
||||
isDragging && "cursor-grabbing"
|
||||
"cursor-grab rounded p-1 transition-colors hover:bg-gray-100",
|
||||
isDragging && "cursor-grabbing",
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
|
|
@ -621,7 +645,7 @@ export function RepeaterTable({
|
|||
</button>
|
||||
</td>
|
||||
{/* 체크박스 */}
|
||||
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
||||
<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)}
|
||||
|
|
@ -630,10 +654,13 @@ export function RepeaterTable({
|
|||
</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` }}
|
||||
<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>
|
||||
|
|
@ -651,4 +678,3 @@ export function RepeaterTable({
|
|||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Columns } from "lucide-react";
|
||||
import { Plus, Columns, AlignJustify } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
|
|
@ -170,8 +170,8 @@ export function TableSectionRenderer({
|
|||
// 체크박스 선택 상태
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
|
||||
// 균등 분배 트리거
|
||||
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
|
||||
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
|
||||
const [widthTrigger, setWidthTrigger] = useState(0);
|
||||
|
||||
// 동적 데이터 소스 활성화 상태
|
||||
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||
|
|
@ -438,12 +438,21 @@ export function TableSectionRenderer({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
|
||||
onClick={() => setWidthTrigger((prev) => prev + 1)}
|
||||
className="h-7 text-xs px-2"
|
||||
title="컬럼 너비 균등 분배"
|
||||
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
|
||||
>
|
||||
<Columns className="h-3.5 w-3.5 mr-1" />
|
||||
균등 분배
|
||||
{widthTrigger % 2 === 0 ? (
|
||||
<>
|
||||
<AlignJustify className="h-3.5 w-3.5 mr-1" />
|
||||
자동 맞춤
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Columns className="h-3.5 w-3.5 mr-1" />
|
||||
균등 분배
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -478,7 +487,7 @@ export function TableSectionRenderer({
|
|||
onDataSourceChange={handleDataSourceChange}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
equalizeWidthsTrigger={equalizeWidthsTrigger}
|
||||
equalizeWidthsTrigger={widthTrigger}
|
||||
/>
|
||||
|
||||
{/* 항목 선택 모달 */}
|
||||
|
|
|
|||
|
|
@ -1232,12 +1232,27 @@ export function TableSectionSettingsModal({
|
|||
|
||||
{(tableConfig.calculations || []).map((calc, index) => (
|
||||
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-muted/30">
|
||||
<Input
|
||||
value={calc.resultField}
|
||||
onChange={(e) => updateCalculation(index, { resultField: e.target.value })}
|
||||
placeholder="결과 필드"
|
||||
className="h-8 text-xs w-[150px]"
|
||||
/>
|
||||
<Select
|
||||
value={calc.resultField || ""}
|
||||
onValueChange={(value) => updateCalculation(index, { resultField: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[150px]">
|
||||
<SelectValue placeholder="결과 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableConfig.columns || []).length === 0 ? (
|
||||
<SelectItem value="__no_columns__" disabled>
|
||||
컬럼 설정에서 먼저 컬럼을 추가하세요
|
||||
</SelectItem>
|
||||
) : (
|
||||
(tableConfig.columns || []).map((col) => (
|
||||
<SelectItem key={col.field} value={col.field}>
|
||||
{col.label || col.field}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">=</span>
|
||||
<Input
|
||||
value={calc.formula}
|
||||
|
|
|
|||
Loading…
Reference in New Issue