화면 목록 저장기능
This commit is contained in:
parent
94ec47afe7
commit
31d25268ce
|
|
@ -150,3 +150,26 @@ export const getLayout = async (req: AuthenticatedRequest, res: Response) => {
|
|||
.json({ success: false, message: "레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 코드 자동 생성
|
||||
export const generateScreenCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { companyCode: paramCompanyCode } = req.params;
|
||||
const { companyCode: userCompanyCode } = req.user as any;
|
||||
|
||||
// 사용자의 회사 코드 또는 파라미터의 회사 코드 사용
|
||||
const targetCompanyCode = paramCompanyCode || userCompanyCode;
|
||||
|
||||
const generatedCode =
|
||||
await screenManagementService.generateScreenCode(targetCompanyCode);
|
||||
res.json({ success: true, data: { screenCode: generatedCode } });
|
||||
} catch (error) {
|
||||
console.error("화면 코드 생성 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "화면 코드 생성에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getTableColumns,
|
||||
saveLayout,
|
||||
getLayout,
|
||||
generateScreenCode,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -22,6 +23,9 @@ router.post("/screens", createScreen);
|
|||
router.put("/screens/:id", updateScreen);
|
||||
router.delete("/screens/:id", deleteScreen);
|
||||
|
||||
// 화면 코드 자동 생성
|
||||
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
||||
|
||||
// 테이블 관리
|
||||
router.get("/tables", getTables);
|
||||
router.get("/tables/:tableName/columns", getTableColumns);
|
||||
|
|
|
|||
|
|
@ -804,6 +804,45 @@ export class ScreenManagementService {
|
|||
createdDate: data.created_date,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 코드 자동 생성 (회사코드 + '_' + 순번)
|
||||
*/
|
||||
async generateScreenCode(companyCode: string): Promise<string> {
|
||||
// 해당 회사의 기존 화면 코드들 조회
|
||||
const existingScreens = await prisma.screen_definitions.findMany({
|
||||
where: {
|
||||
company_code: companyCode,
|
||||
screen_code: {
|
||||
startsWith: companyCode,
|
||||
},
|
||||
},
|
||||
select: { screen_code: true },
|
||||
orderBy: { screen_code: "desc" },
|
||||
});
|
||||
|
||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||
);
|
||||
|
||||
for (const screen of existingScreens) {
|
||||
const match = screen.screen_code.match(pattern);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||
const nextNumber = maxNumber + 1;
|
||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||
|
||||
return `${companyCode}_${paddedNumber}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 인스턴스 export
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화
|
||||
- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y)
|
||||
- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시
|
||||
- **복사/삭제/붙여넣기 범용화**: 단일/다중/그룹 선택 모두에서 동작. 우클릭 위치 붙여넣기, 단축키(Ctrl/Cmd+C, Ctrl/Cmd+V, Delete) 지원, 상단 툴바 버튼 제공. 클립보드 상태 배지 표시(예: "3개 복사됨", "그룹 복사됨").
|
||||
|
||||
### 🎯 **현재 테이블 구조와 100% 호환**
|
||||
|
||||
|
|
@ -657,6 +658,33 @@ return <div>Unknown component</div>;
|
|||
|
||||
````
|
||||
|
||||
### 4. 복사/삭제/붙여넣기 규칙 (구현 완료)
|
||||
|
||||
- 대상 범위
|
||||
- 단일 선택: 선택된 1개 컴포넌트에 대해 복사/삭제/붙여넣기 지원
|
||||
- 다중 선택: Shift+클릭 또는 마키 선택으로 선택된 여러 컴포넌트 일괄 복사/삭제/붙여넣기 지원
|
||||
- 그룹 선택: 그룹과 모든 자식 컴포넌트가 하나의 덩어리로 동작
|
||||
|
||||
- 동작 규칙
|
||||
- 복사 시 선택된 컴포넌트들의 바운딩 박스를 계산하여 상대 좌표 유지
|
||||
- 붙여넣기 시 새 ID로 재생성하고 부모-자식(parentId) 관계 보존
|
||||
- 기본 붙여넣기 위치는 원본 바운딩 박스 + 오프셋(+20px, +20px)
|
||||
- 캔버스 우클릭 시 해당 좌표로 붙여넣기 수행(정확 위치 지정)
|
||||
|
||||
- UI/피드백
|
||||
- 상단 툴바에 복사/삭제/붙여넣기 버튼 제공(선택 상황에 따라 표시/활성화)
|
||||
- 클립보드 상태 배지 표시: 단일(“컴포넌트 복사됨”), 다중(“N개 복사됨”), 그룹(“그룹 복사됨”)
|
||||
|
||||
- 단축키
|
||||
- 복사: Ctrl/Cmd + C
|
||||
- 붙여넣기: Ctrl/Cmd + V
|
||||
- 삭제: Delete 또는 Backspace
|
||||
- 실행 취소/다시 실행: Ctrl/Cmd + Z, Ctrl/Cmd + Y
|
||||
|
||||
- 예외 처리
|
||||
- 선택 없음 상태에서 복사/삭제는 무시
|
||||
- 클립보드가 비어있는 경우 붙여넣기 무시
|
||||
|
||||
## 🔗 테이블 타입 연계
|
||||
|
||||
### 1. 웹 타입 설정 방법
|
||||
|
|
@ -2299,7 +2327,7 @@ export class TableTypeIntegrationService {
|
|||
- [x] 그룹 단위 이동
|
||||
- [x] 그룹 UI 단순화(헤더/박스 제거)
|
||||
- [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI)
|
||||
- [ ] 그룹 단위 삭제/복사/붙여넣기
|
||||
- [x] 그룹 단위 삭제/복사/붙여넣기
|
||||
|
||||
### 2. 레이아웃 저장/로드
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface CreateScreenModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (screen: ScreenDefinition) => void;
|
||||
}
|
||||
|
||||
export default function CreateScreenModal({ open, onOpenChange, onCreated }: CreateScreenModalProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [screenName, setScreenName] = useState("");
|
||||
const [screenCode, setScreenCode] = useState("");
|
||||
const [tableName, setTableName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// 화면 코드 자동 생성
|
||||
const generateCode = async () => {
|
||||
try {
|
||||
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
||||
const generatedCode = await screenApi.generateScreenCode(companyCode);
|
||||
setScreenCode(generatedCode);
|
||||
} catch (e) {
|
||||
console.error("화면 코드 생성 실패", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let abort = false;
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const list = await tableTypeApi.getTables();
|
||||
if (abort) return;
|
||||
setTables(list.map((t) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||
} catch (e) {
|
||||
console.error("테이블 목록 조회 실패", e);
|
||||
setTables([]);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// 모달이 열릴 때 자동으로 화면 코드 생성
|
||||
useEffect(() => {
|
||||
if (open && !screenCode) {
|
||||
generateCode();
|
||||
}
|
||||
}, [open, screenCode]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
||||
}, [screenName, screenCode, tableName]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid || submitting) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
||||
const created = await screenApi.createScreen({
|
||||
screenName: screenName.trim(),
|
||||
screenCode: screenCode.trim(),
|
||||
tableName: tableName.trim(),
|
||||
companyCode,
|
||||
description: description.trim() || undefined,
|
||||
createdBy: (user as any)?.userId,
|
||||
} as any);
|
||||
|
||||
// 날짜 필드 보정
|
||||
const mapped: ScreenDefinition = {
|
||||
...created,
|
||||
createdDate: created.createdDate ? new Date(created.createdDate as any) : new Date(),
|
||||
updatedDate: created.updatedDate ? new Date(created.updatedDate as any) : new Date(),
|
||||
} as ScreenDefinition;
|
||||
|
||||
onCreated?.(mapped);
|
||||
onOpenChange(false);
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
setTableName("");
|
||||
setDescription("");
|
||||
} catch (e) {
|
||||
console.error("화면 생성 실패", e);
|
||||
// 필요 시 토스트 추가 가능
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 화면 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screenName">화면명</Label>
|
||||
<Input id="screenName" value={screenName} onChange={(e) => setScreenName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screenCode">화면 코드</Label>
|
||||
<Input
|
||||
id="screenCode"
|
||||
value={screenCode}
|
||||
readOnly
|
||||
placeholder="자동 생성됩니다..."
|
||||
className="cursor-not-allowed bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">테이블</Label>
|
||||
<select
|
||||
id="tableName"
|
||||
className="w-full rounded-md border px-3 py-2 text-sm"
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
>
|
||||
<option value="">테이블 선택...</option>
|
||||
{tables.map((t) => (
|
||||
<option key={t.tableName} value={t.tableName}>
|
||||
{t.displayName} ({t.tableName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || submitting} className="bg-blue-600 hover:bg-blue-700">
|
||||
생성
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ import {
|
|||
List,
|
||||
AlignLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Clipboard,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ScreenDefinition,
|
||||
|
|
@ -58,13 +60,6 @@ interface ScreenDesignerProps {
|
|||
onBackToList: () => void;
|
||||
}
|
||||
|
||||
interface ComponentMoveState {
|
||||
isMoving: boolean;
|
||||
movingComponent: ComponentData | null;
|
||||
originalPosition: { x: number; y: number };
|
||||
currentPosition: { x: number; y: number };
|
||||
}
|
||||
|
||||
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
||||
const [layout, setLayout] = useState<LayoutData>({
|
||||
components: [],
|
||||
|
|
@ -81,6 +76,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
// 클립보드 상태 (복사/붙여넣기용)
|
||||
const [clipboard, setClipboard] = useState<{
|
||||
type: "single" | "multiple" | "group";
|
||||
data: ComponentData[];
|
||||
offset: { x: number; y: number };
|
||||
boundingBox?: { x: number; y: number; width: number; height: number };
|
||||
} | null>(null);
|
||||
|
||||
// 히스토리에 상태 저장
|
||||
const saveToHistory = useCallback(
|
||||
(newLayout: LayoutData) => {
|
||||
|
|
@ -114,31 +117,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
// 키보드 단축키 지원
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case "z":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z
|
||||
} else {
|
||||
undo(); // Ctrl+Z 또는 Cmd+Z
|
||||
}
|
||||
break;
|
||||
case "y":
|
||||
e.preventDefault();
|
||||
redo(); // Ctrl+Y 또는 Cmd+Y
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [undo, redo]);
|
||||
|
||||
const [dragState, setDragState] = useState({
|
||||
isDragging: false,
|
||||
draggedComponent: null as ComponentData | null,
|
||||
|
|
@ -610,22 +588,190 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 제거 함수
|
||||
const removeComponent = useCallback(
|
||||
(componentId: string) => {
|
||||
// 범용 복사 함수
|
||||
const copyComponents = useCallback(() => {
|
||||
if (!selectedComponent && groupState.selectedComponents.length === 0) return;
|
||||
|
||||
let componentsToCopy: ComponentData[] = [];
|
||||
let copyType: "single" | "multiple" | "group" = "single";
|
||||
|
||||
if (selectedComponent?.type === "group") {
|
||||
// 그룹 복사
|
||||
const children = getGroupChildren(layout.components, selectedComponent.id);
|
||||
componentsToCopy = [selectedComponent, ...children];
|
||||
copyType = "group";
|
||||
} else if (groupState.selectedComponents.length > 1) {
|
||||
// 다중 선택 복사
|
||||
componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||||
copyType = "multiple";
|
||||
} else if (selectedComponent) {
|
||||
// 단일 컴포넌트 복사
|
||||
componentsToCopy = [selectedComponent];
|
||||
copyType = "single";
|
||||
}
|
||||
|
||||
if (componentsToCopy.length === 0) return;
|
||||
|
||||
// 바운딩 박스 계산
|
||||
const positions = componentsToCopy.map((comp) => ({
|
||||
x: comp.position.x,
|
||||
y: comp.position.y,
|
||||
width: comp.size.width,
|
||||
height: comp.size.height,
|
||||
}));
|
||||
|
||||
const minX = Math.min(...positions.map((p) => p.x));
|
||||
const minY = Math.min(...positions.map((p) => p.y));
|
||||
const maxX = Math.max(...positions.map((p) => p.x + p.width));
|
||||
const maxY = Math.max(...positions.map((p) => p.y + p.height));
|
||||
|
||||
setClipboard({
|
||||
type: copyType,
|
||||
data: componentsToCopy,
|
||||
offset: { x: 20, y: 20 },
|
||||
boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
||||
});
|
||||
}, [selectedComponent, groupState.selectedComponents, layout.components]);
|
||||
|
||||
// 범용 삭제 함수
|
||||
const deleteComponents = useCallback(() => {
|
||||
if (!selectedComponent && groupState.selectedComponents.length === 0) return;
|
||||
|
||||
let idsToRemove: string[] = [];
|
||||
|
||||
if (selectedComponent?.type === "group") {
|
||||
// 그룹 삭제 (자식 컴포넌트 포함)
|
||||
const childrenIds = getGroupChildren(layout.components, selectedComponent.id).map((child) => child.id);
|
||||
idsToRemove = [selectedComponent.id, ...childrenIds];
|
||||
} else if (groupState.selectedComponents.length > 1) {
|
||||
// 다중 선택 삭제
|
||||
idsToRemove = [...groupState.selectedComponents];
|
||||
} else if (selectedComponent) {
|
||||
// 단일 컴포넌트 삭제
|
||||
idsToRemove = [selectedComponent.id];
|
||||
}
|
||||
|
||||
if (idsToRemove.length === 0) return;
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: layout.components.filter((comp) => !idsToRemove.includes(comp.id)),
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
// 선택 상태 초기화
|
||||
setSelectedComponent(null);
|
||||
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
|
||||
}, [selectedComponent, groupState.selectedComponents, layout, saveToHistory, setGroupState]);
|
||||
|
||||
// 범용 붙여넣기 함수
|
||||
const pasteComponents = useCallback(
|
||||
(pastePosition?: { x: number; y: number }) => {
|
||||
if (!clipboard || clipboard.data.length === 0) return;
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
const newComponents: ComponentData[] = [];
|
||||
|
||||
// 붙여넣기 위치 결정
|
||||
let targetPosition = pastePosition;
|
||||
if (!targetPosition && clipboard.boundingBox) {
|
||||
targetPosition = {
|
||||
x: clipboard.boundingBox.x + clipboard.offset.x,
|
||||
y: clipboard.boundingBox.y + clipboard.offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
const offsetX = targetPosition ? targetPosition.x - (clipboard.boundingBox?.x || 0) : clipboard.offset.x;
|
||||
const offsetY = targetPosition ? targetPosition.y - (clipboard.boundingBox?.y || 0) : clipboard.offset.y;
|
||||
|
||||
// 모든 컴포넌트에 대해 새 ID 생성
|
||||
clipboard.data.forEach((comp) => {
|
||||
const newId = generateComponentId();
|
||||
idMap.set(comp.id, newId);
|
||||
});
|
||||
|
||||
// 컴포넌트 복사 및 ID/위치 업데이트
|
||||
clipboard.data.forEach((comp) => {
|
||||
const newComp: ComponentData = {
|
||||
...comp,
|
||||
id: idMap.get(comp.id)!,
|
||||
position: {
|
||||
x: comp.position.x + offsetX,
|
||||
y: comp.position.y + offsetY,
|
||||
},
|
||||
// 부모 ID가 있고 매핑되는 경우 업데이트
|
||||
parentId: comp.parentId && idMap.has(comp.parentId) ? idMap.get(comp.parentId)! : undefined,
|
||||
};
|
||||
newComponents.push(newComp);
|
||||
});
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: layout.components.filter((comp) => comp.id !== componentId),
|
||||
components: [...layout.components, ...newComponents],
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
if (selectedComponent?.id === componentId) {
|
||||
setSelectedComponent(null);
|
||||
},
|
||||
[clipboard, layout, saveToHistory],
|
||||
);
|
||||
|
||||
// 캔버스 우클릭 컨텍스트 메뉴
|
||||
const handleCanvasContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// 우클릭 시 붙여넣기 (클립보드에 데이터가 있는 경우)
|
||||
if (clipboard && clipboard.data.length > 0) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
pasteComponents({ x, y });
|
||||
}
|
||||
},
|
||||
[layout, selectedComponent, saveToHistory],
|
||||
[clipboard, pasteComponents],
|
||||
);
|
||||
|
||||
// 키보드 단축키 지원
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case "z":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z
|
||||
} else {
|
||||
undo(); // Ctrl+Z 또는 Cmd+Z
|
||||
}
|
||||
break;
|
||||
case "y":
|
||||
e.preventDefault();
|
||||
redo(); // Ctrl+Y 또는 Cmd+Y
|
||||
break;
|
||||
case "c":
|
||||
e.preventDefault();
|
||||
// 선택된 컴포넌트(들) 복사
|
||||
copyComponents();
|
||||
break;
|
||||
case "v":
|
||||
e.preventDefault();
|
||||
// 클립보드 내용 붙여넣기
|
||||
if (clipboard && clipboard.data.length > 0) {
|
||||
pasteComponents();
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (e.key === "Delete") {
|
||||
e.preventDefault();
|
||||
// 선택된 컴포넌트(들) 삭제
|
||||
deleteComponents();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [undo, redo, copyComponents, pasteComponents, deleteComponents, clipboard]);
|
||||
|
||||
// 컴포넌트 속성 업데이트 함수
|
||||
const updateComponentProperty = useCallback(
|
||||
(componentId: string, propertyPath: string, value: any) => {
|
||||
|
|
@ -1032,6 +1178,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<Badge variant="outline" className="font-mono">
|
||||
{selectedScreen.tableName}
|
||||
</Badge>
|
||||
{clipboard && clipboard.data.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Clipboard className="mr-1 h-3 w-3" />
|
||||
{clipboard.type === "group"
|
||||
? "그룹 복사됨"
|
||||
: clipboard.type === "multiple"
|
||||
? `${clipboard.data.length}개 복사됨`
|
||||
: "컴포넌트 복사됨"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
|
|
@ -1043,6 +1199,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<Group className="mr-2 h-4 w-4" />
|
||||
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
|
||||
</Button>
|
||||
|
||||
{/* 복사/붙여넣기/삭제 버튼들 */}
|
||||
{(selectedComponent || groupState.selectedComponents.length > 0) && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={copyComponents} title="복사 (Ctrl+C)">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={deleteComponents} title="삭제 (Delete 키)">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 붙여넣기 버튼 */}
|
||||
{clipboard && clipboard.data.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => pasteComponents()} title="붙여넣기 (Ctrl+V)">
|
||||
<Clipboard className="mr-2 h-4 w-4" />
|
||||
붙여넣기
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={undo} disabled={historyIndex <= 0} title="실행 취소 (Ctrl+Z)">
|
||||
<Undo className="mr-2 h-4 w-4" />
|
||||
실행 취소
|
||||
|
|
@ -1308,6 +1487,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
onMouseDown={handleMarqueeStart}
|
||||
onMouseMove={handleMarqueeMove}
|
||||
onMouseUp={handleMarqueeEnd}
|
||||
onContextMenu={handleCanvasContextMenu}
|
||||
>
|
||||
{layout.components.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -1567,14 +1747,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<Label htmlFor="parentId">부모 ID</Label>
|
||||
<Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" />
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeComponent(selectedComponent.id)}
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="destructive" size="sm" onClick={deleteComponents} className="w-full">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
컴포넌트 삭제
|
||||
{selectedComponent.type === "group"
|
||||
? "그룹 삭제"
|
||||
: groupState.selectedComponents.length > 1
|
||||
? `${groupState.selectedComponents.length}개 삭제`
|
||||
: "컴포넌트 삭제"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import CreateScreenModal from "./CreateScreenModal";
|
||||
|
||||
interface ScreenListProps {
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
|
|
@ -27,61 +29,34 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
// 샘플 데이터 (실제로는 API에서 가져옴)
|
||||
// 화면 목록 로드 (실제 API)
|
||||
useEffect(() => {
|
||||
const mockScreens: ScreenDefinition[] = [
|
||||
{
|
||||
screenId: 1,
|
||||
screenName: "사용자 관리 화면",
|
||||
screenCode: "USER_MANAGEMENT",
|
||||
tableName: "user_info",
|
||||
companyCode: "COMP001",
|
||||
description: "사용자 정보를 관리하는 화면",
|
||||
isActive: "Y",
|
||||
createdDate: new Date("2024-01-15"),
|
||||
updatedDate: new Date("2024-01-15"),
|
||||
createdBy: "admin",
|
||||
updatedBy: "admin",
|
||||
},
|
||||
{
|
||||
screenId: 2,
|
||||
screenName: "부서 관리 화면",
|
||||
screenCode: "DEPT_MANAGEMENT",
|
||||
tableName: "dept_info",
|
||||
companyCode: "COMP001",
|
||||
description: "부서 정보를 관리하는 화면",
|
||||
isActive: "Y",
|
||||
createdDate: new Date("2024-01-16"),
|
||||
updatedDate: new Date("2024-01-16"),
|
||||
createdBy: "admin",
|
||||
updatedBy: "admin",
|
||||
},
|
||||
{
|
||||
screenId: 3,
|
||||
screenName: "제품 관리 화면",
|
||||
screenCode: "PRODUCT_MANAGEMENT",
|
||||
tableName: "product_info",
|
||||
companyCode: "COMP001",
|
||||
description: "제품 정보를 관리하는 화면",
|
||||
isActive: "Y",
|
||||
createdDate: new Date("2024-01-17"),
|
||||
updatedDate: new Date("2024-01-17"),
|
||||
createdBy: "admin",
|
||||
updatedBy: "admin",
|
||||
},
|
||||
];
|
||||
let abort = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
if (abort) return;
|
||||
// 응답 표준: { success, data, total }
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
} catch (e) {
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
setScreens([]);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
if (!abort) setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
setScreens(mockScreens);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const filteredScreens = screens.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.tableName.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||
|
||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||
onScreenSelect(screen);
|
||||
|
|
@ -132,7 +107,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 화면 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -141,7 +116,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>화면 목록 ({filteredScreens.length})</span>
|
||||
<span>화면 목록 ({screens.length})</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -157,7 +132,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredScreens.map((screen) => (
|
||||
{screens.map((screen) => (
|
||||
<TableRow
|
||||
key={screen.screenId}
|
||||
className={`cursor-pointer hover:bg-gray-50 ${
|
||||
|
|
@ -255,6 +230,15 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 새 화면 생성 모달 */}
|
||||
<CreateScreenModal
|
||||
open={isCreateOpen}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
onCreated={(created) => {
|
||||
// 목록에 즉시 반영 (첫 페이지 기준 상단 추가)
|
||||
setScreens((prev) => [created, ...prev]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,32 @@ export const screenApi = {
|
|||
searchTerm?: string;
|
||||
}): Promise<PaginatedResponse<ScreenDefinition>> => {
|
||||
const response = await apiClient.get("/screen-management/screens", { params });
|
||||
return response.data;
|
||||
const raw = response.data || {};
|
||||
const items: any[] = (raw.data ?? raw.items ?? []) as any[];
|
||||
const mapped: ScreenDefinition[] = items.map((it) => ({
|
||||
...it,
|
||||
// 문자열로 온 날짜를 Date 객체로 변환 (이미 Date이면 그대로 유지)
|
||||
createdDate: it.createdDate ? new Date(it.createdDate) : undefined,
|
||||
updatedDate: it.updatedDate ? new Date(it.updatedDate) : undefined,
|
||||
}));
|
||||
|
||||
const page = raw.page ?? params.page ?? 1;
|
||||
const size = raw.size ?? params.size ?? mapped.length;
|
||||
const total = raw.total ?? mapped.length;
|
||||
const totalPages = raw.totalPages ?? Math.max(1, Math.ceil(total / (size || 1)));
|
||||
|
||||
return { data: mapped, total, page, size, totalPages };
|
||||
},
|
||||
|
||||
// 화면 상세 조회
|
||||
getScreen: async (screenId: number): Promise<ScreenDefinition> => {
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}`);
|
||||
return response.data.data;
|
||||
const raw = response.data?.data || response.data;
|
||||
return {
|
||||
...raw,
|
||||
createdDate: raw?.createdDate ? new Date(raw.createdDate) : undefined,
|
||||
updatedDate: raw?.updatedDate ? new Date(raw.updatedDate) : undefined,
|
||||
} as ScreenDefinition;
|
||||
},
|
||||
|
||||
// 화면 생성
|
||||
|
|
@ -33,6 +52,12 @@ export const screenApi = {
|
|||
return response.data.data;
|
||||
},
|
||||
|
||||
// 화면 코드 자동 생성 (회사코드 + 순번)
|
||||
generateScreenCode: async (companyCode: string): Promise<string> => {
|
||||
const response = await apiClient.get(`/screen-management/generate-screen-code/${companyCode}`);
|
||||
return response.data.data.screenCode;
|
||||
},
|
||||
|
||||
// 화면 수정
|
||||
updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => {
|
||||
const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData);
|
||||
|
|
|
|||
Loading…
Reference in New Issue