화면 목록 저장기능

This commit is contained in:
kjs 2025-09-01 17:57:52 +09:00
parent 94ec47afe7
commit 31d25268ce
8 changed files with 544 additions and 104 deletions

View File

@ -150,3 +150,26 @@ export const getLayout = async (req: AuthenticatedRequest, res: Response) => {
.json({ success: false, message: "레이아웃 조회에 실패했습니다." }); .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: "화면 코드 생성에 실패했습니다." });
}
};

View File

@ -9,6 +9,7 @@ import {
getTableColumns, getTableColumns,
saveLayout, saveLayout,
getLayout, getLayout,
generateScreenCode,
} from "../controllers/screenManagementController"; } from "../controllers/screenManagementController";
const router = express.Router(); const router = express.Router();
@ -22,6 +23,9 @@ router.post("/screens", createScreen);
router.put("/screens/:id", updateScreen); router.put("/screens/:id", updateScreen);
router.delete("/screens/:id", deleteScreen); router.delete("/screens/:id", deleteScreen);
// 화면 코드 자동 생성
router.get("/generate-screen-code/:companyCode", generateScreenCode);
// 테이블 관리 // 테이블 관리
router.get("/tables", getTables); router.get("/tables", getTables);
router.get("/tables/:tableName/columns", getTableColumns); router.get("/tables/:tableName/columns", getTableColumns);

View File

@ -804,6 +804,45 @@ export class ScreenManagementService {
createdDate: data.created_date, 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 // 서비스 인스턴스 export

View File

@ -40,6 +40,7 @@
- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화 - **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화
- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y) - **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y)
- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시 - **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시
- **복사/삭제/붙여넣기 범용화**: 단일/다중/그룹 선택 모두에서 동작. 우클릭 위치 붙여넣기, 단축키(Ctrl/Cmd+C, Ctrl/Cmd+V, Delete) 지원, 상단 툴바 버튼 제공. 클립보드 상태 배지 표시(예: "3개 복사됨", "그룹 복사됨").
### 🎯 **현재 테이블 구조와 100% 호환** ### 🎯 **현재 테이블 구조와 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. 웹 타입 설정 방법 ### 1. 웹 타입 설정 방법
@ -2299,7 +2327,7 @@ export class TableTypeIntegrationService {
- [x] 그룹 단위 이동 - [x] 그룹 단위 이동
- [x] 그룹 UI 단순화(헤더/박스 제거) - [x] 그룹 UI 단순화(헤더/박스 제거)
- [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI) - [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI)
- [ ] 그룹 단위 삭제/복사/붙여넣기 - [x] 그룹 단위 삭제/복사/붙여넣기
### 2. 레이아웃 저장/로드 ### 2. 레이아웃 저장/로드

View File

@ -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>
);
}

View File

