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:
parent
8c045acab3
commit
d9b7ef9ad4
283
POPUPDATE.md
283
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 && (
|
||||||
|
<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*
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ import {
|
||||||
syncMenuToScreenGroupsController,
|
syncMenuToScreenGroupsController,
|
||||||
getSyncStatusController,
|
getSyncStatusController,
|
||||||
syncAllCompaniesController,
|
syncAllCompaniesController,
|
||||||
|
// POP 전용 화면 그룹
|
||||||
|
getPopScreenGroups,
|
||||||
|
createPopScreenGroup,
|
||||||
|
updatePopScreenGroup,
|
||||||
|
deletePopScreenGroup,
|
||||||
|
ensurePopRootGroup,
|
||||||
} from "../controllers/screenGroupController";
|
} from "../controllers/screenGroupController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
||||||
// 전체 회사 동기화 (최고 관리자만)
|
// 전체 회사 동기화 (최고 관리자만)
|
||||||
router.post("/sync/all", syncAllCompaniesController);
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4829,9 +4829,9 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버전 정보 추가
|
// 버전 정보 추가 (프론트엔드 pop-1.0과 통일)
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
version: "2.0",
|
version: "pop-1.0",
|
||||||
...layoutData
|
...layoutData
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,39 +4,71 @@ import { useState, useEffect, useCallback } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { PopDesigner } from "@/components/pop/designer";
|
||||||
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/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 CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 Step = "list" | "design";
|
||||||
type ViewMode = "tree" | "table";
|
|
||||||
type DevicePreview = "mobile" | "tablet";
|
type DevicePreview = "mobile" | "tablet";
|
||||||
|
type RightPanelView = "preview" | "flow";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export default function PopScreenManagementPage() {
|
export default function PopScreenManagementPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// 단계 및 화면 상태
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<PopScreenGroup | null>(null);
|
||||||
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
|
||||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
|
||||||
|
// 화면 데이터
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
||||||
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
|
||||||
|
|
||||||
// POP 레이아웃 존재 화면 ID
|
// POP 레이아웃 존재 화면 ID
|
||||||
const [popLayoutScreenIds, setPopLayoutScreenIds] = useState<Set<number>>(new Set());
|
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 () => {
|
const loadScreens = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -63,7 +95,7 @@ export default function PopScreenManagementPage() {
|
||||||
// 화면 목록 새로고침 이벤트 리스너
|
// 화면 목록 새로고침 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScreenListRefresh = () => {
|
const handleScreenListRefresh = () => {
|
||||||
console.log("🔄 POP 화면 목록 새로고침 이벤트 수신");
|
console.log("POP 화면 목록 새로고침 이벤트 수신");
|
||||||
loadScreens();
|
loadScreens();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -87,16 +119,15 @@ export default function PopScreenManagementPage() {
|
||||||
}
|
}
|
||||||
}, [searchParams, screens]);
|
}, [searchParams, screens]);
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 전체 화면 사용
|
// ============================================================
|
||||||
const isDesignMode = currentStep === "design";
|
// 핸들러
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// 다음 단계로 이동
|
|
||||||
const goToNextStep = (nextStep: Step) => {
|
const goToNextStep = (nextStep: Step) => {
|
||||||
setStepHistory((prev) => [...prev, nextStep]);
|
setStepHistory((prev) => [...prev, nextStep]);
|
||||||
setCurrentStep(nextStep);
|
setCurrentStep(nextStep);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 특정 단계로 이동
|
|
||||||
const goToStep = (step: Step) => {
|
const goToStep = (step: Step) => {
|
||||||
setCurrentStep(step);
|
setCurrentStep(step);
|
||||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||||
|
|
@ -105,13 +136,19 @@ export default function PopScreenManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
// 화면 선택
|
||||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 디자인 핸들러
|
// 그룹 선택
|
||||||
|
const handleGroupSelect = (group: PopScreenGroup | null) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
// 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 디자인 모드 진입
|
||||||
const handleDesignScreen = (screen: ScreenDefinition) => {
|
const handleDesignScreen = (screen: ScreenDefinition) => {
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
goToNextStep("design");
|
goToNextStep("design");
|
||||||
|
|
@ -123,15 +160,22 @@ export default function PopScreenManagementPage() {
|
||||||
window.open(previewUrl, "_blank", "width=800,height=900");
|
window.open(previewUrl, "_blank", "width=800,height=900");
|
||||||
};
|
};
|
||||||
|
|
||||||
// POP 화면만 필터링 (POP 레이아웃이 있는 화면만)
|
// 화면 설정 모달 열기
|
||||||
|
const handleOpenSettings = () => {
|
||||||
|
if (selectedScreen) {
|
||||||
|
setIsSettingModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 필터링된 데이터
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// POP 레이아웃이 있는 화면만 필터링
|
||||||
const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId));
|
const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId));
|
||||||
|
|
||||||
// 검색어 필터링
|
// 검색어 필터링
|
||||||
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
||||||
const filteredScreens = popScreens.filter((screen) => {
|
const filteredScreens = popScreens.filter((screen) => {
|
||||||
if (searchKeywords.length > 1) {
|
|
||||||
return true; // 폴더 계층 검색 시 화면 필터링 없음
|
|
||||||
}
|
|
||||||
if (!searchTerm) return true;
|
if (!searchTerm) return true;
|
||||||
return (
|
return (
|
||||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
|
@ -139,10 +183,14 @@ export default function PopScreenManagementPage() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POP 화면 수
|
|
||||||
const popScreenCount = popLayoutScreenIds.size;
|
const popScreenCount = popLayoutScreenIds.size;
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 POP 전용 디자이너 사용
|
// ============================================================
|
||||||
|
// 디자인 모드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const isDesignMode = currentStep === "design";
|
||||||
|
|
||||||
if (isDesignMode && selectedScreen) {
|
if (isDesignMode && selectedScreen) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
|
|
@ -160,6 +208,10 @@ export default function PopScreenManagementPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 목록 모드 렌더링
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
|
|
@ -173,37 +225,13 @@ export default function PopScreenManagementPage() {
|
||||||
모바일/태블릿
|
모바일/태블릿
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">POP 화면을 그룹별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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}>
|
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -224,7 +252,8 @@ export default function PopScreenManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">POP 화면이 없습니다</h3>
|
<h3 className="text-lg font-semibold mb-2">POP 화면이 없습니다</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||||
아직 생성된 POP 화면이 없습니다.<br />
|
아직 생성된 POP 화면이 없습니다.
|
||||||
|
<br />
|
||||||
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.
|
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||||
|
|
@ -232,10 +261,10 @@ export default function PopScreenManagementPage() {
|
||||||
새 POP 화면 만들기
|
새 POP 화면 만들기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "tree" ? (
|
) : (
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<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="shrink-0 p-3 border-b">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -247,7 +276,6 @@ export default function PopScreenManagementPage() {
|
||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* POP 화면 수 표시 */}
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
<span className="text-xs text-muted-foreground">POP 화면</span>
|
<span className="text-xs text-muted-foreground">POP 화면</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
|
|
@ -255,132 +283,75 @@ export default function PopScreenManagementPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* POP 화면 리스트 */}
|
|
||||||
<div className="flex-1 overflow-auto p-2">
|
{/* 카테고리 트리 */}
|
||||||
{filteredScreens.length === 0 ? (
|
<PopCategoryTree
|
||||||
<div className="text-center text-sm text-muted-foreground py-8">
|
screens={filteredScreens}
|
||||||
검색 결과가 없습니다
|
selectedScreen={selectedScreen}
|
||||||
|
onScreenSelect={handleScreenSelect}
|
||||||
|
onScreenDesign={handleDesignScreen}
|
||||||
|
onGroupSelect={handleGroupSelect}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
{/* 오른쪽: 미리보기 / 화면 흐름 */}
|
||||||
{filteredScreens.map((screen) => (
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div
|
{/* 오른쪽 패널 헤더 */}
|
||||||
key={screen.screenId}
|
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between">
|
||||||
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors ${
|
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
|
||||||
selectedScreen?.screenId === screen.screenId
|
<TabsList className="h-8">
|
||||||
? "bg-primary/10 border border-primary/20"
|
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5">
|
||||||
: "hover:bg-muted"
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
}`}
|
미리보기
|
||||||
onClick={() => handleScreenSelect(screen)}
|
</TabsTrigger>
|
||||||
onDoubleClick={() => handleDesignScreen(screen)}
|
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5">
|
||||||
>
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
<div className="flex-1 min-w-0">
|
화면 흐름
|
||||||
<div className="font-medium text-sm truncate">{screen.screenName}</div>
|
</TabsTrigger>
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
</TabsList>
|
||||||
{screen.screenCode} {screen.tableName && `| ${screen.tableName}`}
|
</Tabs>
|
||||||
</div>
|
|
||||||
</div>
|
{selectedScreen && (
|
||||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
className="h-7 px-2 text-xs"
|
||||||
e.stopPropagation();
|
onClick={() => handlePreviewScreen(selectedScreen)}
|
||||||
handlePreviewScreen(screen);
|
|
||||||
}}
|
|
||||||
title="POP 미리보기"
|
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||||
|
새 탭
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
className="h-7 px-2 text-xs"
|
||||||
e.stopPropagation();
|
onClick={handleOpenSettings}
|
||||||
handleDesignScreen(screen);
|
>
|
||||||
}}
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
{/* 오른쪽 패널 콘텐츠 */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ScreenRelationFlow
|
{rightPanelView === "preview" ? (
|
||||||
screen={selectedScreen}
|
<PopScreenPreview screen={selectedScreen} className="h-full" />
|
||||||
selectedGroup={selectedGroup}
|
|
||||||
initialFocusedScreenId={focusedScreenIdInGroup}
|
|
||||||
isPop={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
// 테이블 뷰 - POP 화면만 표시
|
<PopScreenFlowView screen={selectedScreen} className="h-full" />
|
||||||
<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>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -399,6 +370,19 @@ export default function PopScreenManagementPage() {
|
||||||
isPop={true}
|
isPop={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 화면 설정 모달 */}
|
||||||
|
<PopScreenSettingModal
|
||||||
|
open={isSettingModalOpen}
|
||||||
|
onOpenChange={setIsSettingModalOpen}
|
||||||
|
screen={selectedScreen}
|
||||||
|
onSave={(updatedFields) => {
|
||||||
|
if (selectedScreen) {
|
||||||
|
setSelectedScreen({ ...selectedScreen, ...updatedFields });
|
||||||
|
}
|
||||||
|
loadScreens();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
{/* Scroll to Top 버튼 */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -78,18 +78,24 @@ function PopScreenViewPage() {
|
||||||
setScreen(screenData);
|
setScreen(screenData);
|
||||||
|
|
||||||
// POP 레이아웃 로드 (screen_layouts_pop 테이블에서)
|
// POP 레이아웃 로드 (screen_layouts_pop 테이블에서)
|
||||||
|
// POP 레이아웃은 sections[] 구조 사용 (데스크톱의 components[]와 다름)
|
||||||
try {
|
try {
|
||||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
if (popLayout && popLayout.components && popLayout.components.length > 0) {
|
if (popLayout && popLayout.sections && popLayout.sections.length > 0) {
|
||||||
// POP 레이아웃이 있으면 사용
|
// POP 레이아웃 (sections 구조) - 그대로 저장
|
||||||
console.log("POP 레이아웃 로드:", popLayout.components?.length || 0, "개 컴포넌트");
|
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);
|
setLayout(popLayout as LayoutData);
|
||||||
} else {
|
} else {
|
||||||
// POP 레이아웃이 비어있으면 빈 레이아웃
|
// POP 레이아웃이 비어있으면 빈 레이아웃
|
||||||
console.log("POP 레이아웃 없음, 빈 화면 표시");
|
console.log("POP 레이아웃 없음, 빈 화면 표시");
|
||||||
setLayout({
|
setLayout({
|
||||||
screenId,
|
screenId,
|
||||||
|
sections: [],
|
||||||
components: [],
|
components: [],
|
||||||
gridSettings: {
|
gridSettings: {
|
||||||
columns: 12,
|
columns: 12,
|
||||||
|
|
@ -101,12 +107,13 @@ function PopScreenViewPage() {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
snapToGrid: true,
|
snapToGrid: true,
|
||||||
},
|
},
|
||||||
});
|
} as any);
|
||||||
}
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("POP 레이아웃 로드 실패:", layoutError);
|
console.warn("POP 레이아웃 로드 실패:", layoutError);
|
||||||
setLayout({
|
setLayout({
|
||||||
screenId,
|
screenId,
|
||||||
|
sections: [],
|
||||||
components: [],
|
components: [],
|
||||||
gridSettings: {
|
gridSettings: {
|
||||||
columns: 12,
|
columns: 12,
|
||||||
|
|
@ -118,7 +125,7 @@ function PopScreenViewPage() {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
snapToGrid: true,
|
snapToGrid: true,
|
||||||
},
|
},
|
||||||
});
|
} as any);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("POP 화면 로드 실패:", error);
|
console.error("POP 화면 로드 실패:", error);
|
||||||
|
|
@ -222,8 +229,58 @@ function PopScreenViewPage() {
|
||||||
maxWidth: isPreviewMode ? currentDevice.width : "100%",
|
maxWidth: isPreviewMode ? currentDevice.width : "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 화면 컨텐츠 */}
|
{/* POP 레이아웃: sections 구조 렌더링 */}
|
||||||
{layout && layout.components && layout.components.length > 0 ? (
|
{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}>
|
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||||
<div className="relative w-full min-h-full p-4">
|
<div className="relative w-full min-h-full p-4">
|
||||||
{layout.components
|
{layout.components
|
||||||
|
|
|
||||||
|
|
@ -63,24 +63,31 @@ export default function PopDesigner({
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드
|
||||||
|
// API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLayout = async () => {
|
const loadLayout = async () => {
|
||||||
if (!selectedScreen?.screenId) return;
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await screenApi.getLayoutPop(selectedScreen.screenId);
|
// API가 layout_data 내용을 직접 반환함 (언래핑된 상태)
|
||||||
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (response && response.layout_data) {
|
if (loadedLayout && loadedLayout.version === "pop-1.0") {
|
||||||
const loadedLayout = response.layout_data as PopLayoutData;
|
// 유효한 POP 레이아웃
|
||||||
|
setLayout(loadedLayout as PopLayoutData);
|
||||||
if (loadedLayout.version === "pop-1.0") {
|
console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션");
|
||||||
setLayout(loadedLayout);
|
} else if (loadedLayout && loadedLayout.sections) {
|
||||||
} else {
|
// 버전 태그 없지만 sections 구조가 있으면 사용
|
||||||
console.warn("레이아웃 버전 불일치, 새 레이아웃 생성");
|
console.warn("버전 태그 없음, sections 구조 감지하여 사용");
|
||||||
setLayout(createEmptyPopLayout());
|
setLayout({
|
||||||
}
|
...createEmptyPopLayout(),
|
||||||
|
...loadedLayout,
|
||||||
|
version: "pop-1.0",
|
||||||
|
} as PopLayoutData);
|
||||||
} else {
|
} else {
|
||||||
|
// 레이아웃 없음 - 빈 레이아웃 생성
|
||||||
|
console.log("POP 레이아웃 없음, 빈 레이아웃 생성");
|
||||||
setLayout(createEmptyPopLayout());
|
setLayout(createEmptyPopLayout());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* POP 화면 관리 컴포넌트
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { PopCategoryTree } from "./PopCategoryTree";
|
||||||
|
export { PopScreenPreview } from "./PopScreenPreview";
|
||||||
|
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
|
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue