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