- Revised the full-screen analysis document to reflect the latest updates, including the purpose and core rules for screen development. - Expanded the V2 component usage guide to include a comprehensive catalog of components, their configurations, and usage guidelines for LLM and chatbot applications. - Added a summary of the system architecture and clarified the implementation methods for user business screens and admin menus. - Enhanced the documentation to serve as a reference for AI agents and screen designers, ensuring adherence to the established guidelines. These updates aim to improve clarity and usability for developers and designers working with the WACE ERP screen composition system. Made-with: Cursor
942 lines
36 KiB
TypeScript
942 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef, useMemo, useCallback } 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";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { formatNumber as centralFormatNumber } from "@/lib/formatting";
|
|
|
|
// @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; // 값이 변경되면 균등 분배 실행
|
|
// 🆕 카테고리 라벨 변환용
|
|
categoryColumns?: string[]; // 카테고리 타입 컬럼명 목록
|
|
categoryLabelMap?: Record<string, string>; // 카테고리 코드 → 라벨 매핑
|
|
}
|
|
|
|
export function RepeaterTable({
|
|
columns,
|
|
data,
|
|
onDataChange,
|
|
onRowChange,
|
|
onRowDelete,
|
|
activeDataSources = {},
|
|
onDataSourceChange,
|
|
selectedRows,
|
|
onSelectionChange,
|
|
equalizeWidthsTrigger,
|
|
categoryColumns = [],
|
|
categoryLabelMap = {},
|
|
}: RepeaterTableProps) {
|
|
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
|
|
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
|
|
|
|
// 🆕 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
|
|
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
|
|
|
|
// 🆕 카테고리 옵션 로드
|
|
// categoryRef 형식: "tableName.columnName" (예: "item_info.material")
|
|
useEffect(() => {
|
|
const loadCategoryOptions = async () => {
|
|
// category 타입이면서 categoryRef가 있는 컬럼들 찾기
|
|
const categoryColumns = visibleColumns.filter((col) => col.type === "category");
|
|
|
|
const categoryRefs = categoryColumns
|
|
.filter((col) => col.categoryRef)
|
|
.map((col) => col.categoryRef!)
|
|
.filter((ref, index, self) => self.indexOf(ref) === index); // 중복 제거
|
|
|
|
if (categoryRefs.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const categoryRef of categoryRefs) {
|
|
if (categoryOptionsMap[categoryRef]) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// categoryRef를 tableName.columnName 형식으로 파싱
|
|
const parts = categoryRef.split(".");
|
|
let tableName: string;
|
|
let columnName: string;
|
|
|
|
if (parts.length >= 2) {
|
|
// "tableName.columnName" 형식
|
|
tableName = parts[0];
|
|
columnName = parts.slice(1).join("."); // 컬럼명에 .이 포함될 수 있음
|
|
} else {
|
|
// 단일 값인 경우 컬럼명만 있다고 가정 (테이블명 불명)
|
|
console.warn(`카테고리 참조 형식 오류 (${categoryRef}): tableName.columnName 형식이어야 합니다`);
|
|
continue;
|
|
}
|
|
|
|
const response = await apiClient.get(
|
|
`/table-categories/${tableName}/${columnName}/values?includeInactive=true`,
|
|
);
|
|
|
|
if (response.data?.success && response.data.data) {
|
|
const options = response.data.data.map((item: any) => ({
|
|
value: item.valueCode || item.value_code,
|
|
label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label,
|
|
}));
|
|
setCategoryOptionsMap((prev) => ({
|
|
...prev,
|
|
[categoryRef]: options,
|
|
}));
|
|
} else {
|
|
console.warn(`⚠️ [RepeaterTable] ${categoryRef} API 응답이 success가 아니거나 data가 없음`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadCategoryOptions();
|
|
}, [visibleColumns]);
|
|
|
|
// 컨테이너 ref - 실제 너비 측정용
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
|
|
const initializedRef = useRef(false);
|
|
|
|
// 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용)
|
|
const editableColIndices = useMemo(
|
|
() =>
|
|
visibleColumns.reduce<number[]>((acc, col, idx) => {
|
|
if (col.editable && !col.calculated) acc.push(idx);
|
|
return acc;
|
|
}, []),
|
|
[visibleColumns],
|
|
);
|
|
|
|
// 방향키로 리피터 셀 간 이동
|
|
const handleArrowNavigation = useCallback(
|
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const key = e.key;
|
|
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
const cell = target.closest("[data-repeater-row]") as HTMLElement | null;
|
|
if (!cell) return;
|
|
|
|
const row = Number(cell.dataset.repeaterRow);
|
|
const col = Number(cell.dataset.repeaterCol);
|
|
if (isNaN(row) || isNaN(col)) return;
|
|
|
|
// 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시
|
|
if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") {
|
|
const input = target as HTMLInputElement;
|
|
const len = input.value?.length ?? 0;
|
|
const pos = input.selectionStart ?? 0;
|
|
// 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동
|
|
if (key === "ArrowRight" && pos < len) return;
|
|
if (key === "ArrowLeft" && pos > 0) return;
|
|
}
|
|
|
|
let nextRow = row;
|
|
let nextColPos = editableColIndices.indexOf(col);
|
|
|
|
switch (key) {
|
|
case "ArrowUp":
|
|
nextRow = Math.max(0, row - 1);
|
|
break;
|
|
case "ArrowDown":
|
|
nextRow = Math.min(data.length - 1, row + 1);
|
|
break;
|
|
case "ArrowLeft":
|
|
nextColPos = Math.max(0, nextColPos - 1);
|
|
break;
|
|
case "ArrowRight":
|
|
nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1);
|
|
break;
|
|
}
|
|
|
|
const nextCol = editableColIndices[nextColPos];
|
|
if (nextRow === row && nextCol === col) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`;
|
|
const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null;
|
|
if (!nextCell) return;
|
|
|
|
const focusable = nextCell.querySelector<HTMLElement>(
|
|
'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])',
|
|
);
|
|
if (focusable) {
|
|
focusable.focus();
|
|
if (focusable.tagName === "INPUT") {
|
|
(focusable as HTMLInputElement).select();
|
|
}
|
|
}
|
|
},
|
|
[editableColIndices, data.length],
|
|
);
|
|
|
|
// 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]);
|
|
|
|
// 🆕 트리거 변경 시 자동으로 컬럼 너비 조정 (데이터 기반)
|
|
useEffect(() => {
|
|
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
|
if (!containerRef.current || visibleColumns.length === 0) return;
|
|
|
|
// 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배
|
|
const timer = setTimeout(() => {
|
|
if (data.length > 0) {
|
|
applyAutoFitWidths();
|
|
} else {
|
|
applyEqualizeWidths();
|
|
}
|
|
}, 50);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [equalizeWidthsTrigger, data.length]);
|
|
|
|
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];
|
|
|
|
// 카테고리/셀렉트 라벨 변환 함수
|
|
const getCategoryDisplayValue = (val: any): string => {
|
|
if (!val || typeof val !== "string") return val || "-";
|
|
|
|
// select 타입 컬럼의 selectOptions에서 라벨 찾기
|
|
if (column.selectOptions && column.selectOptions.length > 0) {
|
|
const matchedOption = column.selectOptions.find((opt) => opt.value === val);
|
|
if (matchedOption) return matchedOption.label;
|
|
}
|
|
|
|
const fieldName = column.field.replace(/^_display_/, "");
|
|
const isCategoryColumn = categoryColumns.includes(fieldName);
|
|
|
|
// categoryLabelMap에 직접 매핑이 있으면 바로 변환
|
|
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
|
|
|
// 카테고리 컬럼이 아니면 원래 값 반환
|
|
if (!isCategoryColumn) return val;
|
|
|
|
// 콤마 구분된 다중 값 처리
|
|
const codes = val
|
|
.split(",")
|
|
.map((c: string) => c.trim())
|
|
.filter(Boolean);
|
|
const labels = codes.map((code: string) => categoryLabelMap[code] || code);
|
|
return labels.join(", ");
|
|
};
|
|
|
|
// 🆕 40자 초과 시 ... 처리 및 툴팁 표시 함수
|
|
const truncateText = (text: string, maxLength: number = 40): { truncated: string; isTruncated: boolean } => {
|
|
if (!text || text.length <= maxLength) {
|
|
return { truncated: text || "-", isTruncated: false };
|
|
}
|
|
return { truncated: text.substring(0, maxLength) + "...", isTruncated: true };
|
|
};
|
|
|
|
// 계산 필드는 편집 불가
|
|
if (column.calculated || !column.editable) {
|
|
// 🆕 카테고리 타입이면 라벨로 변환하여 표시
|
|
const displayValue = column.type === "number"
|
|
? (value === undefined || value === null || value === "" ? "0" : centralFormatNumber(value) || "0")
|
|
: getCategoryDisplayValue(value);
|
|
|
|
// 🆕 40자 초과 시 ... 처리 및 툴팁
|
|
const { truncated, isTruncated } = truncateText(String(displayValue));
|
|
|
|
return (
|
|
<div className="truncate px-2 py-1" title={isTruncated ? String(displayValue) : undefined}>
|
|
{truncated}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 편집 가능한 필드
|
|
switch (column.type) {
|
|
case "number":
|
|
// 콤마 포함 숫자 표시
|
|
const displayValue = (() => {
|
|
if (value === undefined || value === null || value === "") return "";
|
|
const num = typeof value === "number" ? value : parseFloat(String(value));
|
|
if (isNaN(num)) return "";
|
|
return centralFormatNumber(num);
|
|
})();
|
|
|
|
return (
|
|
<Input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={displayValue}
|
|
onChange={(e) => {
|
|
const stripped = e.target.value.replace(/,/g, "");
|
|
if (stripped === "" || stripped === "-") {
|
|
handleCellEdit(rowIndex, column.field, 0);
|
|
return;
|
|
}
|
|
if (!/^-?\d*\.?\d*$/.test(stripped)) return;
|
|
if (!stripped.endsWith(".")) {
|
|
handleCellEdit(rowIndex, column.field, parseFloat(stripped) || 0);
|
|
}
|
|
}}
|
|
className="border-border focus:border-primary focus:ring-ring h-8 w-full min-w-0 rounded-none text-right text-xs focus:ring-1"
|
|
/>
|
|
);
|
|
|
|
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="border-border focus:border-primary focus:ring-ring h-8 w-full min-w-0 cursor-pointer rounded-none border bg-white px-2 text-xs focus:ring-1 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="border-border focus:border-primary focus:ring-ring h-8 w-full min-w-0 rounded-none text-xs focus:ring-1">
|
|
<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>
|
|
);
|
|
|
|
case "category": {
|
|
// 🆕 카테고리 타입: categoryRef로 로드된 옵션 사용
|
|
const options = column.categoryRef ? categoryOptionsMap[column.categoryRef] || [] : [];
|
|
return (
|
|
<Select value={value || ""} onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}>
|
|
<SelectTrigger className="border-border focus:border-primary focus:ring-ring h-8 w-full min-w-0 rounded-none text-xs focus:ring-1">
|
|
<SelectValue placeholder="선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.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="border-border focus:border-primary focus:ring-ring h-8 w-full min-w-0 rounded-none text-xs focus:ring-1"
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 드래그 아이템 ID 목록
|
|
const sortableItems = data.map((_, idx) => `row-${idx}`);
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<div
|
|
ref={containerRef}
|
|
className="border-border flex h-full flex-col border bg-white"
|
|
onKeyDown={handleArrowNavigation}
|
|
>
|
|
<div className="min-h-0 flex-1 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-muted sticky top-0 z-20">
|
|
<tr>
|
|
{/* 드래그 핸들 헤더 - 좌측 고정 */}
|
|
<th
|
|
key="header-drag"
|
|
className="border-border bg-muted text-foreground sticky left-0 z-30 w-8 border-r border-b px-1 py-2 text-center font-medium"
|
|
>
|
|
<span className="sr-only">순서</span>
|
|
</th>
|
|
{/* 체크박스 헤더 - 좌측 고정 */}
|
|
<th
|
|
key="header-checkbox"
|
|
className="border-border bg-muted text-foreground sticky left-8 z-30 w-10 border-r border-b px-3 py-2 text-center font-medium"
|
|
>
|
|
<Checkbox
|
|
checked={isAllSelected}
|
|
// @ts-expect-error - indeterminate는 HTML 속성
|
|
data-indeterminate={isIndeterminate}
|
|
onCheckedChange={handleSelectAll}
|
|
className={cn("border-input", isIndeterminate && "data-[state=checked]:bg-primary")}
|
|
/>
|
|
</th>
|
|
{visibleColumns.map((col, colIndex) => {
|
|
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={`header-col-${col.field || colIndex}`}
|
|
className="group border-border text-foreground relative cursor-pointer border-r border-b px-3 py-2 text-left font-medium whitespace-nowrap 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(
|
|
"hover:text-primary inline-flex items-center gap-1 transition-colors",
|
|
"focus-visible:ring-ring -mx-1 rounded px-1 focus:outline-none focus-visible:ring-2",
|
|
)}
|
|
>
|
|
{/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */}
|
|
<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="text-destructive ml-1">*</span>}
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* 리사이즈 핸들 */}
|
|
<div
|
|
className="hover:bg-primary pointer-events-auto absolute top-0 right-0 bottom-0 w-1 cursor-col-resize opacity-0 transition-opacity group-hover:opacity-100"
|
|
onMouseDown={(e) => handleMouseDown(e, col.field)}
|
|
title="드래그하여 너비 조정"
|
|
/>
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
|
<tbody className="bg-white">
|
|
{data.length === 0 ? (
|
|
<tr key="empty-row">
|
|
<td
|
|
key="empty-cell"
|
|
colSpan={visibleColumns.length + 2}
|
|
className="border-border text-muted-foreground border-b px-4 py-8 text-center"
|
|
>
|
|
추가된 항목이 없습니다
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
data.map((row, rowIndex) => (
|
|
<SortableRow
|
|
key={`row-${rowIndex}`}
|
|
id={`row-${rowIndex}`}
|
|
className={cn(
|
|
"hover:bg-primary/10/50 transition-colors",
|
|
selectedRows.has(rowIndex) && "bg-primary/10",
|
|
)}
|
|
>
|
|
{({ attributes, listeners, isDragging }) => (
|
|
<>
|
|
{/* 드래그 핸들 - 좌측 고정 */}
|
|
<td
|
|
key={`drag-${rowIndex}`}
|
|
className={cn(
|
|
"border-border sticky left-0 z-10 border-r border-b px-1 py-1 text-center",
|
|
selectedRows.has(rowIndex) ? "bg-primary/10" : "bg-white",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"hover:bg-muted cursor-grab rounded p-1 transition-colors",
|
|
isDragging && "cursor-grabbing",
|
|
)}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="text-muted-foreground/70 h-4 w-4" />
|
|
</button>
|
|
</td>
|
|
{/* 체크박스 - 좌측 고정 */}
|
|
<td
|
|
key={`check-${rowIndex}`}
|
|
className={cn(
|
|
"border-border sticky left-8 z-10 border-r border-b px-3 py-1 text-center",
|
|
selectedRows.has(rowIndex) ? "bg-primary/10" : "bg-white",
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={selectedRows.has(rowIndex)}
|
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
|
className="border-input"
|
|
/>
|
|
</td>
|
|
{/* 데이터 컬럼들 */}
|
|
{visibleColumns.map((col, colIndex) => (
|
|
<td
|
|
key={`${rowIndex}-${col.field || colIndex}`}
|
|
className="border-border overflow-hidden border-r border-b px-1 py-1"
|
|
style={{
|
|
width: `${columnWidths[col.field]}px`,
|
|
maxWidth: `${columnWidths[col.field]}px`,
|
|
}}
|
|
data-repeater-row={rowIndex}
|
|
data-repeater-col={colIndex}
|
|
>
|
|
{renderCell(row, col, rowIndex)}
|
|
</td>
|
|
))}
|
|
</>
|
|
)}
|
|
</SortableRow>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</SortableContext>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</DndContext>
|
|
);
|
|
}
|