36 KiB
POP 화면 관리 시스템 개발 기록
AI 에이전트 안내: 이 문서는 Progressive Disclosure 방식으로 구성되어 있습니다.
- 먼저 Quick Reference에서 필요한 정보 확인
- 상세 내용이 필요하면 해당 섹션으로 이동
- 코드가 필요하면 파일 직접 참조
Quick Reference
POP이란?
Point of Production - 현장 작업자용 모바일/태블릿 화면 시스템
핵심 결정사항
- 분리 방식: 레이아웃 기반 구분 (screen_layouts_pop 테이블)
- 식별 방법:
screen_layouts_pop에 레코드 존재 여부로 POP 화면 판별 - 데스크톱 영향: 없음 (모든 isPop 기본값 = false)
주요 경로
| 용도 | 경로 |
|---|---|
| POP 뷰어 URL | /pop/screens/{screenId}?preview=true&device=tablet |
| POP 관리 페이지 | /admin/screenMng/popScreenMngList |
| POP 레이아웃 API | /api/screen-management/layout-pop/:screenId |
파일 찾기 가이드
| 작업 | 파일 |
|---|---|
| POP 레이아웃 DB 스키마 | db/migrations/052_create_screen_layouts_pop.sql |
| POP API 서비스 로직 | backend-node/src/services/screenManagementService.ts (getLayoutPop, saveLayoutPop) |
| POP API 라우트 | backend-node/src/routes/screenManagementRoutes.ts |
| 프론트엔드 API 클라이언트 | frontend/lib/api/screen.ts (screenApi.getLayoutPop 등) |
| POP 화면 관리 UI | frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx |
| POP 뷰어 페이지 | frontend/app/(pop)/pop/screens/[screenId]/page.tsx |
| 미리보기 URL 분기 | frontend/components/screen/ScreenSettingModal.tsx (PreviewTab) |
| POP 컴포넌트 설계서 | docs/pop/components-spec.md (13개 컴포넌트 상세) |
섹션 목차
| # | 섹션 | 한 줄 요약 |
|---|---|---|
| 1 | 아키텍처 | 레이아웃 테이블로 POP/데스크톱 분리 |
| 2 | 데이터베이스 | screen_layouts_pop 테이블 (FK 없음) |
| 3 | 백엔드 API | CRUD 4개 엔드포인트 |
| 4 | 프론트엔드 API | screenApi에 4개 함수 추가 |
| 5 | 관리 페이지 | POP 화면만 필터링하여 표시 |
| 6 | 뷰어 | 모바일/태블릿 프레임 미리보기 |
| 7 | 미리보기 | isPop prop으로 URL 분기 |
| 8 | 파일 목록 | 생성 3개, 수정 9개 |
| 9 | 반응형 전략 | Flow 레이아웃 (세로 쌓기) 채택 |
| 10 | POP 사용자 앱 | 대시보드 카드 → 화면 뷰어 |
| 11 | POP 디자이너 | 좌(탭패널) + 우(팬캔버스), 반응형 편집 |
| 12 | 데이터 구조 | PopLayoutData, mobileOverride |
| 13 | 컴포넌트 재사용성 | 2개 재사용, 4개 부분, 7개 신규 |
1. 아키텍처
결정: Option B (레이아웃 기반 구분)
screen_definitions (공용)
├── screen_layouts_v2 (데스크톱)
└── screen_layouts_pop (POP)
선택 이유: 기존 테이블 변경 없음, 데스크톱 영향 없음, 향후 통합 가능
2. 데이터베이스
테이블: screen_layouts_pop
| 컬럼 | 타입 | 설명 |
|---|---|---|
| id | SERIAL | PK |
| screen_id | INTEGER | 화면 ID (unique) |
| layout_data | JSONB | 컴포넌트 JSON |
특이사항: FK 없음 (soft-delete 지원)
파일: db/migrations/052_create_screen_layouts_pop.sql
3. 백엔드 API
| Method | Endpoint | 용도 |
|---|---|---|
| GET | /api/screen-management/layout-pop/:screenId |
조회 |
| POST | /api/screen-management/layout-pop/:screenId |
저장 |
| DELETE | /api/screen-management/layout-pop/:screenId |
삭제 |
| GET | /api/screen-management/pop-layout-screen-ids |
ID 목록 |
파일: backend-node/src/services/screenManagementService.ts
4. 프론트엔드 API
파일: frontend/lib/api/screen.ts
screenApi.getLayoutPop(screenId) // 조회
screenApi.saveLayoutPop(screenId, data) // 저장
screenApi.deleteLayoutPop(screenId) // 삭제
screenApi.getScreenIdsWithPopLayout() // ID 목록
5. 관리 페이지
파일: frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx
핵심 로직:
const popIds = await screenApi.getScreenIdsWithPopLayout();
const filteredScreens = screens.filter(s => new Set(popIds).has(s.screenId));
기능: POP 화면만 표시, 새 POP 화면 생성):, 보기/설계 버튼
6. 뷰어
파일: frontend/app/(pop)/pop/screens/[screenId]/page.tsx
URL 파라미터:
| 파라미터 | 값 | 설명 |
|---|---|---|
| preview | true | 툴바 표시 |
| device | mobile/tablet | 디바이스 크기 (기본: tablet) |
디바이스 크기: mobile(375x812), tablet(768x1024)
7. 미리보기
핵심: isPop prop으로 URL 분기
popScreenMngList
└─► ScreenRelationFlow(isPop=true)
└─► ScreenSettingModal
└─► PreviewTab → /pop/screens/{id}
screenMngList (데스크톱)
└─► ScreenRelationFlow(isPop=false 기본값)
└─► ScreenSettingModal
└─► PreviewTab → /screens/{id}
안전성: isPop 기본값 = false → 데스크톱 영향 없음
8. 파일 목록
생성 (3개)
| 파일 | 용도 |
|---|---|
db/migrations/052_create_screen_layouts_pop.sql |
DB 스키마 |
frontend/app/(pop)/pop/screens/[screenId]/page.tsx |
POP 뷰어 |
frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx |
POP 관리 |
수정 (9개)
| 파일 | 변경 내용 |
|---|---|
backend-node/src/services/screenManagementService.ts |
POP CRUD 함수 |
backend-node/src/controllers/screenManagementController.ts |
컨트롤러 |
backend-node/src/routes/screenManagementRoutes.ts |
라우트 |
frontend/lib/api/screen.ts |
API 클라이언트 |
frontend/components/screen/CreateScreenModal.tsx |
isPop prop |
frontend/components/screen/ScreenSettingModal.tsx |
isPop, PreviewTab |
frontend/components/screen/ScreenRelationFlow.tsx |
isPop 전달 |
frontend/components/screen/ScreenDesigner.tsx |
isPop, 미리보기 |
frontend/components/screen/toolbar/SlimToolbar.tsx |
POP 미리보기 버튼 |
9. 반응형 전략 (신규 결정사항)
문제점
- 데스크톱은 절대 좌표(
position: { x, y }) 사용 - 모바일 화면 크기가 달라지면 레이아웃 깨짐
결정: Flow 레이아웃 채택
| 항목 | 데스크톱 | POP |
|---|---|---|
| 배치 방식 | position: { x, y } |
order: number (순서) |
| 컨테이너 | 자유 배치 | 중첩 구조 (섹션 > 필드) |
| 렌더러 | 절대 좌표 계산 | Flexbox column (세로 쌓기) |
Flow 레이아웃 데이터 구조
{
layoutMode: "flow", // flow | absolute
components: [
{
id: "section-1",
type: "pop-section",
order: 0, // 순서로 배치
children: [...]
}
]
}
10. POP 사용자 앱 구조 (신규 결정사항)
데스크톱 vs POP 진입 구조
| 데스크톱 | POP | |
|---|---|---|
| 메뉴 | 왼쪽 사이드바 | 대시보드 카드 |
| 네비게이션 | 복잡한 트리 구조 | 화면 → 뒤로가기 |
| URL | /screens/{id} |
/pop/screens/{id} |
POP 화면 흐름
/pop/login (POP 로그인)
↓
/pop/dashboard (화면 목록 - 카드형)
↓
/pop/screens/{id} (화면 뷰어)
11. POP 디자이너 (신규 계획)
진입 경로
popScreenMngList → [설계] 버튼 → PopDesigner 컴포넌트
레이아웃 구조 (2026-02-02 수정)
데스크톱 Screen Designer와 유사하게 좌측 탭 패널 + 우측 캔버스:
┌─────────────────────────────────────────────────────────────────┐
│ [툴바] ← 목록 | 화면명 | 📱모바일 📱태블릿 | 🔄 | 💾저장 │
├────────────────┬────────────────────────────────────────────────┤
│ [패널] │ [캔버스 영역] │
│ ◀━━━━▶ │ │
│ (리사이즈) │ ┌────────────────────────┐ │
│ │ │ 디바이스 프레임 │ ← 드래그로 │
│ ┌────────────┐ │ │ │ 팬 이동 │
│ │컴포넌트│편집│ │ │ [섹션 1] │ │
│ └────────────┘ │ │ ├─ 필드 A │ │
│ │ │ └─ 필드 B │ │
│ (컴포넌트 탭) │ │ │ │
│ 📦 섹션 │ │ [섹션 2] │ │
│ 📝 필드 │ │ ├─ 버튼1 ─ 버튼2 │ │
│ 🔘 버튼 │ │ │ │
│ 📋 리스트 │ └────────────────────────┘ │
│ 📊 인디케이터 │ │
│ │ │
│ (편집 탭) │ │
│ 선택된 컴포 │ │
│ 넌트 설정 │ │
└────────────────┴────────────────────────────────────────────────┘
패널 기능
| 기능 | 설명 |
|---|---|
| 리사이즈 | 드래그로 패널 너비 조절 (min: 200px, max: 400px) |
| 컴포넌트 탭 | POP 전용 컴포넌트만 표시 |
| 편집 탭 | 선택된 컴포넌트 설정 (프리셋 기반) |
캔버스 기능
| 기능 | 설명 |
|---|---|
| 팬(Pan) | 마우스 드래그로 보는 위치 이동 |
| 줌 | 마우스 휠로 확대/축소 (선택사항) |
| 디바이스 탭 | 📱모바일 / 📱태블릿 전환 |
| 나란히 보기 | 옵션으로 둘 다 표시 가능 |
| 실시간 미리보기 | 편집 = 미리보기 (별도 창 불필요) |
캔버스 방식: 블록 쌓기
- 섹션끼리는 위→아래로 쌓임
- 섹션 안에서는 가로(row) 또는 세로(column) 선택 가능
- 드래그앤드롭으로 순서 변경
- 캔버스 자체가 실시간 미리보기
기준 해상도
| 디바이스 | 논리적 크기 (dp) | 용도 |
|---|---|---|
| 모바일 | 360 x 640 | Zebra TC52/57 등 산업용 핸드헬드 |
| 태블릿 | 768 x 1024 | 8~10인치 산업용 태블릿 |
터치 타겟 (장갑 착용 고려)
- 최소 버튼 크기: 60dp (일반 앱 48dp보다 큼)
- 버튼 간격: 16dp 이상
반응형 편집 방식
| 모드 | 설명 |
|---|---|
| 기준 디바이스 | 태블릿 (메인 편집) |
| 자동 조정 | CSS flex-wrap, grid로 모바일 자동 줄바꿈 |
| 수동 조정 | 모바일 탭에서 그리드 열 수, 숨기기 설정 |
흐름:
1. 태블릿 탭에서 편집 (기준)
→ 모든 컴포넌트, 섹션, 순서, 데이터 바인딩 설정
2. 모바일 탭에서 확인
A) 자동 조정 OK → 그대로 저장
B) 배치 어색함 → 그리드 열 수 조정 또는 숨기기
섹션 내 컴포넌트 배치 옵션
| 설정 | 옵션 |
|---|---|
| 배치 방향 | row / column |
| 순서 | 드래그로 변경 |
| 비율 | flex (1:1, 2:1, 1:2 등) |
| 정렬 | start / center / end |
| 간격 | none / small / medium / large |
| 줄바꿈 | wrap / nowrap |
| 그리드 열 수 | 태블릿용, 모바일용 각각 설정 가능 |
관리자가 설정 가능한 것
| 항목 | 설정 방식 |
|---|---|
| 섹션 순서 | 드래그로 위/아래 이동 |
| 섹션 내 배치 | 가로(row) / 세로(column) |
| 정렬 | 왼쪽/가운데/오른쪽, 위/가운데/아래 |
| 컴포넌트 비율 | 1:1, 2:1, 1:2 등 (flex) |
| 크기 | S/M/L/XL 프리셋 |
| 여백/간격 | 작음/보통/넓음 프리셋 |
| 아이콘 | 선택 가능 |
| 테마/색상 | 프리셋 또는 커스텀 |
| 그리드 열 수 | 태블릿/모바일 각각 |
| 모바일 숨기기 | 특정 컴포넌트 숨김 |
관리자가 설정 불가능한 것 (반응형 유지)
- 정확한 x, y 좌표
- 정확한 픽셀 크기 (예: 347px)
- 고정 위치 (예: 왼쪽에서 100px)
스타일 분리 원칙
뼈대 (변경 어려움 - 처음부터 잘 설계):
- 데이터 바인딩 구조 (columnName, dataSource)
- 컴포넌트 계층 (섹션 > 필드)
- 액션 로직
옷 (변경 쉬움 - 나중에 조정 가능):
- 색상, 폰트 크기 → CSS 변수/테마
- 버튼 모양 → 프리셋
- 아이콘 → 선택
다국어 연동 (준비)
- 상태:
showMultilangSettingsModal미리 추가 - 버튼: 툴바에 자리만 (비활성)
- 연결: 추후
MultilangSettingsModalimport
데스크톱 시스템 재사용
| 기능 | 재사용 | 비고 |
|---|---|---|
| formData 관리 | O | 그대로 |
| 필드간 연결 | O | cascading, hierarchy |
| 테이블 참조 | O | dataSource, filter |
| 저장 이벤트 | O | beforeFormSave |
| 집계 | O | 스타일만 변경 |
| 설정 패널 | O | 탭 방식 참고 |
| CRUD API | O | 그대로 |
| buttonActions | O | 그대로 |
| 다국어 | O | MultilangSettingsModal |
파일 구조 (신규 생성 예정)
frontend/components/pop/
├── PopDesigner.tsx # 메인 (좌: 패널, 우: 캔버스)
├── PopCanvas.tsx # 캔버스 (팬/줌 + 프레임)
├── PopToolbar.tsx # 상단 툴바
│
├── panels/
│ └── PopPanel.tsx # 통합 패널 (컴포넌트/편집 탭)
│
├── components/ # POP 전용 컴포넌트
│ ├── PopSection.tsx
│ ├── PopField.tsx
│ ├── PopButton.tsx
│ └── ...
│
└── types/
└── pop-layout.ts # PopLayoutData, PopComponentData
12. POP 레이아웃 데이터 구조 (신규)
PopLayoutData
interface PopLayoutData {
version: "pop-1.0";
layoutMode: "flow"; // 항상 flow (절대좌표 없음)
deviceTarget: "mobile" | "tablet" | "both";
components: PopComponentData[];
}
PopComponentData
interface PopComponentData {
id: string;
type: "pop-section" | "pop-field" | "pop-button" | "pop-list" | "pop-indicator";
order: number; // 순서 (x, y 좌표 대신)
// 개별 컴포넌트 flex 비율
flex?: number; // 기본 1
// 섹션인 경우: 내부 레이아웃 설정
layout?: {
direction: "row" | "column";
justify: "start" | "center" | "end" | "between";
align: "start" | "center" | "end";
gap: "none" | "small" | "medium" | "large";
wrap: boolean;
grid?: number; // 태블릿 기준 열 수
};
// 크기 프리셋
size?: "S" | "M" | "L" | "XL" | "full";
// 데이터 바인딩
dataBinding?: {
tableName: string;
columnName: string;
displayField?: string;
};
// 스타일 프리셋
style?: {
variant: "default" | "primary" | "success" | "warning" | "danger";
padding: "none" | "small" | "medium" | "large";
};
// 모바일 오버라이드 (선택사항)
mobileOverride?: {
grid?: number; // 모바일 열 수 (없으면 자동)
hidden?: boolean; // 모바일에서 숨기기
};
// 하위 컴포넌트 (섹션 내부)
children?: PopComponentData[];
// 컴포넌트별 설정
config?: Record<string, any>;
}
데스크톱 vs POP 데이터 비교
| 항목 | 데스크톱 (LayoutData) | POP (PopLayoutData) |
|---|---|---|
| 배치 | position: { x, y, z } |
order: number |
| 크기 | size: { width, height } (픽셀) |
`size: "S" |
| 컨테이너 | 없음 (자유 배치) | layout: { direction, grid } |
| 반응형 | 없음 | mobileOverride |
13. 컴포넌트 재사용성 분석
최종 분류
| 분류 | 개수 | 컴포넌트 |
|---|---|---|
| 완전 재사용 | 2 | form-field, action-button |
| 부분 재사용 | 4 | tab-panel, data-table, kpi-gauge, process-flow |
| 신규 개발 | 7 | section, card-list, status-indicator, number-pad, barcode-scanner, timer, alarm-list |
핵심 컴포넌트 7개 (최소 필수)
| 컴포넌트 | 역할 | 포함 기능 |
|---|---|---|
| pop-section | 레이아웃 컨테이너 | 카드, 그룹핑, 접기/펼치기 |
| pop-field | 데이터 입력/표시 | 텍스트, 숫자, 드롭다운, 바코드, 숫자패드 |
| pop-button | 액션 실행 | 저장, 삭제, API 호출, 화면이동 |
| pop-list | 데이터 목록 | 카드리스트, 선택목록, 테이블 참조 |
| pop-indicator | 상태/수치 표시 | KPI, 게이지, 신호등, 진행률 |
| pop-scanner | 바코드/QR 입력 | 카메라, 외부 스캐너 |
| pop-numpad | 숫자 입력 특화 | 큰 버튼, 계산기 모드 |
TODO
Phase 1: POP 디자이너 개발 (현재 진행)
| # | 작업 | 설명 | 상태 |
|---|---|---|---|
| 1 | PopLayoutData 타입 정의 |
order, layout, mobileOverride | 완료 |
| 2 | PopDesigner.tsx |
좌: 리사이즈 패널, 우: 팬 가능 캔버스 | 완료 |
| 3 | PopPanel.tsx |
탭 (컴포넌트/편집), POP 컴포넌트만 | 완료 |
| 4 | PopCanvas.tsx |
팬/줌 + 디바이스 프레임 + 블록 렌더링 | 완료 |
| 5 | SectionGrid.tsx |
섹션 내부 컴포넌트 배치 (react-grid-layout) | 완료 |
| 6 | 드래그앤드롭 | 팔레트→캔버스 (섹션), 팔레트→섹션 (컴포넌트) | 완료 |
| 7 | 컴포넌트 자유 배치/리사이즈 | 고정 셀 크기(40px) 기반 자동 그리드 | 완료 |
| 8 | 편집 탭 | 그리드 설정, 모바일 오버라이드 | 완료 (기본) |
| 9 | 저장/로드 | 기존 API 재사용 (saveLayoutPop) | 완료 |
Phase 2: POP 컴포넌트 개발
상세: docs/pop/components-spec.md
1단계 (우선):
- pop-section (레이아웃 컨테이너)
- pop-field (범용 입력)
- pop-button (액션)
2단계:
- pop-list (카드형 목록)
- pop-indicator (상태/KPI)
- pop-numpad (숫자패드)
3단계:
- pop-scanner (바코드)
- pop-timer (타이머)
- pop-alarm (알람)
Phase 3: POP 사용자 앱
/pop/login- POP 전용 로그인/pop/dashboard- 화면 목록 (카드형)/pop/screens/[id]- Flow 렌더러 적용
기타
- POP 컴포넌트 레지스트리
- POP 메뉴/폴더 관리
- POP 인증 분리
- 다국어 연동
핵심 파일 참조
기존 파일 (참고용)
| 파일 | 용도 |
|---|---|
frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx |
진입점, PopDesigner 호출 위치 |
frontend/components/screen/ScreenDesigner.tsx |
데스크톱 디자이너 (구조 참고) |
frontend/components/screen/modals/MultilangSettingsModal.tsx |
다국어 모달 (추후 연동) |
frontend/lib/api/screen.ts |
API (getLayoutPop, saveLayoutPop) |
backend-node/src/services/screenManagementService.ts |
POP CRUD (4720~4920행) |
신규 생성 예정
| 파일 | 용도 |
|---|---|
frontend/components/pop/PopDesigner.tsx |
메인 디자이너 |
frontend/components/pop/PopCanvas.tsx |
캔버스 (팬/줌) |
frontend/components/pop/PopToolbar.tsx |
툴바 |
frontend/components/pop/panels/PopPanel.tsx |
통합 패널 |
frontend/components/pop/types/pop-layout.ts |
타입 정의 |
frontend/components/pop/components/PopSection.tsx |
섹션 컴포넌트 |
14. 그리드 시스템 단순화 (2026-02-02 변경)
기존 문제: 이중 그리드 구조
캔버스 (24열, rowHeight 20px)
└─ 섹션 (colSpan/rowSpan으로 크기 지정)
└─ 내부 그리드 (columns/rows로 컴포넌트 배치)
문제점:
- 섹션 크기와 내부 그리드가 독립적이라 동기화 안됨
- 섹션을 늘려도 내부 그리드 점은 그대로 (비례 확대만)
- 사용자가 두 가지 단위를 이해해야 함
변경: 단일 자동계산 그리드
핵심 변경사항:
- 그리드 점(dot) 제거
- 고정 셀 크기(40px) 기반으로 섹션 크기에 따라 열/행 수 자동 계산
- 컴포넌트는 react-grid-layout으로 자유롭게 드래그/리사이즈
코드 (SectionGrid.tsx):
const CELL_SIZE = 40;
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
결과:
- 섹션 크기 변경 → 내부 셀 개수 자동 조정
- 컴포넌트 자유 배치/리사이즈 가능
- 직관적인 사용자 경험
onLayoutChange 대신 onDragStop/onResizeStop 사용
문제: onLayoutChange는 드롭 직후에도 호출되어 섹션 크기가 자동 확대됨
해결:
// 변경 전
<GridLayout onLayoutChange={handleLayoutChange} ... />
// 변경 후
<GridLayout onDragStop={handleDragResizeStop} onResizeStop={handleDragResizeStop} ... />
상태 업데이트는 드래그/리사이즈 완료 후에만 실행
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/...로 중복
해결:
// 변경 전
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컬럼이 존재
해결:
-- 변경 전
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자)를 직접 사용
해결:
-- 변경 전
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 개선
문제: 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확
해결:
- 들여쓰기 증가:
level * 16px→level * 24px - 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시
- 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘
// 하위 레벨에 연결 표시 추가
{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>
미분류 화면 이동 기능 추가
기능: 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴
구현:
// 이동 드롭다운 메뉴
<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단계 처리 - 기존 연결 삭제 후 새 연결 추가
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 개선
변경 사항:
- 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일)
- 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거
- 폴더 메뉴에도 위로/아래로 이동 추가
순서 변경 구현:
// 그룹 순서 변경 (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();
};
카테고리 이동 모달 (서브메뉴 → 모달 방식)
문제: 카테고리가 많아지면 서브메뉴 방식은 관리 어려움
해결: 검색 기능이 있는 모달로 변경
구현:
// 이동 모달 상태
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 화면 │
│ 📁 홈 관리 │
│ 📁 출고관리 │
│ 📁 수주관리 │
│ 📁 생산 관리 (현재) │
├─────────────────────────────┤
│ [ 취소 ] │
└─────────────────────────────┘
14. 비율 기반 그리드 시스템 (2026-02-03)
문제 발견
POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생.
근본 원인 분석
- 기존 구조:
canvasGrid.rowHeight = 20(고정 픽셀) - react-grid-layout 동작: 작은 리사이즈 →
rowSpan: 1로 반올림 → DB 저장 - 뷰어 렌더링:
gridAutoRows: 20px→ 섹션 높이 = 20px (매우 얇음) - 비교: 가로(columns)는
1fr비율 기반으로 잘 작동
해결책: 비율 기반 행 시스템
| 구분 | 이전 | 이후 |
|---|---|---|
| 타입 | rowHeight: number (px) |
rows: number (개수) |
| 기본값 | rowHeight: 20 |
rows: 24 |
| 뷰어 CSS | gridAutoRows: 20px |
gridTemplateRows: repeat(24, 1fr) |
| 디자이너 계산 | 고정 20px | resolution.height / 24 |
수정된 파일
| 파일 | 변경 내용 |
|---|---|
types/pop-layout.ts |
PopCanvasGrid.rowHeight → rows, DEFAULT_CANVAS_GRID.rows = 24 |
renderers/PopLayoutRenderer.tsx |
gridAutoRows → gridTemplateRows: repeat(rows, 1fr) |
PopCanvas.tsx |
rowHeight = Math.floor(resolution.height / canvasGrid.rows) |
모드별 행 높이 계산
| 모드 | 해상도 높이 | 행 높이 (24행 기준) |
|---|---|---|
| tablet_landscape | 768px | 32px |
| tablet_portrait | 1024px | 42.7px |
| mobile_landscape | 375px | 15.6px |
| mobile_portrait | 667px | 27.8px |
기존 데이터 호환성
- 기존
rowHeight: 20데이터는rows || 24fallback으로 처리 - 기존
rowSpan: 1데이터는 1/24 = 4.17%로 렌더링 (여전히 작음) - 권장: 디자이너에서 섹션 재조정 후 재저장
15. 화면 삭제 기능 추가 (2026-02-03)
추가된 기능
POP 카테고리 트리에서 화면 자체를 삭제하는 기능 추가.
UI 변경
| 위치 | 메뉴 항목 | 동작 |
|---|---|---|
| 그룹 내 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 |
| 미분류 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 |
삭제 흐름
1. 드롭다운 메뉴에서 "화면 삭제" 클릭
2. 확인 다이얼로그 표시 ("삭제된 화면은 휴지통으로 이동됩니다")
3. 확인 → DELETE /api/screen-management/screens/:id
4. 화면 is_deleted = 'Y'로 변경 (soft delete)
5. 그룹 목록 새로고침
완전 삭제 vs 휴지통 이동
| API | 동작 | 복원 가능 |
|---|---|---|
DELETE /screens/:id |
휴지통으로 이동 (is_deleted='Y') | O |
DELETE /screens/:id/permanent |
DB에서 완전 삭제 | X |
수정된 파일
| 파일 | 변경 내용 |
|---|---|
PopCategoryTree.tsx |
handleDeleteScreen, confirmDeleteScreen 함수 추가 |
PopCategoryTree.tsx |
isScreenDeleteDialogOpen, deletingScreen 상태 추가 |
PopCategoryTree.tsx |
TreeNode에 onDeleteScreen prop 추가 |
PopCategoryTree.tsx |
화면 삭제 확인 AlertDialog 추가 |
16. 멀티테넌시 이슈 해결 (2026-02-03)
문제
화면 그룹에서 제거 시 404 에러 발생.
원인
- DB 데이터:
company_code = "*"(최고 관리자 전용) - 현재 세션:
company_code = "COMPANY_7" - 컨트롤러 WHERE 조건:
id = $1 AND company_code = $2→ 0 rows
해결
세션 불일치 문제로 DB에서 직접 삭제 처리.
교훈
- 최고 관리자로 생성한 데이터는 일반 회사 사용자가 삭제 불가
- 로그인 후 토큰 갱신 필요 시 브라우저 완전 새로고침
트러블슈팅
Export default doesn't exist in target module
문제: import apiClient from "@/lib/api/client" 에러
원인: apiClient가 named export로 정의됨
해결: import { apiClient } from "@/lib/api/client" 사용
섹션이 매우 얇게 렌더링되는 문제
문제: 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시
원인: canvasGrid.rowHeight = 20 고정값 + react-grid-layout의 rowSpan 반올림
해결: 비율 기반 rows 시스템으로 변경 (섹션 14 참조)
화면 삭제 404 에러
문제: 화면 그룹에서 제거 시 404 에러
원인: company_code 불일치 (세션 vs DB)
해결: 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리
관련 파일
| 파일 | 역할 |
|---|---|
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 라우트 |
frontend/components/pop/designer/types/pop-layout.ts |
POP 레이아웃 타입 정의 |
frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx |
CSS Grid 기반 렌더러 |
frontend/components/pop/designer/PopCanvas.tsx |
react-grid-layout 디자이너 캔버스 |
최종 업데이트: 2026-02-03