feat(pop): POP 화면 카테고리 관리 시스템 구현 및 저장/로드 안정화

POP 전용 카테고리 트리 UI 구현 (계층적 폴더 구조)
카테고리 CRUD API 추가 (hierarchy_path LIKE 'POP/%' 필터)
화면 이동 기능 (기존 연결 삭제 후 새 연결 추가 방식)
카테고리/화면 순서 변경 기능 (display_order 교환)
이동 UI를 서브메뉴에서 검색 가능한 모달로 개선
POP 레이아웃 버전 통일 (pop-1.0) 및 로드 로직 수정
DB 스키마 호환성 수정 (writer 컬럼, is_active VARCHAR)
This commit is contained in:
SeongHyun Kim 2026-02-02 18:01:05 +09:00
parent 8c045acab3
commit d9b7ef9ad4
13 changed files with 3104 additions and 209 deletions

View File

@ -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 && (
<span className="text-muted-foreground/50 text-xs mr-1"></span>
)}
// 루트와 하위 폴더 시각적 구분
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
```
### 미분류 화면 이동 기능 추가
**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴
**구현:**
```tsx
// 이동 드롭다운 메뉴
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoveRight className="h-3 w-3 mr-1" />
이동
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{treeData.map((g) => (
<DropdownMenuItem onClick={() => handleMoveScreenToGroup(screen, g)}>
<Folder className="h-4 w-4 mr-2" />
{g.group_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
// 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<ScreenDefinition | null>(null);
const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(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*

View File

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

View File

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

View File

@ -4829,9 +4829,9 @@ export class ScreenManagementService {
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가
// 버전 정보 추가 (프론트엔드 pop-1.0과 통일)
const dataToSave = {
version: "2.0",
version: "pop-1.0",
...layoutData
};

View File

@ -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<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [selectedGroup, setSelectedGroup] = useState<PopScreenGroup | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
// 화면 데이터
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
// POP 레이아웃 존재 화면 ID
const [popLayoutScreenIds, setPopLayoutScreenIds] = useState<Set<number>>(new Set());
// 화면 목록 및 POP 레이아웃 존재 여부 로드
// UI 상태
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("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 (
<div className="fixed inset-0 z-50 bg-background">
<PopDesigner
selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")}
<PopDesigner
selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")}
onScreenUpdate={(updatedFields) => {
setSelectedScreen({
...selectedScreen,
@ -160,6 +208,10 @@ export default function PopScreenManagementPage() {
);
}
// ============================================================
// 목록 모드 렌더링
// ============================================================
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
@ -173,37 +225,13 @@ export default function PopScreenManagementPage() {
/릿
</Badge>
</div>
<p className="text-sm text-muted-foreground">POP /릿 </p>
<p className="text-sm text-muted-foreground">
POP /릿
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* 디바이스 미리보기 선택 */}
<Tabs value={devicePreview} onValueChange={(v) => setDevicePreview(v as DevicePreview)}>
<TabsList className="h-9">
<TabsTrigger value="mobile" className="gap-1.5 px-3">
<Smartphone className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="tablet" className="gap-1.5 px-3">
<Tablet className="h-4 w-4" />
릿
</TabsTrigger>
</TabsList>
</Tabs>
<div className="w-px h-6 bg-border mx-1" />
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
@ -224,7 +252,8 @@ export default function PopScreenManagementPage() {
</div>
<h3 className="text-lg font-semibold mb-2">POP </h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
POP .<br />
POP .
<br />
"새 POP 화면" /릿 .
</p>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
@ -232,10 +261,10 @@ export default function PopScreenManagementPage() {
POP
</Button>
</div>
) : viewMode === "tree" ? (
) : (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: POP 화면 목록 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="shrink-0 p-3 border-b">
<div className="relative">
@ -247,7 +276,6 @@ export default function PopScreenManagementPage() {
className="pl-9 h-9"
/>
</div>
{/* POP 화면 수 표시 */}
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">POP </span>
<Badge variant="outline" className="text-xs">
@ -255,132 +283,75 @@ export default function PopScreenManagementPage() {
</Badge>
</div>
</div>
{/* POP 화면 리스트 */}
<div className="flex-1 overflow-auto p-2">
{filteredScreens.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8">
</div>
) : (
<div className="space-y-1">
{filteredScreens.map((screen) => (
<div
key={screen.screenId}
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors ${
selectedScreen?.screenId === screen.screenId
? "bg-primary/10 border border-primary/20"
: "hover:bg-muted"
}`}
onClick={() => handleScreenSelect(screen)}
onDoubleClick={() => handleDesignScreen(screen)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{screen.screenName}</div>
<div className="text-xs text-muted-foreground truncate">
{screen.screenCode} {screen.tableName && `| ${screen.tableName}`}
</div>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePreviewScreen(screen);
}}
title="POP 미리보기"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDesignScreen(screen);
}}
>
</Button>
</div>
</div>
))}
{/* 카테고리 트리 */}
<PopCategoryTree
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
onGroupSelect={handleGroupSelect}
searchTerm={searchTerm}
/>
</div>
{/* 오른쪽: 미리보기 / 화면 흐름 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 오른쪽 패널 헤더 */}
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between">
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
<TabsList className="h-8">
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5">
<LayoutGrid className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
{selectedScreen && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => handlePreviewScreen(selectedScreen)}
>
<Eye className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleOpenSettings}
>
<Settings className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="default"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => handleDesignScreen(selectedScreen)}
>
</Button>
</div>
)}
</div>
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
initialFocusedScreenId={focusedScreenIdInGroup}
isPop={true}
/>
</div>
</div>
) : (
// 테이블 뷰 - POP 화면만 표시
<div className="flex-1 overflow-auto p-6">
<div className="rounded-lg border">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium text-sm"></th>
<th className="text-left p-3 font-medium text-sm"></th>
<th className="text-left p-3 font-medium text-sm"></th>
<th className="text-left p-3 font-medium text-sm"></th>
<th className="text-right p-3 font-medium text-sm"></th>
</tr>
</thead>
<tbody>
{filteredScreens.map((screen) => (
<tr
key={screen.screenId}
className={`border-t cursor-pointer transition-colors ${
selectedScreen?.screenId === screen.screenId
? "bg-primary/5"
: "hover:bg-muted/50"
}`}
onClick={() => handleScreenSelect(screen)}
>
<td className="p-3 text-sm font-medium">{screen.screenName}</td>
<td className="p-3 text-sm text-muted-foreground">{screen.screenCode}</td>
<td className="p-3 text-sm text-muted-foreground">{screen.tableName || "-"}</td>
<td className="p-3 text-sm text-muted-foreground">
{screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR") : "-"}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePreviewScreen(screen);
}}
title="POP 미리보기"
>
<Eye className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDesignScreen(screen);
}}
>
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* 오른쪽 패널 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{rightPanelView === "preview" ? (
<PopScreenPreview screen={selectedScreen} className="h-full" />
) : (
<PopScreenFlowView screen={selectedScreen} className="h-full" />
)}
</div>
</div>
</div>
)}
@ -399,6 +370,19 @@ export default function PopScreenManagementPage() {
isPop={true}
/>
{/* 화면 설정 모달 */}
<PopScreenSettingModal
open={isSettingModalOpen}
onOpenChange={setIsSettingModalOpen}
screen={selectedScreen}
onSave={(updatedFields) => {
if (selectedScreen) {
setSelectedScreen({ ...selectedScreen, ...updatedFields });
}
loadScreens();
}}
/>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>

View File

@ -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 ? (
<div className="w-full min-h-full p-2">
{/* 그리드 레이아웃으로 섹션 배치 */}
<div
className="grid gap-1"
style={{
gridTemplateColumns: `repeat(${(layout as any).canvasGrid?.columns || 24}, 1fr)`,
}}
>
{(layout as any).sections.map((section: any) => (
<div
key={section.id}
className="bg-gray-50 border border-gray-200 rounded-lg p-2"
style={{
gridColumn: `${section.grid?.col || 1} / span ${section.grid?.colSpan || 6}`,
gridRow: `${section.grid?.row || 1} / span ${section.grid?.rowSpan || 4}`,
minHeight: `${(section.grid?.rowSpan || 4) * 20}px`,
}}
>
{/* 섹션 라벨 */}
{section.label && (
<div className="text-xs font-medium text-gray-500 mb-1">
{section.label}
</div>
)}
{/* 섹션 내 컴포넌트들 */}
{section.components && section.components.length > 0 ? (
<div className="space-y-1">
{section.components.map((comp: any) => (
<div
key={comp.id}
className="bg-white border border-gray-100 rounded p-2 text-sm"
>
{/* TODO: POP 전용 컴포넌트 렌더러 구현 필요 */}
<span className="text-gray-600">
{comp.label || comp.type || comp.id}
</span>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-400 text-center py-2">
</div>
)}
</div>
))}
</div>
</div>
) : layout && layout.components && layout.components.length > 0 ? (
// 이전 형식 (components 구조) - 호환성 유지
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
<div className="relative w-full min-h-full p-4">
{layout.components

View File

@ -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) {

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<div
className={cn(
"px-4 py-3 rounded-lg border-2 shadow-sm min-w-[140px] text-center transition-colors",
isMain
? "bg-primary/10 border-primary text-primary"
: "bg-background border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2 mb-1">
{isMain ? (
<Monitor className="h-4 w-4" />
) : (
<Layers className="h-4 w-4" />
)}
<span className="text-xs text-muted-foreground">
{isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"}
</span>
</div>
<div className="font-medium text-sm">{data.label}</div>
</div>
);
}
const nodeTypes = {
screenNode: ScreenNode,
};
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) {
const [loading, setLoading] = useState(false);
const [layoutData, setLayoutData] = useState<PopLayoutData | null>(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 (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm"> .</p>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
if (!layoutData) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm">POP .</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium"> </h3>
<span className="text-xs text-muted-foreground">
{screen.screenName}
</span>
</div>
{!hasSubScreens && (
<span className="text-xs text-muted-foreground">
</span>
)}
</div>
<div className="flex-1">
{hasSubScreens ? (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.5}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background color="#ddd" gap={16} />
<Controls showInteractive={false} />
<MiniMap
nodeColor={(node) => (node.data?.isMain ? "#3b82f6" : "#9ca3af")}
maskColor="rgba(0, 0, 0, 0.1)"
className="!bg-muted/50"
/>
</ReactFlow>
) : (
// 하위 화면이 없으면 간단한 단일 노드 표시
<div className="h-full flex items-center justify-center bg-muted/10">
<div className="text-center">
<div className="inline-flex items-center justify-center px-6 py-4 rounded-lg border-2 border-primary bg-primary/10">
<Monitor className="h-5 w-5 mr-2 text-primary" />
<span className="font-medium text-primary">{screen.screenName}</span>
</div>
<p className="text-xs text-muted-foreground mt-4">
() .
<br />
.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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<DeviceType>("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 (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
{/* 헤더 */}
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium"></h3>
{screen && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{screen.screenName}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* 디바이스 선택 */}
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
<TabsList className="h-8">
<TabsTrigger value="mobile" className="h-7 px-2">
<Smartphone className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="tablet" className="h-7 px-2">
<Tablet className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
{screen && hasLayout && (
<>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshPreview}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={openInNewTab}>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
{/* 미리보기 영역 */}
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
{!screen ? (
// 화면 미선택
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
{deviceType === "mobile" ? (
<Smartphone className="h-8 w-8" />
) : (
<Tablet className="h-8 w-8" />
)}
</div>
<p className="text-sm"> .</p>
</div>
) : loading ? (
// 로딩 중
<div className="text-center text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-3" />
<p className="text-sm"> ...</p>
</div>
) : !hasLayout ? (
// 레이아웃 없음
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
{deviceType === "mobile" ? (
<Smartphone className="h-8 w-8" />
) : (
<Tablet className="h-8 w-8" />
)}
</div>
<p className="text-sm mb-2">POP .</p>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (
// 디바이스 프레임 + iframe
<div
className="relative bg-gray-900 rounded-[2rem] p-2 shadow-xl"
style={{
width: deviceSize.width * scale + 16,
height: deviceSize.height * scale + 16,
}}
>
{/* 디바이스 노치 (모바일) */}
{deviceType === "mobile" && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-6 bg-gray-900 rounded-b-xl z-10" />
)}
{/* 디바이스 홈 버튼 (태블릿) */}
{deviceType === "tablet" && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 bg-gray-800 rounded-full" />
)}
{/* iframe 컨테이너 */}
<div
className="bg-white rounded-[1.5rem] overflow-hidden"
style={{
width: deviceSize.width * scale,
height: deviceSize.height * scale,
}}
>
<iframe
key={key}
src={previewUrl || ""}
className="w-full h-full border-0"
style={{
width: deviceSize.width,
height: deviceSize.height,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
title="POP Screen Preview"
/>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,442 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FileText,
Layers,
GitBranch,
Plus,
Trash2,
GripVertical,
Loader2,
Save,
} from "lucide-react";
import { toast } from "sonner";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup";
import { PopScreenFlowView } from "./PopScreenFlowView";
// ============================================================
// 타입 정의
// ============================================================
interface PopScreenSettingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
screen: ScreenDefinition | null;
onSave?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
interface SubScreenItem {
id: string;
name: string;
type: "modal" | "drawer" | "fullscreen";
triggerFrom?: string;
}
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenSettingModal({
open,
onOpenChange,
screen,
onSave,
}: PopScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// 개요 탭 상태
const [screenName, setScreenName] = useState("");
const [screenDescription, setScreenDescription] = useState("");
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
const [screenIcon, setScreenIcon] = useState("");
// 하위 화면 탭 상태
const [subScreens, setSubScreens] = useState<SubScreenItem[]>([]);
// 카테고리 목록
const [categories, setCategories] = useState<PopScreenGroup[]>([]);
// 초기 데이터 로드
useEffect(() => {
if (!open || !screen) return;
// 화면 정보 설정
setScreenName(screen.screenName || "");
setScreenDescription(screen.description || "");
setScreenIcon("");
setSelectedCategoryId("");
// 카테고리 목록 로드
loadCategories();
// 레이아웃에서 하위 화면 정보 로드
loadLayoutData();
}, [open, screen]);
const loadCategories = async () => {
try {
const data = await getPopScreenGroups();
setCategories(data.filter((g) => g.hierarchy_path?.startsWith("POP/")));
} catch (error) {
console.error("카테고리 로드 실패:", error);
}
};
const loadLayoutData = async () => {
if (!screen) return;
try {
setLoading(true);
const layout = await screenApi.getLayoutPop(screen.screenId);
if (layout && layout.subScreens) {
setSubScreens(
layout.subScreens.map((sub: any) => ({
id: sub.id || `sub-${Date.now()}`,
name: sub.name || "",
type: sub.type || "modal",
triggerFrom: sub.triggerFrom || "main",
}))
);
} else {
setSubScreens([]);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
setSubScreens([]);
} finally {
setLoading(false);
}
};
// 하위 화면 추가
const addSubScreen = () => {
const newSubScreen: SubScreenItem = {
id: `sub-${Date.now()}`,
name: `새 모달 ${subScreens.length + 1}`,
type: "modal",
triggerFrom: "main",
};
setSubScreens([...subScreens, newSubScreen]);
};
// 하위 화면 삭제
const removeSubScreen = (id: string) => {
setSubScreens(subScreens.filter((s) => s.id !== id));
};
// 하위 화면 업데이트
const updateSubScreen = (id: string, field: keyof SubScreenItem, value: string) => {
setSubScreens(
subScreens.map((s) => (s.id === id ? { ...s, [field]: value } : s))
);
};
// 저장
const handleSave = async () => {
if (!screen) return;
try {
setSaving(true);
// 화면 기본 정보 업데이트
const screenUpdate: Partial<ScreenDefinition> = {
screenName,
description: screenDescription,
};
// 레이아웃에 하위 화면 정보 저장
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
const updatedLayout = {
...currentLayout,
version: "pop-1.0",
subScreens: subScreens,
// flow 배열 자동 생성 (메인 → 각 서브)
flow: subScreens.map((sub) => ({
from: sub.triggerFrom || "main",
to: sub.id,
})),
};
await screenApi.saveLayoutPop(screen.screenId, updatedLayout);
toast.success("화면 설정이 저장되었습니다.");
onSave?.(screenUpdate);
onOpenChange(false);
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
if (!screen) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0 shrink-0">
<DialogTitle className="text-base sm:text-lg">POP </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{screen.screenName} ({screen.screenCode})
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
<TabsTrigger
value="overview"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<FileText className="h-4 w-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="subscreens"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<Layers className="h-4 w-4 mr-2" />
{subScreens.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{subScreens.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger
value="flow"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<GitBranch className="h-4 w-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4 max-w-[500px]">
<div>
<Label htmlFor="screenName" className="text-xs sm:text-sm">
*
</Label>
<Input
id="screenName"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="화면 이름"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="category" className="text-xs sm:text-sm">
</Label>
<Select value={selectedCategoryId} onValueChange={setSelectedCategoryId}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.group_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={screenDescription}
onChange={(e) => setScreenDescription(e.target.value)}
placeholder="화면에 대한 설명"
rows={3}
className="text-xs sm:text-sm resize-none"
/>
</div>
<div>
<Label htmlFor="icon" className="text-xs sm:text-sm">
</Label>
<Input
id="icon"
value={screenIcon}
onChange={(e) => setScreenIcon(e.target.value)}
placeholder="lucide 아이콘 이름 (예: Package)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground mt-1">
lucide-react .
</p>
</div>
</div>
)}
</TabsContent>
{/* 하위 화면 탭 */}
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
, .
</p>
<Button size="sm" onClick={addSubScreen}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<ScrollArea className="h-[300px]">
{subScreens.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm"> .</p>
<Button variant="link" className="text-xs" onClick={addSubScreen}>
</Button>
</div>
) : (
<div className="space-y-3">
{subScreens.map((subScreen, index) => (
<div
key={subScreen.id}
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
>
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Input
value={subScreen.name}
onChange={(e) =>
updateSubScreen(subScreen.id, "name", e.target.value)
}
placeholder="화면 이름"
className="h-8 text-xs flex-1"
/>
<Select
value={subScreen.type}
onValueChange={(v) =>
updateSubScreen(subScreen.id, "type", v)
}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"></SelectItem>
<SelectItem value="drawer"></SelectItem>
<SelectItem value="fullscreen"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">
:
</span>
<Select
value={subScreen.triggerFrom || "main"}
onValueChange={(v) =>
updateSubScreen(subScreen.id, "triggerFrom", v)
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="main"> </SelectItem>
{subScreens
.filter((s) => s.id !== subScreen.id)
.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeSubScreen(subScreen.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</div>
</TabsContent>
{/* 화면 흐름 탭 */}
<TabsContent value="flow" className="flex-1 m-0 overflow-hidden">
<PopScreenFlowView screen={screen} className="h-full" />
</TabsContent>
</Tabs>
{/* 푸터 */}
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,8 @@
/**
* POP
*/
export { PopCategoryTree } from "./PopCategoryTree";
export { PopScreenPreview } from "./PopScreenPreview";
export { PopScreenFlowView } from "./PopScreenFlowView";
export { PopScreenSettingModal } from "./PopScreenSettingModal";

View File

@ -0,0 +1,182 @@
/**
* POP API
* - hierarchy_path LIKE 'POP/%' POP
* - screen_groups와 ,
*/
import { apiClient } from "./client";
import { ScreenGroup, ScreenGroupScreen } from "./screenGroup";
// ============================================================
// POP 화면 그룹 타입 (ScreenGroup 재활용)
// ============================================================
export interface PopScreenGroup extends ScreenGroup {
// 추가 필드 필요시 여기에 정의
children?: PopScreenGroup[]; // 트리 구조용
}
export interface CreatePopScreenGroupRequest {
group_name: string;
group_code: string;
description?: string;
icon?: string;
display_order?: number;
parent_group_id?: number | null;
target_company_code?: string; // 최고관리자용
}
export interface UpdatePopScreenGroupRequest {
group_name?: string;
description?: string;
icon?: string;
display_order?: number;
is_active?: boolean;
}
// ============================================================
// API 함수
// ============================================================
/**
* POP
* - hierarchy_path가 'POP'
*/
export async function getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]> {
try {
const params = new URLSearchParams();
if (searchTerm) {
params.append("searchTerm", searchTerm);
}
const url = `/screen-groups/pop/groups${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiClient.get<{ success: boolean; data: PopScreenGroup[] }>(url);
if (response.data?.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("POP 화면 그룹 조회 실패:", error);
return [];
}
}
/**
* POP
*/
export async function createPopScreenGroup(
data: CreatePopScreenGroupRequest
): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
try {
const response = await apiClient.post<{ success: boolean; data: PopScreenGroup; message: string }>(
"/screen-groups/pop/groups",
data
);
return response.data;
} catch (error: any) {
console.error("POP 화면 그룹 생성 실패:", error);
return { success: false, message: error.response?.data?.message || "생성에 실패했습니다." };
}
}
/**
* POP
*/
export async function updatePopScreenGroup(
id: number,
data: UpdatePopScreenGroupRequest
): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
try {
const response = await apiClient.put<{ success: boolean; data: PopScreenGroup; message: string }>(
`/screen-groups/pop/groups/${id}`,
data
);
return response.data;
} catch (error: any) {
console.error("POP 화면 그룹 수정 실패:", error);
return { success: false, message: error.response?.data?.message || "수정에 실패했습니다." };
}
}
/**
* POP
*/
export async function deletePopScreenGroup(
id: number
): Promise<{ success: boolean; message?: string }> {
try {
const response = await apiClient.delete<{ success: boolean; message: string }>(
`/screen-groups/pop/groups/${id}`
);
return response.data;
} catch (error: any) {
console.error("POP 화면 그룹 삭제 실패:", error);
return { success: false, message: error.response?.data?.message || "삭제에 실패했습니다." };
}
}
/**
* POP ( )
*/
export async function ensurePopRootGroup(): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
try {
const response = await apiClient.post<{ success: boolean; data: PopScreenGroup; message: string }>(
"/screen-groups/pop/ensure-root"
);
return response.data;
} catch (error: any) {
console.error("POP 루트 그룹 확보 실패:", error);
return { success: false, message: error.response?.data?.message || "루트 그룹 확보에 실패했습니다." };
}
}
// ============================================================
// 유틸리티 함수
// ============================================================
/**
*
*/
export function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] {
const groupMap = new Map<number, PopScreenGroup>();
const rootGroups: PopScreenGroup[] = [];
// 먼저 모든 그룹을 맵에 저장
groups.forEach((group) => {
groupMap.set(group.id, { ...group, children: [] });
});
// 트리 구조 생성
groups.forEach((group) => {
const node = groupMap.get(group.id)!;
if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
// 부모가 있으면 부모의 children에 추가
const parent = groupMap.get(group.parent_group_id)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
// 부모가 없거나 POP 루트면 최상위에 추가
// hierarchy_path가 'POP'이거나 'POP/XXX' 형태인지 확인
if (group.hierarchy_path === "POP" ||
(group.hierarchy_path?.startsWith("POP/") &&
group.hierarchy_path.split("/").length === 2)) {
rootGroups.push(node);
}
}
});
// display_order로 정렬
const sortByOrder = (a: PopScreenGroup, b: PopScreenGroup) =>
(a.display_order || 0) - (b.display_order || 0);
rootGroups.sort(sortByOrder);
rootGroups.forEach((group) => {
if (group.children) {
group.children.sort(sortByOrder);
}
});
return rootGroups;
}