화면 복사기능 구현

This commit is contained in:
kjs 2025-09-03 18:23:47 +09:00
parent b5edef274f
commit 3c86b22a99
8 changed files with 1591 additions and 1055 deletions

View File

@ -108,6 +108,41 @@ export const deleteScreen = async (
}
};
// 화면 복사
export const copyScreen = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const { screenName, screenCode, description } = req.body;
const { companyCode, userId } = req.user as any;
const copiedScreen = await screenManagementService.copyScreen(
parseInt(id),
{
screenName,
screenCode,
description,
companyCode,
createdBy: userId,
}
);
res.json({
success: true,
data: copiedScreen,
message: "화면이 복사되었습니다.",
});
} catch (error: any) {
console.error("화면 복사 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면 복사에 실패했습니다.",
});
}
};
// 테이블 목록 조회 (모든 테이블)
export const getTables = async (req: AuthenticatedRequest, res: Response) => {
try {

View File

@ -6,6 +6,7 @@ import {
createScreen,
updateScreen,
deleteScreen,
copyScreen,
getTables,
getTableInfo,
getTableColumns,
@ -28,6 +29,7 @@ router.get("/screens/:id", getScreen);
router.post("/screens", createScreen);
router.put("/screens/:id", updateScreen);
router.delete("/screens/:id", deleteScreen);
router.post("/screens/:id/copy", copyScreen);
// 화면 코드 자동 생성
router.get("/generate-screen-code/:companyCode", generateScreenCode);

View File

@ -14,8 +14,18 @@ import {
WebType,
WidgetData,
} from "../types/screen";
import { generateId } from "../utils/generateId";
// 화면 복사 요청 인터페이스
interface CopyScreenRequest {
screenName: string;
screenCode: string;
description?: string;
companyCode: string;
createdBy: string;
}
// 백엔드에서 사용할 테이블 정보 타입
interface TableInfo {
tableName: string;
@ -968,6 +978,120 @@ export class ScreenManagementService {
return `${companyCode}_${paddedNumber}`;
}
/**
* ( + )
*/
async copyScreen(
sourceScreenId: number,
copyData: CopyScreenRequest
): Promise<ScreenDefinition> {
// 트랜잭션으로 처리
return await prisma.$transaction(async (tx) => {
// 1. 원본 화면 정보 조회
const sourceScreen = await tx.screen_definitions.findFirst({
where: {
screen_id: sourceScreenId,
company_code: copyData.companyCode,
},
});
if (!sourceScreen) {
throw new Error("복사할 화면을 찾을 수 없습니다.");
}
// 2. 화면 코드 중복 체크
const existingScreen = await tx.screen_definitions.findFirst({
where: {
screen_code: copyData.screenCode,
company_code: copyData.companyCode,
},
});
if (existingScreen) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 3. 새 화면 생성
const newScreen = await tx.screen_definitions.create({
data: {
screen_code: copyData.screenCode,
screen_name: copyData.screenName,
description: copyData.description || sourceScreen.description,
company_code: copyData.companyCode,
table_name: sourceScreen.table_name,
is_active: sourceScreen.is_active,
created_by: copyData.createdBy,
created_date: new Date(),
updated_by: copyData.createdBy,
updated_date: new Date(),
},
});
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayouts = await tx.screen_layouts.findMany({
where: {
screen_id: sourceScreenId,
},
orderBy: { display_order: "asc" },
});
// 5. 레이아웃이 있다면 복사
if (sourceLayouts.length > 0) {
try {
// ID 매핑 맵 생성
const idMapping: { [oldId: string]: string } = {};
// 새로운 컴포넌트 ID 미리 생성
sourceLayouts.forEach((layout) => {
idMapping[layout.component_id] = generateId();
});
// 각 레이아웃 컴포넌트 복사
for (const sourceLayout of sourceLayouts) {
const newComponentId = idMapping[sourceLayout.component_id];
const newParentId = sourceLayout.parent_id
? idMapping[sourceLayout.parent_id]
: null;
await tx.screen_layouts.create({
data: {
screen_id: newScreen.screen_id,
component_type: sourceLayout.component_type,
component_id: newComponentId,
parent_id: newParentId,
position_x: sourceLayout.position_x,
position_y: sourceLayout.position_y,
width: sourceLayout.width,
height: sourceLayout.height,
properties: sourceLayout.properties as any,
display_order: sourceLayout.display_order,
created_date: new Date(),
},
});
}
} catch (error) {
console.error("레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}
// 6. 생성된 화면 정보 반환
return {
screenId: newScreen.screen_id,
screenCode: newScreen.screen_code,
screenName: newScreen.screen_name,
description: newScreen.description || "",
companyCode: newScreen.company_code,
tableName: newScreen.table_name,
isActive: newScreen.is_active,
createdBy: newScreen.created_by || undefined,
createdDate: newScreen.created_date,
updatedBy: newScreen.updated_by || undefined,
updatedDate: newScreen.updated_date,
};
});
}
}
// 서비스 인스턴스 export

View File

@ -0,0 +1,192 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Copy } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
interface CopyScreenModalProps {
isOpen: boolean;
onClose: () => void;
sourceScreen: ScreenDefinition | null;
onCopySuccess: () => void;
}
export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopySuccess }: CopyScreenModalProps) {
const [screenName, setScreenName] = useState("");
const [screenCode, setScreenCode] = useState("");
const [description, setDescription] = useState("");
const [isCopying, setIsCopying] = useState(false);
// 모달이 열릴 때 초기값 설정
useEffect(() => {
if (isOpen && sourceScreen) {
setScreenName(`${sourceScreen.screenName} (복사본)`);
setDescription(sourceScreen.description || "");
// 화면 코드 자동 생성
generateNewScreenCode();
}
}, [isOpen, sourceScreen]);
// 새로운 화면 코드 자동 생성
const generateNewScreenCode = async () => {
if (!sourceScreen?.companyCode) return;
try {
const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode);
setScreenCode(newCode);
} catch (error) {
console.error("화면 코드 생성 실패:", error);
toast.error("화면 코드 생성에 실패했습니다.");
}
};
// 화면 복사 실행
const handleCopy = async () => {
if (!sourceScreen) return;
// 입력값 검증
if (!screenName.trim()) {
toast.error("화면명을 입력해주세요.");
return;
}
if (!screenCode.trim()) {
toast.error("화면 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요.");
return;
}
try {
setIsCopying(true);
// 화면 복사 API 호출
await screenApi.copyScreen(sourceScreen.screenId, {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
description: description.trim(),
});
toast.success("화면이 성공적으로 복사되었습니다.");
onCopySuccess();
handleClose();
} catch (error: any) {
console.error("화면 복사 실패:", error);
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
toast.error(errorMessage);
} finally {
setIsCopying(false);
}
};
// 모달 닫기
const handleClose = () => {
setScreenName("");
setScreenCode("");
setDescription("");
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{sourceScreen?.screenName} . .
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 원본 화면 정보 */}
<div className="rounded-md bg-gray-50 p-3">
<h4 className="mb-2 text-sm font-medium text-gray-700"> </h4>
<div className="space-y-1 text-sm text-gray-600">
<div>
<span className="font-medium">:</span> {sourceScreen?.screenName}
</div>
<div>
<span className="font-medium">:</span> {sourceScreen?.screenCode}
</div>
<div>
<span className="font-medium">:</span> {sourceScreen?.companyCode}
</div>
</div>
</div>
{/* 새 화면 정보 입력 */}
<div className="space-y-3">
<div>
<Label htmlFor="screenName"> *</Label>
<Input
id="screenName"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="복사될 화면의 이름을 입력하세요"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="screenCode"> ()</Label>
<Input
id="screenCode"
value={screenCode}
readOnly
className="mt-1 bg-gray-50"
placeholder="화면 코드가 자동으로 생성됩니다"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="화면 설명을 입력하세요 (선택사항)"
className="mt-1"
rows={3}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -16,6 +16,7 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal";
interface ScreenListProps {
onScreenSelect: (screen: ScreenDefinition) => void;
@ -30,6 +31,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 화면 목록 로드 (실제 API)
useEffect(() => {
@ -58,6 +61,20 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const filteredScreens = screens; // 서버 필터 기준 사용
// 화면 목록 다시 로드
const reloadScreens = async () => {
try {
setLoading(true);
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
setScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
} catch (e) {
console.error("화면 목록 조회 실패", e);
} finally {
setLoading(false);
}
};
const handleScreenSelect = (screen: ScreenDefinition) => {
onScreenSelect(screen);
};
@ -75,8 +92,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
};
const handleCopy = (screen: ScreenDefinition) => {
// 복사 모달 열기
console.log("복사:", screen);
setScreenToCopy(screen);
setIsCopyOpen(true);
};
const handleView = (screen: ScreenDefinition) => {
@ -84,6 +101,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
console.log("미리보기:", screen);
};
const handleCopySuccess = () => {
// 복사 성공 후 화면 목록 다시 로드
reloadScreens();
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
@ -239,6 +261,14 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
setScreens((prev) => [created, ...prev]);
}}
/>
{/* 화면 복사 모달 */}
<CopyScreenModal
isOpen={isCopyOpen}
onClose={() => setIsCopyOpen(false)}
sourceScreen={screenToCopy}
onCopySuccess={handleCopySuccess}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -54,6 +54,8 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
canGroup = false,
canUngroup = false,
}) => {
// 데이터테이블 설정 탭 상태를 여기서 관리
const [dataTableActiveTab, setDataTableActiveTab] = useState("basic");
// 최신 값들의 참조를 유지
const selectedComponentRef = useRef(selectedComponent);
const onUpdatePropertyRef = useRef(onUpdateProperty);
@ -170,6 +172,8 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`}
component={selectedComponent as DataTableComponent}
tables={tables}
activeTab={dataTableActiveTab}
onTabChange={setDataTableActiveTab}
onUpdateComponent={(updates) => {
console.log("🔄 DataTable 컴포넌트 업데이트:", updates);
console.log("🔄 업데이트 항목들:", Object.keys(updates));

View File

@ -79,6 +79,19 @@ export const screenApi = {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
return response.data.data;
},
// 화면 복사 (화면정보 + 레이아웃 모두 복사)
copyScreen: async (
sourceScreenId: number,
copyData: {
screenName: string;
screenCode: string;
description?: string;
},
): Promise<ScreenDefinition> => {
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy`, copyData);
return response.data.data;
},
};
// 템플릿 관련 API