@ -27,6 +27,8 @@ import {
List, List,
AlignLeft, AlignLeft,
ChevronRight, ChevronRight,
Copy,
Clipboard,
} from "lucide-react"; } from "lucide-react";
import { import {
ScreenDefinition, ScreenDefinition,
@ -58,13 +60,6 @@ interface ScreenDesignerProps {
onBackToList: () => void; 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) { export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
const [layout, setLayout] = useState<LayoutData>({ const [layout, setLayout] = useState<LayoutData>({
components: [], components: [],
@ -81,6 +76,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
]); ]);
const [historyIndex, setHistoryIndex] = useState(0); 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( const saveToHistory = useCallback(
(newLayout: LayoutData) => { (newLayout: LayoutData) => {
@ -114,31 +117,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} }
}, [historyIndex, history]); }, [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({ const [dragState, setDragState] = useState({
isDragging: false, isDragging: false,
draggedComponent: null as ComponentData | null, draggedComponent: null as ComponentData | null,
@ -610,22 +588,190 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} }
}, []); }, []);
// 컴포넌트 제거 함수 // 범용 복사 함수
const removeComponent = useCallback( const copyComponents = useCallback(() => {
(componentId: string) => { 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 = { const newLayout = {
...layout, ...layout,
components: layout.components.filter((comp) => comp.id !== componentId), components: layout.components.filter((comp) => !idsToRemove.includes(comp.id)),
}; };
setLayout(newLayout); setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
if (selectedComponent?.id === componentId) {
// 선택 상태 초기화
setSelectedComponent(null); 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, ...newComponents],
};
setLayout(newLayout);
saveToHistory(newLayout);
},
[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( const updateComponentProperty = useCallback(
(componentId: string, propertyPath: string, value: any) => { (componentId: string, propertyPath: string, value: any) => {
@ -1032,6 +1178,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<Badge variant="outline" className="font-mono"> <Badge variant="outline" className="font-mono">
{selectedScreen.tableName} {selectedScreen.tableName}
</Badge> </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>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
@ -1043,6 +1199,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<Group className="mr-2 h-4 w-4" /> <Group className="mr-2 h-4 w-4" />
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"} {groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
</Button> </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)"> <Button variant="outline" size="sm" onClick={undo} disabled={historyIndex <= 0} title="실행 취소 (Ctrl+Z)">
<Undo className="mr-2 h-4 w-4" /> <Undo className="mr-2 h-4 w-4" />
@ -1308,6 +1487,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onMouseDown={handleMarqueeStart} onMouseDown={handleMarqueeStart}
onMouseMove={handleMarqueeMove} onMouseMove={handleMarqueeMove}
onMouseUp={handleMarqueeEnd} onMouseUp={handleMarqueeEnd}
onContextMenu={handleCanvasContextMenu}
> >
{layout.components.length === 0 ? ( {layout.components.length === 0 ? (
<div className="flex h-full items-center justify-center"> <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> <Label htmlFor="parentId"> ID</Label>
<Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" /> <Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" />
</div> </div>
<Button <Button variant="destructive" size="sm" onClick={deleteComponents} className="w-full">
variant="destructive"
size="sm"
onClick={() => removeComponent(selectedComponent.id)}
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{selectedComponent.type === "group"
? "그룹 삭제"
: groupState.selectedComponents.length > 1
? `${groupState.selectedComponents.length}개 삭제`
: "컴포넌트 삭제"}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -14,6 +14,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "lucide-react"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "lucide-react";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import CreateScreenModal from "./CreateScreenModal";
interface ScreenListProps { interface ScreenListProps {
onScreenSelect: (screen: ScreenDefinition) => void; onScreenSelect: (screen: ScreenDefinition) => void;
@ -27,61 +29,34 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
// 샘플 데이터 (실제로는 API에서 가져옴) // 화면 목록 로드 (실제 API)
useEffect(() => { useEffect(() => {
const mockScreens: ScreenDefinition[] = [ let abort = false;
{ const load = async () => {
screenId: 1, try {
screenName: "사용자 관리 화면", setLoading(true);
screenCode: "USER_MANAGEMENT", const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
tableName: "user_info", if (abort) return;
companyCode: "COMP001", // 응답 표준: { success, data, total }
description: "사용자 정보를 관리하는 화면", setScreens(resp.data || []);
isActive: "Y", setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
createdDate: new Date("2024-01-15"), } catch (e) {
updatedDate: new Date("2024-01-15"), console.error("화면 목록 조회 실패", e);
createdBy: "admin", setScreens([]);
updatedBy: "admin", setTotalPages(1);
}, } finally {
{ if (!abort) setLoading(false);
screenId: 2, }
screenName: "부서 관리 화면", };
screenCode: "DEPT_MANAGEMENT", load();
tableName: "dept_info", return () => {
companyCode: "COMP001", abort = true;
description: "부서 정보를 관리하는 화면", };
isActive: "Y", }, [currentPage, searchTerm]);
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",
},
];
setScreens(mockScreens); const filteredScreens = screens; // 서버 필터 기준 사용
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 handleScreenSelect = (screen: ScreenDefinition) => { const handleScreenSelect = (screen: ScreenDefinition) => {
onScreenSelect(screen); onScreenSelect(screen);
@ -132,7 +107,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
/> />
</div> </div>
</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" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
@ -141,7 +116,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span> ({filteredScreens.length})</span> <span> ({screens.length})</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -157,7 +132,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredScreens.map((screen) => ( {screens.map((screen) => (
<TableRow <TableRow
key={screen.screenId} key={screen.screenId}
className={`cursor-pointer hover:bg-gray-50 ${ className={`cursor-pointer hover:bg-gray-50 ${
@ -255,6 +230,15 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</Button> </Button>
</div> </div>
)} )}
{/* 새 화면 생성 모달 */}
<CreateScreenModal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={(created) => {
// 목록에 즉시 반영 (첫 페이지 기준 상단 추가)
setScreens((prev) => [created, ...prev]);
}}
/>
</div> </div>
); );
} }

View File

@ -18,13 +18,32 @@ export const screenApi = {
searchTerm?: string; searchTerm?: string;
}): Promise<PaginatedResponse<ScreenDefinition>> => { }): Promise<PaginatedResponse<ScreenDefinition>> => {
const response = await apiClient.get("/screen-management/screens", { params }); 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> => { getScreen: async (screenId: number): Promise<ScreenDefinition> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}`); 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; 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> => { updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => {
const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData); const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData);