컴포넌트 그룹화(Grouping) 기능 구현
This commit is contained in:
parent
d01ade4e4f
commit
f8be19c49f
|
|
@ -168,45 +168,71 @@
|
||||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
||||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
- `frontend/components/report/designer/CanvasComponent.tsx`
|
||||||
|
|
||||||
|
### 12. 레이아웃 도구 (완료!)
|
||||||
|
|
||||||
|
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
|
||||||
|
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
|
||||||
|
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
|
||||||
|
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
|
||||||
|
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
|
||||||
|
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
|
||||||
|
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
|
||||||
|
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
|
||||||
|
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
|
||||||
|
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
|
||||||
|
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
|
||||||
|
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
|
||||||
|
|
||||||
|
**파일**:
|
||||||
|
|
||||||
|
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
|
||||||
|
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
|
||||||
|
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
|
||||||
|
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
|
||||||
|
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 진행 중인 작업 🚧
|
## 진행 중인 작업 🚧
|
||||||
|
|
||||||
없음 (현재 모든 핵심 기능 구현 완료)
|
없음 (모든 레이아웃 도구 구현 완료!)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 남은 작업 (우선순위순) 📋
|
## 남은 작업 (우선순위순) 📋
|
||||||
|
|
||||||
### Phase 1: 사용성 개선 (권장)
|
### Phase 1: 추가 컴포넌트 ⬅️ 다음 권장 작업
|
||||||
|
|
||||||
1. **레이아웃 도구** ⬅️ 다음 권장 작업
|
1. **이미지 컴포넌트**
|
||||||
|
|
||||||
- 격자 스냅 (Grid Snap)
|
- 이미지 업로드 및 URL 입력
|
||||||
- 정렬 가이드라인
|
- 크기 조절 및 정렬
|
||||||
- 컴포넌트 그룹화
|
- 로고, 서명, 도장 등에 활용
|
||||||
- 실행 취소/다시 실행 (Undo/Redo)
|
|
||||||
|
|
||||||
2. **쿼리 관리 개선**
|
2. **구분선 컴포넌트 (Divider)**
|
||||||
- 쿼리 미리보기 개선 (테이블 형태)
|
|
||||||
- 쿼리 저장/불러오기
|
|
||||||
- 쿼리 템플릿
|
|
||||||
|
|
||||||
### Phase 2: 추가 컴포넌트
|
- 가로/세로 구분선
|
||||||
|
- 두께, 색상, 스타일(실선/점선) 설정
|
||||||
|
|
||||||
4. **다양한 컴포넌트 추가**
|
3. **차트 컴포넌트** (선택사항)
|
||||||
|
- 막대 차트
|
||||||
|
- 선 차트
|
||||||
|
- 원형 차트
|
||||||
|
- 쿼리 데이터 연동
|
||||||
|
|
||||||
- 이미지 컴포넌트
|
### Phase 2: 고급 기능
|
||||||
- 차트 컴포넌트 (막대, 선, 원형)
|
|
||||||
- 바코드/QR코드 (선택사항)
|
4. **조건부 서식**
|
||||||
- 구분선 (Divider)
|
|
||||||
- 체크박스/라디오 버튼
|
|
||||||
|
|
||||||
5. **조건부 서식**
|
|
||||||
- 특정 조건에 따른 스타일 변경
|
- 특정 조건에 따른 스타일 변경
|
||||||
- 값 범위에 따른 색상 표시
|
- 값 범위에 따른 색상 표시
|
||||||
- 수식 기반 표시/숨김
|
- 수식 기반 표시/숨김
|
||||||
|
|
||||||
|
5. **쿼리 관리 개선**
|
||||||
|
- 쿼리 미리보기 개선 (테이블 형태)
|
||||||
|
- 쿼리 저장/불러오기
|
||||||
|
- 쿼리 템플릿
|
||||||
|
|
||||||
### Phase 3: 성능 및 보안
|
### Phase 3: 성능 및 보안
|
||||||
|
|
||||||
6. **성능 최적화**
|
6. **성능 최적화**
|
||||||
|
|
@ -313,4 +339,4 @@
|
||||||
|
|
||||||
**최종 업데이트**: 2025-10-01
|
**최종 업데이트**: 2025-10-01
|
||||||
**작성자**: AI Assistant
|
**작성자**: AI Assistant
|
||||||
**상태**: PDF/WORD 내보내기 완료 (95% 완료)
|
**상태**: 레이아웃 도구 완료 (Phase 1 완료, 약 98% 완료)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface CanvasComponentProps {
|
||||||
|
|
||||||
export function CanvasComponent({ component }: CanvasComponentProps) {
|
export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const {
|
const {
|
||||||
|
components,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
selectedComponentIds,
|
selectedComponentIds,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
|
|
@ -28,6 +29,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
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 handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
|
@ -48,7 +50,17 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
|
|
||||||
// Ctrl/Cmd 키 감지 (다중 선택)
|
// Ctrl/Cmd 키 감지 (다중 선택)
|
||||||
const isMultiSelect = e.ctrlKey || e.metaKey;
|
const isMultiSelect = e.ctrlKey || e.metaKey;
|
||||||
selectComponent(component.id, isMultiSelect);
|
|
||||||
|
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
|
||||||
|
if (isGrouped && !isMultiSelect) {
|
||||||
|
const groupMembers = components.filter((c) => c.groupId === component.groupId);
|
||||||
|
const groupMemberIds = groupMembers.map((c) => c.id);
|
||||||
|
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
|
||||||
|
selectComponent(groupMemberIds[0], false);
|
||||||
|
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
|
||||||
|
} else {
|
||||||
|
selectComponent(component.id, isMultiSelect);
|
||||||
|
}
|
||||||
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setDragStart({
|
setDragStart({
|
||||||
|
|
@ -89,11 +101,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
// 정렬 가이드라인 계산
|
// 정렬 가이드라인 계산
|
||||||
calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height);
|
calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height);
|
||||||
|
|
||||||
// Grid Snap 적용
|
// 이동 거리 계산
|
||||||
|
const deltaX = snappedX - component.x;
|
||||||
|
const deltaY = snappedY - component.y;
|
||||||
|
|
||||||
|
// 현재 컴포넌트 이동
|
||||||
updateComponent(component.id, {
|
updateComponent(component.id, {
|
||||||
x: snappedX,
|
x: snappedX,
|
||||||
y: snappedY,
|
y: snappedY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
|
||||||
|
if (isGrouped) {
|
||||||
|
components.forEach((c) => {
|
||||||
|
if (c.groupId === component.groupId && c.id !== component.id) {
|
||||||
|
updateComponent(c.id, {
|
||||||
|
x: c.x + deltaX,
|
||||||
|
y: c.y + deltaY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (isResizing) {
|
} else if (isResizing) {
|
||||||
const deltaX = e.clientX - resizeStart.x;
|
const deltaX = e.clientX - resizeStart.x;
|
||||||
const deltaY = e.clientY - resizeStart.y;
|
const deltaY = e.clientY - resizeStart.y;
|
||||||
|
|
@ -131,8 +159,13 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
resizeStart.width,
|
resizeStart.width,
|
||||||
resizeStart.height,
|
resizeStart.height,
|
||||||
component.id,
|
component.id,
|
||||||
|
component.x,
|
||||||
|
component.y,
|
||||||
component.width,
|
component.width,
|
||||||
component.height,
|
component.height,
|
||||||
|
component.groupId,
|
||||||
|
isGrouped,
|
||||||
|
components,
|
||||||
updateComponent,
|
updateComponent,
|
||||||
snapValueToGrid,
|
snapValueToGrid,
|
||||||
calculateAlignmentGuides,
|
calculateAlignmentGuides,
|
||||||
|
|
@ -324,6 +357,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
|
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 그룹화 표시 */}
|
||||||
|
{isGrouped && !isLocked && (
|
||||||
|
<div className="absolute top-1 left-1 rounded bg-purple-500 px-1 py-0.5 text-[10px] text-white">👥</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
|
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
|
||||||
{isSelected && !isLocked && (
|
{isSelected && !isLocked && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import {
|
||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
Ruler as RulerIcon,
|
Ruler as RulerIcon,
|
||||||
|
Group,
|
||||||
|
Ungroup,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
|
|
@ -86,6 +88,8 @@ export function ReportDesignerToolbar() {
|
||||||
unlockComponents,
|
unlockComponents,
|
||||||
showRuler,
|
showRuler,
|
||||||
setShowRuler,
|
setShowRuler,
|
||||||
|
groupComponents,
|
||||||
|
ungroupComponents,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
|
|
@ -95,6 +99,7 @@ export function ReportDesignerToolbar() {
|
||||||
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
|
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
|
||||||
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
|
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
|
||||||
const hasSelection = selectedComponentIds && selectedComponentIds.length >= 1;
|
const hasSelection = selectedComponentIds && selectedComponentIds.length >= 1;
|
||||||
|
const canGroup = selectedComponentIds && selectedComponentIds.length >= 2;
|
||||||
|
|
||||||
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
|
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
|
||||||
const canSaveAsTemplate = components.length > 0;
|
const canSaveAsTemplate = components.length > 0;
|
||||||
|
|
@ -406,6 +411,32 @@ export function ReportDesignerToolbar() {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* 그룹화 드롭다운 */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
className="gap-2"
|
||||||
|
title="컴포넌트 그룹화/해제"
|
||||||
|
>
|
||||||
|
<Group className="h-4 w-4" />
|
||||||
|
그룹
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={groupComponents} disabled={!canGroup}>
|
||||||
|
<Group className="mr-2 h-4 w-4" />
|
||||||
|
그룹화 (2개 이상)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
|
||||||
|
<Ungroup className="mr-2 h-4 w-4" />
|
||||||
|
그룹 해제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
|
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
초기화
|
초기화
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,10 @@ interface ReportDesignerContextType {
|
||||||
// 눈금자 표시
|
// 눈금자 표시
|
||||||
showRuler: boolean;
|
showRuler: boolean;
|
||||||
setShowRuler: (show: boolean) => void;
|
setShowRuler: (show: boolean) => void;
|
||||||
|
|
||||||
|
// 그룹화
|
||||||
|
groupComponents: () => void;
|
||||||
|
ungroupComponents: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
||||||
|
|
@ -927,6 +931,83 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
toast({ title: "잠금 해제", description: "선택된 컴포넌트의 잠금이 해제되었습니다." });
|
toast({ title: "잠금 해제", description: "선택된 컴포넌트의 잠금이 해제되었습니다." });
|
||||||
}, [selectedComponentId, selectedComponentIds, toast]);
|
}, [selectedComponentId, selectedComponentIds, toast]);
|
||||||
|
|
||||||
|
// 그룹화 함수들
|
||||||
|
const groupComponents = useCallback(() => {
|
||||||
|
if (selectedComponentIds.length < 2) {
|
||||||
|
toast({
|
||||||
|
title: "그룹화 불가",
|
||||||
|
description: "2개 이상의 컴포넌트를 선택해야 합니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 그룹 ID 생성
|
||||||
|
const newGroupId = `group_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
|
setComponents((prev) =>
|
||||||
|
prev.map((c) => {
|
||||||
|
if (selectedComponentIds.includes(c.id)) {
|
||||||
|
return { ...c, groupId: newGroupId };
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "그룹화 완료",
|
||||||
|
description: `${selectedComponentIds.length}개의 컴포넌트가 그룹화되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentIds, toast]);
|
||||||
|
|
||||||
|
const ungroupComponents = useCallback(() => {
|
||||||
|
if (!selectedComponentId && selectedComponentIds.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "그룹 해제 불가",
|
||||||
|
description: "그룹을 해제할 컴포넌트를 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idsToUngroup =
|
||||||
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
||||||
|
|
||||||
|
// 선택된 컴포넌트들의 그룹 ID 수집
|
||||||
|
const groupIds = new Set<string>();
|
||||||
|
components.forEach((c) => {
|
||||||
|
if (idsToUngroup.includes(c.id) && c.groupId) {
|
||||||
|
groupIds.add(c.groupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupIds.size === 0) {
|
||||||
|
toast({
|
||||||
|
title: "그룹 해제 불가",
|
||||||
|
description: "선택된 컴포넌트 중 그룹화된 것이 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해당 그룹 ID를 가진 모든 컴포넌트의 그룹 해제
|
||||||
|
setComponents((prev) =>
|
||||||
|
prev.map((c) => {
|
||||||
|
if (c.groupId && groupIds.has(c.groupId)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { groupId, ...rest } = c;
|
||||||
|
return rest as ComponentConfig;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "그룹 해제 완료",
|
||||||
|
description: `${groupIds.size}개의 그룹이 해제되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
// 캔버스 설정 (기본값)
|
// 캔버스 설정 (기본값)
|
||||||
const [canvasWidth, setCanvasWidth] = useState(210);
|
const [canvasWidth, setCanvasWidth] = useState(210);
|
||||||
const [canvasHeight, setCanvasHeight] = useState(297);
|
const [canvasHeight, setCanvasHeight] = useState(297);
|
||||||
|
|
@ -1350,6 +1431,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 눈금자 표시
|
// 눈금자 표시
|
||||||
showRuler,
|
showRuler,
|
||||||
setShowRuler,
|
setShowRuler,
|
||||||
|
// 그룹화
|
||||||
|
groupComponents,
|
||||||
|
ungroupComponents,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export interface ComponentConfig {
|
||||||
printable?: boolean;
|
printable?: boolean;
|
||||||
conditional?: string;
|
conditional?: string;
|
||||||
locked?: boolean; // 잠금 여부 (편집/이동/삭제 방지)
|
locked?: boolean; // 잠금 여부 (편집/이동/삭제 방지)
|
||||||
|
groupId?: string; // 그룹 ID (같은 그룹 ID를 가진 컴포넌트는 함께 움직임)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 상세
|
// 리포트 상세
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue