Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node
This commit is contained in:
commit
ea3b6d2083
|
|
@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
selectedComponentIds,
|
selectedComponentIds,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
selectMultipleComponents,
|
||||||
updateComponent,
|
updateComponent,
|
||||||
getQueryResult,
|
getQueryResult,
|
||||||
snapValueToGrid,
|
snapValueToGrid,
|
||||||
|
|
@ -178,20 +179,192 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
margins,
|
margins,
|
||||||
layoutConfig,
|
layoutConfig,
|
||||||
currentPageId,
|
currentPageId,
|
||||||
|
duplicateAtPosition,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||||
const componentRef = useRef<HTMLDivElement>(null);
|
const componentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Alt+드래그 복제를 위한 상태
|
||||||
|
const [isAltDuplicating, setIsAltDuplicating] = useState(false);
|
||||||
|
const duplicatedIdsRef = useRef<string[]>([]);
|
||||||
|
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
|
||||||
|
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
|
// 인라인 편집 상태
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const isSelected = selectedComponentId === component.id;
|
const isSelected = selectedComponentId === component.id;
|
||||||
const isMultiSelected = selectedComponentIds.includes(component.id);
|
const isMultiSelected = selectedComponentIds.includes(component.id);
|
||||||
const isLocked = component.locked === true;
|
const isLocked = component.locked === true;
|
||||||
const isGrouped = !!component.groupId;
|
const isGrouped = !!component.groupId;
|
||||||
|
|
||||||
|
// 표시할 값 결정
|
||||||
|
const getDisplayValue = (): string => {
|
||||||
|
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
|
||||||
|
if (component.queryId && component.fieldName) {
|
||||||
|
const queryResult = getQueryResult(component.queryId);
|
||||||
|
|
||||||
|
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
|
||||||
|
if (queryResult && queryResult.rows.length > 0) {
|
||||||
|
const firstRow = queryResult.rows[0];
|
||||||
|
const value = firstRow[component.fieldName];
|
||||||
|
|
||||||
|
// 값이 있으면 문자열로 변환하여 반환
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행 결과가 없거나 값이 없으면 필드명 표시
|
||||||
|
return `{${component.fieldName}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값이 있으면 기본값 표시
|
||||||
|
if (component.defaultValue) {
|
||||||
|
return component.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 둘 다 없으면 타입에 따라 기본 텍스트
|
||||||
|
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절
|
||||||
|
const fitTextToContent = () => {
|
||||||
|
if (isLocked) return;
|
||||||
|
if (component.type !== "text" && component.type !== "label") return;
|
||||||
|
|
||||||
|
const minWidth = 50;
|
||||||
|
const minHeight = 30;
|
||||||
|
|
||||||
|
// 여백을 px로 변환
|
||||||
|
const marginRightPx = margins.right * MM_TO_PX;
|
||||||
|
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||||
|
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||||
|
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||||
|
|
||||||
|
// 최대 크기 (여백 고려)
|
||||||
|
const maxWidth = canvasWidthPx - marginRightPx - component.x;
|
||||||
|
const maxHeight = canvasHeightPx - marginBottomPx - component.y;
|
||||||
|
|
||||||
|
const displayValue = getDisplayValue();
|
||||||
|
const fontSize = component.fontSize || 14;
|
||||||
|
|
||||||
|
// 줄바꿈으로 분리하여 각 줄의 너비 측정
|
||||||
|
const lines = displayValue.split("\n");
|
||||||
|
let maxLineWidth = 0;
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const measureEl = document.createElement("span");
|
||||||
|
measureEl.style.position = "absolute";
|
||||||
|
measureEl.style.visibility = "hidden";
|
||||||
|
measureEl.style.whiteSpace = "nowrap";
|
||||||
|
measureEl.style.fontSize = `${fontSize}px`;
|
||||||
|
measureEl.style.fontWeight = component.fontWeight || "normal";
|
||||||
|
measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
|
||||||
|
measureEl.textContent = line || " "; // 빈 줄은 공백으로
|
||||||
|
document.body.appendChild(measureEl);
|
||||||
|
|
||||||
|
const lineWidth = measureEl.getBoundingClientRect().width;
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||||||
|
document.body.removeChild(measureEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트 padding (p-2 = 8px * 2) + 여유분
|
||||||
|
const horizontalPadding = 24;
|
||||||
|
const verticalPadding = 20;
|
||||||
|
|
||||||
|
// 줄 높이 계산 (font-size * line-height 약 1.5)
|
||||||
|
const lineHeight = fontSize * 1.5;
|
||||||
|
const totalHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
|
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
|
||||||
|
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
|
||||||
|
|
||||||
|
const newWidth = Math.max(minWidth, finalWidth);
|
||||||
|
const newHeight = Math.max(minHeight, finalHeight);
|
||||||
|
|
||||||
|
// 크기 업데이트
|
||||||
|
updateComponent(component.id, {
|
||||||
|
width: snapValueToGrid(newWidth),
|
||||||
|
height: snapValueToGrid(newHeight),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입)
|
||||||
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||||
|
if (component.type !== "text" && component.type !== "label") return;
|
||||||
|
if (isLocked) return; // 잠긴 컴포넌트는 편집 불가
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 인라인 편집 모드 진입
|
||||||
|
setEditValue(component.defaultValue || "");
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 시작 시 textarea에 포커스
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.select();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
// 선택 해제 시 편집 모드 종료를 위한 ref
|
||||||
|
const editValueRef = useRef(editValue);
|
||||||
|
const isEditingRef = useRef(isEditing);
|
||||||
|
editValueRef.current = editValue;
|
||||||
|
isEditingRef.current = isEditing;
|
||||||
|
|
||||||
|
// 선택 해제 시 편집 모드 종료 (저장 후 종료)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSelected && !isMultiSelected && isEditingRef.current) {
|
||||||
|
// 현재 편집 값으로 저장
|
||||||
|
if (editValueRef.current !== component.defaultValue) {
|
||||||
|
updateComponent(component.id, { defaultValue: editValueRef.current });
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]);
|
||||||
|
|
||||||
|
// 인라인 편집 저장
|
||||||
|
const handleEditSave = () => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
updateComponent(component.id, {
|
||||||
|
defaultValue: editValue,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 취소
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집 키보드 핸들러
|
||||||
|
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEditCancel();
|
||||||
|
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
// Enter: 저장 (Shift+Enter는 줄바꿈)
|
||||||
|
e.preventDefault();
|
||||||
|
handleEditSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 드래그 시작
|
// 드래그 시작
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
// 편집 모드에서는 드래그 비활성화
|
||||||
|
if (isEditing) return;
|
||||||
|
|
||||||
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -209,16 +382,83 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
|
|
||||||
// Ctrl/Cmd 키 감지 (다중 선택)
|
// Ctrl/Cmd 키 감지 (다중 선택)
|
||||||
const isMultiSelect = e.ctrlKey || e.metaKey;
|
const isMultiSelect = e.ctrlKey || e.metaKey;
|
||||||
|
// Alt 키 감지 (복제 드래그)
|
||||||
|
const isAltPressed = e.altKey;
|
||||||
|
|
||||||
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
|
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
|
||||||
if (isGrouped && !isMultiSelect) {
|
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
|
||||||
const groupMembers = components.filter((c) => c.groupId === component.groupId);
|
|
||||||
const groupMemberIds = groupMembers.map((c) => c.id);
|
if (!isPartOfMultiSelection) {
|
||||||
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
|
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
|
||||||
selectComponent(groupMemberIds[0], false);
|
if (isGrouped && !isMultiSelect) {
|
||||||
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
|
const groupMembers = components.filter((c) => c.groupId === component.groupId);
|
||||||
} else {
|
const groupMemberIds = groupMembers.map((c) => c.id);
|
||||||
selectComponent(component.id, isMultiSelect);
|
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
|
||||||
|
selectComponent(groupMemberIds[0], false);
|
||||||
|
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
|
||||||
|
} else {
|
||||||
|
selectComponent(component.id, isMultiSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+드래그: 복제 모드
|
||||||
|
if (isAltPressed) {
|
||||||
|
// 복제할 컴포넌트 ID 목록 결정
|
||||||
|
let idsToClone: string[] = [];
|
||||||
|
|
||||||
|
if (isPartOfMultiSelection) {
|
||||||
|
// 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제
|
||||||
|
idsToClone = selectedComponentIds.filter((id) => {
|
||||||
|
const c = components.find((comp) => comp.id === id);
|
||||||
|
return c && !c.locked;
|
||||||
|
});
|
||||||
|
} else if (isGrouped) {
|
||||||
|
// 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제
|
||||||
|
idsToClone = components
|
||||||
|
.filter((c) => c.groupId === component.groupId && !c.locked)
|
||||||
|
.map((c) => c.id);
|
||||||
|
} else {
|
||||||
|
// 단일 컴포넌트
|
||||||
|
idsToClone = [component.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idsToClone.length > 0) {
|
||||||
|
// 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용)
|
||||||
|
const positionsMap = new Map<string, { x: number; y: number }>();
|
||||||
|
idsToClone.forEach((id) => {
|
||||||
|
const comp = components.find((c) => c.id === id);
|
||||||
|
if (comp) {
|
||||||
|
positionsMap.set(id, { x: comp.x, y: comp.y });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 복제 생성 (오프셋 없이 원래 위치에)
|
||||||
|
const newIds = duplicateAtPosition(idsToClone, 0, 0);
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
// 복제된 컴포넌트 ID와 원본 위치 매핑
|
||||||
|
// newIds[i]는 idsToClone[i]에서 복제됨
|
||||||
|
const dupPositionsMap = new Map<string, { x: number; y: number }>();
|
||||||
|
newIds.forEach((newId, index) => {
|
||||||
|
const originalId = idsToClone[index];
|
||||||
|
const originalPos = positionsMap.get(originalId);
|
||||||
|
if (originalPos) {
|
||||||
|
dupPositionsMap.set(newId, originalPos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
originalPositionsRef.current = dupPositionsMap;
|
||||||
|
|
||||||
|
// 복제된 컴포넌트들을 선택하고 드래그 시작
|
||||||
|
duplicatedIdsRef.current = newIds;
|
||||||
|
setIsAltDuplicating(true);
|
||||||
|
|
||||||
|
// 복제된 컴포넌트들 선택
|
||||||
|
if (newIds.length === 1) {
|
||||||
|
selectComponent(newIds[0], false);
|
||||||
|
} else {
|
||||||
|
selectMultipleComponents(newIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
|
@ -284,14 +524,58 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const deltaX = snappedX - component.x;
|
const deltaX = snappedX - component.x;
|
||||||
const deltaY = snappedY - component.y;
|
const deltaY = snappedY - component.y;
|
||||||
|
|
||||||
|
// Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동
|
||||||
|
if (isAltDuplicating && duplicatedIdsRef.current.length > 0) {
|
||||||
|
// 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정)
|
||||||
|
duplicatedIdsRef.current.forEach((dupId) => {
|
||||||
|
const dupComp = components.find((c) => c.id === dupId);
|
||||||
|
const originalPos = originalPositionsRef.current.get(dupId);
|
||||||
|
|
||||||
|
if (dupComp && originalPos) {
|
||||||
|
// 각 복제본의 원본 위치에서 delta만큼 이동
|
||||||
|
const targetX = originalPos.x + deltaX;
|
||||||
|
const targetY = originalPos.y + deltaY;
|
||||||
|
|
||||||
|
// 경계 체크
|
||||||
|
const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width;
|
||||||
|
const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height;
|
||||||
|
|
||||||
|
updateComponent(dupId, {
|
||||||
|
x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX),
|
||||||
|
y: Math.min(Math.max(marginTopPx, targetY), dupMaxY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // 원본 컴포넌트는 이동하지 않음
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 컴포넌트 이동
|
// 현재 컴포넌트 이동
|
||||||
updateComponent(component.id, {
|
updateComponent(component.id, {
|
||||||
x: snappedX,
|
x: snappedX,
|
||||||
y: snappedY,
|
y: snappedY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동
|
||||||
|
if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) {
|
||||||
|
components.forEach((c) => {
|
||||||
|
// 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외
|
||||||
|
if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) {
|
||||||
|
const newMultiX = c.x + deltaX;
|
||||||
|
const newMultiY = c.y + deltaY;
|
||||||
|
|
||||||
|
// 경계 체크
|
||||||
|
const multiMaxX = canvasWidthPx - marginRightPx - c.width;
|
||||||
|
const multiMaxY = canvasHeightPx - marginBottomPx - c.height;
|
||||||
|
|
||||||
|
updateComponent(c.id, {
|
||||||
|
x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX),
|
||||||
|
y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
|
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
|
||||||
if (isGrouped) {
|
else if (isGrouped) {
|
||||||
components.forEach((c) => {
|
components.forEach((c) => {
|
||||||
if (c.groupId === component.groupId && c.id !== component.id) {
|
if (c.groupId === component.groupId && c.id !== component.id) {
|
||||||
const newGroupX = c.x + deltaX;
|
const newGroupX = c.x + deltaX;
|
||||||
|
|
@ -369,6 +653,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
|
// Alt 복제 상태 초기화
|
||||||
|
setIsAltDuplicating(false);
|
||||||
|
duplicatedIdsRef.current = [];
|
||||||
|
originalPositionsRef.current = new Map();
|
||||||
// 가이드라인 초기화
|
// 가이드라인 초기화
|
||||||
clearAlignmentGuides();
|
clearAlignmentGuides();
|
||||||
};
|
};
|
||||||
|
|
@ -383,6 +671,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
}, [
|
}, [
|
||||||
isDragging,
|
isDragging,
|
||||||
isResizing,
|
isResizing,
|
||||||
|
isAltDuplicating,
|
||||||
dragStart.x,
|
dragStart.x,
|
||||||
dragStart.y,
|
dragStart.y,
|
||||||
resizeStart.x,
|
resizeStart.x,
|
||||||
|
|
@ -405,36 +694,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 표시할 값 결정
|
|
||||||
const getDisplayValue = (): string => {
|
|
||||||
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
|
|
||||||
if (component.queryId && component.fieldName) {
|
|
||||||
const queryResult = getQueryResult(component.queryId);
|
|
||||||
|
|
||||||
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
|
|
||||||
if (queryResult && queryResult.rows.length > 0) {
|
|
||||||
const firstRow = queryResult.rows[0];
|
|
||||||
const value = firstRow[component.fieldName];
|
|
||||||
|
|
||||||
// 값이 있으면 문자열로 변환하여 반환
|
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행 결과가 없거나 값이 없으면 필드명 표시
|
|
||||||
return `{${component.fieldName}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본값이 있으면 기본값 표시
|
|
||||||
if (component.defaultValue) {
|
|
||||||
return component.defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 둘 다 없으면 타입에 따라 기본 텍스트
|
|
||||||
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 타입별 렌더링
|
// 컴포넌트 타입별 렌더링
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const displayValue = getDisplayValue();
|
const displayValue = getDisplayValue();
|
||||||
|
|
@ -443,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
switch (component.type) {
|
switch (component.type) {
|
||||||
case "text":
|
case "text":
|
||||||
case "label":
|
case "label":
|
||||||
|
// 인라인 편집 모드
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleEditSave}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
className="h-full w-full resize-none border-none bg-transparent p-0 outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
style={{
|
||||||
|
fontSize: `${component.fontSize}px`,
|
||||||
|
color: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
textAlign: component.textAlign as "left" | "center" | "right",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
|
@ -1182,6 +1462,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
: "1px solid #e5e7eb",
|
: "1px solid #e5e7eb",
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
||||||
|
|
@ -201,6 +201,7 @@ export function ReportDesignerCanvas() {
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
margins,
|
margins,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
selectMultipleComponents,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
selectedComponentIds,
|
selectedComponentIds,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
|
|
@ -210,12 +211,32 @@ export function ReportDesignerCanvas() {
|
||||||
alignmentGuides,
|
alignmentGuides,
|
||||||
copyComponents,
|
copyComponents,
|
||||||
pasteComponents,
|
pasteComponents,
|
||||||
|
duplicateComponents,
|
||||||
|
copyStyles,
|
||||||
|
pasteStyles,
|
||||||
|
fitSelectedToContent,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
showRuler,
|
showRuler,
|
||||||
layoutConfig,
|
layoutConfig,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
|
|
||||||
|
// 드래그 영역 선택 (Marquee Selection) 상태
|
||||||
|
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||||
|
const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
|
||||||
|
const [marqueeEnd, setMarqueeEnd] = useState({ x: 0, y: 0 });
|
||||||
|
// 클로저 문제 해결을 위한 refs (동기적으로 업데이트)
|
||||||
|
const marqueeStartRef = useRef({ x: 0, y: 0 });
|
||||||
|
const marqueeEndRef = useRef({ x: 0, y: 0 });
|
||||||
|
const componentsRef = useRef(components);
|
||||||
|
const selectMultipleRef = useRef(selectMultipleComponents);
|
||||||
|
// 마퀴 선택 직후 click 이벤트 무시를 위한 플래그
|
||||||
|
const justFinishedMarqueeRef = useRef(false);
|
||||||
|
|
||||||
|
// refs 동기적 업데이트 (useEffect 대신 직접 할당)
|
||||||
|
componentsRef.current = components;
|
||||||
|
selectMultipleRef.current = selectMultipleComponents;
|
||||||
|
|
||||||
const [{ isOver }, drop] = useDrop(() => ({
|
const [{ isOver }, drop] = useDrop(() => ({
|
||||||
accept: "component",
|
accept: "component",
|
||||||
drop: (item: { componentType: string }, monitor) => {
|
drop: (item: { componentType: string }, monitor) => {
|
||||||
|
|
@ -420,12 +441,127 @@ export function ReportDesignerCanvas() {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
|
||||||
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.target === e.currentTarget) {
|
// 마퀴 선택 직후의 click 이벤트는 무시
|
||||||
|
if (justFinishedMarqueeRef.current) {
|
||||||
|
justFinishedMarqueeRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target === e.currentTarget && !isMarqueeSelecting) {
|
||||||
selectComponent(null);
|
selectComponent(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 영역 선택 시작
|
||||||
|
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외)
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// state와 ref 모두 설정
|
||||||
|
setIsMarqueeSelecting(true);
|
||||||
|
setMarqueeStart({ x, y });
|
||||||
|
setMarqueeEnd({ x, y });
|
||||||
|
marqueeStartRef.current = { x, y };
|
||||||
|
marqueeEndRef.current = { x, y };
|
||||||
|
|
||||||
|
// Ctrl/Cmd 키가 눌리지 않았으면 기존 선택 해제
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
selectComponent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 영역 선택 중
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMarqueeSelecting) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(e.clientX - rect.left, canvasWidth * MM_TO_PX));
|
||||||
|
const y = Math.max(0, Math.min(e.clientY - rect.top, canvasHeight * MM_TO_PX));
|
||||||
|
|
||||||
|
// state와 ref 둘 다 업데이트
|
||||||
|
setMarqueeEnd({ x, y });
|
||||||
|
marqueeEndRef.current = { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
// ref에서 최신 값 가져오기 (클로저 문제 해결)
|
||||||
|
const currentStart = marqueeStartRef.current;
|
||||||
|
const currentEnd = marqueeEndRef.current;
|
||||||
|
const currentComponents = componentsRef.current;
|
||||||
|
const currentSelectMultiple = selectMultipleRef.current;
|
||||||
|
|
||||||
|
// 선택 영역 계산
|
||||||
|
const selectionRect = {
|
||||||
|
left: Math.min(currentStart.x, currentEnd.x),
|
||||||
|
top: Math.min(currentStart.y, currentEnd.y),
|
||||||
|
right: Math.max(currentStart.x, currentEnd.x),
|
||||||
|
bottom: Math.max(currentStart.y, currentEnd.y),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
|
||||||
|
const dragDistance = Math.sqrt(
|
||||||
|
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dragDistance > 5) {
|
||||||
|
// 선택 영역과 교차하는 컴포넌트 찾기
|
||||||
|
const intersectingComponents = currentComponents.filter((comp) => {
|
||||||
|
const compRect = {
|
||||||
|
left: comp.x,
|
||||||
|
top: comp.y,
|
||||||
|
right: comp.x + comp.width,
|
||||||
|
bottom: comp.y + comp.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 두 사각형이 교차하는지 확인
|
||||||
|
return !(
|
||||||
|
compRect.right < selectionRect.left ||
|
||||||
|
compRect.left > selectionRect.right ||
|
||||||
|
compRect.bottom < selectionRect.top ||
|
||||||
|
compRect.top > selectionRect.bottom
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 교차하는 컴포넌트들 한번에 선택
|
||||||
|
if (intersectingComponents.length > 0) {
|
||||||
|
const ids = intersectingComponents.map((comp) => comp.id);
|
||||||
|
currentSelectMultiple(ids);
|
||||||
|
// click 이벤트가 선택을 해제하지 않도록 플래그 설정
|
||||||
|
justFinishedMarqueeRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMarqueeSelecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isMarqueeSelecting, canvasWidth, canvasHeight]);
|
||||||
|
|
||||||
|
// 선택 영역 사각형 계산
|
||||||
|
const getMarqueeRect = () => {
|
||||||
|
return {
|
||||||
|
left: Math.min(marqueeStart.x, marqueeEnd.x),
|
||||||
|
top: Math.min(marqueeStart.y, marqueeEnd.y),
|
||||||
|
width: Math.abs(marqueeEnd.x - marqueeStart.x),
|
||||||
|
height: Math.abs(marqueeEnd.y - marqueeStart.y),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
|
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -497,16 +633,46 @@ export function ReportDesignerCanvas() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+C (또는 Cmd+Shift+C): 스타일 복사 (일반 복사보다 먼저 체크)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||||
|
e.preventDefault();
|
||||||
|
copyStyles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+V (또는 Cmd+Shift+V): 스타일 붙여넣기 (일반 붙여넣기보다 먼저 체크)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "v") {
|
||||||
|
e.preventDefault();
|
||||||
|
pasteStyles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+F (또는 Cmd+Shift+F): 텍스트 크기 자동 맞춤
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "f") {
|
||||||
|
e.preventDefault();
|
||||||
|
fitSelectedToContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+C (또는 Cmd+C): 복사
|
// Ctrl+C (또는 Cmd+C): 복사
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
copyComponents();
|
copyComponents();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V (또는 Cmd+V): 붙여넣기
|
// Ctrl+V (또는 Cmd+V): 붙여넣기
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
pasteComponents();
|
pasteComponents();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+D (또는 Cmd+D): 복제
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
|
||||||
|
e.preventDefault();
|
||||||
|
duplicateComponents();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
|
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
|
||||||
|
|
@ -538,6 +704,10 @@ export function ReportDesignerCanvas() {
|
||||||
removeComponent,
|
removeComponent,
|
||||||
copyComponents,
|
copyComponents,
|
||||||
pasteComponents,
|
pasteComponents,
|
||||||
|
duplicateComponents,
|
||||||
|
copyStyles,
|
||||||
|
pasteStyles,
|
||||||
|
fitSelectedToContent,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
]);
|
]);
|
||||||
|
|
@ -592,8 +762,10 @@ export function ReportDesignerCanvas() {
|
||||||
`
|
`
|
||||||
: undefined,
|
: undefined,
|
||||||
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
||||||
|
cursor: isMarqueeSelecting ? "crosshair" : "default",
|
||||||
}}
|
}}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
|
onMouseDown={handleCanvasMouseDown}
|
||||||
>
|
>
|
||||||
{/* 페이지 여백 가이드 */}
|
{/* 페이지 여백 가이드 */}
|
||||||
{currentPage && (
|
{currentPage && (
|
||||||
|
|
@ -648,6 +820,20 @@ export function ReportDesignerCanvas() {
|
||||||
<CanvasComponent key={component.id} component={component} />
|
<CanvasComponent key={component.id} component={component} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 드래그 영역 선택 사각형 */}
|
||||||
|
{isMarqueeSelecting && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
|
||||||
|
style={{
|
||||||
|
left: `${getMarqueeRect().left}px`,
|
||||||
|
top: `${getMarqueeRect().top}px`,
|
||||||
|
width: `${getMarqueeRect().width}px`,
|
||||||
|
height: `${getMarqueeRect().height}px`,
|
||||||
|
zIndex: 10000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 빈 캔버스 안내 */}
|
{/* 빈 캔버스 안내 */}
|
||||||
{components.length === 0 && (
|
{components.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ interface ReportDesignerContextType {
|
||||||
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
||||||
removeComponent: (id: string) => void;
|
removeComponent: (id: string) => void;
|
||||||
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
|
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
|
||||||
|
selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택
|
||||||
|
|
||||||
// 레이아웃 관리
|
// 레이아웃 관리
|
||||||
updateLayout: (updates: Partial<ReportLayout>) => void;
|
updateLayout: (updates: Partial<ReportLayout>) => void;
|
||||||
|
|
@ -100,6 +101,11 @@ interface ReportDesignerContextType {
|
||||||
// 복사/붙여넣기
|
// 복사/붙여넣기
|
||||||
copyComponents: () => void;
|
copyComponents: () => void;
|
||||||
pasteComponents: () => void;
|
pasteComponents: () => void;
|
||||||
|
duplicateComponents: () => void; // Ctrl+D 즉시 복제
|
||||||
|
copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
|
||||||
|
pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
|
||||||
|
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
|
||||||
|
fitSelectedToContent: () => void; // Ctrl+Shift+F 텍스트 크기 자동 맞춤
|
||||||
|
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
|
|
@ -267,6 +273,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 클립보드 (복사/붙여넣기)
|
// 클립보드 (복사/붙여넣기)
|
||||||
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
||||||
|
|
||||||
|
// 스타일 클립보드 (스타일만 복사/붙여넣기)
|
||||||
|
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | null>(null);
|
||||||
|
|
||||||
// Undo/Redo 히스토리
|
// Undo/Redo 히스토리
|
||||||
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
|
@ -284,7 +293,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 복사 (Ctrl+C)
|
// 복사 (Ctrl+C)
|
||||||
const copyComponents = useCallback(() => {
|
const copyComponents = useCallback(() => {
|
||||||
if (selectedComponentIds.length > 0) {
|
if (selectedComponentIds.length > 0) {
|
||||||
const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id));
|
// 잠긴 컴포넌트는 복사에서 제외
|
||||||
|
const componentsToCopy = components.filter(
|
||||||
|
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
|
||||||
|
);
|
||||||
|
if (componentsToCopy.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "복사 불가",
|
||||||
|
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setClipboard(componentsToCopy);
|
setClipboard(componentsToCopy);
|
||||||
toast({
|
toast({
|
||||||
title: "복사 완료",
|
title: "복사 완료",
|
||||||
|
|
@ -293,6 +313,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
} else if (selectedComponentId) {
|
} else if (selectedComponentId) {
|
||||||
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
|
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
|
||||||
if (componentToCopy) {
|
if (componentToCopy) {
|
||||||
|
// 잠긴 컴포넌트는 복사 불가
|
||||||
|
if (componentToCopy.locked) {
|
||||||
|
toast({
|
||||||
|
title: "복사 불가",
|
||||||
|
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setClipboard([componentToCopy]);
|
setClipboard([componentToCopy]);
|
||||||
toast({
|
toast({
|
||||||
title: "복사 완료",
|
title: "복사 완료",
|
||||||
|
|
@ -332,6 +361,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
});
|
});
|
||||||
}, [clipboard, components.length, toast]);
|
}, [clipboard, components.length, toast]);
|
||||||
|
|
||||||
|
// 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제
|
||||||
|
const duplicateComponents = useCallback(() => {
|
||||||
|
// 복제할 컴포넌트 결정
|
||||||
|
let componentsToDuplicate: ComponentConfig[] = [];
|
||||||
|
|
||||||
|
if (selectedComponentIds.length > 0) {
|
||||||
|
componentsToDuplicate = components.filter(
|
||||||
|
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
|
||||||
|
);
|
||||||
|
} else if (selectedComponentId) {
|
||||||
|
const comp = components.find((c) => c.id === selectedComponentId);
|
||||||
|
if (comp && !comp.locked) {
|
||||||
|
componentsToDuplicate = [comp];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentsToDuplicate.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "복제 불가",
|
||||||
|
description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComponents = componentsToDuplicate.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
x: comp.x + 20,
|
||||||
|
y: comp.y + 20,
|
||||||
|
zIndex: components.length,
|
||||||
|
locked: false, // 복제된 컴포넌트는 잠금 해제
|
||||||
|
}));
|
||||||
|
|
||||||
|
setComponents((prev) => [...prev, ...newComponents]);
|
||||||
|
|
||||||
|
// 복제된 컴포넌트 선택
|
||||||
|
if (newComponents.length === 1) {
|
||||||
|
setSelectedComponentId(newComponents[0].id);
|
||||||
|
setSelectedComponentIds([newComponents[0].id]);
|
||||||
|
} else {
|
||||||
|
setSelectedComponentIds(newComponents.map((c) => c.id));
|
||||||
|
setSelectedComponentId(newComponents[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "복제 완료",
|
||||||
|
description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// 스타일 복사 (Ctrl+Shift+C)
|
||||||
|
const copyStyles = useCallback(() => {
|
||||||
|
// 단일 컴포넌트만 스타일 복사 가능
|
||||||
|
const targetId = selectedComponentId || selectedComponentIds[0];
|
||||||
|
if (!targetId) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 복사 불가",
|
||||||
|
description: "컴포넌트를 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = components.find((c) => c.id === targetId);
|
||||||
|
if (!component) return;
|
||||||
|
|
||||||
|
// 스타일 관련 속성만 추출
|
||||||
|
const styleProperties: Partial<ComponentConfig> = {
|
||||||
|
fontSize: component.fontSize,
|
||||||
|
fontColor: component.fontColor,
|
||||||
|
fontWeight: component.fontWeight,
|
||||||
|
fontFamily: component.fontFamily,
|
||||||
|
textAlign: component.textAlign,
|
||||||
|
backgroundColor: component.backgroundColor,
|
||||||
|
borderWidth: component.borderWidth,
|
||||||
|
borderColor: component.borderColor,
|
||||||
|
borderStyle: component.borderStyle,
|
||||||
|
borderRadius: component.borderRadius,
|
||||||
|
boxShadow: component.boxShadow,
|
||||||
|
opacity: component.opacity,
|
||||||
|
padding: component.padding,
|
||||||
|
letterSpacing: component.letterSpacing,
|
||||||
|
lineHeight: component.lineHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
// undefined 값 제거
|
||||||
|
Object.keys(styleProperties).forEach((key) => {
|
||||||
|
if (styleProperties[key as keyof typeof styleProperties] === undefined) {
|
||||||
|
delete styleProperties[key as keyof typeof styleProperties];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStyleClipboard(styleProperties);
|
||||||
|
toast({
|
||||||
|
title: "스타일 복사 완료",
|
||||||
|
description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.",
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// 스타일 붙여넣기 (Ctrl+Shift+V)
|
||||||
|
const pasteStyles = useCallback(() => {
|
||||||
|
if (!styleClipboard) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 붙여넣기 불가",
|
||||||
|
description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 컴포넌트들에 스타일 적용
|
||||||
|
const targetIds =
|
||||||
|
selectedComponentIds.length > 0
|
||||||
|
? selectedComponentIds
|
||||||
|
: selectedComponentId
|
||||||
|
? [selectedComponentId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 붙여넣기 불가",
|
||||||
|
description: "스타일을 적용할 컴포넌트를 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 잠긴 컴포넌트 필터링
|
||||||
|
const applicableIds = targetIds.filter((id) => {
|
||||||
|
const comp = components.find((c) => c.id === id);
|
||||||
|
return comp && !comp.locked;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (applicableIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "스타일 붙여넣기 불가",
|
||||||
|
description: "잠긴 컴포넌트에는 스타일을 적용할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setComponents((prev) =>
|
||||||
|
prev.map((comp) => {
|
||||||
|
if (applicableIds.includes(comp.id)) {
|
||||||
|
return { ...comp, ...styleClipboard };
|
||||||
|
}
|
||||||
|
return comp;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "스타일 적용 완료",
|
||||||
|
description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제
|
||||||
|
const duplicateAtPosition = useCallback(
|
||||||
|
(componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => {
|
||||||
|
const componentsToDuplicate = components.filter(
|
||||||
|
(comp) => componentIds.includes(comp.id) && !comp.locked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (componentsToDuplicate.length === 0) return [];
|
||||||
|
|
||||||
|
const newComponents = componentsToDuplicate.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
x: comp.x + offsetX,
|
||||||
|
y: comp.y + offsetY,
|
||||||
|
zIndex: components.length,
|
||||||
|
locked: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setComponents((prev) => [...prev, ...newComponents]);
|
||||||
|
|
||||||
|
return newComponents.map((c) => c.id);
|
||||||
|
},
|
||||||
|
[components]
|
||||||
|
);
|
||||||
|
|
||||||
// 히스토리에 현재 상태 저장
|
// 히스토리에 현재 상태 저장
|
||||||
const saveToHistory = useCallback(
|
const saveToHistory = useCallback(
|
||||||
(newComponents: ComponentConfig[]) => {
|
(newComponents: ComponentConfig[]) => {
|
||||||
|
|
@ -1292,6 +1504,114 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
[currentPageId],
|
[currentPageId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 텍스트 컴포넌트 크기 자동 맞춤 (Ctrl+Shift+F)
|
||||||
|
const fitSelectedToContent = useCallback(() => {
|
||||||
|
const MM_TO_PX = 4; // 고정 스케일 팩터
|
||||||
|
|
||||||
|
// 선택된 컴포넌트 ID 결정
|
||||||
|
const targetIds =
|
||||||
|
selectedComponentIds.length > 0
|
||||||
|
? selectedComponentIds
|
||||||
|
: selectedComponentId
|
||||||
|
? [selectedComponentId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (targetIds.length === 0) return;
|
||||||
|
|
||||||
|
// 텍스트/레이블 컴포넌트만 필터링
|
||||||
|
const textComponents = components.filter(
|
||||||
|
(c) =>
|
||||||
|
targetIds.includes(c.id) &&
|
||||||
|
(c.type === "text" || c.type === "label") &&
|
||||||
|
!c.locked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textComponents.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "크기 조정 불가",
|
||||||
|
description: "선택된 텍스트 컴포넌트가 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 페이지 설정 가져오기
|
||||||
|
const page = currentPage;
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const canvasWidthPx = page.width * MM_TO_PX;
|
||||||
|
const canvasHeightPx = page.height * MM_TO_PX;
|
||||||
|
const marginRightPx = (page.margins?.right || 10) * MM_TO_PX;
|
||||||
|
const marginBottomPx = (page.margins?.bottom || 10) * MM_TO_PX;
|
||||||
|
|
||||||
|
// 각 텍스트 컴포넌트 크기 조정
|
||||||
|
textComponents.forEach((comp) => {
|
||||||
|
const displayValue = comp.defaultValue || (comp.type === "text" ? "텍스트 입력" : "레이블 텍스트");
|
||||||
|
const fontSize = comp.fontSize || 14;
|
||||||
|
|
||||||
|
// 최대 크기 (여백 고려)
|
||||||
|
const maxWidth = canvasWidthPx - marginRightPx - comp.x;
|
||||||
|
const maxHeight = canvasHeightPx - marginBottomPx - comp.y;
|
||||||
|
|
||||||
|
// 줄바꿈으로 분리하여 각 줄의 너비 측정
|
||||||
|
const lines = displayValue.split("\n");
|
||||||
|
let maxLineWidth = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
const measureEl = document.createElement("span");
|
||||||
|
measureEl.style.position = "absolute";
|
||||||
|
measureEl.style.visibility = "hidden";
|
||||||
|
measureEl.style.whiteSpace = "nowrap";
|
||||||
|
measureEl.style.fontSize = `${fontSize}px`;
|
||||||
|
measureEl.style.fontWeight = comp.fontWeight || "normal";
|
||||||
|
measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
|
||||||
|
measureEl.textContent = line || " ";
|
||||||
|
document.body.appendChild(measureEl);
|
||||||
|
|
||||||
|
const lineWidth = measureEl.getBoundingClientRect().width;
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||||||
|
document.body.removeChild(measureEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 패딩 및 높이 계산
|
||||||
|
const horizontalPadding = 24;
|
||||||
|
const verticalPadding = 20;
|
||||||
|
const lineHeight = fontSize * 1.5;
|
||||||
|
const totalHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
|
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
|
||||||
|
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
|
||||||
|
|
||||||
|
const newWidth = Math.max(50, finalWidth);
|
||||||
|
const newHeight = Math.max(30, finalHeight);
|
||||||
|
|
||||||
|
// 크기 업데이트 - setLayoutConfig 직접 사용
|
||||||
|
setLayoutConfig((prev) => ({
|
||||||
|
pages: prev.pages.map((p) =>
|
||||||
|
p.page_id === currentPageId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
components: p.components.map((c) =>
|
||||||
|
c.id === comp.id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
width: snapToGrid ? Math.round(newWidth / gridSize) * gridSize : newWidth,
|
||||||
|
height: snapToGrid ? Math.round(newHeight / gridSize) * gridSize : newHeight,
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "크기 조정 완료",
|
||||||
|
description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, currentPage, currentPageId, snapToGrid, gridSize, toast]);
|
||||||
|
|
||||||
// 컴포넌트 삭제 (현재 페이지에서)
|
// 컴포넌트 삭제 (현재 페이지에서)
|
||||||
const removeComponent = useCallback(
|
const removeComponent = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
|
|
@ -1344,6 +1664,17 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 여러 컴포넌트 한번에 선택 (마퀴 선택용)
|
||||||
|
const selectMultipleComponents = useCallback((ids: string[]) => {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setSelectedComponentIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedComponentId(ids[0]);
|
||||||
|
setSelectedComponentIds(ids);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 레이아웃 업데이트
|
// 레이아웃 업데이트
|
||||||
const updateLayout = useCallback(
|
const updateLayout = useCallback(
|
||||||
(updates: Partial<ReportLayout>) => {
|
(updates: Partial<ReportLayout>) => {
|
||||||
|
|
@ -1639,6 +1970,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
updateComponent,
|
updateComponent,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
selectMultipleComponents,
|
||||||
updateLayout,
|
updateLayout,
|
||||||
saveLayout,
|
saveLayout,
|
||||||
loadLayout,
|
loadLayout,
|
||||||
|
|
@ -1662,6 +1994,11 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 복사/붙여넣기
|
// 복사/붙여넣기
|
||||||
copyComponents,
|
copyComponents,
|
||||||
pasteComponents,
|
pasteComponents,
|
||||||
|
duplicateComponents,
|
||||||
|
copyStyles,
|
||||||
|
pasteStyles,
|
||||||
|
duplicateAtPosition,
|
||||||
|
fitSelectedToContent,
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
|
|
||||||
|
|
@ -1636,7 +1636,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label} ({col.field})
|
{col.label} ({col.field})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1900,7 +1900,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="현재 행 필드" />
|
<SelectValue placeholder="현재 행 필드" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -2056,7 +2056,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label} ({col.field})
|
{col.label} ({col.field})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -2303,7 +2303,7 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="현재 행 필드" />
|
<SelectValue placeholder="현재 행 필드" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col) => (
|
{(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -481,7 +481,7 @@ export function RepeaterTable({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{column.selectOptions?.map((option) => (
|
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -561,7 +561,7 @@ export function SimpleRepeaterTableComponent({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{column.selectOptions?.map((option) => (
|
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -1539,7 +1539,7 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).filter(c => c.type === "number").map((col) => (
|
{(localConfig.columns || []).filter(c => c.type === "number" && c.field && c.field !== "").map((col) => (
|
||||||
<SelectItem key={col.field} value={col.field}>
|
<SelectItem key={col.field} value={col.field}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -498,11 +498,43 @@ export function TableSectionRenderer({
|
||||||
if (!hasDynamicSelectColumns) return;
|
if (!hasDynamicSelectColumns) return;
|
||||||
if (!conditionalConfig?.sourceFilter?.enabled) return;
|
if (!conditionalConfig?.sourceFilter?.enabled) return;
|
||||||
if (!activeConditionTab) return;
|
if (!activeConditionTab) return;
|
||||||
|
if (!tableConfig.source?.tableName) return;
|
||||||
|
|
||||||
// 조건 변경 시 캐시 리셋하고 다시 로드
|
// 조건 변경 시 캐시 리셋하고 즉시 다시 로드
|
||||||
sourceDataLoadedRef.current = false;
|
sourceDataLoadedRef.current = false;
|
||||||
setSourceDataCache([]);
|
setSourceDataCache([]);
|
||||||
}, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled]);
|
|
||||||
|
// 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출)
|
||||||
|
const loadSourceData = async () => {
|
||||||
|
try {
|
||||||
|
const filterCondition: Record<string, any> = {};
|
||||||
|
filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab;
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableConfig.source!.tableName}/data`,
|
||||||
|
{
|
||||||
|
search: filterCondition,
|
||||||
|
size: 1000,
|
||||||
|
page: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data) {
|
||||||
|
setSourceDataCache(response.data.data.data);
|
||||||
|
sourceDataLoadedRef.current = true;
|
||||||
|
console.log("[TableSectionRenderer] 조건 탭 변경 - 소스 데이터 로드 완료:", {
|
||||||
|
tableName: tableConfig.source!.tableName,
|
||||||
|
rowCount: response.data.data.data.length,
|
||||||
|
filter: filterCondition,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSourceData();
|
||||||
|
}, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]);
|
||||||
|
|
||||||
// 컬럼별 동적 Select 옵션 생성
|
// 컬럼별 동적 Select 옵션 생성
|
||||||
const dynamicSelectOptionsMap = useMemo(() => {
|
const dynamicSelectOptionsMap = useMemo(() => {
|
||||||
|
|
@ -540,6 +572,45 @@ export function TableSectionRenderer({
|
||||||
return optionsMap;
|
return optionsMap;
|
||||||
}, [sourceDataCache, tableConfig.columns]);
|
}, [sourceDataCache, tableConfig.columns]);
|
||||||
|
|
||||||
|
// 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - 다른 함수에서 참조하므로 먼저 정의
|
||||||
|
const handleDataChange = useCallback(
|
||||||
|
(newData: any[]) => {
|
||||||
|
let processedData = newData;
|
||||||
|
|
||||||
|
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
|
||||||
|
const batchApplyColumns = tableConfig.columns.filter(
|
||||||
|
(col) => col.type === "date" && col.batchApply === true
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const dateCol of batchApplyColumns) {
|
||||||
|
// 이미 일괄 적용된 컬럼은 건너뜀
|
||||||
|
if (batchAppliedFields.has(dateCol.field)) continue;
|
||||||
|
|
||||||
|
// 해당 컬럼에 값이 있는 행과 없는 행 분류
|
||||||
|
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
|
||||||
|
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
|
||||||
|
|
||||||
|
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
|
||||||
|
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
||||||
|
const selectedDate = itemsWithDate[0][dateCol.field];
|
||||||
|
|
||||||
|
// 모든 행에 동일한 날짜 적용
|
||||||
|
processedData = processedData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
[dateCol.field]: selectedDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 플래그 활성화 (이후 개별 수정 가능)
|
||||||
|
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableData(processedData);
|
||||||
|
onTableDataChange(processedData);
|
||||||
|
},
|
||||||
|
[onTableDataChange, tableConfig.columns, batchAppliedFields]
|
||||||
|
);
|
||||||
|
|
||||||
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
|
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
|
||||||
const handleDynamicSelectChange = useCallback(
|
const handleDynamicSelectChange = useCallback(
|
||||||
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
|
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
|
||||||
|
|
@ -617,6 +688,91 @@ export function TableSectionRenderer({
|
||||||
[tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange]
|
[tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회)
|
||||||
|
const loadReferenceColumnValues = useCallback(async (data: any[]) => {
|
||||||
|
// saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
|
||||||
|
const referenceColumns = (tableConfig.columns || []).filter(
|
||||||
|
(col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay
|
||||||
|
);
|
||||||
|
|
||||||
|
if (referenceColumns.length === 0) return;
|
||||||
|
|
||||||
|
const sourceTableName = tableConfig.source?.tableName;
|
||||||
|
if (!sourceTableName) {
|
||||||
|
console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참조 ID들 수집 (중복 제거)
|
||||||
|
const referenceIdSet = new Set<string>();
|
||||||
|
|
||||||
|
for (const col of referenceColumns) {
|
||||||
|
const refDisplay = col.saveConfig!.referenceDisplay!;
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const refId = row[refDisplay.referenceIdField];
|
||||||
|
if (refId !== undefined && refId !== null && refId !== "") {
|
||||||
|
referenceIdSet.add(String(refId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referenceIdSet.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 소스 테이블에서 참조 ID에 해당하는 데이터 조회
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${sourceTableName}/data`,
|
||||||
|
{
|
||||||
|
search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회
|
||||||
|
size: 1000,
|
||||||
|
page: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data?.success || !response.data?.data?.data) {
|
||||||
|
console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceData: any[] = response.data.data.data;
|
||||||
|
|
||||||
|
// ID를 키로 하는 맵 생성
|
||||||
|
const sourceDataMap: Record<string, any> = {};
|
||||||
|
for (const sourceRow of sourceData) {
|
||||||
|
sourceDataMap[String(sourceRow.id)] = sourceRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 행에 참조 컬럼 값 채우기
|
||||||
|
const updatedData = data.map((row) => {
|
||||||
|
const newRow = { ...row };
|
||||||
|
|
||||||
|
for (const col of referenceColumns) {
|
||||||
|
const refDisplay = col.saveConfig!.referenceDisplay!;
|
||||||
|
const refId = row[refDisplay.referenceIdField];
|
||||||
|
|
||||||
|
if (refId !== undefined && refId !== null && refId !== "") {
|
||||||
|
const sourceRow = sourceDataMap[String(refId)];
|
||||||
|
if (sourceRow) {
|
||||||
|
newRow[col.field] = sourceRow[refDisplay.sourceColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", {
|
||||||
|
referenceColumns: referenceColumns.map((c) => c.field),
|
||||||
|
updatedRowCount: updatedData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTableData(updatedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}, [tableConfig.columns, tableConfig.source?.tableName]);
|
||||||
|
|
||||||
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이미 초기화되었으면 스킵
|
// 이미 초기화되었으면 스킵
|
||||||
|
|
@ -632,8 +788,11 @@ export function TableSectionRenderer({
|
||||||
});
|
});
|
||||||
setTableData(initialData);
|
setTableData(initialData);
|
||||||
initialDataLoadedRef.current = true;
|
initialDataLoadedRef.current = true;
|
||||||
|
|
||||||
|
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
|
||||||
|
loadReferenceColumnValues(initialData);
|
||||||
}
|
}
|
||||||
}, [sectionId, formData]);
|
}, [sectionId, formData, loadReferenceColumnValues]);
|
||||||
|
|
||||||
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
|
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
|
||||||
const columns: RepeaterColumnConfig[] = useMemo(() => {
|
const columns: RepeaterColumnConfig[] = useMemo(() => {
|
||||||
|
|
@ -691,45 +850,6 @@ export function TableSectionRenderer({
|
||||||
[calculateRow]
|
[calculateRow]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함)
|
|
||||||
const handleDataChange = useCallback(
|
|
||||||
(newData: any[]) => {
|
|
||||||
let processedData = newData;
|
|
||||||
|
|
||||||
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
|
|
||||||
const batchApplyColumns = tableConfig.columns.filter(
|
|
||||||
(col) => col.type === "date" && col.batchApply === true
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const dateCol of batchApplyColumns) {
|
|
||||||
// 이미 일괄 적용된 컬럼은 건너뜀
|
|
||||||
if (batchAppliedFields.has(dateCol.field)) continue;
|
|
||||||
|
|
||||||
// 해당 컬럼에 값이 있는 행과 없는 행 분류
|
|
||||||
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
|
|
||||||
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
|
|
||||||
|
|
||||||
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
|
|
||||||
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
|
||||||
const selectedDate = itemsWithDate[0][dateCol.field];
|
|
||||||
|
|
||||||
// 모든 행에 동일한 날짜 적용
|
|
||||||
processedData = processedData.map((item) => ({
|
|
||||||
...item,
|
|
||||||
[dateCol.field]: selectedDate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 플래그 활성화 (이후 개별 수정 가능)
|
|
||||||
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTableData(processedData);
|
|
||||||
onTableDataChange(processedData);
|
|
||||||
},
|
|
||||||
[onTableDataChange, tableConfig.columns, batchAppliedFields]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
|
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
|
||||||
const handleRowChange = useCallback(
|
const handleRowChange = useCallback(
|
||||||
(index: number, newRow: any, conditionValue?: string) => {
|
(index: number, newRow: any, conditionValue?: string) => {
|
||||||
|
|
@ -1377,9 +1497,10 @@ export function TableSectionRenderer({
|
||||||
const { triggerType } = conditionalConfig;
|
const { triggerType } = conditionalConfig;
|
||||||
|
|
||||||
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
|
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
|
||||||
const effectiveOptions = conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
|
// 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음)
|
||||||
|
const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
|
||||||
? dynamicOptions
|
? dynamicOptions
|
||||||
: conditionalConfig.options || [];
|
: conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== "");
|
||||||
|
|
||||||
// 로딩 중이면 로딩 표시
|
// 로딩 중이면 로딩 표시
|
||||||
if (dynamicOptionsLoading) {
|
if (dynamicOptionsLoading) {
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,13 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
options.map((option) => (
|
options
|
||||||
<SelectItem key={option.value} value={option.value}>
|
.filter((option) => option.value && option.value !== "")
|
||||||
{option.label}
|
.map((option) => (
|
||||||
</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
))
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -1081,6 +1083,14 @@ export function UniversalFormModalComponent({
|
||||||
// 공통 필드 병합 + 개별 품목 데이터
|
// 공통 필드 병합 + 개별 품목 데이터
|
||||||
const itemToSave = { ...commonFieldsData, ...item };
|
const itemToSave = { ...commonFieldsData, ...item };
|
||||||
|
|
||||||
|
// saveToTarget: false인 컬럼은 저장에서 제외
|
||||||
|
const columns = section.tableConfig?.columns || [];
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) {
|
||||||
|
delete itemToSave[col.field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 메인 레코드와 연결이 필요한 경우
|
// 메인 레코드와 연결이 필요한 경우
|
||||||
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
||||||
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
||||||
|
|
@ -1680,11 +1690,13 @@ export function UniversalFormModalComponent({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sourceData.length > 0 ? (
|
{sourceData.length > 0 ? (
|
||||||
sourceData.map((row, index) => (
|
sourceData
|
||||||
<SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
|
.filter((row) => row[valueColumn] !== null && row[valueColumn] !== undefined && String(row[valueColumn]) !== "")
|
||||||
{getDisplayText(row)}
|
.map((row, index) => (
|
||||||
</SelectItem>
|
<SelectItem key={`${row[valueColumn]}_${index}`} value={String(row[valueColumn])}>
|
||||||
))
|
{getDisplayText(row)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<SelectItem value="_empty" disabled>
|
<SelectItem value="_empty" disabled>
|
||||||
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
|
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
|
||||||
|
|
@ -2345,11 +2357,13 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
||||||
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{options.map((option) => (
|
{options
|
||||||
<SelectItem key={option.value} value={option.value}>
|
.filter((option) => option.value && option.value !== "")
|
||||||
{option.label}
|
.map((option) => (
|
||||||
</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
))}
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -728,13 +728,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
||||||
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
||||||
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
|
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
||||||
{section.tableConfig.columns.slice(0, 4).map((col) => (
|
{section.tableConfig.columns.slice(0, 4).map((col, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={col.field}
|
key={col.field || `col_${idx}`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
|
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label || col.field || `컬럼 ${idx + 1}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{section.tableConfig.columns.length > 4 && (
|
{section.tableConfig.columns.length > 4 && (
|
||||||
|
|
|
||||||
|
|
@ -450,10 +450,13 @@ function ColumnSettingItem({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={fieldSearchOpen}
|
aria-expanded={fieldSearchOpen}
|
||||||
className="h-8 w-full justify-between text-xs mt-1"
|
className={cn(
|
||||||
|
"h-8 w-full justify-between text-xs mt-1",
|
||||||
|
!col.field && "text-muted-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{col.field || "필드 선택..."}
|
{col.field || "(저장 안 함)"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -466,6 +469,25 @@ function ColumnSettingItem({
|
||||||
필드를 찾을 수 없습니다.
|
필드를 찾을 수 없습니다.
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
{/* 선택 안 함 옵션 */}
|
||||||
|
<CommandItem
|
||||||
|
key="__none__"
|
||||||
|
value="__none__"
|
||||||
|
onSelect={() => {
|
||||||
|
onUpdate({ field: "" });
|
||||||
|
setFieldSearchOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
!col.field ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="italic">(선택 안 함 - 저장하지 않음)</span>
|
||||||
|
</CommandItem>
|
||||||
|
{/* 실제 컬럼 목록 */}
|
||||||
{saveTableColumns.map((column) => (
|
{saveTableColumns.map((column) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.column_name}
|
key={column.column_name}
|
||||||
|
|
@ -1786,6 +1808,185 @@ function ColumnSettingItem({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ============================================ */}
|
||||||
|
{/* 저장 설정 섹션 */}
|
||||||
|
{/* ============================================ */}
|
||||||
|
<div className="space-y-2 border-t pt-3">
|
||||||
|
<Label className="text-xs font-semibold flex items-center gap-2">
|
||||||
|
저장 설정
|
||||||
|
</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
이 컬럼의 값을 DB에 저장할지 설정합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 저장 여부 라디오 버튼 */}
|
||||||
|
<div className="space-y-2 pl-2">
|
||||||
|
{/* 저장함 옵션 */}
|
||||||
|
<label className="flex items-start gap-3 p-2 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`saveConfig_${col.field}`}
|
||||||
|
checked={col.saveConfig?.saveToTarget !== false}
|
||||||
|
onChange={() => {
|
||||||
|
onUpdate({
|
||||||
|
saveConfig: {
|
||||||
|
saveToTarget: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-medium">저장함 (기본)</span>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
사용자가 입력/선택한 값이 DB에 저장됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 저장 안 함 옵션 */}
|
||||||
|
<label className="flex items-start gap-3 p-2 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`saveConfig_${col.field}`}
|
||||||
|
checked={col.saveConfig?.saveToTarget === false}
|
||||||
|
onChange={() => {
|
||||||
|
onUpdate({
|
||||||
|
saveConfig: {
|
||||||
|
saveToTarget: false,
|
||||||
|
referenceDisplay: {
|
||||||
|
referenceIdField: "",
|
||||||
|
sourceColumn: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-medium">저장 안 함 - 참조만 표시</span>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
다른 컬럼의 ID로 소스 테이블을 조회해서 표시만 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참조 설정 패널 (저장 안 함 선택 시) */}
|
||||||
|
{col.saveConfig?.saveToTarget === false && (
|
||||||
|
<div className="ml-6 p-3 border-2 border-dashed border-amber-300 rounded-lg bg-amber-50/50 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Search className="h-4 w-4 text-amber-600" />
|
||||||
|
<span className="text-xs font-semibold text-amber-700">참조 설정</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: ID 컬럼 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] font-medium">
|
||||||
|
1. 어떤 ID 컬럼을 기준으로 조회할까요?
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={col.saveConfig?.referenceDisplay?.referenceIdField || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdate({
|
||||||
|
saveConfig: {
|
||||||
|
...col.saveConfig!,
|
||||||
|
referenceDisplay: {
|
||||||
|
...col.saveConfig!.referenceDisplay!,
|
||||||
|
referenceIdField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="ID 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableConfig.columns || [])
|
||||||
|
.filter((c) => c.field !== col.field) // 현재 컬럼 제외
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.field} value={c.field} className="text-xs">
|
||||||
|
{c.label || c.field}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
이 컬럼에 저장된 ID로 소스 테이블을 조회합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: 소스 컬럼 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] font-medium">
|
||||||
|
2. 소스 테이블의 어떤 컬럼 값을 표시할까요?
|
||||||
|
</Label>
|
||||||
|
{sourceTableColumns.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={col.saveConfig?.referenceDisplay?.sourceColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdate({
|
||||||
|
saveConfig: {
|
||||||
|
...col.saveConfig!,
|
||||||
|
referenceDisplay: {
|
||||||
|
...col.saveConfig!.referenceDisplay!,
|
||||||
|
sourceColumn: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="소스 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceTableColumns.map((c) => (
|
||||||
|
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
|
||||||
|
{c.column_name} {c.comment && `(${c.comment})`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={col.saveConfig?.referenceDisplay?.sourceColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdate({
|
||||||
|
saveConfig: {
|
||||||
|
...col.saveConfig!,
|
||||||
|
referenceDisplay: {
|
||||||
|
...col.saveConfig!.referenceDisplay!,
|
||||||
|
sourceColumn: e.target.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="소스 컬럼명 입력"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
조회된 행에서 이 컬럼의 값을 화면에 표시합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 요약 */}
|
||||||
|
{col.saveConfig?.referenceDisplay?.referenceIdField && col.saveConfig?.referenceDisplay?.sourceColumn && (
|
||||||
|
<div className="text-[10px] text-amber-700 bg-amber-100 rounded p-2 mt-2">
|
||||||
|
<strong>설정 요약:</strong>
|
||||||
|
<br />
|
||||||
|
- 이 컬럼({col.label || col.field})은 저장되지 않습니다.
|
||||||
|
<br />
|
||||||
|
- 수정 화면에서 <strong>{col.saveConfig.referenceDisplay.referenceIdField}</strong>로{" "}
|
||||||
|
<strong>{sourceTableName}</strong> 테이블을 조회하여{" "}
|
||||||
|
<strong>{col.saveConfig.referenceDisplay.sourceColumn}</strong> 값을 표시합니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2826,11 +3027,13 @@ export function TableSectionSettingsModal({
|
||||||
컬럼 설정에서 먼저 컬럼을 추가하세요
|
컬럼 설정에서 먼저 컬럼을 추가하세요
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
(tableConfig.columns || []).map((col) => (
|
(tableConfig.columns || [])
|
||||||
<SelectItem key={col.field} value={col.field}>
|
.filter((col) => col.field) // 빈 필드명 제외
|
||||||
{col.label || col.field}
|
.map((col, idx) => (
|
||||||
</SelectItem>
|
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
|
||||||
))
|
{col.label || col.field}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,31 @@ export interface TableColumnConfig {
|
||||||
// 부모에서 값 받기 (모든 행에 동일한 값 적용)
|
// 부모에서 값 받기 (모든 행에 동일한 값 적용)
|
||||||
receiveFromParent?: boolean; // 부모에서 값 받기 활성화
|
receiveFromParent?: boolean; // 부모에서 값 받기 활성화
|
||||||
parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일)
|
parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일)
|
||||||
|
|
||||||
|
// 저장 설정 (컬럼별 저장 여부 및 참조 표시)
|
||||||
|
saveConfig?: TableColumnSaveConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼 저장 설정
|
||||||
|
* - 컬럼별로 저장 여부를 설정하고, 저장하지 않는 컬럼은 참조 ID로 조회하여 표시
|
||||||
|
*/
|
||||||
|
export interface TableColumnSaveConfig {
|
||||||
|
// 저장 여부 (기본값: true)
|
||||||
|
// true: 사용자가 입력/선택한 값을 DB에 저장
|
||||||
|
// false: 저장하지 않고, 참조 ID로 소스 테이블을 조회하여 표시만 함
|
||||||
|
saveToTarget: boolean;
|
||||||
|
|
||||||
|
// 참조 표시 설정 (saveToTarget이 false일 때 사용)
|
||||||
|
referenceDisplay?: {
|
||||||
|
// 참조할 ID 컬럼 (같은 테이블 내의 다른 컬럼)
|
||||||
|
// 예: "inspection_standard_id"
|
||||||
|
referenceIdField: string;
|
||||||
|
|
||||||
|
// 소스 테이블에서 가져올 컬럼
|
||||||
|
// 예: "inspection_item" → 소스 테이블의 inspection_item 값을 표시
|
||||||
|
sourceColumn: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue