- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations. - Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context. - Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations. - Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions. These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
940 lines
37 KiB
TypeScript
940 lines
37 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";
|
|
|
|
// @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 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");
|
|
}
|
|
};
|
|
|
|
// 🆕 카테고리 타입이면 라벨로 변환하여 표시
|
|
const displayValue = column.type === "number" ? formatNumber(value) : 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(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>
|
|
);
|
|
|
|
case "category": {
|
|
// 🆕 카테고리 타입: categoryRef로 로드된 옵션 사용
|
|
const options = column.categoryRef ? categoryOptionsMap[column.categoryRef] || [] : [];
|
|
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 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="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="flex h-full flex-col border border-gray-200 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="sticky top-0 z-20 bg-gray-50">
|
|
<tr>
|
|
{/* 드래그 핸들 헤더 - 좌측 고정 */}
|
|
<th
|
|
key="header-drag"
|
|
className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700"
|
|
>
|
|
<span className="sr-only">순서</span>
|
|
</th>
|
|
{/* 체크박스 헤더 - 좌측 고정 */}
|
|
<th
|
|
key="header-checkbox"
|
|
className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 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, 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 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="text-orange-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 key="empty-row">
|
|
<td
|
|
key="empty-cell"
|
|
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
|
|
key={`drag-${rowIndex}`}
|
|
className={cn(
|
|
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
|
|
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
|
|
)}
|
|
>
|
|
<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
|
|
key={`check-${rowIndex}`}
|
|
className={cn(
|
|
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
|
|
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={selectedRows.has(rowIndex)}
|
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
|
className="border-gray-400"
|
|
/>
|
|
</td>
|
|
{/* 데이터 컬럼들 */}
|
|
{visibleColumns.map((col, colIndex) => (
|
|
<td
|
|
key={`${rowIndex}-${col.field || colIndex}`}
|
|
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`,
|
|
}}
|
|
data-repeater-row={rowIndex}
|
|
data-repeater-col={colIndex}
|
|
>
|
|
{renderCell(row, col, rowIndex)}
|
|
</td>
|
|
))}
|
|
</>
|
|
)}
|
|
</SortableRow>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</SortableContext>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</DndContext>
|
|
);
|
|
}
|