컴포넌트 잠금기능 구현

This commit is contained in:
dohyeons 2025-10-01 16:23:20 +09:00
parent 172ecf34b3
commit a1ddf4678d
5 changed files with 151 additions and 9 deletions

View File

@ -27,6 +27,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
@ -34,6 +35,15 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return;
}
// 잠긴 컴포넌트는 드래그 불가
if (isLocked) {
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
selectComponent(component.id, isMultiSelect);
return;
}
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
@ -49,6 +59,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
// 잠긴 컴포넌트는 리사이즈 불가
if (isLocked) {
e.stopPropagation();
return;
}
e.stopPropagation();
setIsResizing(true);
setResizeStart({
@ -277,8 +293,16 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return (
<div
ref={componentRef}
className={`absolute cursor-move p-2 shadow-sm ${
isSelected ? "ring-2 ring-blue-500" : isMultiSelected ? "ring-2 ring-blue-300" : ""
className={`absolute p-2 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
isSelected
? isLocked
? "ring-2 ring-red-500"
: "ring-2 ring-blue-500"
: isMultiSelected
? isLocked
? "ring-2 ring-red-300"
: "ring-2 ring-blue-300"
: ""
}`}
style={{
left: `${component.x}px`,
@ -295,8 +319,13 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
>
{renderContent()}
{/* 리사이즈 핸들 (선택된 경우만) */}
{isSelected && (
{/* 잠금 표시 */}
{isLocked && (
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
)}
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
{isSelected && !isLocked && (
<div
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
style={{ transform: "translate(50%, 50%)" }}

View File

@ -103,10 +103,10 @@ export function ReportDesignerCanvas() {
const idsToMove =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
// 각 컴포넌트 이동
// 각 컴포넌트 이동 (잠긴 컴포넌트는 제외)
idsToMove.forEach((id) => {
const component = components.find((c) => c.id === id);
if (!component) return;
if (!component || component.locked) return;
let newX = component.x;
let newY = component.y;
@ -132,12 +132,20 @@ export function ReportDesignerCanvas() {
return;
}
// Delete 키: 삭제
// Delete 키: 삭제 (잠긴 컴포넌트는 제외)
if (e.key === "Delete") {
if (selectedComponentIds.length > 0) {
selectedComponentIds.forEach((id) => removeComponent(id));
selectedComponentIds.forEach((id) => {
const component = components.find((c) => c.id === id);
if (component && !component.locked) {
removeComponent(id);
}
});
} else if (selectedComponentId) {
removeComponent(selectedComponentId);
const component = components.find((c) => c.id === selectedComponentId);
if (component && !component.locked) {
removeComponent(selectedComponentId);
}
}
}

View File

@ -26,6 +26,8 @@ import {
ChevronsDown,
ChevronsUp,
ChevronUp,
Lock,
Unlock,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
@ -78,6 +80,9 @@ export function ReportDesignerToolbar() {
sendToBack,
bringForward,
sendBackward,
toggleLock,
lockComponents,
unlockComponents,
} = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
@ -357,6 +362,37 @@ export function ReportDesignerToolbar() {
</DropdownMenuContent>
</DropdownMenu>
{/* 잠금 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="컴포넌트 잠금/해제 (1개 이상 선택 필요)"
>
<Lock className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={toggleLock}>
<Lock className="mr-2 h-4 w-4" />
(/)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={lockComponents}>
<Lock className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={unlockComponents}>
<Unlock className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />

View File

@ -351,6 +351,11 @@ interface ReportDesignerContextType {
sendToBack: () => void;
bringForward: () => void;
sendBackward: () => void;
// 잠금 관리
toggleLock: () => void;
lockComponents: () => void;
unlockComponents: () => void;
}
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
@ -856,6 +861,65 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
toast({ title: "레이어 변경", description: "한 단계 뒤로 이동했습니다." });
}, [selectedComponentId, selectedComponentIds, toast]);
// 잠금 관리 함수들
const toggleLock = useCallback(() => {
if (!selectedComponentId && selectedComponentIds.length === 0) return;
const idsToUpdate =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
setComponents((prev) =>
prev.map((c) => {
if (idsToUpdate.includes(c.id)) {
return { ...c, locked: !c.locked };
}
return c;
}),
);
const isLocking = components.find((c) => idsToUpdate.includes(c.id))?.locked === false;
toast({
title: isLocking ? "잠금 설정" : "잠금 해제",
description: isLocking ? "선택된 컴포넌트가 잠겼습니다." : "선택된 컴포넌트의 잠금이 해제되었습니다.",
});
}, [selectedComponentId, selectedComponentIds, components, toast]);
const lockComponents = useCallback(() => {
if (!selectedComponentId && selectedComponentIds.length === 0) return;
const idsToUpdate =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
setComponents((prev) =>
prev.map((c) => {
if (idsToUpdate.includes(c.id)) {
return { ...c, locked: true };
}
return c;
}),
);
toast({ title: "잠금 설정", description: "선택된 컴포넌트가 잠겼습니다." });
}, [selectedComponentId, selectedComponentIds, toast]);
const unlockComponents = useCallback(() => {
if (!selectedComponentId && selectedComponentIds.length === 0) return;
const idsToUpdate =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
setComponents((prev) =>
prev.map((c) => {
if (idsToUpdate.includes(c.id)) {
return { ...c, locked: false };
}
return c;
}),
);
toast({ title: "잠금 해제", description: "선택된 컴포넌트의 잠금이 해제되었습니다." });
}, [selectedComponentId, selectedComponentIds, toast]);
// 캔버스 설정 (기본값)
const [canvasWidth, setCanvasWidth] = useState(210);
const [canvasHeight, setCanvasHeight] = useState(297);
@ -1272,6 +1336,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
sendToBack,
bringForward,
sendBackward,
// 잠금 관리
toggleLock,
lockComponents,
unlockComponents,
};
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;

View File

@ -106,6 +106,7 @@ export interface ComponentConfig {
visible?: boolean;
printable?: boolean;
conditional?: string;
locked?: boolean; // 잠금 여부 (편집/이동/삭제 방지)
}
// 리포트 상세