From d9b7ef9ad4d8116bf9023b4a611428fb09da1e8a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 2 Feb 2026 18:01:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20POP=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5/=EB=A1=9C=EB=93=9C=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=20POP=20=EC=A0=84=EC=9A=A9=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=8A=B8=EB=A6=AC=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=EA=B3=84=EC=B8=B5=EC=A0=81=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0)=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=20CRUD=20API=20=EC=B6=94=EA=B0=80=20(hierarchy=5Fpath=20LIKE?= =?UTF-8?q?=20'POP/%'=20=ED=95=84=ED=84=B0)=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20(=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20?= =?UTF-8?q?=EC=83=88=20=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D)=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC/=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20(display=5Forder=20=EA=B5=90=ED=99=98)=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20UI=EB=A5=BC=20=EC=84=9C=EB=B8=8C=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=80=EC=83=89=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?POP=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20(pop-1.0)=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20DB=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=ED=98=B8=ED=99=98=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(writer=20=EC=BB=AC=EB=9F=BC,=20is=5Factiv?= =?UTF-8?q?e=20VARCHAR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- POPUPDATE.md | 283 ++++- .../src/controllers/screenGroupController.ts | 278 +++++ backend-node/src/routes/screenGroupRoutes.ts | 15 + .../src/services/screenManagementService.ts | 4 +- .../admin/screenMng/popScreenMngList/page.tsx | 362 +++--- .../app/(pop)/pop/screens/[screenId]/page.tsx | 71 +- .../components/pop/designer/PopDesigner.tsx | 27 +- .../pop/management/PopCategoryTree.tsx | 1095 +++++++++++++++++ .../pop/management/PopScreenFlowView.tsx | 347 ++++++ .../pop/management/PopScreenPreview.tsx | 199 +++ .../pop/management/PopScreenSettingModal.tsx | 442 +++++++ frontend/components/pop/management/index.ts | 8 + frontend/lib/api/popScreenGroup.ts | 182 +++ 13 files changed, 3104 insertions(+), 209 deletions(-) create mode 100644 frontend/components/pop/management/PopCategoryTree.tsx create mode 100644 frontend/components/pop/management/PopScreenFlowView.tsx create mode 100644 frontend/components/pop/management/PopScreenPreview.tsx create mode 100644 frontend/components/pop/management/PopScreenSettingModal.tsx create mode 100644 frontend/components/pop/management/index.ts create mode 100644 frontend/lib/api/popScreenGroup.ts diff --git a/POPUPDATE.md b/POPUPDATE.md index 139cd8f9..49331154 100644 --- a/POPUPDATE.md +++ b/POPUPDATE.md @@ -627,4 +627,285 @@ const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)) --- -*최종 업데이트: 2026-02-02* +## POP 화면 관리 페이지 개발 (2026-02-02) + +### POP 카테고리 트리 API 구현 + +**기능:** +- POP 화면을 카테고리별로 관리하는 트리 구조 구현 +- 기존 `screen_groups` 테이블을 `hierarchy_path LIKE 'POP/%'` 조건으로 필터링하여 재사용 +- 데스크탑 화면 관리와 별도로 POP 전용 카테고리 체계 구성 + +**백엔드 API:** +- `GET /api/screen-groups/pop/groups` - POP 그룹 목록 조회 +- `POST /api/screen-groups/pop/groups` - POP 그룹 생성 +- `PUT /api/screen-groups/pop/groups/:id` - POP 그룹 수정 +- `DELETE /api/screen-groups/pop/groups/:id` - POP 그룹 삭제 +- `POST /api/screen-groups/pop/ensure-root` - POP 루트 그룹 자동 생성 + +### 트러블슈팅: API 경로 중복 문제 + +**문제:** 카테고리 생성 시 404 에러 발생 + +**원인:** +- `apiClient`의 baseURL이 이미 `http://localhost:8080/api`로 설정됨 +- API 호출 경로에 `/api/screen-groups/...`를 사용하여 최종 URL이 `/api/api/screen-groups/...`로 중복 + +**해결:** +```typescript +// 변경 전 +const response = await apiClient.post("/api/screen-groups/pop/groups", data); + +// 변경 후 +const response = await apiClient.post("/screen-groups/pop/groups", data); +``` + +### 트러블슈팅: created_by 컬럼 오류 + +**문제:** `column "created_by" of relation "screen_groups" does not exist` + +**원인:** +- 신규 작성 코드에서 `created_by` 컬럼을 사용했으나 +- 기존 `screen_groups` 테이블 스키마에는 `writer` 컬럼이 존재 + +**해결:** +```sql +-- 변경 전 +INSERT INTO screen_groups (..., created_by) VALUES (..., $9) + +-- 변경 후 +INSERT INTO screen_groups (..., writer) VALUES (..., $9) +``` + +### 트러블슈팅: is_active 컬럼 타입 불일치 + +**문제:** `value too long for type character varying(1)` 에러로 카테고리 생성 실패 + +**원인:** +- `is_active` 컬럼이 `VARCHAR(1)` 타입 +- INSERT 쿼리에서 `true`(boolean, 4자)를 직접 사용 + +**해결:** +```sql +-- 변경 전 +INSERT INTO screen_groups (..., is_active) VALUES (..., true) + +-- 변경 후 +INSERT INTO screen_groups (..., is_active) VALUES (..., 'Y') +``` + +**교훈:** +- 기존 테이블 스키마를 반드시 확인 후 쿼리 작성 +- `is_active`는 `VARCHAR(1)` 타입으로 'Y'/'N' 값 사용 +- `created_by` 대신 `writer` 컬럼명 사용 + +### 카테고리 트리 UI 개선 + +**문제:** 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확 + +**해결:** +1. 들여쓰기 증가: `level * 16px` → `level * 24px` +2. 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시 +3. 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘 + +```tsx +// 하위 레벨에 연결 표시 추가 +{level > 0 && ( + +)} + +// 루트와 하위 폴더 시각적 구분 + +{group.group_name} +``` + +### 미분류 화면 이동 기능 추가 + +**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴 + +**구현:** +```tsx +// 이동 드롭다운 메뉴 + + + + + + {treeData.map((g) => ( + handleMoveScreenToGroup(screen, g)}> + + {g.group_name} + + ))} + + + +// API 호출 (apiClient 사용) +const handleMoveScreenToGroup = async (screen, group) => { + await apiClient.post("/screen-groups/group-screens", { + group_id: group.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); +}; +``` + +**주의:** API 호출 시 `apiClient`를 사용해야 환경별 URL이 자동 처리됨 + +### 화면 이동 로직 수정 (복사 → 이동) + +**문제:** 화면을 다른 카테고리로 이동할 때 복사가 되어 중복 발생 + +**원인:** 기존 그룹 연결 삭제 없이 새 그룹에만 연결 추가 + +**해결:** 2단계 처리 - 기존 연결 삭제 후 새 연결 추가 + +```tsx +const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { + // 1. 기존 연결 찾기 및 삭제 + for (const g of groups) { + const existingLink = g.screens?.find((s) => s.screen_id === screen.screenId); + if (existingLink) { + await apiClient.delete(`/screen-groups/group-screens/${existingLink.id}`); + break; + } + } + + // 2. 새 그룹에 연결 추가 + await apiClient.post("/screen-groups/group-screens", { + group_id: targetGroup.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); + + loadGroups(); // 목록 새로고침 +}; +``` + +### 화면/카테고리 메뉴 UI 개선 + +**변경 사항:** +1. 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일) +2. 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거 +3. 폴더 메뉴에도 위로/아래로 이동 추가 + +**순서 변경 구현:** +```tsx +// 그룹 순서 변경 (display_order 교환) +const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { + const siblingGroups = groups + .filter((g) => g.parent_id === targetGroup.parent_id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex <= 0) return; + + const prevGroup = siblingGroups[currentIndex - 1]; + + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order }), + apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order }), + ]); + + loadGroups(); +}; + +// 화면 순서 변경 (screen_group_screens의 display_order 교환) +const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { + const targetGroup = groups.find((g) => g.id === groupId); + const sortedScreens = [...targetGroup.screens].sort((a, b) => a.display_order - b.display_order); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + + if (currentIndex <= 0) return; + + const currentLink = sortedScreens[currentIndex]; + const prevLink = sortedScreens[currentIndex - 1]; + + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order }), + apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order }), + ]); + + loadGroups(); +}; +``` + +### 카테고리 이동 모달 (서브메뉴 → 모달 방식) + +**문제:** 카테고리가 많아지면 서브메뉴 방식은 관리 어려움 + +**해결:** 검색 기능이 있는 모달로 변경 + +**구현:** +```tsx +// 이동 모달 상태 +const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); +const [movingScreen, setMovingScreen] = useState(null); +const [movingFromGroupId, setMovingFromGroupId] = useState(null); +const [moveSearchTerm, setMoveSearchTerm] = useState(""); + +// 필터링된 그룹 목록 +const filteredMoveGroups = useMemo(() => { + if (!moveSearchTerm) return flattenedGroups; + const searchLower = moveSearchTerm.toLowerCase(); + return flattenedGroups.filter((g) => + (g._displayName || g.group_name).toLowerCase().includes(searchLower) + ); +}, [flattenedGroups, moveSearchTerm]); + +// 모달 UI 특징: +// 1. 검색 입력창 (Search 아이콘 포함) +// 2. 트리 구조 표시 (depth에 따라 들여쓰기) +// 3. 현재 소속 그룹 표시 및 선택 불가 처리 +// 4. ScrollArea로 긴 목록 스크롤 지원 +``` + +**모달 구조:** +``` +┌─────────────────────────────┐ +│ 카테고리로 이동 │ +│ "화면명" 화면을 이동할... │ +├─────────────────────────────┤ +│ 🔍 카테고리 검색... │ +├─────────────────────────────┤ +│ 📁 POP 화면 │ +│ 📁 홈 관리 │ +│ 📁 출고관리 │ +│ 📁 수주관리 │ +│ 📁 생산 관리 (현재) │ +├─────────────────────────────┤ +│ [ 취소 ] │ +└─────────────────────────────┘ +``` + +--- + +## 트러블슈팅 + +### Export default doesn't exist in target module + +**문제:** `import apiClient from "@/lib/api/client"` 에러 + +**원인:** `apiClient`가 named export로 정의됨 + +**해결:** `import { apiClient } from "@/lib/api/client"` 사용 + +### 관련 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/components/pop/management/PopCategoryTree.tsx` | POP 카테고리 트리 (전체 UI) | +| `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 | +| `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 | +| `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 | + +--- + +*최종 업데이트: 2026-01-29* diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 69a63491..f8fb1c09 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2424,3 +2424,281 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res } }; +// ============================================================ +// POP 전용 화면 그룹 API +// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회 +// ============================================================ + +// POP 화면 그룹 목록 조회 (카테고리 트리용) +export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { searchTerm } = req.query; + + let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터링 (멀티테넌시) + if (companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터링 + if (searchTerm) { + whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${searchTerm}%`); + paramIndex++; + } + + // POP 그룹 조회 (계층 구조를 위해 전체 조회) + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + ${whereClause} + ORDER BY sg.display_order ASC, sg.hierarchy_path ASC + `; + + const result = await pool.query(dataQuery, params); + + logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length }); + + res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("POP 화면 그룹 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 생성 (hierarchy_path 자동 설정) +export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; + const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + // 회사 코드 결정 + const effectiveCompanyCode = target_company_code || userCompanyCode; + if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) { + return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." }); + } + + // hierarchy_path 계산 - POP 하위로 설정 + let hierarchyPath = "POP"; + if (parent_group_id) { + // 부모 그룹의 hierarchy_path 조회 + const parentResult = await pool.query( + `SELECT hierarchy_path FROM screen_groups WHERE id = $1`, + [parent_group_id] + ); + if (parentResult.rows.length > 0) { + hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`; + } + } else { + // 최상위 POP 카테고리 + hierarchyPath = `POP/${group_code}`; + } + + // 중복 체크 + const duplicateCheck = await pool.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [group_code, effectiveCompanyCode] + ); + if (duplicateCheck.rows.length > 0) { + return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." }); + } + + // 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, description, icon, display_order, + parent_group_id, hierarchy_path, company_code, writer, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y') + RETURNING * + `; + const insertParams = [ + group_name, + group_code, + description || null, + icon || null, + display_order || 0, + parent_group_id || null, + hierarchyPath, + effectiveCompanyCode, + userId, + ]; + + const result = await pool.query(insertQuery, insertParams); + + logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 생성 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 수정 +export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { group_name, description, icon, display_order, is_active } = req.body; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." }); + } + + // 업데이트 + const updateQuery = ` + UPDATE screen_groups + SET group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + icon = COALESCE($3, icon), + display_order = COALESCE($4, display_order), + is_active = COALESCE($5, is_active), + updated_date = NOW() + WHERE id = $6 + RETURNING * + `; + const updateParams = [group_name, description, icon, display_order, is_active, id]; + const result = await pool.query(updateQuery, updateParams); + + logger.info("POP 화면 그룹 수정", { groupId: id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 수정 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message }); + } +}; + +// POP 화면 그룹 삭제 +export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 기존 그룹 확인 + let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`; + const checkParams: any[] = [id]; + if (companyCode !== "*") { + checkQuery += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await pool.query(checkQuery, checkParams); + if (existing.rows.length === 0) { + return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + } + + // POP 그룹인지 확인 + if (!existing.rows[0].hierarchy_path?.startsWith("POP")) { + return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." }); + } + + // 하위 그룹 확인 + const childCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`, + [id] + ); + if (parseInt(childCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + } + + // 연결된 화면 확인 + const screenCheck = await pool.query( + `SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`, + [id] + ); + if (parseInt(screenCheck.rows[0].count) > 0) { + return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + } + + // 삭제 + await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]); + + logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode }); + + res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." }); + } catch (error: any) { + logger.error("POP 화면 그룹 삭제 실패:", error); + res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message }); + } +}; + +// POP 루트 그룹 확보 (없으면 자동 생성) +export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + + // POP 루트 그룹 확인 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 + `; + const existing = await pool.query(checkQuery, [companyCode]); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + } + + // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) + const insertQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, hierarchy_path, company_code, + description, display_order, is_active, writer + ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + RETURNING * + `; + const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + + res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("POP 루트 그룹 확보 실패:", error); + res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); + } +}; + diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index 614e6d61..86b97b31 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -36,6 +36,12 @@ import { syncMenuToScreenGroupsController, getSyncStatusController, syncAllCompaniesController, + // POP 전용 화면 그룹 + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, } from "../controllers/screenGroupController"; const router = Router(); @@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController); // 전체 회사 동기화 (최고 관리자만) router.post("/sync/all", syncAllCompaniesController); +// ============================================================ +// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%') +// ============================================================ +router.get("/pop/groups", getPopScreenGroups); +router.post("/pop/groups", createPopScreenGroup); +router.put("/pop/groups/:id", updatePopScreenGroup); +router.delete("/pop/groups/:id", deletePopScreenGroup); +router.post("/pop/ensure-root", ensurePopRootGroup); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 96ee11d2..7dfab16d 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4829,9 +4829,9 @@ export class ScreenManagementService { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // 버전 정보 추가 + // 버전 정보 추가 (프론트엔드 pop-1.0과 통일) const dataToSave = { - version: "2.0", + version: "pop-1.0", ...layoutData }; diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx index 6477800a..d9e289ca 100644 --- a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -4,39 +4,71 @@ import { useState, useEffect, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, RefreshCw, Search, LayoutGrid, LayoutList, Smartphone, Tablet, Eye } from "lucide-react"; +import { + Plus, + RefreshCw, + Search, + Smartphone, + Eye, + Settings, + LayoutGrid, + GitBranch, +} from "lucide-react"; import { PopDesigner } from "@/components/pop/designer"; -import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import CreateScreenModal from "@/components/screen/CreateScreenModal"; import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + PopCategoryTree, + PopScreenPreview, + PopScreenFlowView, + PopScreenSettingModal, +} from "@/components/pop/management"; +import { PopScreenGroup } from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ -// 단계별 진행을 위한 타입 정의 type Step = "list" | "design"; -type ViewMode = "tree" | "table"; type DevicePreview = "mobile" | "tablet"; +type RightPanelView = "preview" | "flow"; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ export default function PopScreenManagementPage() { const searchParams = useSearchParams(); + + // 단계 및 화면 상태 const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); - const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); - const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); - const [viewMode, setViewMode] = useState("tree"); + + // 화면 데이터 const [screens, setScreens] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [devicePreview, setDevicePreview] = useState("tablet"); - + // POP 레이아웃 존재 화면 ID const [popLayoutScreenIds, setPopLayoutScreenIds] = useState>(new Set()); - // 화면 목록 및 POP 레이아웃 존재 여부 로드 + // UI 상태 + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [devicePreview, setDevicePreview] = useState("tablet"); + const [rightPanelView, setRightPanelView] = useState("preview"); + + // ============================================================ + // 데이터 로드 + // ============================================================ + const loadScreens = useCallback(async () => { try { setLoading(true); @@ -44,7 +76,7 @@ export default function PopScreenManagementPage() { screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }), screenApi.getScreenIdsWithPopLayout(), ]); - + if (result.data && result.data.length > 0) { setScreens(result.data); } @@ -63,7 +95,7 @@ export default function PopScreenManagementPage() { // 화면 목록 새로고침 이벤트 리스너 useEffect(() => { const handleScreenListRefresh = () => { - console.log("🔄 POP 화면 목록 새로고침 이벤트 수신"); + console.log("POP 화면 목록 새로고침 이벤트 수신"); loadScreens(); }; @@ -87,16 +119,15 @@ export default function PopScreenManagementPage() { } }, [searchParams, screens]); - // 화면 설계 모드일 때는 전체 화면 사용 - const isDesignMode = currentStep === "design"; + // ============================================================ + // 핸들러 + // ============================================================ - // 다음 단계로 이동 const goToNextStep = (nextStep: Step) => { setStepHistory((prev) => [...prev, nextStep]); setCurrentStep(nextStep); }; - // 특정 단계로 이동 const goToStep = (step: Step) => { setCurrentStep(step); const stepIndex = stepHistory.findIndex((s) => s === step); @@ -105,13 +136,19 @@ export default function PopScreenManagementPage() { } }; - // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) + // 화면 선택 const handleScreenSelect = (screen: ScreenDefinition) => { setSelectedScreen(screen); setSelectedGroup(null); }; - // 화면 디자인 핸들러 + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup | null) => { + setSelectedGroup(group); + // 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지) + }; + + // 화면 디자인 모드 진입 const handleDesignScreen = (screen: ScreenDefinition) => { setSelectedScreen(screen); goToNextStep("design"); @@ -123,32 +160,43 @@ export default function PopScreenManagementPage() { window.open(previewUrl, "_blank", "width=800,height=900"); }; - // POP 화면만 필터링 (POP 레이아웃이 있는 화면만) - const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); - - // 검색어 필터링 - const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); - const filteredScreens = popScreens.filter((screen) => { - if (searchKeywords.length > 1) { - return true; // 폴더 계층 검색 시 화면 필터링 없음 + // 화면 설정 모달 열기 + const handleOpenSettings = () => { + if (selectedScreen) { + setIsSettingModalOpen(true); } + }; + + // ============================================================ + // 필터링된 데이터 + // ============================================================ + + // POP 레이아웃이 있는 화면만 필터링 + const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId)); + + // 검색어 필터링 + const filteredScreens = popScreens.filter((screen) => { if (!searchTerm) return true; return ( screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) ); }); - - // POP 화면 수 + const popScreenCount = popLayoutScreenIds.size; - // 화면 설계 모드일 때는 POP 전용 디자이너 사용 + // ============================================================ + // 디자인 모드 + // ============================================================ + + const isDesignMode = currentStep === "design"; + if (isDesignMode && selectedScreen) { return (
- goToStep("list")} + goToStep("list")} onScreenUpdate={(updatedFields) => { setSelectedScreen({ ...selectedScreen, @@ -160,6 +208,10 @@ export default function PopScreenManagementPage() { ); } + // ============================================================ + // 목록 모드 렌더링 + // ============================================================ + return (
{/* 페이지 헤더 */} @@ -173,37 +225,13 @@ export default function PopScreenManagementPage() { 모바일/태블릿
-

POP 화면을 그룹별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다

+

+ POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다 +

+
- {/* 디바이스 미리보기 선택 */} - setDevicePreview(v as DevicePreview)}> - - - - 모바일 - - - - 태블릿 - - - -
- {/* 뷰 모드 전환 */} - setViewMode(v as ViewMode)}> - - - - 트리 - - - - 테이블 - - - @@ -224,7 +252,8 @@ export default function PopScreenManagementPage() {

POP 화면이 없습니다

- 아직 생성된 POP 화면이 없습니다.
+ 아직 생성된 POP 화면이 없습니다. +
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.

- ) : viewMode === "tree" ? ( + ) : (
- {/* 왼쪽: POP 화면 목록 */} -
+ {/* 왼쪽: 카테고리 트리 + 화면 목록 */} +
{/* 검색 */}
@@ -247,7 +276,6 @@ export default function PopScreenManagementPage() { className="pl-9 h-9" />
- {/* POP 화면 수 표시 */}
POP 화면 @@ -255,132 +283,75 @@ export default function PopScreenManagementPage() {
- {/* POP 화면 리스트 */} -
- {filteredScreens.length === 0 ? ( -
- 검색 결과가 없습니다 -
- ) : ( -
- {filteredScreens.map((screen) => ( -
handleScreenSelect(screen)} - onDoubleClick={() => handleDesignScreen(screen)} - > -
-
{screen.screenName}
-
- {screen.screenCode} {screen.tableName && `| ${screen.tableName}`} -
-
-
- - -
-
- ))} + + {/* 카테고리 트리 */} + +
+ + {/* 오른쪽: 미리보기 / 화면 흐름 */} +
+ {/* 오른쪽 패널 헤더 */} +
+ setRightPanelView(v as RightPanelView)}> + + + + 미리보기 + + + + 화면 흐름 + + + + + {selectedScreen && ( +
+ + +
)}
-
- {/* 오른쪽: 관계 시각화 (React Flow) */} -
- -
-
- ) : ( - // 테이블 뷰 - POP 화면만 표시 -
-
- - - - - - - - - - - - {filteredScreens.map((screen) => ( - handleScreenSelect(screen)} - > - - - - - - - ))} - -
화면명화면코드테이블명생성일작업
{screen.screenName}{screen.screenCode}{screen.tableName || "-"} - {screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR") : "-"} - -
- - -
-
+ {/* 오른쪽 패널 콘텐츠 */} +
+ {rightPanelView === "preview" ? ( + + ) : ( + + )} +
)} @@ -399,6 +370,19 @@ export default function PopScreenManagementPage() { isPop={true} /> + {/* 화면 설정 모달 */} + { + if (selectedScreen) { + setSelectedScreen({ ...selectedScreen, ...updatedFields }); + } + loadScreens(); + }} + /> + {/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 31de64bd..daa2350d 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -78,18 +78,24 @@ function PopScreenViewPage() { setScreen(screenData); // POP 레이아웃 로드 (screen_layouts_pop 테이블에서) + // POP 레이아웃은 sections[] 구조 사용 (데스크톱의 components[]와 다름) try { const popLayout = await screenApi.getLayoutPop(screenId); - if (popLayout && popLayout.components && popLayout.components.length > 0) { - // POP 레이아웃이 있으면 사용 - console.log("POP 레이아웃 로드:", popLayout.components?.length || 0, "개 컴포넌트"); + if (popLayout && popLayout.sections && popLayout.sections.length > 0) { + // POP 레이아웃 (sections 구조) - 그대로 저장 + console.log("POP 레이아웃 로드:", popLayout.sections?.length || 0, "개 섹션"); + setLayout(popLayout as any); // sections 구조 그대로 사용 + } else if (popLayout && popLayout.components && popLayout.components.length > 0) { + // 이전 형식 (components 구조) - 호환성 유지 + console.log("POP 레이아웃 로드 (이전 형식):", popLayout.components?.length || 0, "개 컴포넌트"); setLayout(popLayout as LayoutData); } else { // POP 레이아웃이 비어있으면 빈 레이아웃 console.log("POP 레이아웃 없음, 빈 화면 표시"); setLayout({ screenId, + sections: [], components: [], gridSettings: { columns: 12, @@ -101,12 +107,13 @@ function PopScreenViewPage() { opacity: 0.5, snapToGrid: true, }, - }); + } as any); } } catch (layoutError) { console.warn("POP 레이아웃 로드 실패:", layoutError); setLayout({ screenId, + sections: [], components: [], gridSettings: { columns: 12, @@ -118,7 +125,7 @@ function PopScreenViewPage() { opacity: 0.5, snapToGrid: true, }, - }); + } as any); } } catch (error) { console.error("POP 화면 로드 실패:", error); @@ -222,8 +229,58 @@ function PopScreenViewPage() { maxWidth: isPreviewMode ? currentDevice.width : "100%", }} > - {/* 화면 컨텐츠 */} - {layout && layout.components && layout.components.length > 0 ? ( + {/* POP 레이아웃: sections 구조 렌더링 */} + {layout && (layout as any).sections && (layout as any).sections.length > 0 ? ( +
+ {/* 그리드 레이아웃으로 섹션 배치 */} +
+ {(layout as any).sections.map((section: any) => ( +
+ {/* 섹션 라벨 */} + {section.label && ( +
+ {section.label} +
+ )} + {/* 섹션 내 컴포넌트들 */} + {section.components && section.components.length > 0 ? ( +
+ {section.components.map((comp: any) => ( +
+ {/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */} + + {comp.label || comp.type || comp.id} + +
+ ))} +
+ ) : ( +
+ 빈 섹션 +
+ )} +
+ ))} +
+
+ ) : layout && layout.components && layout.components.length > 0 ? ( + // 이전 형식 (components 구조) - 호환성 유지
{layout.components diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 62499544..2d6df485 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -63,24 +63,31 @@ export default function PopDesigner({ : null; // 레이아웃 로드 + // API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터 useEffect(() => { const loadLayout = async () => { if (!selectedScreen?.screenId) return; setIsLoading(true); try { - const response = await screenApi.getLayoutPop(selectedScreen.screenId); + // API가 layout_data 내용을 직접 반환함 (언래핑된 상태) + const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); - if (response && response.layout_data) { - const loadedLayout = response.layout_data as PopLayoutData; - - if (loadedLayout.version === "pop-1.0") { - setLayout(loadedLayout); - } else { - console.warn("레이아웃 버전 불일치, 새 레이아웃 생성"); - setLayout(createEmptyPopLayout()); - } + if (loadedLayout && loadedLayout.version === "pop-1.0") { + // 유효한 POP 레이아웃 + setLayout(loadedLayout as PopLayoutData); + console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션"); + } else if (loadedLayout && loadedLayout.sections) { + // 버전 태그 없지만 sections 구조가 있으면 사용 + console.warn("버전 태그 없음, sections 구조 감지하여 사용"); + setLayout({ + ...createEmptyPopLayout(), + ...loadedLayout, + version: "pop-1.0", + } as PopLayoutData); } else { + // 레이아웃 없음 - 빈 레이아웃 생성 + console.log("POP 레이아웃 없음, 빈 레이아웃 생성"); setLayout(createEmptyPopLayout()); } } catch (error) { diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx new file mode 100644 index 00000000..d7562fd0 --- /dev/null +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -0,0 +1,1095 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { + ChevronRight, + ChevronDown, + ChevronUp, + Folder, + FolderOpen, + Monitor, + Plus, + MoreVertical, + Edit, + Trash2, + Loader2, + RefreshCw, + FolderPlus, + MoveRight, + ArrowUp, + ArrowDown, + Search, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { ScreenDefinition } from "@/types/screen"; +import { apiClient } from "@/lib/api/client"; +import { + PopScreenGroup, + getPopScreenGroups, + createPopScreenGroup, + updatePopScreenGroup, + deletePopScreenGroup, + ensurePopRootGroup, + buildPopGroupTree, +} from "@/lib/api/popScreenGroup"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface PopCategoryTreeProps { + screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록 + selectedScreen: ScreenDefinition | null; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + onGroupSelect?: (group: PopScreenGroup | null) => void; + searchTerm?: string; +} + +interface TreeNodeProps { + group: PopScreenGroup; + level: number; + expandedGroups: Set; + onToggle: (groupId: number) => void; + selectedGroupId: number | null; + selectedScreenId: number | null; + onGroupSelect: (group: PopScreenGroup) => void; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + onEditGroup: (group: PopScreenGroup) => void; + onDeleteGroup: (group: PopScreenGroup) => void; + onAddSubGroup: (parentGroup: PopScreenGroup) => void; + screensMap: Map; + // 화면 이동/삭제 관련 + onOpenMoveModal: (screen: ScreenDefinition, fromGroupId: number | null) => void; + onRemoveScreenFromGroup: (screen: ScreenDefinition, groupId: number) => void; + // 순서 변경 관련 + siblingGroups: PopScreenGroup[]; // 같은 레벨의 그룹들 + onMoveGroupUp: (group: PopScreenGroup) => void; + onMoveGroupDown: (group: PopScreenGroup) => void; + onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; + onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; +} + +// ============================================================ +// 트리 노드 컴포넌트 +// ============================================================ + +function TreeNode({ + group, + level, + onOpenMoveModal, + onRemoveScreenFromGroup, + siblingGroups, + onMoveGroupUp, + onMoveGroupDown, + onMoveScreenUp, + onMoveScreenDown, + expandedGroups, + onToggle, + selectedGroupId, + selectedScreenId, + onGroupSelect, + onScreenSelect, + onScreenDesign, + onEditGroup, + onDeleteGroup, + onAddSubGroup, + screensMap, +}: TreeNodeProps) { + const isExpanded = expandedGroups.has(group.id); + const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0); + const isSelected = selectedGroupId === group.id; + + // 그룹에 연결된 화면 목록 + const groupScreens = useMemo(() => { + if (!group.screens) return []; + return group.screens + .map((gs) => screensMap.get(gs.screen_id)) + .filter((s): s is ScreenDefinition => s !== undefined); + }, [group.screens, screensMap]); + + // 루트 레벨(POP 화면)인지 확인 + const isRootLevel = level === 0; + + // 그룹 순서 변경 가능 여부 계산 + const groupIndex = siblingGroups.findIndex((g) => g.id === group.id); + const canMoveGroupUp = groupIndex > 0; + const canMoveGroupDown = groupIndex < siblingGroups.length - 1; + + return ( +
+ {/* 그룹 노드 */} +
onGroupSelect(group)} + > + {/* 트리 연결 표시 (하위 레벨만) */} + {level > 0 && ( + + )} + + {/* 확장/축소 버튼 */} + + + {/* 폴더 아이콘 - 루트는 다른 색상 */} + {isExpanded && hasChildren ? ( + + ) : ( + + )} + + {/* 그룹명 - 루트는 볼드체 */} + {group.group_name} + + {/* 화면 수 배지 */} + {group.screen_count && group.screen_count > 0 && ( + + {group.screen_count} + + )} + + {/* 더보기 메뉴 */} + + + + + + onAddSubGroup(group)}> + + 하위 그룹 추가 + + onEditGroup(group)}> + + 수정 + + + onMoveGroupUp(group)} + disabled={!canMoveGroupUp} + > + + 위로 이동 + + onMoveGroupDown(group)} + disabled={!canMoveGroupDown} + > + + 아래로 이동 + + + onDeleteGroup(group)} + > + + 삭제 + + + +
+ + {/* 확장된 경우 하위 요소 렌더링 */} + {isExpanded && ( + <> + {/* 하위 그룹 */} + {group.children?.map((child) => ( + + ))} + + {/* 그룹에 연결된 화면 */} + {groupScreens.map((screen, screenIndex) => { + const canMoveScreenUp = screenIndex > 0; + const canMoveScreenDown = screenIndex < groupScreens.length - 1; + + return ( +
onScreenSelect(screen)} + onDoubleClick={() => onScreenDesign(screen)} + > + {/* 트리 연결 표시 */} + + + {screen.screenName} + + {/* 더보기 메뉴 (폴더와 동일한 스타일) */} + + + + + + onScreenDesign(screen)}> + + 설계 + + + onMoveScreenUp(screen, group.id)} + disabled={!canMoveScreenUp} + > + + 위로 이동 + + onMoveScreenDown(screen, group.id)} + disabled={!canMoveScreenDown} + > + + 아래로 이동 + + + onOpenMoveModal(screen, group.id)}> + + 다른 카테고리로 이동 + + + onRemoveScreenFromGroup(screen, group.id)} + > + + 그룹에서 제거 + + + +
+ ); + })} + + )} +
+ ); +} + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopCategoryTree({ + screens, + selectedScreen, + onScreenSelect, + onScreenDesign, + onGroupSelect, + searchTerm = "", +}: PopCategoryTreeProps) { + // 상태 관리 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + // 그룹 모달 상태 + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [parentGroupId, setParentGroupId] = useState(null); + const [groupFormData, setGroupFormData] = useState({ + group_name: "", + group_code: "", + description: "", + icon: "", + }); + + // 삭제 다이얼로그 상태 + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(null); + + // 이동 모달 상태 + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [movingScreen, setMovingScreen] = useState(null); + const [movingFromGroupId, setMovingFromGroupId] = useState(null); + const [moveSearchTerm, setMoveSearchTerm] = useState(""); + + // 화면 맵 생성 (screen_id로 빠르게 조회) + const screensMap = useMemo(() => { + const map = new Map(); + screens.forEach((s) => map.set(s.screenId, s)); + return map; + }, [screens]); + + // 그룹 데이터 로드 + const loadGroups = useCallback(async () => { + try { + setLoading(true); + + // 먼저 POP 루트 그룹 확보 + await ensurePopRootGroup(); + + // 그룹 목록 조회 + const data = await getPopScreenGroups(searchTerm); + setGroups(data); + + // 첫 로드 시 루트 그룹들 자동 확장 + if (expandedGroups.size === 0 && data.length > 0) { + const rootIds = data + .filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2) + .map((g) => g.id); + setExpandedGroups(new Set(rootIds)); + } + } catch (error) { + console.error("POP 그룹 로드 실패:", error); + toast.error("그룹 목록 로드에 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchTerm]); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); + + // 트리 구조로 변환 + const treeData = useMemo(() => buildPopGroupTree(groups), [groups]); + + // 그룹 토글 + const handleToggle = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 그룹 선택 + const handleGroupSelect = (group: PopScreenGroup) => { + setSelectedGroupId(group.id); + onGroupSelect?.(group); + }; + + // 그룹 생성/수정 모달 열기 + const openGroupModal = (parentGroup?: PopScreenGroup, editGroup?: PopScreenGroup) => { + if (editGroup) { + setEditingGroup(editGroup); + setParentGroupId(editGroup.parent_group_id || null); + setGroupFormData({ + group_name: editGroup.group_name, + group_code: editGroup.group_code, + description: editGroup.description || "", + icon: editGroup.icon || "", + }); + } else { + setEditingGroup(null); + setParentGroupId(parentGroup?.id || null); + setGroupFormData({ + group_name: "", + group_code: "", + description: "", + icon: "", + }); + } + setIsGroupModalOpen(true); + }; + + // 그룹 저장 + const handleSaveGroup = async () => { + if (!groupFormData.group_name || !groupFormData.group_code) { + toast.error("그룹명과 그룹코드는 필수입니다."); + return; + } + + try { + if (editingGroup) { + // 수정 + const result = await updatePopScreenGroup(editingGroup.id, { + group_name: groupFormData.group_name, + description: groupFormData.description, + icon: groupFormData.icon, + }); + if (result.success) { + toast.success("그룹이 수정되었습니다."); + loadGroups(); + } else { + toast.error(result.message || "수정에 실패했습니다."); + } + } else { + // 생성 + const result = await createPopScreenGroup({ + group_name: groupFormData.group_name, + group_code: groupFormData.group_code, + description: groupFormData.description, + icon: groupFormData.icon, + parent_group_id: parentGroupId, + }); + if (result.success) { + toast.success("그룹이 생성되었습니다."); + loadGroups(); + } else { + toast.error(result.message || "생성에 실패했습니다."); + } + } + setIsGroupModalOpen(false); + } catch (error) { + console.error("그룹 저장 실패:", error); + toast.error("그룹 저장에 실패했습니다."); + } + }; + + // 그룹 삭제 + const handleDeleteGroup = async () => { + if (!deletingGroup) return; + + try { + const result = await deletePopScreenGroup(deletingGroup.id); + if (result.success) { + toast.success("그룹이 삭제되었습니다."); + loadGroups(); + if (selectedGroupId === deletingGroup.id) { + setSelectedGroupId(null); + onGroupSelect?.(null); + } + } else { + toast.error(result.message || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("그룹 삭제 실패:", error); + toast.error("그룹 삭제에 실패했습니다."); + } finally { + setIsDeleteDialogOpen(false); + setDeletingGroup(null); + } + }; + + // 화면을 그룹으로 이동 (기존 연결 삭제 후 새 연결 추가) + const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { + try { + // 1. 기존 연결 정보 찾기 (모든 그룹에서 해당 화면의 연결 찾기) + let existingLinkId: number | null = null; + for (const g of groups) { + const screenLink = g.screens?.find((s) => s.screen_id === screen.screenId); + if (screenLink) { + existingLinkId = screenLink.id; + break; + } + } + + // 2. 기존 연결이 있으면 삭제 + if (existingLinkId) { + await apiClient.delete(`/screen-groups/group-screens/${existingLinkId}`); + } + + // 3. 새 그룹에 연결 추가 + const response = await apiClient.post("/screen-groups/group-screens", { + group_id: targetGroup.id, + screen_id: screen.screenId, + screen_role: "main", + display_order: 0, + is_default: false, + }); + + if (response.data.success) { + toast.success(`"${screen.screenName}"을(를) "${(targetGroup as any)._displayName || targetGroup.group_name}"으로 이동했습니다.`); + loadGroups(); // 그룹 목록 새로고침 + } else { + throw new Error(response.data.message || "이동 실패"); + } + } catch (error: any) { + console.error("화면 이동 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 이동에 실패했습니다."); + } + }; + + // 그룹에서 화면 제거 + const handleRemoveScreenFromGroup = async (screen: ScreenDefinition, groupId: number) => { + try { + // 해당 그룹에서 화면 연결 정보 찾기 + const targetGroup = groups.find((g) => g.id === groupId); + const screenLink = targetGroup?.screens?.find((s) => s.screen_id === screen.screenId); + + if (!screenLink) { + toast.error("연결 정보를 찾을 수 없습니다."); + return; + } + + await apiClient.delete(`/screen-groups/group-screens/${screenLink.id}`); + toast.success(`"${screen.screenName}"을(를) 그룹에서 제거했습니다.`); + loadGroups(); + } catch (error: any) { + console.error("화면 제거 실패:", error); + toast.error(error.response?.data?.message || error.message || "화면 제거에 실패했습니다."); + } + }; + + // 그룹 순서 위로 이동 + const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { + try { + // 같은 부모의 형제 그룹들 찾기 + const parentId = targetGroup.parent_id; + const siblingGroups = groups + .filter((g) => g.parent_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex <= 0) return; + + const prevGroup = siblingGroups[currentIndex - 1]; + + // 두 그룹의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { + display_order: prevGroup.display_order || currentIndex - 1 + }), + apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { + display_order: targetGroup.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("그룹 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 그룹 순서 아래로 이동 + const handleMoveGroupDown = async (targetGroup: PopScreenGroup) => { + try { + // 같은 부모의 형제 그룹들 찾기 + const parentId = targetGroup.parent_id; + const siblingGroups = groups + .filter((g) => g.parent_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); + if (currentIndex >= siblingGroups.length - 1) return; + + const nextGroup = siblingGroups[currentIndex + 1]; + + // 두 그룹의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { + display_order: nextGroup.display_order || currentIndex + 1 + }), + apiClient.put(`/screen-groups/groups/${nextGroup.id}`, { + display_order: targetGroup.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("그룹 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 화면 순서 위로 이동 + const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { + try { + const targetGroup = groups.find((g) => g.id === groupId); + if (!targetGroup?.screens) return; + + const sortedScreens = [...targetGroup.screens].sort( + (a, b) => (a.display_order || 0) - (b.display_order || 0) + ); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + if (currentIndex <= 0) return; + + const currentLink = sortedScreens[currentIndex]; + const prevLink = sortedScreens[currentIndex - 1]; + + // 두 화면의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { + display_order: prevLink.display_order || currentIndex - 1 + }), + apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { + display_order: currentLink.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("화면 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 화면 순서 아래로 이동 + const handleMoveScreenDown = async (screen: ScreenDefinition, groupId: number) => { + try { + const targetGroup = groups.find((g) => g.id === groupId); + if (!targetGroup?.screens) return; + + const sortedScreens = [...targetGroup.screens].sort( + (a, b) => (a.display_order || 0) - (b.display_order || 0) + ); + const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); + if (currentIndex >= sortedScreens.length - 1) return; + + const currentLink = sortedScreens[currentIndex]; + const nextLink = sortedScreens[currentIndex + 1]; + + // 두 화면의 display_order 교환 + await Promise.all([ + apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { + display_order: nextLink.display_order || currentIndex + 1 + }), + apiClient.put(`/screen-groups/group-screens/${nextLink.id}`, { + display_order: currentLink.display_order || currentIndex + }), + ]); + + loadGroups(); + } catch (error: any) { + console.error("화면 순서 변경 실패:", error); + toast.error("순서 변경에 실패했습니다."); + } + }; + + // 미분류 화면 (그룹에 연결되지 않은 화면) + const ungroupedScreens = useMemo(() => { + const groupedScreenIds = new Set(); + groups.forEach((g) => { + g.screens?.forEach((gs) => groupedScreenIds.add(gs.screen_id)); + }); + return screens.filter((s) => !groupedScreenIds.has(s.screenId)); + }, [groups, screens]); + + // 전체 그룹 평탄화 (이동 드롭다운용) + const flattenedGroups = useMemo(() => { + const result: PopScreenGroup[] = []; + const flatten = (groups: PopScreenGroup[], parentName?: string) => { + groups.forEach((g) => { + // 표시 이름에 부모 경로 추가 + const displayGroup = { + ...g, + _displayName: parentName ? `${parentName} > ${g.group_name}` : g.group_name + }; + result.push(displayGroup); + if (g.children && g.children.length > 0) { + flatten(g.children, displayGroup._displayName); + } + }); + }; + flatten(treeData); + return result; + }, [treeData]); + + // 이동 모달 열기 + const openMoveModal = (screen: ScreenDefinition, fromGroupId: number | null) => { + setMovingScreen(screen); + setMovingFromGroupId(fromGroupId); + setMoveSearchTerm(""); + setIsMoveModalOpen(true); + }; + + // 이동 모달에서 그룹 선택 처리 + const handleMoveToSelectedGroup = async (targetGroup: PopScreenGroup) => { + if (!movingScreen) return; + + await handleMoveScreenToGroup(movingScreen, targetGroup); + setIsMoveModalOpen(false); + setMovingScreen(null); + setMovingFromGroupId(null); + }; + + // 이동 모달용 필터링된 그룹 목록 + const filteredMoveGroups = useMemo(() => { + if (!moveSearchTerm) return flattenedGroups; + const searchLower = moveSearchTerm.toLowerCase(); + return flattenedGroups.filter((g: any) => + (g._displayName || g.group_name).toLowerCase().includes(searchLower) + ); + }, [flattenedGroups, moveSearchTerm]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

POP 카테고리

+
+ + +
+
+ + {/* 트리 영역 */} + +
+ {treeData.length === 0 && ungroupedScreens.length === 0 ? ( +
+ 카테고리가 없습니다. +
+ +
+ ) : ( + <> + {/* 트리 렌더링 */} + {treeData.map((group) => ( + openGroupModal(undefined, g)} + onDeleteGroup={(g) => { + setDeletingGroup(g); + setIsDeleteDialogOpen(true); + }} + onAddSubGroup={(g) => openGroupModal(g)} + screensMap={screensMap} + onOpenMoveModal={openMoveModal} + onRemoveScreenFromGroup={handleRemoveScreenFromGroup} + siblingGroups={treeData} + onMoveGroupUp={handleMoveGroupUp} + onMoveGroupDown={handleMoveGroupDown} + onMoveScreenUp={handleMoveScreenUp} + onMoveScreenDown={handleMoveScreenDown} + /> + ))} + + {/* 미분류 화면 */} + {ungroupedScreens.length > 0 && ( +
+
+ 미분류 ({ungroupedScreens.length}) +
+ {ungroupedScreens.map((screen) => ( +
onScreenSelect(screen)} + onDoubleClick={() => onScreenDesign(screen)} + > + + {screen.screenName} + + {/* 더보기 메뉴 */} + + + + + + onScreenDesign(screen)}> + + 설계 + + + openMoveModal(screen, null)}> + + 카테고리로 이동 + + + +
+ ))} +
+ )} + + )} +
+
+ + {/* 그룹 생성/수정 모달 */} + + + + + {editingGroup ? "카테고리 수정" : "새 카테고리"} + + + {editingGroup ? "카테고리 정보를 수정합니다." : "POP 화면을 분류할 카테고리를 추가합니다."} + + + +
+
+ + setGroupFormData((prev) => ({ ...prev, group_name: e.target.value }))} + placeholder="예: 생산관리" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {!editingGroup && ( +
+ + setGroupFormData((prev) => ({ ...prev, group_code: e.target.value.toUpperCase() }))} + placeholder="예: PRODUCTION" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 영문 대문자와 밑줄만 사용 가능합니다. +

+
+ )} + +
+ + setGroupFormData((prev) => ({ ...prev, description: e.target.value }))} + placeholder="카테고리에 대한 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + + + +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 카테고리 삭제 + + "{deletingGroup?.group_name}" 카테고리를 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 화면 이동 모달 */} + + + + + 카테고리로 이동 + + + "{movingScreen?.screenName}" 화면을 이동할 카테고리를 선택하세요. + + + + {/* 검색 입력 */} +
+ + setMoveSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ + {/* 카테고리 트리 목록 */} + +
+ {filteredMoveGroups.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredMoveGroups.map((group: any) => { + const isCurrentGroup = group.id === movingFromGroupId; + const displayName = group._displayName || group.group_name; + const depth = (displayName.match(/>/g) || []).length; + + return ( + + ); + }) + )} +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenFlowView.tsx b/frontend/components/pop/management/PopScreenFlowView.tsx new file mode 100644 index 00000000..4b01076e --- /dev/null +++ b/frontend/components/pop/management/PopScreenFlowView.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + ReactFlow, + Node, + Edge, + Position, + MarkerType, + Background, + Controls, + MiniMap, + useNodesState, + useEdgesState, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { cn } from "@/lib/utils"; +import { Monitor, Layers, ArrowRight, Loader2 } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface PopScreenFlowViewProps { + screen: ScreenDefinition | null; + className?: string; + onSubScreenSelect?: (subScreenId: string) => void; +} + +interface PopLayoutData { + version?: string; + sections?: any[]; + mainScreen?: { + id: string; + name: string; + }; + subScreens?: SubScreen[]; + flow?: FlowConnection[]; +} + +interface SubScreen { + id: string; + name: string; + type: "modal" | "drawer" | "fullscreen"; + triggerFrom?: string; // 어느 화면/버튼에서 트리거되는지 +} + +interface FlowConnection { + from: string; + to: string; + trigger?: string; + label?: string; +} + +// ============================================================ +// 커스텀 노드 컴포넌트 +// ============================================================ + +interface ScreenNodeData { + label: string; + type: "main" | "modal" | "drawer" | "fullscreen"; + isMain?: boolean; +} + +function ScreenNode({ data }: { data: ScreenNodeData }) { + const isMain = data.type === "main" || data.isMain; + + return ( +
+
+ {isMain ? ( + + ) : ( + + )} + + {isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"} + +
+
{data.label}
+
+ ); +} + +const nodeTypes = { + screenNode: ScreenNode, +}; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) { + const [loading, setLoading] = useState(false); + const [layoutData, setLayoutData] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // 레이아웃 데이터 로드 + useEffect(() => { + if (!screen) { + setLayoutData(null); + setNodes([]); + setEdges([]); + return; + } + + const loadLayout = async () => { + try { + setLoading(true); + const layout = await screenApi.getLayoutPop(screen.screenId); + + if (layout && layout.version === "pop-1.0") { + setLayoutData(layout); + } else { + setLayoutData(null); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + setLayoutData(null); + } finally { + setLoading(false); + } + }; + + loadLayout(); + }, [screen]); + + // 레이아웃 데이터에서 노드/엣지 생성 + useEffect(() => { + if (!layoutData || !screen) { + return; + } + + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + + // 메인 화면 노드 + const mainNodeId = "main"; + newNodes.push({ + id: mainNodeId, + type: "screenNode", + position: { x: 50, y: 100 }, + data: { + label: screen.screenName, + type: "main", + isMain: true, + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + + // 하위 화면 노드들 + const subScreens = layoutData.subScreens || []; + const horizontalGap = 200; + const verticalGap = 100; + + subScreens.forEach((subScreen, index) => { + // 세로로 나열, 여러 개일 경우 열 분리 + const col = Math.floor(index / 3); + const row = index % 3; + + newNodes.push({ + id: subScreen.id, + type: "screenNode", + position: { + x: 300 + col * horizontalGap, + y: 50 + row * verticalGap, + }, + data: { + label: subScreen.name, + type: subScreen.type || "modal", + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }); + }); + + // 플로우 연결 (flow 배열 또는 triggerFrom 기반) + const flows = layoutData.flow || []; + + if (flows.length > 0) { + // 명시적 flow 배열 사용 + flows.forEach((flow, index) => { + newEdges.push({ + id: `edge-${index}`, + source: flow.from, + target: flow.to, + type: "smoothstep", + animated: true, + label: flow.label || flow.trigger, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#888", + }, + style: { stroke: "#888", strokeWidth: 2 }, + }); + }); + } else { + // triggerFrom 기반으로 엣지 생성 (기본: 메인 → 서브) + subScreens.forEach((subScreen, index) => { + const sourceId = subScreen.triggerFrom || mainNodeId; + newEdges.push({ + id: `edge-${index}`, + source: sourceId, + target: subScreen.id, + type: "smoothstep", + animated: true, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#888", + }, + style: { stroke: "#888", strokeWidth: 2 }, + }); + }); + } + + setNodes(newNodes); + setEdges(newEdges); + }, [layoutData, screen, setNodes, setEdges]); + + // 노드 클릭 핸들러 + const onNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + if (node.id !== "main" && onSubScreenSelect) { + onSubScreenSelect(node.id); + } + }, + [onSubScreenSelect] + ); + + // 레이아웃 또는 하위 화면이 없는 경우 + const hasSubScreens = layoutData?.subScreens && layoutData.subScreens.length > 0; + + if (!screen) { + return ( +
+
+

화면 흐름

+
+
+
+ +

화면을 선택하면 흐름이 표시됩니다.

+
+
+
+ ); + } + + if (loading) { + return ( +
+
+

화면 흐름

+
+
+ +
+
+ ); + } + + if (!layoutData) { + return ( +
+
+

화면 흐름

+
+
+
+ +

POP 레이아웃이 없습니다.

+
+
+
+ ); + } + + return ( +
+
+
+

화면 흐름

+ + {screen.screenName} + +
+ {!hasSubScreens && ( + + 하위 화면 없음 + + )} +
+ +
+ {hasSubScreens ? ( + + + + (node.data?.isMain ? "#3b82f6" : "#9ca3af")} + maskColor="rgba(0, 0, 0, 0.1)" + className="!bg-muted/50" + /> + + ) : ( + // 하위 화면이 없으면 간단한 단일 노드 표시 +
+
+
+ + {screen.screenName} +
+

+ 이 화면에 연결된 하위 화면(모달)이 없습니다. +
+ 화면 설정에서 하위 화면을 추가할 수 있습니다. +

+
+
+ )} +
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenPreview.tsx b/frontend/components/pop/management/PopScreenPreview.tsx new file mode 100644 index 00000000..b6b95fce --- /dev/null +++ b/frontend/components/pop/management/PopScreenPreview.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { Smartphone, Tablet, Loader2, ExternalLink, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +type DeviceType = "mobile" | "tablet"; + +interface PopScreenPreviewProps { + screen: ScreenDefinition | null; + className?: string; +} + +// 디바이스 프레임 크기 +const DEVICE_SIZES = { + mobile: { width: 375, height: 667 }, // iPhone SE 기준 + tablet: { width: 768, height: 1024 }, // iPad 기준 +}; + +// ============================================================ +// 메인 컴포넌트 +// ============================================================ + +export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) { + const [deviceType, setDeviceType] = useState("tablet"); + const [loading, setLoading] = useState(false); + const [hasLayout, setHasLayout] = useState(false); + const [key, setKey] = useState(0); // iframe 새로고침용 + + // 레이아웃 존재 여부 확인 + useEffect(() => { + if (!screen) { + setHasLayout(false); + return; + } + + const checkLayout = async () => { + try { + setLoading(true); + const layout = await screenApi.getLayoutPop(screen.screenId); + setHasLayout(layout && layout.sections && layout.sections.length > 0); + } catch { + setHasLayout(false); + } finally { + setLoading(false); + } + }; + + checkLayout(); + }, [screen]); + + // 미리보기 URL + const previewUrl = screen ? `/pop/screens/${screen.screenId}?preview=true&device=${deviceType}` : null; + + // 새 탭에서 열기 + const openInNewTab = () => { + if (previewUrl) { + const size = DEVICE_SIZES[deviceType]; + window.open(previewUrl, "_blank", `width=${size.width + 40},height=${size.height + 80}`); + } + }; + + // iframe 새로고침 + const refreshPreview = () => { + setKey((prev) => prev + 1); + }; + + const deviceSize = DEVICE_SIZES[deviceType]; + // 미리보기 컨테이너에 맞게 스케일 조정 + const scale = deviceType === "tablet" ? 0.5 : 0.6; + + return ( +
+ {/* 헤더 */} +
+
+

미리보기

+ {screen && ( + + {screen.screenName} + + )} +
+ +
+ {/* 디바이스 선택 */} + setDeviceType(v as DeviceType)}> + + + + + + + + + + + {screen && hasLayout && ( + <> + + + + )} +
+
+ + {/* 미리보기 영역 */} +
+ {!screen ? ( + // 화면 미선택 +
+
+ {deviceType === "mobile" ? ( + + ) : ( + + )} +
+

화면을 선택하면 미리보기가 표시됩니다.

+
+ ) : loading ? ( + // 로딩 중 +
+ +

레이아웃 확인 중...

+
+ ) : !hasLayout ? ( + // 레이아웃 없음 +
+
+ {deviceType === "mobile" ? ( + + ) : ( + + )} +
+

POP 레이아웃이 없습니다.

+

+ 화면을 더블클릭하여 설계 모드로 이동하세요. +

+
+ ) : ( + // 디바이스 프레임 + iframe +
+ {/* 디바이스 노치 (모바일) */} + {deviceType === "mobile" && ( +
+ )} + + {/* 디바이스 홈 버튼 (태블릿) */} + {deviceType === "tablet" && ( +
+ )} + + {/* iframe 컨테이너 */} +
+